Skip to main content

max / makenotwork

13.2 KB · 383 lines History Blame Raw
1 //! Validators for projects, labels, and git repositories.
2
3 use super::limits;
4 use crate::error::AppError;
5
6 pub fn validate_project_title(title: &str) -> Result<(), AppError> {
7 if title.is_empty() {
8 return Err(AppError::validation(
9 "Project title is required".to_string(),
10 ));
11 }
12 if title.chars().count() > limits::PROJECT_TITLE_MAX {
13 return Err(AppError::validation(format!(
14 "Project title must be {} characters or less",
15 limits::PROJECT_TITLE_MAX
16 )));
17 }
18 super::reject_control_chars("Project title", title)?;
19 Ok(())
20 }
21
22 pub fn validate_project_description(description: &str) -> Result<(), AppError> {
23 if description.chars().count() > limits::PROJECT_DESCRIPTION_MAX {
24 return Err(AppError::validation(format!(
25 "Project description must be {} characters or less",
26 limits::PROJECT_DESCRIPTION_MAX
27 )));
28 }
29 super::reject_control_chars_multiline("Project description", description)?;
30 Ok(())
31 }
32
33 /// Validate a project slug (delegates to [`validate_slug`](super::validate_slug)).
34 pub fn validate_project_slug(slug: &str) -> Result<(), AppError> {
35 super::validate_slug(slug)
36 }
37
38 pub fn validate_issue_title(title: &str) -> Result<(), AppError> {
39 if title.is_empty() {
40 return Err(AppError::validation("Issue title is required".to_string()));
41 }
42 if title.chars().count() > limits::ISSUE_TITLE_MAX {
43 return Err(AppError::validation(format!(
44 "Issue title must be {} characters or less",
45 limits::ISSUE_TITLE_MAX
46 )));
47 }
48 super::reject_control_chars("Issue title", title)?;
49 Ok(())
50 }
51
52 /// Validate an issue body (markdown)
53 pub fn validate_issue_body(body: &str) -> Result<(), AppError> {
54 if body.chars().count() > limits::ISSUE_BODY_MAX {
55 return Err(AppError::validation(format!(
56 "Issue body must be {} characters or less",
57 limits::ISSUE_BODY_MAX
58 )));
59 }
60 super::reject_control_chars_multiline("Issue body", body)?;
61 Ok(())
62 }
63
64 /// Validate an issue comment body (markdown)
65 pub fn validate_issue_comment_body(body: &str) -> Result<(), AppError> {
66 if body.trim().is_empty() {
67 return Err(AppError::validation("Comment body is required".to_string()));
68 }
69 if body.chars().count() > limits::ISSUE_COMMENT_BODY_MAX {
70 return Err(AppError::validation(format!(
71 "Comment must be {} characters or less",
72 limits::ISSUE_COMMENT_BODY_MAX
73 )));
74 }
75 super::reject_control_chars_multiline("Comment", body)?;
76 Ok(())
77 }
78
79 /// Validate an issue label name
80 pub fn validate_label_name(name: &str) -> Result<(), AppError> {
81 if name.trim().is_empty() {
82 return Err(AppError::validation("Label name is required".to_string()));
83 }
84 if name.chars().count() > limits::ISSUE_LABEL_NAME_MAX {
85 return Err(AppError::validation(format!(
86 "Label name must be {} characters or less",
87 limits::ISSUE_LABEL_NAME_MAX
88 )));
89 }
90 // Only allow printable characters (letters, numbers, punctuation, spaces).
91 // Rejects control characters, null bytes, and non-printable unicode.
92 if name.chars().any(char::is_control) {
93 return Err(AppError::validation(
94 "Label name cannot contain control characters".to_string(),
95 ));
96 }
97 Ok(())
98 }
99
100 /// Validate a label color (hex format: #RRGGBB)
101 pub fn validate_label_color(color: &str) -> Result<(), AppError> {
102 if color.len() != 7 || !color.starts_with('#') {
103 return Err(AppError::validation(
104 "Label color must be in #RRGGBB format".to_string(),
105 ));
106 }
107 if !color[1..].chars().all(|c| c.is_ascii_hexdigit()) {
108 return Err(AppError::validation(
109 "Label color must be a valid hex color".to_string(),
110 ));
111 }
112 Ok(())
113 }
114
115 /// Validate a git repo description (length check, trimmed input expected).
116 pub fn validate_repo_description(desc: &str) -> Result<(), AppError> {
117 if desc.chars().count() > limits::REPO_DESCRIPTION_MAX {
118 return Err(AppError::validation(format!(
119 "Repository description must be {} characters or less",
120 limits::REPO_DESCRIPTION_MAX
121 )));
122 }
123 super::reject_control_chars_multiline("Repository description", desc)?;
124 Ok(())
125 }
126
127 /// Validate a git repository name: 1-64 chars, ASCII alphanumeric + hyphens/underscores/dots, no leading dot.
128 pub fn validate_git_repo_name(name: &str) -> Result<(), AppError> {
129 if name.is_empty() || name.len() > 64 {
130 return Err(AppError::validation(
131 "Git repo name must be 1-64 characters".to_string(),
132 ));
133 }
134 if name.starts_with('.') {
135 return Err(AppError::validation(
136 "Git repo name cannot start with a dot".to_string(),
137 ));
138 }
139 if !name
140 .chars()
141 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
142 {
143 return Err(AppError::validation(
144 "Git repo name can only contain letters, numbers, hyphens, underscores, and dots"
145 .to_string(),
146 ));
147 }
148 Ok(())
149 }
150
151 #[cfg(test)]
152 mod tests {
153 use super::*;
154
155 #[test]
156 fn test_validate_project_slug_valid() {
157 assert!(validate_project_slug("my-project").is_ok());
158 assert!(validate_project_slug("ab").is_ok());
159 assert!(validate_project_slug("project123").is_ok());
160 }
161
162 #[test]
163 fn test_validate_project_slug_invalid() {
164 assert!(validate_project_slug("a").is_err()); // too short
165 assert!(validate_project_slug("my_project").is_err()); // underscores
166 assert!(validate_project_slug("my project").is_err()); // spaces
167 assert!(validate_project_slug(&"a".repeat(101)).is_err()); // too long
168 }
169
170 #[test]
171 fn test_validate_project_title() {
172 assert!(validate_project_title("My Project").is_ok());
173 assert!(validate_project_title("").is_err()); // empty
174 assert!(validate_project_title(&"a".repeat(201)).is_err()); // too long
175 assert!(validate_project_title("Proj\r\nInjection").is_err()); // no line breaks
176 }
177
178 #[test]
179 fn test_validate_project_description() {
180 assert!(validate_project_description("A cool project").is_ok());
181 assert!(validate_project_description("").is_ok()); // empty is valid
182 assert!(validate_project_description(&"a".repeat(2001)).is_err()); // too long
183 }
184
185 #[test]
186 fn test_validate_issue_title() {
187 assert!(validate_issue_title("Bug report").is_ok());
188 assert!(validate_issue_title("").is_err()); // empty
189 assert!(validate_issue_title(&"a".repeat(200)).is_ok()); // at limit
190 assert!(validate_issue_title(&"a".repeat(201)).is_err()); // over limit
191 }
192
193 #[test]
194 fn test_validate_issue_body() {
195 assert!(validate_issue_body("Detailed description").is_ok());
196 assert!(validate_issue_body("").is_ok()); // empty is valid
197 assert!(validate_issue_body(&"a".repeat(50_000)).is_ok()); // at limit
198 assert!(validate_issue_body(&"a".repeat(50_001)).is_err()); // over limit
199 }
200
201 #[test]
202 fn test_validate_issue_comment_body() {
203 assert!(validate_issue_comment_body("Good point").is_ok());
204 assert!(validate_issue_comment_body("").is_err()); // empty
205 assert!(validate_issue_comment_body(&"a".repeat(50_000)).is_ok());
206 assert!(validate_issue_comment_body(&"a".repeat(50_001)).is_err());
207 }
208
209 #[test]
210 fn test_validate_label_name() {
211 assert!(validate_label_name("bug").is_ok());
212 assert!(validate_label_name("").is_err());
213 assert!(validate_label_name(&"a".repeat(50)).is_ok());
214 assert!(validate_label_name(&"a".repeat(51)).is_err());
215 }
216
217 #[test]
218 fn test_validate_label_color() {
219 assert!(validate_label_color("#ff0000").is_ok());
220 assert!(validate_label_color("#6c5ce7").is_ok());
221 assert!(validate_label_color("#AABBCC").is_ok());
222 assert!(validate_label_color("ff0000").is_err()); // no #
223 assert!(validate_label_color("#fff").is_err()); // too short
224 assert!(validate_label_color("#gggggg").is_err()); // invalid hex
225 assert!(validate_label_color("#12345678").is_err()); // too long
226 }
227
228 #[test]
229 fn test_validate_repo_description() {
230 assert!(validate_repo_description("").is_ok());
231 assert!(validate_repo_description("A short description").is_ok());
232 assert!(validate_repo_description(&"a".repeat(500)).is_ok());
233 assert!(validate_repo_description(&"a".repeat(501)).is_err());
234 }
235
236 #[test]
237 fn test_validate_git_repo_name() {
238 // Valid names
239 assert!(validate_git_repo_name("my-repo").is_ok());
240 assert!(validate_git_repo_name("my_repo").is_ok());
241 assert!(validate_git_repo_name("MyRepo123").is_ok());
242 assert!(validate_git_repo_name("repo.name").is_ok());
243 assert!(validate_git_repo_name("a").is_ok()); // single char
244 assert!(validate_git_repo_name(&"a".repeat(64)).is_ok()); // at limit
245
246 // Invalid: empty
247 assert!(validate_git_repo_name("").is_err());
248 // Invalid: too long
249 assert!(validate_git_repo_name(&"a".repeat(65)).is_err());
250 // Invalid: leading dot
251 assert!(validate_git_repo_name(".hidden").is_err());
252 // Invalid: spaces
253 assert!(validate_git_repo_name("my repo").is_err());
254 // Invalid: slashes
255 assert!(validate_git_repo_name("foo/bar").is_err());
256 // Invalid: special chars
257 assert!(validate_git_repo_name("repo@name").is_err());
258 assert!(validate_git_repo_name("repo!").is_err());
259 }
260
261 // ── Edge cases (test-fuzz) ──
262
263 #[test]
264 fn test_git_repo_name_path_traversal() {
265 // ".." could be dangerous for path traversal, but it starts with "."
266 assert!(validate_git_repo_name("..").is_err());
267 // "a.." is valid (doesn't start with dot)
268 assert!(validate_git_repo_name("a..").is_ok());
269 }
270
271 #[test]
272 fn test_git_repo_name_dot_git() {
273 assert!(validate_git_repo_name(".git").is_err()); // starts with dot
274 assert!(validate_git_repo_name("repo.git").is_ok()); // doesn't start with dot
275 }
276
277 #[test]
278 fn test_label_color_with_lowercase() {
279 assert!(validate_label_color("#aabbcc").is_ok());
280 }
281
282 #[test]
283 fn test_label_color_empty() {
284 assert!(validate_label_color("").is_err());
285 }
286
287 #[test]
288 fn test_label_color_just_hash() {
289 assert!(validate_label_color("#").is_err());
290 }
291
292 #[test]
293 fn test_validate_issue_body_empty_is_valid() {
294 // Issue body can be empty (unlike comment body)
295 assert!(validate_issue_body("").is_ok());
296 }
297
298 #[test]
299 fn test_validate_issue_comment_body_whitespace_only() {
300 // Whitespace-only comment is rejected (trim before empty check)
301 assert!(validate_issue_comment_body(" ").is_err());
302 }
303
304 #[test]
305 fn test_project_slug_exactly_two_chars() {
306 assert!(validate_project_slug("ab").is_ok());
307 }
308
309 #[test]
310 fn test_project_slug_one_char() {
311 assert!(validate_project_slug("a").is_err());
312 }
313
314 // ── Adversarial tests (test-fuzz) ──
315
316 #[test]
317 fn test_git_repo_name_null_bytes() {
318 assert!(validate_git_repo_name("repo\0name").is_err());
319 }
320
321 #[test]
322 fn test_git_repo_name_unicode() {
323 assert!(validate_git_repo_name("r\u{00e9}po").is_err());
324 }
325
326 #[test]
327 fn test_git_repo_name_path_separators() {
328 assert!(validate_git_repo_name("foo/bar").is_err());
329 assert!(validate_git_repo_name("foo\\bar").is_err());
330 }
331
332 #[test]
333 fn test_git_repo_name_ends_with_dot() {
334 // Ending with dot is valid (only leading dot is rejected)
335 assert!(validate_git_repo_name("repo.").is_ok());
336 }
337
338 #[test]
339 fn test_git_repo_name_all_dots() {
340 assert!(validate_git_repo_name(".").is_err()); // leading dot
341 assert!(validate_git_repo_name("..").is_err()); // leading dot
342 assert!(validate_git_repo_name("...").is_err()); // leading dot
343 }
344
345 #[test]
346 fn test_label_color_unicode_hex_digits() {
347 // Full-width digits shouldn't be accepted
348 assert!(validate_label_color("#\u{FF10}\u{FF11}\u{FF12}\u{FF13}\u{FF14}\u{FF15}").is_err());
349 }
350
351 #[test]
352 fn test_issue_comment_body_single_char() {
353 assert!(validate_issue_comment_body("x").is_ok());
354 }
355
356 #[test]
357 fn test_label_name_with_special_chars() {
358 // Label names have no character restrictions beyond length
359 assert!(validate_label_name("bug \u{1F41B}").is_ok());
360 assert!(validate_label_name("<script>").is_ok());
361 }
362
363 // ── Property-based tests (test-fuzz) ──
364
365 proptest::proptest! {
366 #[test]
367 fn prop_git_repo_name_valid_always_accepted(s in "[a-zA-Z][a-zA-Z0-9._\\-]{0,63}") {
368 proptest::prop_assert!(validate_git_repo_name(&s).is_ok(), "Valid repo name rejected: {:?}", s);
369 }
370
371 #[test]
372 fn prop_label_color_valid_always_accepted(hex in "[0-9a-fA-F]{6}") {
373 let color = format!("#{hex}");
374 proptest::prop_assert!(validate_label_color(&color).is_ok(), "Valid color rejected: {:?}", color);
375 }
376
377 #[test]
378 fn prop_issue_title_never_panics(s in "\\PC{0,300}") {
379 let _ = validate_issue_title(&s);
380 }
381 }
382 }
383