Skip to main content

max / makenotwork

18.0 KB · 495 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 /// Namespaces MNW writes itself and nobody else may.
152 ///
153 /// `refs/notes/mnw/*` is where build results, issue links and scan verdicts go
154 /// once P6 mirrors them out of Postgres. Reserving it before anyone can write
155 /// there is the cheap order: taking a prefix back after repositories carry
156 /// user notes under it means deciding what happens to those notes.
157 pub(crate) const RESERVED_NOTE_NAMESPACE: &str = "mnw";
158
159 /// Validate a git notes namespace: the part after `refs/notes/`.
160 ///
161 /// It becomes a ref path, so it has to survive git's own rules — and it is
162 /// user input that gets concatenated into a ref name, so anything clever with
163 /// dots, slashes or control characters is rejected rather than normalized.
164 pub fn validate_note_namespace(namespace: &str) -> Result<(), AppError> {
165 if namespace.is_empty() || namespace.len() > limits::NOTE_NAMESPACE_MAX {
166 return Err(AppError::validation(format!(
167 "Notes namespace must be 1-{} characters",
168 limits::NOTE_NAMESPACE_MAX
169 )));
170 }
171 if !namespace
172 .chars()
173 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
174 {
175 return Err(AppError::validation(
176 "Notes namespace can only contain letters, numbers, hyphens, underscores, dots and slashes".to_string(),
177 ));
178 }
179
180 // Git's ref rules, the subset the character set above leaves reachable.
181 for component in namespace.split('/') {
182 if component.is_empty() {
183 return Err(AppError::validation(
184 "Notes namespace cannot have an empty path component".to_string(),
185 ));
186 }
187 // Compared as bytes: git's `.lock` rule is a literal, case-sensitive
188 // suffix on the ref name rather than a file extension, and the string
189 // form reads to clippy as the latter.
190 let lock_suffix = component.as_bytes().ends_with(b".lock");
191 if component.starts_with('.') || lock_suffix || component.contains("..") {
192 return Err(AppError::validation(
193 "Notes namespace components cannot start with a dot, contain '..', or end in '.lock'"
194 .to_string(),
195 ));
196 }
197 }
198
199 let reserved = namespace == RESERVED_NOTE_NAMESPACE
200 || namespace.starts_with(&format!("{RESERVED_NOTE_NAMESPACE}/"));
201 if reserved {
202 return Err(AppError::validation(format!(
203 "The {RESERVED_NOTE_NAMESPACE} namespace is written by makenot.work and cannot be edited here"
204 )));
205 }
206 Ok(())
207 }
208
209 /// Validate the body of a git note.
210 pub fn validate_note_content(content: &str) -> Result<(), AppError> {
211 if content.trim().is_empty() {
212 return Err(AppError::validation(
213 "A note cannot be empty. Remove it instead.".to_string(),
214 ));
215 }
216 if content.chars().count() > limits::NOTE_CONTENT_MAX {
217 return Err(AppError::validation(format!(
218 "A note must be {} characters or less",
219 limits::NOTE_CONTENT_MAX
220 )));
221 }
222 super::reject_control_chars_multiline("Note", content)?;
223 Ok(())
224 }
225
226 #[cfg(test)]
227 mod tests {
228 use super::*;
229
230 #[test]
231 fn a_notes_namespace_has_to_survive_being_a_ref_path() {
232 assert!(validate_note_namespace("commits").is_ok());
233 assert!(validate_note_namespace("review/security").is_ok());
234
235 assert!(validate_note_namespace("").is_err());
236 assert!(validate_note_namespace("has space").is_err());
237 assert!(validate_note_namespace("trailing/").is_err());
238 assert!(validate_note_namespace("double//slash").is_err());
239 assert!(validate_note_namespace(".hidden").is_err());
240 assert!(validate_note_namespace("up/../out").is_err());
241 assert!(validate_note_namespace("branch.lock").is_err());
242 assert!(validate_note_namespace(&"a".repeat(65)).is_err());
243 }
244
245 #[test]
246 fn the_server_owned_namespace_is_not_writable_from_the_browser() {
247 // Reserved before anything can be written there, because taking the
248 // prefix back afterwards would mean deciding what happens to the notes
249 // already under it.
250 assert!(validate_note_namespace("mnw").is_err());
251 assert!(validate_note_namespace("mnw/builds").is_err());
252 // Not a blanket prefix match: only the namespace and what is under it.
253 assert!(validate_note_namespace("mnwish").is_ok());
254 }
255
256 #[test]
257 fn note_content_is_bounded_and_never_empty() {
258 assert!(validate_note_content("a real note").is_ok());
259 assert!(validate_note_content("").is_err());
260 assert!(validate_note_content(" \n ").is_err());
261 assert!(validate_note_content(&"a".repeat(50_001)).is_err());
262 assert!(validate_note_content("null\0byte").is_err());
263 // Newlines are the point of a note body.
264 assert!(validate_note_content("two\nlines").is_ok());
265 }
266
267 #[test]
268 fn test_validate_project_slug_valid() {
269 assert!(validate_project_slug("my-project").is_ok());
270 assert!(validate_project_slug("ab").is_ok());
271 assert!(validate_project_slug("project123").is_ok());
272 }
273
274 #[test]
275 fn test_validate_project_slug_invalid() {
276 assert!(validate_project_slug("a").is_err()); // too short
277 assert!(validate_project_slug("my_project").is_err()); // underscores
278 assert!(validate_project_slug("my project").is_err()); // spaces
279 assert!(validate_project_slug(&"a".repeat(101)).is_err()); // too long
280 }
281
282 #[test]
283 fn test_validate_project_title() {
284 assert!(validate_project_title("My Project").is_ok());
285 assert!(validate_project_title("").is_err()); // empty
286 assert!(validate_project_title(&"a".repeat(201)).is_err()); // too long
287 assert!(validate_project_title("Proj\r\nInjection").is_err()); // no line breaks
288 }
289
290 #[test]
291 fn test_validate_project_description() {
292 assert!(validate_project_description("A cool project").is_ok());
293 assert!(validate_project_description("").is_ok()); // empty is valid
294 assert!(validate_project_description(&"a".repeat(2001)).is_err()); // too long
295 }
296
297 #[test]
298 fn test_validate_issue_title() {
299 assert!(validate_issue_title("Bug report").is_ok());
300 assert!(validate_issue_title("").is_err()); // empty
301 assert!(validate_issue_title(&"a".repeat(200)).is_ok()); // at limit
302 assert!(validate_issue_title(&"a".repeat(201)).is_err()); // over limit
303 }
304
305 #[test]
306 fn test_validate_issue_body() {
307 assert!(validate_issue_body("Detailed description").is_ok());
308 assert!(validate_issue_body("").is_ok()); // empty is valid
309 assert!(validate_issue_body(&"a".repeat(50_000)).is_ok()); // at limit
310 assert!(validate_issue_body(&"a".repeat(50_001)).is_err()); // over limit
311 }
312
313 #[test]
314 fn test_validate_issue_comment_body() {
315 assert!(validate_issue_comment_body("Good point").is_ok());
316 assert!(validate_issue_comment_body("").is_err()); // empty
317 assert!(validate_issue_comment_body(&"a".repeat(50_000)).is_ok());
318 assert!(validate_issue_comment_body(&"a".repeat(50_001)).is_err());
319 }
320
321 #[test]
322 fn test_validate_label_name() {
323 assert!(validate_label_name("bug").is_ok());
324 assert!(validate_label_name("").is_err());
325 assert!(validate_label_name(&"a".repeat(50)).is_ok());
326 assert!(validate_label_name(&"a".repeat(51)).is_err());
327 }
328
329 #[test]
330 fn test_validate_label_color() {
331 assert!(validate_label_color("#ff0000").is_ok());
332 assert!(validate_label_color("#6c5ce7").is_ok());
333 assert!(validate_label_color("#AABBCC").is_ok());
334 assert!(validate_label_color("ff0000").is_err()); // no #
335 assert!(validate_label_color("#fff").is_err()); // too short
336 assert!(validate_label_color("#gggggg").is_err()); // invalid hex
337 assert!(validate_label_color("#12345678").is_err()); // too long
338 }
339
340 #[test]
341 fn test_validate_repo_description() {
342 assert!(validate_repo_description("").is_ok());
343 assert!(validate_repo_description("A short description").is_ok());
344 assert!(validate_repo_description(&"a".repeat(500)).is_ok());
345 assert!(validate_repo_description(&"a".repeat(501)).is_err());
346 }
347
348 #[test]
349 fn test_validate_git_repo_name() {
350 // Valid names
351 assert!(validate_git_repo_name("my-repo").is_ok());
352 assert!(validate_git_repo_name("my_repo").is_ok());
353 assert!(validate_git_repo_name("MyRepo123").is_ok());
354 assert!(validate_git_repo_name("repo.name").is_ok());
355 assert!(validate_git_repo_name("a").is_ok()); // single char
356 assert!(validate_git_repo_name(&"a".repeat(64)).is_ok()); // at limit
357
358 // Invalid: empty
359 assert!(validate_git_repo_name("").is_err());
360 // Invalid: too long
361 assert!(validate_git_repo_name(&"a".repeat(65)).is_err());
362 // Invalid: leading dot
363 assert!(validate_git_repo_name(".hidden").is_err());
364 // Invalid: spaces
365 assert!(validate_git_repo_name("my repo").is_err());
366 // Invalid: slashes
367 assert!(validate_git_repo_name("foo/bar").is_err());
368 // Invalid: special chars
369 assert!(validate_git_repo_name("repo@name").is_err());
370 assert!(validate_git_repo_name("repo!").is_err());
371 }
372
373 // ── Edge cases (test-fuzz) ──
374
375 #[test]
376 fn test_git_repo_name_path_traversal() {
377 // ".." could be dangerous for path traversal, but it starts with "."
378 assert!(validate_git_repo_name("..").is_err());
379 // "a.." is valid (doesn't start with dot)
380 assert!(validate_git_repo_name("a..").is_ok());
381 }
382
383 #[test]
384 fn test_git_repo_name_dot_git() {
385 assert!(validate_git_repo_name(".git").is_err()); // starts with dot
386 assert!(validate_git_repo_name("repo.git").is_ok()); // doesn't start with dot
387 }
388
389 #[test]
390 fn test_label_color_with_lowercase() {
391 assert!(validate_label_color("#aabbcc").is_ok());
392 }
393
394 #[test]
395 fn test_label_color_empty() {
396 assert!(validate_label_color("").is_err());
397 }
398
399 #[test]
400 fn test_label_color_just_hash() {
401 assert!(validate_label_color("#").is_err());
402 }
403
404 #[test]
405 fn test_validate_issue_body_empty_is_valid() {
406 // Issue body can be empty (unlike comment body)
407 assert!(validate_issue_body("").is_ok());
408 }
409
410 #[test]
411 fn test_validate_issue_comment_body_whitespace_only() {
412 // Whitespace-only comment is rejected (trim before empty check)
413 assert!(validate_issue_comment_body(" ").is_err());
414 }
415
416 #[test]
417 fn test_project_slug_exactly_two_chars() {
418 assert!(validate_project_slug("ab").is_ok());
419 }
420
421 #[test]
422 fn test_project_slug_one_char() {
423 assert!(validate_project_slug("a").is_err());
424 }
425
426 // ── Adversarial tests (test-fuzz) ──
427
428 #[test]
429 fn test_git_repo_name_null_bytes() {
430 assert!(validate_git_repo_name("repo\0name").is_err());
431 }
432
433 #[test]
434 fn test_git_repo_name_unicode() {
435 assert!(validate_git_repo_name("r\u{00e9}po").is_err());
436 }
437
438 #[test]
439 fn test_git_repo_name_path_separators() {
440 assert!(validate_git_repo_name("foo/bar").is_err());
441 assert!(validate_git_repo_name("foo\\bar").is_err());
442 }
443
444 #[test]
445 fn test_git_repo_name_ends_with_dot() {
446 // Ending with dot is valid (only leading dot is rejected)
447 assert!(validate_git_repo_name("repo.").is_ok());
448 }
449
450 #[test]
451 fn test_git_repo_name_all_dots() {
452 assert!(validate_git_repo_name(".").is_err()); // leading dot
453 assert!(validate_git_repo_name("..").is_err()); // leading dot
454 assert!(validate_git_repo_name("...").is_err()); // leading dot
455 }
456
457 #[test]
458 fn test_label_color_unicode_hex_digits() {
459 // Full-width digits shouldn't be accepted
460 assert!(validate_label_color("#\u{FF10}\u{FF11}\u{FF12}\u{FF13}\u{FF14}\u{FF15}").is_err());
461 }
462
463 #[test]
464 fn test_issue_comment_body_single_char() {
465 assert!(validate_issue_comment_body("x").is_ok());
466 }
467
468 #[test]
469 fn test_label_name_with_special_chars() {
470 // Label names have no character restrictions beyond length
471 assert!(validate_label_name("bug \u{1F41B}").is_ok());
472 assert!(validate_label_name("<script>").is_ok());
473 }
474
475 // ── Property-based tests (test-fuzz) ──
476
477 proptest::proptest! {
478 #[test]
479 fn prop_git_repo_name_valid_always_accepted(s in "[a-zA-Z][a-zA-Z0-9._\\-]{0,63}") {
480 proptest::prop_assert!(validate_git_repo_name(&s).is_ok(), "Valid repo name rejected: {:?}", s);
481 }
482
483 #[test]
484 fn prop_label_color_valid_always_accepted(hex in "[0-9a-fA-F]{6}") {
485 let color = format!("#{hex}");
486 proptest::prop_assert!(validate_label_color(&color).is_ok(), "Valid color rejected: {:?}", color);
487 }
488
489 #[test]
490 fn prop_issue_title_never_panics(s in "\\PC{0,300}") {
491 let _ = validate_issue_title(&s);
492 }
493 }
494 }
495