Skip to main content

mur_common/
agent_name.rs

1//! Canonical agent-name validation. Used by both `mur agent create` (CLI) and
2//! the `/api/v1/agents/{name}` HTTP routes so the two surfaces never disagree
3//! on what names are valid.
4
5use std::fmt;
6
7pub const MAX_AGENT_NAME_LEN: usize = 64;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum AgentNameError {
11    Empty,
12    TooLong { max: usize, actual: usize },
13    InvalidChar(char),
14    LeadingDash,
15    ContainsTraversal,
16}
17
18impl fmt::Display for AgentNameError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => write!(f, "agent name must not be empty"),
22            Self::TooLong { max, actual } => {
23                write!(f, "agent name is {actual} chars; max is {max}")
24            }
25            Self::InvalidChar(c) => write!(
26                f,
27                "agent name contains invalid character {c:?} \
28                 (allowed: ASCII letters, digits, '-', '_')"
29            ),
30            Self::LeadingDash => write!(
31                f,
32                "agent name must not start with '-' (parses as a CLI flag)"
33            ),
34            Self::ContainsTraversal => {
35                write!(f, "agent name must not contain '..' (path traversal)")
36            }
37        }
38    }
39}
40
41impl std::error::Error for AgentNameError {}
42
43/// Validate an agent name from any caller (HTTP path param, CLI argument,
44/// import file). Returns `Ok(())` if the name is safe to use as a directory
45/// component under `~/.mur/agents/`.
46pub fn validate_agent_name(name: &str) -> Result<(), AgentNameError> {
47    if name.is_empty() {
48        return Err(AgentNameError::Empty);
49    }
50    if name.len() > MAX_AGENT_NAME_LEN {
51        return Err(AgentNameError::TooLong {
52            max: MAX_AGENT_NAME_LEN,
53            actual: name.len(),
54        });
55    }
56    if name.starts_with('-') {
57        return Err(AgentNameError::LeadingDash);
58    }
59    if name.contains("..") {
60        return Err(AgentNameError::ContainsTraversal);
61    }
62    for c in name.chars() {
63        if !c.is_ascii_alphanumeric() && !matches!(c, '-' | '_') {
64            return Err(AgentNameError::InvalidChar(c));
65        }
66    }
67    Ok(())
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn accepts_typical_names() {
76        for name in ["alpha", "support_bot", "agent-1", "research_v2", "a", "X"] {
77            assert!(
78                validate_agent_name(name).is_ok(),
79                "expected {name} to be valid"
80            );
81        }
82    }
83
84    #[test]
85    fn rejects_empty() {
86        assert_eq!(validate_agent_name(""), Err(AgentNameError::Empty));
87    }
88
89    #[test]
90    fn rejects_traversal() {
91        assert_eq!(
92            validate_agent_name(".."),
93            Err(AgentNameError::ContainsTraversal)
94        );
95        assert_eq!(
96            validate_agent_name("a..b"),
97            Err(AgentNameError::ContainsTraversal)
98        );
99    }
100
101    #[test]
102    fn rejects_path_separators_and_other_specials() {
103        for bad in ["a/b", "a\\b", "a b", "a.b", "a:b", "a$b", "a@b", "a\0b"] {
104            let err = validate_agent_name(bad).unwrap_err();
105            // Specifically expect InvalidChar (not Empty / TooLong / etc.)
106            assert!(
107                matches!(err, AgentNameError::InvalidChar(_)),
108                "expected InvalidChar for {bad:?}, got {err:?}",
109            );
110        }
111    }
112
113    #[test]
114    fn rejects_unicode_lookalikes() {
115        // Cyrillic 'а' (U+0430) vs ASCII 'a' (U+0061)
116        assert!(matches!(
117            validate_agent_name("аlpha").unwrap_err(),
118            AgentNameError::InvalidChar(_)
119        ));
120    }
121
122    #[test]
123    fn rejects_leading_dash() {
124        assert_eq!(validate_agent_name("-rf"), Err(AgentNameError::LeadingDash));
125    }
126
127    #[test]
128    fn rejects_too_long() {
129        let long = "a".repeat(MAX_AGENT_NAME_LEN + 1);
130        assert!(matches!(
131            validate_agent_name(&long).unwrap_err(),
132            AgentNameError::TooLong { .. }
133        ));
134    }
135
136    #[test]
137    fn accepts_max_length() {
138        let max = "a".repeat(MAX_AGENT_NAME_LEN);
139        assert!(validate_agent_name(&max).is_ok());
140    }
141}