//! Validators for items, chapters, tags, blog posts, collections, and related content. use super::limits; use crate::error::AppError; pub fn validate_item_title(title: &str) -> Result<(), AppError> { if title.is_empty() { return Err(AppError::validation("Title is required".to_string())); } if title.chars().count() > limits::ITEM_TITLE_MAX { return Err(AppError::validation(format!( "Title must be {} characters or less", limits::ITEM_TITLE_MAX ))); } super::reject_control_chars("Title", title)?; Ok(()) } pub fn validate_item_description(description: &str) -> Result<(), AppError> { if description.chars().count() > limits::ITEM_DESCRIPTION_MAX { return Err(AppError::validation(format!( "Description must be {} characters or less", limits::ITEM_DESCRIPTION_MAX ))); } super::reject_control_chars_multiline("Description", description)?; Ok(()) } pub fn validate_chapter_title(title: &str) -> Result<(), AppError> { if title.is_empty() { return Err(AppError::validation( "Chapter title is required".to_string(), )); } if title.chars().count() > limits::CHAPTER_TITLE_MAX { return Err(AppError::validation(format!( "Chapter title must be {} characters or less", limits::CHAPTER_TITLE_MAX ))); } super::reject_control_chars("Chapter title", title)?; Ok(()) } pub fn validate_item_text_body(body: &str) -> Result<(), AppError> { if body.chars().count() > limits::ITEM_TEXT_BODY_MAX { return Err(AppError::validation(format!( "Text body must be {} characters or less", limits::ITEM_TEXT_BODY_MAX ))); } super::reject_control_chars_multiline("Text body", body)?; Ok(()) } /// Validate a tag name (for admin tag creation). /// /// Regular users select tags from the taxonomy via typeahead search, /// so this is only needed when creating new tags. pub fn validate_tag_name(name: &str) -> Result<(), AppError> { if name.is_empty() { return Err(AppError::validation("Tag name cannot be empty".to_string())); } if name.chars().count() > limits::TAG_MAX { return Err(AppError::validation(format!( "Tag name must be {} characters or less", limits::TAG_MAX ))); } // Tags can only contain alphanumeric characters, spaces, and hyphens if !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-') { return Err(AppError::validation( "Tag names can only contain letters, numbers, spaces, and hyphens".to_string(), )); } Ok(()) } /// Validate a tag slug using the tagtree standard. /// /// Tag slugs follow a 3-level hierarchy: `type.category.value` /// (e.g. `audio.genre.electronic`, `software.language.rust`). /// `semantic_depth: 2` enforces at least 3 segments. pub const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { max_depth: 5, max_length: 100, semantic_depth: 2, }; pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> { tagtree::validate_with(slug, &MNW_TAG_CONFIG) .map_err(|e| AppError::validation(format!("Invalid tag: {e}"))) } pub fn validate_version_number(version: &str) -> Result<(), AppError> { if version.is_empty() { return Err(AppError::validation( "Version number is required".to_string(), )); } if version.chars().count() > limits::VERSION_NUMBER_MAX { return Err(AppError::validation(format!( "Version number must be {} characters or less", limits::VERSION_NUMBER_MAX ))); } super::reject_control_chars("Version number", version)?; Ok(()) } /// Validate changelog text pub fn validate_changelog(changelog: &str) -> Result<(), AppError> { if changelog.chars().count() > limits::CHANGELOG_MAX { return Err(AppError::validation(format!( "Changelog must be {} characters or less", limits::CHANGELOG_MAX ))); } super::reject_control_chars_multiline("Changelog", changelog)?; Ok(()) } pub fn validate_waitlist_pitch(pitch: &str) -> Result<(), AppError> { if pitch.chars().count() < limits::WAITLIST_PITCH_MIN { return Err(AppError::validation(format!( "Pitch must be at least {} characters", limits::WAITLIST_PITCH_MIN ))); } if pitch.chars().count() > limits::WAITLIST_PITCH_MAX { return Err(AppError::validation(format!( "Pitch must be {} characters or less", limits::WAITLIST_PITCH_MAX ))); } super::reject_control_chars_multiline("Pitch", pitch)?; Ok(()) } pub fn validate_blog_post_title(title: &str) -> Result<(), AppError> { if title.is_empty() { return Err(AppError::validation( "Blog post title is required".to_string(), )); } if title.chars().count() > limits::BLOG_POST_TITLE_MAX { return Err(AppError::validation(format!( "Blog post title must be {} characters or less", limits::BLOG_POST_TITLE_MAX ))); } super::reject_control_chars("Blog post title", title)?; Ok(()) } /// Validate a blog post slug (delegates to [`validate_slug`](super::validate_slug)). pub fn validate_blog_post_slug(slug: &str) -> Result<(), AppError> { super::validate_slug(slug) } pub fn validate_blog_post_body(body: &str) -> Result<(), AppError> { if body.chars().count() > limits::BLOG_POST_BODY_MAX { return Err(AppError::validation(format!( "Blog post body must be {} characters or less", limits::BLOG_POST_BODY_MAX ))); } super::reject_control_chars_multiline("Blog post body", body)?; Ok(()) } /// Validate a license key code format: 5 or 6 hyphen-separated lowercase /// ASCII words. The generator currently emits 6 (raised from 5 after the /// birthday-collision review in `crypto.rs`); the validator accepts both so /// keys already issued at 5 words continue to validate. pub fn validate_key_code(code: &str) -> Result<(), AppError> { if code.is_empty() { return Err(AppError::validation("Key code is required".to_string())); } if code.chars().count() > limits::KEY_CODE_MAX { return Err(AppError::validation(format!( "Key code must be {} characters or less", limits::KEY_CODE_MAX ))); } let parts: Vec<&str> = code.split('-').collect(); if !matches!(parts.len(), 5 | 6) { return Err(AppError::validation("Invalid key code format".to_string())); } for part in &parts { if part.is_empty() || !part.chars().all(|c| c.is_ascii_lowercase()) { return Err(AppError::validation("Invalid key code format".to_string())); } } Ok(()) } pub fn validate_collection_title(title: &str) -> Result<(), AppError> { if title.is_empty() { return Err(AppError::validation( "Collection title is required".to_string(), )); } if title.chars().count() > limits::COLLECTION_TITLE_MAX { return Err(AppError::validation(format!( "Collection title must be {} characters or less", limits::COLLECTION_TITLE_MAX ))); } super::reject_control_chars("Collection title", title)?; Ok(()) } pub fn validate_collection_description(description: &str) -> Result<(), AppError> { if description.chars().count() > limits::COLLECTION_DESCRIPTION_MAX { return Err(AppError::validation(format!( "Collection description must be {} characters or less", limits::COLLECTION_DESCRIPTION_MAX ))); } super::reject_control_chars_multiline("Collection description", description)?; Ok(()) } /// Validate an item section title pub fn validate_section_title(title: &str) -> Result<(), AppError> { let trimmed = title.trim(); if trimmed.is_empty() { return Err(AppError::validation( "Section title is required".to_string(), )); } if trimmed.chars().count() > limits::SECTION_TITLE_MAX { return Err(AppError::validation(format!( "Section title must be {} characters or less", limits::SECTION_TITLE_MAX ))); } super::reject_control_chars("Section title", trimmed)?; Ok(()) } /// Validate an item section body pub fn validate_section_body(body: &str) -> Result<(), AppError> { if body.chars().count() > limits::SECTION_BODY_MAX { return Err(AppError::validation(format!( "Section body must be {} characters or less", limits::SECTION_BODY_MAX ))); } super::reject_control_chars_multiline("Section body", body)?; Ok(()) } // Price validation lives on the `PriceCents` newtype (`db::validated_types`), // which every live write path constructs, the former free-standing // `validate_price_cents` here was a dead second source of truth and was removed. #[cfg(test)] mod tests { use super::*; #[test] fn multiline_bodies_reject_control_bytes_but_allow_newlines() { // Multi-line fields keep newlines/tabs (legitimate formatting)... assert!(validate_item_description("line one\nline two\twith tab").is_ok()); assert!(validate_item_text_body("para one\n\npara two").is_ok()); // ...but reject NUL and other non-whitespace control bytes. assert!(validate_item_description("bad\0null").is_err()); assert!(validate_item_text_body("esc\u{1b}[31m").is_err()); assert!(validate_changelog("fixed\u{7f}del").is_err()); } #[test] fn test_validate_item_title() { assert!(validate_item_title("My Song").is_ok()); assert!(validate_item_title("").is_err()); // empty assert!(validate_item_title(&"a".repeat(201)).is_err()); // too long // Single-line: no CR/LF/tab/control chars (would break email subjects). assert!(validate_item_title("Song\r\nBcc: attacker@evil.com").is_err()); assert!(validate_item_title("Song\nInjection").is_err()); assert!(validate_item_title("Song\ttab").is_err()); assert!(validate_item_title("Song\0null").is_err()); } #[test] fn test_validate_item_description() { assert!(validate_item_description("Great item").is_ok()); assert!(validate_item_description("").is_ok()); // empty is valid assert!(validate_item_description(&"a".repeat(5001)).is_err()); // too long } #[test] fn test_validate_chapter_title() { assert!(validate_chapter_title("Introduction").is_ok()); assert!(validate_chapter_title("X").is_ok()); // single char assert!(validate_chapter_title("").is_err()); // empty assert!(validate_chapter_title(&"a".repeat(200)).is_ok()); // at limit assert!(validate_chapter_title(&"a".repeat(201)).is_err()); // over limit assert!(validate_chapter_title("Intro\r\nX").is_err()); // no line breaks } #[test] fn test_validate_item_text_body() { assert!(validate_item_text_body("Some content").is_ok()); assert!(validate_item_text_body("").is_ok()); // empty is valid assert!(validate_item_text_body(&"a".repeat(500_000)).is_ok()); // at limit assert!(validate_item_text_body(&"a".repeat(500_001)).is_err()); // over limit } #[test] fn test_validate_tag_name() { assert!(validate_tag_name("music").is_ok()); assert!(validate_tag_name("lo-fi").is_ok()); assert!(validate_tag_name("ambient music").is_ok()); assert!(validate_tag_name("").is_err()); assert!(validate_tag_name("tag@invalid").is_err()); } #[test] fn test_validate_version_number() { assert!(validate_version_number("1.0.0").is_ok()); assert!(validate_version_number("v2").is_ok()); assert!(validate_version_number("").is_err()); // empty assert!(validate_version_number(&"a".repeat(51)).is_err()); // too long } #[test] fn test_validate_changelog() { assert!(validate_changelog("Fixed bugs").is_ok()); assert!(validate_changelog("").is_ok()); // empty is valid assert!(validate_changelog(&"a".repeat(10001)).is_err()); // too long } #[test] fn test_validate_waitlist_pitch() { assert!(validate_waitlist_pitch(&"a".repeat(20)).is_ok()); // minimum assert!(validate_waitlist_pitch(&"a".repeat(500)).is_ok()); // maximum assert!(validate_waitlist_pitch(&"a".repeat(19)).is_err()); // too short assert!(validate_waitlist_pitch(&"a".repeat(501)).is_err()); // too long } #[test] fn test_validate_blog_post_title() { assert!(validate_blog_post_title("My First Post").is_ok()); assert!(validate_blog_post_title("").is_err()); // empty assert!(validate_blog_post_title(&"a".repeat(200)).is_ok()); // at limit assert!(validate_blog_post_title(&"a".repeat(201)).is_err()); // over limit assert!(validate_blog_post_title("Post\r\nSubject-Injection").is_err()); } #[test] fn test_validate_collection_title_rejects_line_breaks() { assert!(validate_collection_title("My Collection").is_ok()); assert!(validate_collection_title("Coll\r\nX").is_err()); } #[test] fn test_validate_section_title_rejects_line_breaks() { assert!(validate_section_title("Overview").is_ok()); assert!(validate_section_title("Sec\ntion").is_err()); } #[test] fn test_validate_blog_post_slug() { assert!(validate_blog_post_slug("my-post").is_ok()); assert!(validate_blog_post_slug("ab").is_ok()); // minimum length assert!(validate_blog_post_slug("post123").is_ok()); assert!(validate_blog_post_slug("a").is_err()); // too short assert!(validate_blog_post_slug("my_post").is_err()); // underscores assert!(validate_blog_post_slug("my post").is_err()); // spaces assert!(validate_blog_post_slug(&"a".repeat(100)).is_ok()); // at limit assert!(validate_blog_post_slug(&"a".repeat(101)).is_err()); // over limit } #[test] fn test_validate_blog_post_body() { assert!(validate_blog_post_body("Some content").is_ok()); assert!(validate_blog_post_body("").is_ok()); // empty is valid assert!(validate_blog_post_body(&"a".repeat(100_000)).is_ok()); // at limit assert!(validate_blog_post_body(&"a".repeat(100_001)).is_err()); // over limit } #[test] fn test_validate_key_code() { assert!(validate_key_code("bright-castle-forest-river-falcon").is_ok()); assert!(validate_key_code("abc-def-ghi-jkl-mno").is_ok()); assert!(validate_key_code("").is_err()); // empty assert!(validate_key_code("one-two-three").is_err()); // too few parts assert!(validate_key_code("one-two-three-four-five-six").is_ok()); // 6 parts now accepted assert!(validate_key_code("one-two-three-four-five-six-seven").is_err()); // too many parts assert!(validate_key_code("ONE-TWO-THREE-FOUR-FIVE").is_err()); // uppercase assert!(validate_key_code("one-tw0-three-four-five").is_err()); // digit assert!(validate_key_code("----").is_err()); // empty segments assert!(validate_key_code("a--b-c-d").is_err()); // empty middle segment } #[test] fn test_validate_section_title() { assert!(validate_section_title("Features").is_ok()); assert!(validate_section_title(" Features ").is_ok()); // trimmed assert!(validate_section_title("").is_err()); // empty assert!(validate_section_title(" ").is_err()); // whitespace only assert!(validate_section_title(&"a".repeat(100)).is_ok()); // at limit assert!(validate_section_title(&"a".repeat(101)).is_err()); // over limit } #[test] fn test_validate_section_body() { assert!(validate_section_body("Some markdown content").is_ok()); assert!(validate_section_body("").is_ok()); // empty is valid assert!(validate_section_body(&"a".repeat(100_000)).is_ok()); // at limit assert!(validate_section_body(&"a".repeat(100_001)).is_err()); // over limit } #[test] fn test_multibyte_characters_counted_correctly() { // CJK characters are 3 bytes each in UTF-8, but should count as 1 character // Validate that item title handles multi-byte correctly let cjk_title: String = "\u{6d4b}".repeat(200); // 200 CJK chars assert_eq!(cjk_title.len(), 600); // 600 bytes assert!(validate_item_title(&cjk_title).is_ok()); // 200 chars <= 200 max let cjk_title_over: String = "\u{6d4b}".repeat(201); assert!(validate_item_title(&cjk_title_over).is_err()); // 201 > 200 // Slug min-length with multi-byte: test waitlist pitch min instead, // since it accepts any characters let pitch_cjk: String = "\u{6587}".repeat(20); // 20 CJK chars assert_eq!(pitch_cjk.len(), 60); // 60 bytes assert!(validate_waitlist_pitch(&pitch_cjk).is_ok()); // 20 chars >= 20 min } // ── Adversarial tests (test-fuzz) ── #[test] fn test_validate_tag_name_unicode_rejected() { // Tags only allow ASCII alphanumeric + spaces + hyphens assert!(validate_tag_name("\u{00e9}lectronic").is_err()); assert!(validate_tag_name("lo\u{2010}fi").is_err()); // Unicode hyphen U+2010 } #[test] fn test_validate_tag_name_null_bytes() { assert!(validate_tag_name("music\0").is_err()); } #[test] fn test_validate_tag_name_at_max() { assert!(validate_tag_name(&"a".repeat(50)).is_ok()); assert!(validate_tag_name(&"a".repeat(51)).is_err()); } #[test] fn test_validate_key_code_with_unicode_words() { assert!(validate_key_code("\u{00e9}-two-three-four-five").is_err()); } #[test] fn test_validate_key_code_null_bytes() { assert!(validate_key_code("one\0-two-three-four-five").is_err()); } #[test] fn test_validate_key_code_single_char_words() { assert!(validate_key_code("a-b-c-d-e").is_ok()); } #[test] fn test_validate_section_title_only_whitespace_padded() { // Leading/trailing whitespace with content in the middle should pass assert!(validate_section_title(" Features ").is_ok()); // Tab-only should fail (trimmed to empty) assert!(validate_section_title("\t\t").is_err()); // Newline-only should fail assert!(validate_section_title("\n").is_err()); } #[test] fn test_validate_collection_title_at_boundary() { assert!(validate_collection_title(&"a".repeat(100)).is_ok()); assert!(validate_collection_title(&"a".repeat(101)).is_err()); } #[test] fn test_validate_collection_description_at_boundary() { assert!(validate_collection_description(&"a".repeat(500)).is_ok()); assert!(validate_collection_description(&"a".repeat(501)).is_err()); } #[test] fn test_validate_waitlist_pitch_boundary_multibyte() { // 19 CJK chars = 57 bytes but only 19 characters, should fail min check let pitch_short: String = "\u{6587}".repeat(19); assert_eq!(pitch_short.chars().count(), 19); assert!(validate_waitlist_pitch(&pitch_short).is_err()); } // ── Property-based tests (test-fuzz) ── proptest::proptest! { #[test] fn prop_tag_name_valid_always_accepted(s in "[a-zA-Z0-9 \\-]{1,50}") { proptest::prop_assert!(validate_tag_name(&s).is_ok(), "Valid tag rejected: {:?}", s); } #[test] fn prop_key_code_valid_always_accepted( a in "[a-z]{1,8}", b in "[a-z]{1,8}", c in "[a-z]{1,8}", d in "[a-z]{1,8}", e in "[a-z]{1,8}", ) { let code = format!("{a}-{b}-{c}-{d}-{e}"); if code.chars().count() <= 50 { proptest::prop_assert!(validate_key_code(&code).is_ok(), "Valid key code rejected: {:?}", code); } } } }