//! Validators for projects, labels, and git repositories. use super::limits; use crate::error::AppError; pub fn validate_project_title(title: &str) -> Result<(), AppError> { if title.is_empty() { return Err(AppError::validation( "Project title is required".to_string(), )); } if title.chars().count() > limits::PROJECT_TITLE_MAX { return Err(AppError::validation(format!( "Project title must be {} characters or less", limits::PROJECT_TITLE_MAX ))); } super::reject_control_chars("Project title", title)?; Ok(()) } pub fn validate_project_description(description: &str) -> Result<(), AppError> { if description.chars().count() > limits::PROJECT_DESCRIPTION_MAX { return Err(AppError::validation(format!( "Project description must be {} characters or less", limits::PROJECT_DESCRIPTION_MAX ))); } super::reject_control_chars_multiline("Project description", description)?; Ok(()) } /// Validate a project slug (delegates to [`validate_slug`](super::validate_slug)). pub fn validate_project_slug(slug: &str) -> Result<(), AppError> { super::validate_slug(slug) } pub fn validate_issue_title(title: &str) -> Result<(), AppError> { if title.is_empty() { return Err(AppError::validation("Issue title is required".to_string())); } if title.chars().count() > limits::ISSUE_TITLE_MAX { return Err(AppError::validation(format!( "Issue title must be {} characters or less", limits::ISSUE_TITLE_MAX ))); } super::reject_control_chars("Issue title", title)?; Ok(()) } /// Validate an issue body (markdown) pub fn validate_issue_body(body: &str) -> Result<(), AppError> { if body.chars().count() > limits::ISSUE_BODY_MAX { return Err(AppError::validation(format!( "Issue body must be {} characters or less", limits::ISSUE_BODY_MAX ))); } super::reject_control_chars_multiline("Issue body", body)?; Ok(()) } /// Validate an issue comment body (markdown) pub fn validate_issue_comment_body(body: &str) -> Result<(), AppError> { if body.trim().is_empty() { return Err(AppError::validation("Comment body is required".to_string())); } if body.chars().count() > limits::ISSUE_COMMENT_BODY_MAX { return Err(AppError::validation(format!( "Comment must be {} characters or less", limits::ISSUE_COMMENT_BODY_MAX ))); } super::reject_control_chars_multiline("Comment", body)?; Ok(()) } /// Validate an issue label name pub fn validate_label_name(name: &str) -> Result<(), AppError> { if name.trim().is_empty() { return Err(AppError::validation("Label name is required".to_string())); } if name.chars().count() > limits::ISSUE_LABEL_NAME_MAX { return Err(AppError::validation(format!( "Label name must be {} characters or less", limits::ISSUE_LABEL_NAME_MAX ))); } // Only allow printable characters (letters, numbers, punctuation, spaces). // Rejects control characters, null bytes, and non-printable unicode. if name.chars().any(char::is_control) { return Err(AppError::validation( "Label name cannot contain control characters".to_string(), )); } Ok(()) } /// Validate a label color (hex format: #RRGGBB) pub fn validate_label_color(color: &str) -> Result<(), AppError> { if color.len() != 7 || !color.starts_with('#') { return Err(AppError::validation( "Label color must be in #RRGGBB format".to_string(), )); } if !color[1..].chars().all(|c| c.is_ascii_hexdigit()) { return Err(AppError::validation( "Label color must be a valid hex color".to_string(), )); } Ok(()) } /// Validate a git repo description (length check, trimmed input expected). pub fn validate_repo_description(desc: &str) -> Result<(), AppError> { if desc.chars().count() > limits::REPO_DESCRIPTION_MAX { return Err(AppError::validation(format!( "Repository description must be {} characters or less", limits::REPO_DESCRIPTION_MAX ))); } super::reject_control_chars_multiline("Repository description", desc)?; Ok(()) } /// Validate a git repository name: 1-64 chars, ASCII alphanumeric + hyphens/underscores/dots, no leading dot. pub fn validate_git_repo_name(name: &str) -> Result<(), AppError> { if name.is_empty() || name.len() > 64 { return Err(AppError::validation( "Git repo name must be 1-64 characters".to_string(), )); } if name.starts_with('.') { return Err(AppError::validation( "Git repo name cannot start with a dot".to_string(), )); } if !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { return Err(AppError::validation( "Git repo name can only contain letters, numbers, hyphens, underscores, and dots" .to_string(), )); } Ok(()) } /// Namespaces MNW writes itself and nobody else may. /// /// `refs/notes/mnw/*` is where build results, issue links and scan verdicts go /// once P6 mirrors them out of Postgres. Reserving it before anyone can write /// there is the cheap order: taking a prefix back after repositories carry /// user notes under it means deciding what happens to those notes. pub(crate) const RESERVED_NOTE_NAMESPACE: &str = "mnw"; /// Validate a git notes namespace: the part after `refs/notes/`. /// /// It becomes a ref path, so it has to survive git's own rules — and it is /// user input that gets concatenated into a ref name, so anything clever with /// dots, slashes or control characters is rejected rather than normalized. pub fn validate_note_namespace(namespace: &str) -> Result<(), AppError> { if namespace.is_empty() || namespace.len() > limits::NOTE_NAMESPACE_MAX { return Err(AppError::validation(format!( "Notes namespace must be 1-{} characters", limits::NOTE_NAMESPACE_MAX ))); } if !namespace .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/') { return Err(AppError::validation( "Notes namespace can only contain letters, numbers, hyphens, underscores, dots and slashes".to_string(), )); } // Git's ref rules, the subset the character set above leaves reachable. for component in namespace.split('/') { if component.is_empty() { return Err(AppError::validation( "Notes namespace cannot have an empty path component".to_string(), )); } // Compared as bytes: git's `.lock` rule is a literal, case-sensitive // suffix on the ref name rather than a file extension, and the string // form reads to clippy as the latter. let lock_suffix = component.as_bytes().ends_with(b".lock"); if component.starts_with('.') || lock_suffix || component.contains("..") { return Err(AppError::validation( "Notes namespace components cannot start with a dot, contain '..', or end in '.lock'" .to_string(), )); } } let reserved = namespace == RESERVED_NOTE_NAMESPACE || namespace.starts_with(&format!("{RESERVED_NOTE_NAMESPACE}/")); if reserved { return Err(AppError::validation(format!( "The {RESERVED_NOTE_NAMESPACE} namespace is written by makenot.work and cannot be edited here" ))); } Ok(()) } /// Validate the body of a git note. pub fn validate_note_content(content: &str) -> Result<(), AppError> { if content.trim().is_empty() { return Err(AppError::validation( "A note cannot be empty. Remove it instead.".to_string(), )); } if content.chars().count() > limits::NOTE_CONTENT_MAX { return Err(AppError::validation(format!( "A note must be {} characters or less", limits::NOTE_CONTENT_MAX ))); } super::reject_control_chars_multiline("Note", content)?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_notes_namespace_has_to_survive_being_a_ref_path() { assert!(validate_note_namespace("commits").is_ok()); assert!(validate_note_namespace("review/security").is_ok()); assert!(validate_note_namespace("").is_err()); assert!(validate_note_namespace("has space").is_err()); assert!(validate_note_namespace("trailing/").is_err()); assert!(validate_note_namespace("double//slash").is_err()); assert!(validate_note_namespace(".hidden").is_err()); assert!(validate_note_namespace("up/../out").is_err()); assert!(validate_note_namespace("branch.lock").is_err()); assert!(validate_note_namespace(&"a".repeat(65)).is_err()); } #[test] fn the_server_owned_namespace_is_not_writable_from_the_browser() { // Reserved before anything can be written there, because taking the // prefix back afterwards would mean deciding what happens to the notes // already under it. assert!(validate_note_namespace("mnw").is_err()); assert!(validate_note_namespace("mnw/builds").is_err()); // Not a blanket prefix match: only the namespace and what is under it. assert!(validate_note_namespace("mnwish").is_ok()); } #[test] fn note_content_is_bounded_and_never_empty() { assert!(validate_note_content("a real note").is_ok()); assert!(validate_note_content("").is_err()); assert!(validate_note_content(" \n ").is_err()); assert!(validate_note_content(&"a".repeat(50_001)).is_err()); assert!(validate_note_content("null\0byte").is_err()); // Newlines are the point of a note body. assert!(validate_note_content("two\nlines").is_ok()); } #[test] fn test_validate_project_slug_valid() { assert!(validate_project_slug("my-project").is_ok()); assert!(validate_project_slug("ab").is_ok()); assert!(validate_project_slug("project123").is_ok()); } #[test] fn test_validate_project_slug_invalid() { assert!(validate_project_slug("a").is_err()); // too short assert!(validate_project_slug("my_project").is_err()); // underscores assert!(validate_project_slug("my project").is_err()); // spaces assert!(validate_project_slug(&"a".repeat(101)).is_err()); // too long } #[test] fn test_validate_project_title() { assert!(validate_project_title("My Project").is_ok()); assert!(validate_project_title("").is_err()); // empty assert!(validate_project_title(&"a".repeat(201)).is_err()); // too long assert!(validate_project_title("Proj\r\nInjection").is_err()); // no line breaks } #[test] fn test_validate_project_description() { assert!(validate_project_description("A cool project").is_ok()); assert!(validate_project_description("").is_ok()); // empty is valid assert!(validate_project_description(&"a".repeat(2001)).is_err()); // too long } #[test] fn test_validate_issue_title() { assert!(validate_issue_title("Bug report").is_ok()); assert!(validate_issue_title("").is_err()); // empty assert!(validate_issue_title(&"a".repeat(200)).is_ok()); // at limit assert!(validate_issue_title(&"a".repeat(201)).is_err()); // over limit } #[test] fn test_validate_issue_body() { assert!(validate_issue_body("Detailed description").is_ok()); assert!(validate_issue_body("").is_ok()); // empty is valid assert!(validate_issue_body(&"a".repeat(50_000)).is_ok()); // at limit assert!(validate_issue_body(&"a".repeat(50_001)).is_err()); // over limit } #[test] fn test_validate_issue_comment_body() { assert!(validate_issue_comment_body("Good point").is_ok()); assert!(validate_issue_comment_body("").is_err()); // empty assert!(validate_issue_comment_body(&"a".repeat(50_000)).is_ok()); assert!(validate_issue_comment_body(&"a".repeat(50_001)).is_err()); } #[test] fn test_validate_label_name() { assert!(validate_label_name("bug").is_ok()); assert!(validate_label_name("").is_err()); assert!(validate_label_name(&"a".repeat(50)).is_ok()); assert!(validate_label_name(&"a".repeat(51)).is_err()); } #[test] fn test_validate_label_color() { assert!(validate_label_color("#ff0000").is_ok()); assert!(validate_label_color("#6c5ce7").is_ok()); assert!(validate_label_color("#AABBCC").is_ok()); assert!(validate_label_color("ff0000").is_err()); // no # assert!(validate_label_color("#fff").is_err()); // too short assert!(validate_label_color("#gggggg").is_err()); // invalid hex assert!(validate_label_color("#12345678").is_err()); // too long } #[test] fn test_validate_repo_description() { assert!(validate_repo_description("").is_ok()); assert!(validate_repo_description("A short description").is_ok()); assert!(validate_repo_description(&"a".repeat(500)).is_ok()); assert!(validate_repo_description(&"a".repeat(501)).is_err()); } #[test] fn test_validate_git_repo_name() { // Valid names assert!(validate_git_repo_name("my-repo").is_ok()); assert!(validate_git_repo_name("my_repo").is_ok()); assert!(validate_git_repo_name("MyRepo123").is_ok()); assert!(validate_git_repo_name("repo.name").is_ok()); assert!(validate_git_repo_name("a").is_ok()); // single char assert!(validate_git_repo_name(&"a".repeat(64)).is_ok()); // at limit // Invalid: empty assert!(validate_git_repo_name("").is_err()); // Invalid: too long assert!(validate_git_repo_name(&"a".repeat(65)).is_err()); // Invalid: leading dot assert!(validate_git_repo_name(".hidden").is_err()); // Invalid: spaces assert!(validate_git_repo_name("my repo").is_err()); // Invalid: slashes assert!(validate_git_repo_name("foo/bar").is_err()); // Invalid: special chars assert!(validate_git_repo_name("repo@name").is_err()); assert!(validate_git_repo_name("repo!").is_err()); } // ── Edge cases (test-fuzz) ── #[test] fn test_git_repo_name_path_traversal() { // ".." could be dangerous for path traversal, but it starts with "." assert!(validate_git_repo_name("..").is_err()); // "a.." is valid (doesn't start with dot) assert!(validate_git_repo_name("a..").is_ok()); } #[test] fn test_git_repo_name_dot_git() { assert!(validate_git_repo_name(".git").is_err()); // starts with dot assert!(validate_git_repo_name("repo.git").is_ok()); // doesn't start with dot } #[test] fn test_label_color_with_lowercase() { assert!(validate_label_color("#aabbcc").is_ok()); } #[test] fn test_label_color_empty() { assert!(validate_label_color("").is_err()); } #[test] fn test_label_color_just_hash() { assert!(validate_label_color("#").is_err()); } #[test] fn test_validate_issue_body_empty_is_valid() { // Issue body can be empty (unlike comment body) assert!(validate_issue_body("").is_ok()); } #[test] fn test_validate_issue_comment_body_whitespace_only() { // Whitespace-only comment is rejected (trim before empty check) assert!(validate_issue_comment_body(" ").is_err()); } #[test] fn test_project_slug_exactly_two_chars() { assert!(validate_project_slug("ab").is_ok()); } #[test] fn test_project_slug_one_char() { assert!(validate_project_slug("a").is_err()); } // ── Adversarial tests (test-fuzz) ── #[test] fn test_git_repo_name_null_bytes() { assert!(validate_git_repo_name("repo\0name").is_err()); } #[test] fn test_git_repo_name_unicode() { assert!(validate_git_repo_name("r\u{00e9}po").is_err()); } #[test] fn test_git_repo_name_path_separators() { assert!(validate_git_repo_name("foo/bar").is_err()); assert!(validate_git_repo_name("foo\\bar").is_err()); } #[test] fn test_git_repo_name_ends_with_dot() { // Ending with dot is valid (only leading dot is rejected) assert!(validate_git_repo_name("repo.").is_ok()); } #[test] fn test_git_repo_name_all_dots() { assert!(validate_git_repo_name(".").is_err()); // leading dot assert!(validate_git_repo_name("..").is_err()); // leading dot assert!(validate_git_repo_name("...").is_err()); // leading dot } #[test] fn test_label_color_unicode_hex_digits() { // Full-width digits shouldn't be accepted assert!(validate_label_color("#\u{FF10}\u{FF11}\u{FF12}\u{FF13}\u{FF14}\u{FF15}").is_err()); } #[test] fn test_issue_comment_body_single_char() { assert!(validate_issue_comment_body("x").is_ok()); } #[test] fn test_label_name_with_special_chars() { // Label names have no character restrictions beyond length assert!(validate_label_name("bug \u{1F41B}").is_ok()); assert!(validate_label_name("