//! Validators for projects, labels, and git repositories. use crate::error::AppError; use super::limits; /// Validate a project title 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 ))); } Ok(()) } /// Validate a project description 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 ))); } 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) } /// Validate an issue title 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 ))); } 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 ))); } 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 ))); } 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(|c| c.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 ))); } 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(()) } #[cfg(test)] mod tests { use super::*; #[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 } #[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("