Skip to main content

fallow_engine/
validate.rs

1use std::path::PathBuf;
2
3pub fn validate_git_ref(s: &str) -> Result<&str, String> {
4    crate::changed_files::validate_git_ref(s)
5}
6
7pub fn validate_root(root: &std::path::Path) -> Result<PathBuf, String> {
8    let canonical = dunce::canonicalize(root)
9        .map_err(|e| format!("invalid root path '{}': {e}", root.display()))?;
10    if !canonical.is_dir() {
11        return Err(format!("root path '{}' is not a directory", root.display()));
12    }
13    Ok(canonical)
14}
15
16/// Reject strings containing control characters (bytes < 0x20) except
17/// newline (0x0A) and tab (0x09). This prevents agents from accidentally
18/// passing invisible characters in CLI arguments.
19pub fn validate_no_control_chars(s: &str, arg_name: &str) -> Result<(), String> {
20    for (i, byte) in s.bytes().enumerate() {
21        if byte < 0x20 && byte != b'\n' && byte != b'\t' {
22            return Err(format!(
23                "{arg_name} contains control character (byte 0x{byte:02x}) at position {i}"
24            ));
25        }
26    }
27    Ok(())
28}
29
30#[cfg(test)]
31mod tests {
32    use super::*;
33
34    #[test]
35    fn control_chars_rejects_null_byte() {
36        let result = validate_no_control_chars("main\x00branch", "--changed-since");
37        assert!(result.is_err());
38        let err = result.unwrap_err();
39        assert!(err.contains("0x00"));
40        assert!(err.contains("--changed-since"));
41    }
42
43    #[test]
44    fn control_chars_rejects_bell() {
45        assert!(validate_no_control_chars("test\x07ref", "--workspace").is_err());
46    }
47
48    #[test]
49    fn control_chars_rejects_escape() {
50        assert!(validate_no_control_chars("\x1b[31mred", "--config").is_err());
51    }
52
53    #[test]
54    fn control_chars_rejects_carriage_return() {
55        assert!(validate_no_control_chars("main\rinjected", "--changed-since").is_err());
56    }
57
58    #[test]
59    fn control_chars_allows_normal_text() {
60        assert!(validate_no_control_chars("main", "--changed-since").is_ok());
61    }
62
63    #[test]
64    fn control_chars_allows_newline() {
65        assert!(validate_no_control_chars("line1\nline2", "--config").is_ok());
66    }
67
68    #[test]
69    fn control_chars_allows_tab() {
70        assert!(validate_no_control_chars("col1\tcol2", "--config").is_ok());
71    }
72
73    #[test]
74    fn control_chars_allows_empty_string() {
75        assert!(validate_no_control_chars("", "--workspace").is_ok());
76    }
77
78    #[test]
79    fn control_chars_allows_unicode() {
80        assert!(validate_no_control_chars("my-package-日本語", "--workspace").is_ok());
81    }
82
83    #[test]
84    fn control_chars_allows_paths_with_dots_and_slashes() {
85        assert!(validate_no_control_chars("./path/to/config.toml", "--config").is_ok());
86    }
87
88    #[test]
89    fn git_ref_allows_reflog_timestamp() {
90        assert_eq!(
91            validate_git_ref("HEAD@{2025-01-01}").unwrap(),
92            "HEAD@{2025-01-01}"
93        );
94    }
95
96    #[test]
97    fn git_ref_allows_reflog_relative_date() {
98        assert_eq!(
99            validate_git_ref("HEAD@{1 week ago}").unwrap(),
100            "HEAD@{1 week ago}"
101        );
102    }
103
104    #[test]
105    fn git_ref_rejects_unclosed_brace() {
106        let result = validate_git_ref("HEAD@{");
107        assert!(result.is_err());
108        let err = result.unwrap_err();
109        assert!(
110            err.contains("unclosed"),
111            "Error should mention unclosed brace, got: {err}"
112        );
113    }
114
115    #[test]
116    fn git_ref_rejects_colon_outside_braces() {
117        let result = validate_git_ref("HEAD:file.txt");
118        assert!(result.is_err());
119        let err = result.unwrap_err();
120        assert!(
121            err.contains("disallowed character"),
122            "Error should mention disallowed character, got: {err}"
123        );
124        assert!(
125            err.contains(':'),
126            "Error should mention the colon, got: {err}"
127        );
128    }
129
130    #[test]
131    fn git_ref_rejects_space_outside_braces() {
132        let result = validate_git_ref("some ref");
133        assert!(result.is_err());
134        let err = result.unwrap_err();
135        assert!(
136            err.contains("disallowed character"),
137            "Error should mention disallowed character, got: {err}"
138        );
139    }
140
141    #[test]
142    fn git_ref_allows_reflog_index() {
143        assert_eq!(
144            validate_git_ref("origin/main@{0}").unwrap(),
145            "origin/main@{0}"
146        );
147    }
148
149    #[test]
150    fn git_ref_allows_simple_branch_names() {
151        assert_eq!(validate_git_ref("main").unwrap(), "main");
152        assert_eq!(
153            validate_git_ref("feature/my-branch").unwrap(),
154            "feature/my-branch"
155        );
156    }
157
158    #[test]
159    fn git_ref_allows_head_tilde_caret() {
160        assert_eq!(validate_git_ref("HEAD~3").unwrap(), "HEAD~3");
161        assert_eq!(validate_git_ref("HEAD^2").unwrap(), "HEAD^2");
162    }
163
164    #[test]
165    fn git_ref_allows_commit_sha() {
166        assert_eq!(validate_git_ref("abc123def456").unwrap(), "abc123def456");
167    }
168
169    #[test]
170    fn git_ref_rejects_empty() {
171        let result = validate_git_ref("");
172        assert!(result.is_err());
173        assert!(result.unwrap_err().contains("empty"));
174    }
175
176    #[test]
177    fn git_ref_rejects_leading_dash() {
178        let result = validate_git_ref("--evil-flag");
179        assert!(result.is_err());
180        assert!(result.unwrap_err().contains("start with '-'"));
181    }
182
183    #[test]
184    fn git_ref_allows_multiple_braces_segments() {
185        assert!(validate_git_ref("HEAD@{0}~3").is_ok());
186    }
187
188    #[test]
189    fn git_ref_allows_space_in_complex_reflog() {
190        assert_eq!(
191            validate_git_ref("HEAD@{3 days ago}").unwrap(),
192            "HEAD@{3 days ago}"
193        );
194    }
195
196    #[test]
197    fn git_ref_rejects_semicolon() {
198        let result = validate_git_ref("main;rm -rf /");
199        assert!(result.is_err());
200    }
201
202    #[test]
203    fn git_ref_rejects_backtick() {
204        let result = validate_git_ref("main`whoami`");
205        assert!(result.is_err());
206    }
207
208    #[test]
209    fn git_ref_rejects_dollar_sign() {
210        let result = validate_git_ref("main$HOME");
211        assert!(result.is_err());
212    }
213
214    #[test]
215    fn git_ref_rejects_pipe() {
216        let result = validate_git_ref("main|cat /etc/passwd");
217        assert!(result.is_err());
218    }
219
220    #[test]
221    fn git_ref_rejects_ampersand() {
222        let result = validate_git_ref("main&&echo pwned");
223        assert!(result.is_err());
224    }
225
226    #[test]
227    fn git_ref_rejects_parentheses() {
228        let result = validate_git_ref("$(whoami)");
229        assert!(result.is_err());
230    }
231
232    #[test]
233    fn git_ref_allows_dots_in_branch() {
234        assert_eq!(validate_git_ref("v1.2.3").unwrap(), "v1.2.3");
235    }
236
237    #[test]
238    fn git_ref_allows_underscores() {
239        assert_eq!(
240            validate_git_ref("feature_branch").unwrap(),
241            "feature_branch"
242        );
243    }
244
245    #[test]
246    fn validate_root_nonexistent_path() {
247        let result = validate_root(std::path::Path::new(
248            "/nonexistent/path/that/does/not/exist",
249        ));
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn validate_root_valid_dir() {
255        let temp = std::env::temp_dir();
256        let result = validate_root(&temp);
257        assert!(result.is_ok());
258    }
259
260    #[test]
261    fn control_chars_rejects_form_feed() {
262        assert!(validate_no_control_chars("abc\x0cdef", "--arg").is_err());
263    }
264
265    #[test]
266    fn control_chars_rejects_backspace() {
267        assert!(validate_no_control_chars("abc\x08def", "--arg").is_err());
268    }
269
270    #[test]
271    fn control_chars_allows_space() {
272        assert!(validate_no_control_chars("hello world", "--arg").is_ok());
273    }
274
275    #[test]
276    fn control_chars_error_includes_position() {
277        let result = validate_no_control_chars("ab\x01cd", "--test");
278        let err = result.unwrap_err();
279        assert!(err.contains("position 2"), "got: {err}");
280        assert!(err.contains("--test"), "got: {err}");
281    }
282}