1use 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
43pub 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 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 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}