Skip to main content

synaps_cli/extensions/
validation.rs

1//! Shared validation helpers for extension capability identifiers.
2//!
3//! Centralizes the rules for capability IDs (tool names, provider IDs, model
4//! IDs, plugin IDs) so tools, providers, and hooks share consistent
5//! validation behavior. New capability authors should reuse these helpers
6//! rather than re-deriving the rules inline.
7
8/// Maximum length for any capability identifier segment.
9pub const MAX_ID_LENGTH: usize = 64;
10
11/// Reserved characters that must not appear in capability IDs.
12/// Currently `:` (used as namespace separator).
13pub const RESERVED_CHARS: &[char] = &[':'];
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum IdValidationError {
17    Empty,
18    TooLong { len: usize, max: usize },
19    ContainsReserved { ch: char },
20    ContainsWhitespace,
21    ContainsControl { ch: char },
22}
23
24impl std::fmt::Display for IdValidationError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::Empty => write!(f, "must not be empty"),
28            Self::TooLong { len, max } => write!(f, "must be at most {max} chars (got {len})"),
29            Self::ContainsReserved { ch } => {
30                write!(f, "must not contain reserved character '{}'", ch)
31            }
32            Self::ContainsWhitespace => write!(f, "must not contain whitespace"),
33            Self::ContainsControl { ch } => {
34                write!(f, "must not contain control character U+{:04X}", *ch as u32)
35            }
36        }
37    }
38}
39
40impl std::error::Error for IdValidationError {}
41
42/// Validate a capability ID segment. Used for tool names, provider IDs, model IDs.
43pub fn validate_id_segment(id: &str) -> Result<(), IdValidationError> {
44    if id.is_empty() {
45        return Err(IdValidationError::Empty);
46    }
47    if id.len() > MAX_ID_LENGTH {
48        return Err(IdValidationError::TooLong {
49            len: id.len(),
50            max: MAX_ID_LENGTH,
51        });
52    }
53    if let Some(ch) = id.chars().find(|c| c.is_control() && !c.is_whitespace()) {
54        return Err(IdValidationError::ContainsControl { ch });
55    }
56    if let Some(ch) = id.chars().find(|c| RESERVED_CHARS.contains(c)) {
57        return Err(IdValidationError::ContainsReserved { ch });
58    }
59    if id.chars().any(|c| c.is_whitespace()) {
60        return Err(IdValidationError::ContainsWhitespace);
61    }
62    Ok(())
63}
64
65/// Strip control characters from a display string (tool descriptions, help text, etc.).
66/// Preserves newlines and tabs but removes ESC, BEL, and other C0/C1 control codes
67/// that could inject ANSI escape sequences into the TUI.
68pub fn sanitize_display_string(s: &str) -> String {
69    s.chars()
70        .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
71        .collect()
72}
73
74/// Build a context-prefixed error message for validation failures.
75///
76/// Example: `validation_error("provider", "my:provider", err)` →
77/// `"invalid provider 'my:provider': must not contain reserved character ':'"`.
78pub fn validation_error(kind: &str, id: &str, err: IdValidationError) -> String {
79    format!("invalid {} '{}': {}", kind, id, err)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn empty_id_is_rejected() {
88        assert_eq!(validate_id_segment(""), Err(IdValidationError::Empty));
89    }
90
91    #[test]
92    fn single_char_id_is_accepted() {
93        assert!(validate_id_segment("a").is_ok());
94    }
95
96    #[test]
97    fn reasonable_id_is_accepted() {
98        assert!(validate_id_segment("foo-bar_baz.123").is_ok());
99    }
100
101    #[test]
102    fn over_max_length_is_rejected() {
103        let id = "a".repeat(MAX_ID_LENGTH + 1);
104        assert_eq!(
105            validate_id_segment(&id),
106            Err(IdValidationError::TooLong {
107                len: MAX_ID_LENGTH + 1,
108                max: MAX_ID_LENGTH,
109            })
110        );
111    }
112
113    #[test]
114    fn at_max_length_is_accepted() {
115        let id = "a".repeat(MAX_ID_LENGTH);
116        assert!(validate_id_segment(&id).is_ok());
117    }
118
119    #[test]
120    fn reserved_colon_is_rejected() {
121        assert_eq!(
122            validate_id_segment("foo:bar"),
123            Err(IdValidationError::ContainsReserved { ch: ':' })
124        );
125    }
126
127    #[test]
128    fn space_is_rejected() {
129        assert_eq!(
130            validate_id_segment("foo bar"),
131            Err(IdValidationError::ContainsWhitespace)
132        );
133    }
134
135    #[test]
136    fn tab_is_rejected() {
137        assert_eq!(
138            validate_id_segment("foo\tbar"),
139            Err(IdValidationError::ContainsWhitespace)
140        );
141    }
142
143    #[test]
144    fn validation_error_formats_context_and_cause() {
145        let msg = validation_error(
146            "tool",
147            "x:y",
148            IdValidationError::ContainsReserved { ch: ':' },
149        );
150        assert!(msg.contains("invalid tool 'x:y'"), "msg={msg}");
151        assert!(msg.contains("':'"), "msg={msg}");
152    }
153
154    #[test]
155    fn empty_error_displays_human_readable() {
156        let msg = format!("{}", IdValidationError::Empty);
157        assert_eq!(msg, "must not be empty");
158    }
159
160    #[test]
161    fn too_long_error_displays_lengths() {
162        let msg = format!("{}", IdValidationError::TooLong { len: 65, max: 64 });
163        assert!(msg.contains("65"));
164        assert!(msg.contains("64"));
165    }
166
167    #[test]
168    fn rejects_control_characters() {
169        assert_eq!(
170            validate_id_segment("foo\x1Bbar"),
171            Err(IdValidationError::ContainsControl { ch: '\x1B' })
172        );
173        assert_eq!(
174            validate_id_segment("foo\x07bar"),
175            Err(IdValidationError::ContainsControl { ch: '\x07' })
176        );
177    }
178
179    #[test]
180    fn sanitize_display_string_strips_controls() {
181        assert_eq!(sanitize_display_string("hello\x1B[31mworld"), "hello[31mworld");
182        assert_eq!(sanitize_display_string("ok\x07bell"), "okbell");
183        // Preserves newlines and tabs
184        assert_eq!(sanitize_display_string("a\nb\tc"), "a\nb\tc");
185    }
186}