Skip to content

Commit c4d1cb7

Browse files
authored
fix: make command follow the spec / no trailing slash (#178)
1 parent bc58ac9 commit c4d1cb7

File tree

4 files changed

+280
-19
lines changed

4 files changed

+280
-19
lines changed

ucan/src/command.rs

Lines changed: 277 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,97 @@
11
//! Command helpers.
2+
//!
3+
//! Commands MUST be lowercase, and begin with a slash (`/`).
4+
//! Segments MUST be separated by a slash.
5+
//! A trailing slash MUST NOT be present.
26
37
use serde::{Deserialize, Serialize, Serializer};
8+
use thiserror::Error;
9+
10+
/// Errors that can occur when parsing a Command.
11+
#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
12+
pub enum CommandParseError {
13+
/// Command must begin with a slash (`/`).
14+
#[error("command must begin with a slash")]
15+
MissingLeadingSlash,
16+
17+
/// Command must not have a trailing slash.
18+
#[error("command must not have a trailing slash")]
19+
TrailingSlash,
20+
21+
/// Command must be lowercase.
22+
#[error("command must be lowercase")]
23+
NotLowercase,
24+
25+
/// Command segments must not be empty (e.g., `/crud//create` is invalid).
26+
#[error("command segments must not be empty")]
27+
EmptySegment,
28+
}
429

530
/// Command type representing a sequence of command segments.
31+
///
32+
/// Commands are `/`-delimited paths that describe a set of commands.
33+
/// For example: `/`, `/crud`, `/crud/create`, `/msg/send`.
34+
///
35+
/// Valid commands:
36+
/// - `/` (root command - all commands)
37+
/// - `/crud`
38+
/// - `/crud/create`
39+
/// - `/msg/send`
40+
/// - `/foo/bar/baz`
41+
///
42+
/// Invalid commands:
43+
/// - `crud` (missing leading slash)
44+
/// - `/crud/` (trailing slash)
45+
/// - `/CRUD` (not lowercase)
46+
/// - `/crud//create` (empty segment)
647
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
748
pub struct Command(pub Vec<String>);
849

950
impl Command {
51+
/// Parse a command string into a Command.
52+
///
53+
/// # Errors
54+
///
55+
/// Returns an error if the command string is not valid:
56+
/// - Missing leading slash
57+
/// - Has trailing slash (except for root `/`)
58+
/// - Contains uppercase characters
59+
/// - Contains empty segments
60+
pub fn parse(s: &str) -> Result<Self, CommandParseError> {
61+
// Must begin with a slash
62+
if !s.starts_with('/') {
63+
return Err(CommandParseError::MissingLeadingSlash);
64+
}
65+
66+
// Root command "/" is valid
67+
if s == "/" {
68+
return Ok(Command(vec![]));
69+
}
70+
71+
// Must not have trailing slash (except root)
72+
if s.ends_with('/') {
73+
return Err(CommandParseError::TrailingSlash);
74+
}
75+
76+
// Must be lowercase
77+
if s.chars().any(char::is_uppercase) {
78+
return Err(CommandParseError::NotLowercase);
79+
}
80+
81+
// Parse segments (skip first empty segment from leading slash)
82+
let segments: Vec<String> = s[1..].split('/').map(String::from).collect();
83+
84+
// Check for empty segments (e.g., "/crud//create")
85+
if segments.iter().any(String::is_empty) {
86+
return Err(CommandParseError::EmptySegment);
87+
}
88+
89+
Ok(Command(segments))
90+
}
91+
1092
/// Create a new Command from a vector of strings.
93+
///
94+
/// This does not validate the segments. Use `parse` for validated construction.
1195
#[must_use]
1296
pub const fn new(segments: Vec<String>) -> Self {
1397
Command(segments)
@@ -43,35 +127,212 @@ impl From<Command> for Vec<String> {
43127

44128
impl std::fmt::Display for Command {
45129
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46-
let cleaned = self
47-
.0
48-
.iter()
49-
.filter(|s| !s.is_empty())
50-
.cloned()
51-
.collect::<Vec<_>>();
52-
if cleaned.is_empty() {
130+
if self.0.is_empty() {
53131
f.write_str("/")
54132
} else {
55-
write!(f, "/{}/", cleaned.join("/"))
133+
write!(f, "/{}", self.0.join("/"))
56134
}
57135
}
58136
}
59137

60138
impl Serialize for Command {
61139
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
62-
serializer.serialize_str(self.to_string().as_str())
140+
serializer.serialize_str(&self.to_string())
63141
}
64142
}
65143

66144
impl<'de> Deserialize<'de> for Command {
67145
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
68146
let s = String::deserialize(deserializer)?;
69-
let trimmed = s.trim_matches('/');
70-
let parts: Vec<String> = trimmed
71-
.split('/')
72-
.map(String::from)
73-
.filter(|s| !s.is_empty())
74-
.collect();
75-
Ok(Command(parts))
147+
Command::parse(&s).map_err(serde::de::Error::custom)
148+
}
149+
}
150+
151+
impl std::str::FromStr for Command {
152+
type Err = CommandParseError;
153+
154+
fn from_str(s: &str) -> Result<Self, Self::Err> {
155+
Command::parse(s)
156+
}
157+
}
158+
159+
#[cfg(test)]
160+
mod tests {
161+
use super::*;
162+
163+
// Valid command examples from the spec
164+
#[test]
165+
fn test_valid_root_command() {
166+
let cmd = Command::parse("/").unwrap();
167+
assert_eq!(cmd.segments().len(), 0);
168+
assert_eq!(cmd.to_string(), "/");
169+
}
170+
171+
#[test]
172+
fn test_valid_single_segment() {
173+
let cmd = Command::parse("/crud").unwrap();
174+
assert_eq!(cmd.segments(), &["crud"]);
175+
assert_eq!(cmd.to_string(), "/crud");
176+
}
177+
178+
#[test]
179+
fn test_valid_two_segments() {
180+
let cmd = Command::parse("/crud/create").unwrap();
181+
assert_eq!(cmd.segments(), &["crud", "create"]);
182+
assert_eq!(cmd.to_string(), "/crud/create");
183+
}
184+
185+
#[test]
186+
fn test_valid_many_segments() {
187+
let cmd = Command::parse("/foo/bar/baz/qux/quux").unwrap();
188+
assert_eq!(cmd.segments(), &["foo", "bar", "baz", "qux", "quux"]);
189+
assert_eq!(cmd.to_string(), "/foo/bar/baz/qux/quux");
190+
}
191+
192+
#[test]
193+
fn test_valid_unicode() {
194+
// From spec: /ほげ/ふが
195+
let cmd = Command::parse("/ほげ/ふが").unwrap();
196+
assert_eq!(cmd.segments(), &["ほげ", "ふが"]);
197+
assert_eq!(cmd.to_string(), "/ほげ/ふが");
198+
}
199+
200+
// Invalid command examples
201+
#[test]
202+
fn test_invalid_missing_leading_slash() {
203+
let err = Command::parse("crud").unwrap_err();
204+
assert_eq!(err, CommandParseError::MissingLeadingSlash);
205+
}
206+
207+
#[test]
208+
fn test_invalid_trailing_slash() {
209+
let err = Command::parse("/crud/").unwrap_err();
210+
assert_eq!(err, CommandParseError::TrailingSlash);
211+
}
212+
213+
#[test]
214+
fn test_invalid_trailing_slash_nested() {
215+
let err = Command::parse("/crud/create/").unwrap_err();
216+
assert_eq!(err, CommandParseError::TrailingSlash);
217+
}
218+
219+
#[test]
220+
fn test_invalid_uppercase() {
221+
let err = Command::parse("/CRUD").unwrap_err();
222+
assert_eq!(err, CommandParseError::NotLowercase);
223+
}
224+
225+
#[test]
226+
fn test_invalid_mixed_case() {
227+
let err = Command::parse("/Crud/Create").unwrap_err();
228+
assert_eq!(err, CommandParseError::NotLowercase);
229+
}
230+
231+
#[test]
232+
fn test_invalid_empty_segment() {
233+
let err = Command::parse("/crud//create").unwrap_err();
234+
assert_eq!(err, CommandParseError::EmptySegment);
235+
}
236+
237+
// Roundtrip tests
238+
#[test]
239+
fn test_json_roundtrip() {
240+
let original = "\"/msg/send\"";
241+
let cmd: Command = serde_json::from_str(original).unwrap();
242+
let serialized = serde_json::to_string(&cmd).unwrap();
243+
assert_eq!(serialized, original);
244+
245+
let cmd2: Command = serde_json::from_str(&serialized).unwrap();
246+
assert_eq!(cmd, cmd2);
247+
}
248+
249+
#[test]
250+
fn test_json_roundtrip_root() {
251+
let original = "\"/\"";
252+
let cmd: Command = serde_json::from_str(original).unwrap();
253+
let serialized = serde_json::to_string(&cmd).unwrap();
254+
assert_eq!(serialized, original);
255+
256+
let cmd2: Command = serde_json::from_str(&serialized).unwrap();
257+
assert_eq!(cmd, cmd2);
258+
}
259+
260+
#[test]
261+
fn test_cbor_roundtrip() {
262+
let cmd: Command = Command::parse("/store/put").unwrap();
263+
264+
let cbor = serde_ipld_dagcbor::to_vec(&cmd).unwrap();
265+
let cmd2: Command = serde_ipld_dagcbor::from_slice(&cbor).unwrap();
266+
assert_eq!(cmd, cmd2);
267+
268+
let cbor2 = serde_ipld_dagcbor::to_vec(&cmd2).unwrap();
269+
assert_eq!(cbor, cbor2);
270+
}
271+
272+
#[test]
273+
fn test_cbor_roundtrip_root() {
274+
let cmd: Command = Command::parse("/").unwrap();
275+
276+
let cbor = serde_ipld_dagcbor::to_vec(&cmd).unwrap();
277+
let cmd2: Command = serde_ipld_dagcbor::from_slice(&cbor).unwrap();
278+
assert_eq!(cmd, cmd2);
279+
280+
let cbor2 = serde_ipld_dagcbor::to_vec(&cmd2).unwrap();
281+
assert_eq!(cbor, cbor2);
282+
}
283+
284+
// Deserialization should reject invalid commands
285+
#[test]
286+
fn test_deserialize_rejects_missing_leading_slash() {
287+
let result: Result<Command, _> = serde_json::from_str("\"crud\"");
288+
assert!(result.is_err());
289+
}
290+
291+
#[test]
292+
fn test_deserialize_rejects_trailing_slash() {
293+
let result: Result<Command, _> = serde_json::from_str("\"/crud/\"");
294+
assert!(result.is_err());
295+
}
296+
297+
#[test]
298+
fn test_deserialize_rejects_uppercase() {
299+
let result: Result<Command, _> = serde_json::from_str("\"/CRUD\"");
300+
assert!(result.is_err());
301+
}
302+
303+
#[test]
304+
fn test_deserialize_rejects_empty_segment() {
305+
let result: Result<Command, _> = serde_json::from_str("\"/crud//create\"");
306+
assert!(result.is_err());
307+
}
308+
309+
// starts_with tests (for delegation hierarchy)
310+
#[test]
311+
fn test_starts_with_root_matches_all() {
312+
let root = Command::parse("/").unwrap();
313+
let cmd = Command::parse("/crypto/sign").unwrap();
314+
assert!(cmd.starts_with(&root));
315+
}
316+
317+
#[test]
318+
fn test_starts_with_prefix_matches() {
319+
let prefix = Command::parse("/crypto").unwrap();
320+
let cmd = Command::parse("/crypto/sign").unwrap();
321+
assert!(cmd.starts_with(&prefix));
322+
}
323+
324+
#[test]
325+
fn test_starts_with_different_prefix_no_match() {
326+
let prefix = Command::parse("/crypto").unwrap();
327+
let cmd = Command::parse("/stack/pop").unwrap();
328+
assert!(!cmd.starts_with(&prefix));
329+
}
330+
331+
#[test]
332+
fn test_starts_with_similar_prefix_no_match() {
333+
// /crypto cannot prove /cryptocurrency
334+
let prefix = Command::parse("/crypto").unwrap();
335+
let cmd = Command::parse("/cryptocurrency").unwrap();
336+
assert!(!cmd.starts_with(&prefix));
76337
}
77338
}

ucan/src/delegation.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ mod tests {
492492
issuer: iss,
493493
audience: aud,
494494
subject: DelegatedSubject::Any,
495-
command: Command(vec!["/".to_string()]),
495+
command: Command::new(vec!["/".to_string()]),
496496
policy: vec![],
497497
expiration: None,
498498
not_before: None,

ucan/src/delegation/builder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ impl<
153153
issuer: self.issuer,
154154
audience: self.audience,
155155
subject: self.subject,
156-
command: Command(command),
156+
command: Command::new(command),
157157
policy: self.policy,
158158
expiration: self.expiration,
159159
not_before: self.not_before,

ucan/src/invocation/builder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ impl<
171171
issuer: self.issuer,
172172
audience: self.audience,
173173
subject: self.subject,
174-
command: Command(command),
174+
command: Command::new(command),
175175
arguments: self.arguments,
176176
proofs: self.proofs,
177177
cause: self.cause,

0 commit comments

Comments
 (0)