Skip to main content

fallow_engine/
validate.rs

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