//! Validators for items, chapters, tags, blog posts, collections, and related content. use crate::constants; use crate::error::AppError; use super::limits; /// Validate an item title 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 ))); } Ok(()) } /// Validate an item description 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 ))); } Ok(()) } /// Validate a chapter title 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 ))); } Ok(()) } /// Validate an item text body 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 ))); } 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.0))) } /// Validate a version number 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 ))); } 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 ))); } Ok(()) } /// Validate a waitlist pitch 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 ))); } Ok(()) } /// Validate a blog post title 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 ))); } 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) } /// Validate a blog post body 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 ))); } 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(()) } /// Validate a collection title 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 ))); } Ok(()) } /// Validate a collection description 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 ))); } 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 ))); } 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 ))); } Ok(()) } /// Validate price in cents (must be non-negative) pub fn validate_price_cents(price: i32) -> Result<(), AppError> { if price < 0 { return Err(AppError::validation("Price cannot be negative".to_string())); } // Cap at $10,000 if price > constants::MAX_PRICE_CENTS { return Err(AppError::validation("Price cannot exceed $10,000".to_string())); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[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 } #[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 } #[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 } #[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_price_cents() { assert!(validate_price_cents(0).is_ok()); assert!(validate_price_cents(999).is_ok()); assert!(validate_price_cents(1_000_000).is_ok()); // $10,000 assert!(validate_price_cents(-1).is_err()); // negative assert!(validate_price_cents(1_000_001).is_err()); // over cap } #[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_price_cents_exact_max() { assert!(validate_price_cents(constants::MAX_PRICE_CENTS).is_ok()); assert!(validate_price_cents(constants::MAX_PRICE_CENTS + 1).is_err()); } #[test] fn test_validate_price_cents_i32_extremes() { assert!(validate_price_cents(i32::MIN).is_err()); assert!(validate_price_cents(i32::MAX).is_err()); } #[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); } } #[test] fn prop_price_cents_valid_range(price in 0..=1_000_000i32) { proptest::prop_assert!(validate_price_cents(price).is_ok(), "Valid price rejected: {}", price); } #[test] fn prop_price_cents_negative_always_rejected(price in i32::MIN..0i32) { proptest::prop_assert!(validate_price_cents(price).is_err(), "Negative price accepted: {}", price); } } }