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_bytes_below_space_except_newline_and_tab() {
49        for input in [
50            "test\x07ref",
51            "\x1b[31mred",
52            "main\rinjected",
53            "abc\x0cdef",
54            "abc\x08def",
55        ] {
56            assert!(
57                validate_no_control_chars(input, "--arg").is_err(),
58                "{input:?} must be rejected"
59            );
60        }
61    }
62
63    #[test]
64    fn control_chars_allows_printable_text_newline_and_tab() {
65        for input in [
66            "main",
67            "line1\nline2",
68            "col1\tcol2",
69            "",
70            "my-package-日本語",
71            "./path/to/config.toml",
72            "hello world",
73        ] {
74            assert_eq!(
75                validate_no_control_chars(input, "--arg"),
76                Ok(()),
77                "{input:?} must be accepted"
78            );
79        }
80    }
81
82    #[test]
83    fn git_ref_rejects_shell_metacharacters_and_option_like_refs() {
84        for input in [
85            "main;rm -rf /",
86            "main`whoami`",
87            "main$HOME",
88            "main|cat /etc/passwd",
89            "main&&echo pwned",
90            "$(whoami)",
91            "--upload-pack=evil",
92            "-flag",
93        ] {
94            assert!(
95                validate_git_ref(input).is_err(),
96                "{input:?} must be rejected"
97            );
98        }
99    }
100
101    #[test]
102    fn git_ref_allows_ref_syntax_and_reflog_selectors() {
103        for input in [
104            "main",
105            "feature/my-branch",
106            "HEAD~3",
107            "HEAD^2",
108            "abc123def456",
109            "v1.2.3",
110            "feature_branch",
111            "HEAD@{0}~3",
112            "origin/main@{0}",
113            "HEAD@{2025-01-01}",
114            "HEAD@{1 week ago}",
115            "HEAD@{3 days ago}",
116        ] {
117            assert_eq!(
118                validate_git_ref(input),
119                Ok(input),
120                "{input:?} must be accepted"
121            );
122        }
123    }
124
125    #[test]
126    fn control_chars_rejects_null_byte() {
127        let result = validate_no_control_chars("main\x00branch", "--changed-since");
128        assert!(result.is_err());
129        let err = result.unwrap_err();
130        assert!(err.contains("0x00"));
131        assert!(err.contains("--changed-since"));
132    }
133
134    #[test]
135    fn git_ref_rejects_unclosed_brace() {
136        let result = validate_git_ref("HEAD@{");
137        assert!(result.is_err());
138        let err = result.unwrap_err();
139        assert!(
140            err.contains("unclosed"),
141            "Error should mention unclosed brace, got: {err}"
142        );
143    }
144
145    #[test]
146    fn git_ref_rejects_colon_outside_braces() {
147        let result = validate_git_ref("HEAD:file.txt");
148        assert!(result.is_err());
149        let err = result.unwrap_err();
150        assert!(
151            err.contains("disallowed character"),
152            "Error should mention disallowed character, got: {err}"
153        );
154        assert!(
155            err.contains(':'),
156            "Error should mention the colon, got: {err}"
157        );
158    }
159
160    #[test]
161    fn git_ref_rejects_space_outside_braces() {
162        let result = validate_git_ref("some ref");
163        assert!(result.is_err());
164        let err = result.unwrap_err();
165        assert!(
166            err.contains("disallowed character"),
167            "Error should mention disallowed character, got: {err}"
168        );
169    }
170
171    #[test]
172    fn git_ref_rejects_empty() {
173        let result = validate_git_ref("");
174        assert!(result.is_err());
175        assert!(result.unwrap_err().contains("empty"));
176    }
177
178    #[test]
179    fn git_ref_rejects_leading_dash() {
180        let result = validate_git_ref("--evil-flag");
181        assert!(result.is_err());
182        assert!(result.unwrap_err().contains("start with '-'"));
183    }
184
185    #[test]
186    fn validate_root_nonexistent_path() {
187        let result = validate_root(std::path::Path::new(
188            "/nonexistent/path/that/does/not/exist",
189        ));
190        assert!(result.is_err());
191    }
192
193    #[test]
194    fn validate_root_valid_dir() {
195        let temp = std::env::temp_dir();
196        let result = validate_root(&temp);
197        assert!(result.is_ok());
198    }
199
200    #[test]
201    fn control_chars_error_includes_position() {
202        let result = validate_no_control_chars("ab\x01cd", "--test");
203        let err = result.unwrap_err();
204        assert!(err.contains("position 2"), "got: {err}");
205        assert!(err.contains("--test"), "got: {err}");
206    }
207}