Skip to main content

max / makenotwork

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