fallow_engine/
validate.rs1use std::path::PathBuf;
2
3pub fn validate_git_ref(s: &str) -> Result<&str, String> {
11 crate::changed_files::validate_git_ref(s)
12}
13
14pub 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
29pub 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}