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_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}