fallow_engine/
validate.rs1use 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
16pub 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}