//! Input validation utilities //! //! Provides validation functions for user input with consistent error messages. mod items; mod payments; mod projects; mod users; pub use items::*; pub use payments::*; pub use projects::*; pub use users::*; use crate::error::AppError; /// Maximum lengths for various fields pub mod limits { pub const DISPLAY_NAME_MAX: usize = 100; pub const BIO_MAX: usize = 2000; pub const LINK_URL_MAX: usize = 500; pub const LINK_TITLE_MAX: usize = 100; pub const ITEM_TITLE_MAX: usize = 200; pub const ITEM_DESCRIPTION_MAX: usize = 5000; pub const TAG_MAX: usize = 50; pub const PROJECT_TITLE_MAX: usize = 200; pub const PROJECT_DESCRIPTION_MAX: usize = 2000; pub const PROJECT_SLUG_MAX: usize = 100; pub const VERSION_NUMBER_MAX: usize = 50; pub const CHANGELOG_MAX: usize = 10000; pub const WAITLIST_PITCH_MIN: usize = 20; pub const WAITLIST_PITCH_MAX: usize = 500; pub const BLOG_POST_TITLE_MAX: usize = 200; pub const BLOG_POST_SLUG_MAX: usize = 100; pub const BLOG_POST_BODY_MAX: usize = 100_000; pub const CHAPTER_TITLE_MAX: usize = 200; pub const ITEM_TEXT_BODY_MAX: usize = 500_000; pub const KEY_CODE_MAX: usize = 50; pub const MACHINE_ID_MAX: usize = 255; pub const ACTIVATION_LABEL_MAX: usize = 100; // Subscriptions pub const TIER_NAME_MAX: usize = 100; pub const TIER_DESCRIPTION_MAX: usize = 2000; // SyncKit pub const SYNC_APP_NAME_MAX: usize = 100; pub const SYNC_DEVICE_NAME_MAX: usize = 100; pub const SYNC_TABLE_NAME_MAX: usize = 100; pub const SYNC_ROW_ID_MAX: usize = 255; pub const SYNC_BLOB_HASH_LEN: usize = 64; // SHA-256 hex pub const SYNC_KEY_MAX: usize = 255; // SSH keys pub const SSH_KEY_LABEL_MAX: usize = 128; // Git issues pub const ISSUE_TITLE_MAX: usize = 200; pub const ISSUE_BODY_MAX: usize = 50_000; pub const ISSUE_COMMENT_BODY_MAX: usize = 50_000; pub const ISSUE_LABEL_NAME_MAX: usize = 50; // Git repo settings pub const REPO_DESCRIPTION_MAX: usize = 500; // Collections pub const COLLECTION_TITLE_MAX: usize = 100; pub const COLLECTION_DESCRIPTION_MAX: usize = 500; // Item sections pub const SECTION_TITLE_MAX: usize = 100; pub const SECTION_BODY_MAX: usize = 100_000; } /// Validate a slug: 2-100 chars, alphanumeric + hyphens. /// /// Shared rule for project slugs, blog post slugs, and tag slugs. /// Also used by the `Slug` newtype's `Deserialize` impl. pub fn validate_slug(slug: &str) -> Result<(), AppError> { let len = slug.chars().count(); if len < 2 { return Err(AppError::validation( "URL name must be at least 2 characters".to_string(), )); } if len > limits::PROJECT_SLUG_MAX { return Err(AppError::validation(format!( "URL name must be {} characters or less", limits::PROJECT_SLUG_MAX ))); } if !slug .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(AppError::validation( "URL name can only contain lowercase letters, numbers, and hyphens".to_string(), )); } if !slug.chars().any(|c| c.is_ascii_alphanumeric()) { return Err(AppError::validation( "URL name must contain at least one letter or number".to_string(), )); } Ok(()) } // ── SyncKit validation ── /// Validate a sync app name pub fn validate_sync_app_name(name: &str) -> Result<(), AppError> { if name.is_empty() { return Err(AppError::validation("App name is required".to_string())); } if name.chars().count() > limits::SYNC_APP_NAME_MAX { return Err(AppError::validation(format!( "App name must be {} characters or less", limits::SYNC_APP_NAME_MAX ))); } Ok(()) } /// Validate a sync device name pub fn validate_sync_device_name(name: &str) -> Result<(), AppError> { if name.is_empty() { return Err(AppError::validation("Device name is required".to_string())); } if name.chars().count() > limits::SYNC_DEVICE_NAME_MAX { return Err(AppError::validation(format!( "Device name must be {} characters or less", limits::SYNC_DEVICE_NAME_MAX ))); } Ok(()) } /// Validate a sync table name pub fn validate_sync_table_name(name: &str) -> Result<(), AppError> { if name.is_empty() { return Err(AppError::validation("Table name is required".to_string())); } if name.chars().count() > limits::SYNC_TABLE_NAME_MAX { return Err(AppError::validation(format!( "Table name must be {} characters or less", limits::SYNC_TABLE_NAME_MAX ))); } if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { return Err(AppError::validation( "Table name can only contain letters, numbers, and underscores".to_string(), )); } Ok(()) } /// Validate a sync row ID pub fn validate_sync_row_id(row_id: &str) -> Result<(), AppError> { if row_id.is_empty() { return Err(AppError::validation("Row ID is required".to_string())); } if row_id.chars().count() > limits::SYNC_ROW_ID_MAX { return Err(AppError::validation(format!( "Row ID must be {} characters or less", limits::SYNC_ROW_ID_MAX ))); } // Reject null bytes and control characters — these can cause issues in // DB queries, file paths, and log output downstream. if row_id.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) { return Err(AppError::validation( "Row ID contains invalid characters".to_string(), )); } Ok(()) } /// Validate a sync blob hash (must be exactly 64 lowercase hex characters) pub fn validate_sync_blob_hash(hash: &str) -> Result<(), AppError> { if hash.len() != limits::SYNC_BLOB_HASH_LEN { return Err(AppError::validation(format!( "Blob hash must be exactly {} hex characters", limits::SYNC_BLOB_HASH_LEN ))); } if !hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) { return Err(AppError::validation( "Blob hash must be lowercase hexadecimal".to_string(), )); } Ok(()) } /// Validate a developer-defined SDK key. Opaque string identifying which /// workspace/org/end-user a JWT session belongs to. Rejects empty, oversize, /// null bytes, and control characters — same character rules as `validate_sync_row_id` /// since downstream goes through SQL and log lines. pub fn validate_synckit_key(key: &str) -> Result<(), AppError> { if key.is_empty() { return Err(AppError::validation("SDK key is required".to_string())); } if key.len() > limits::SYNC_KEY_MAX { return Err(AppError::validation(format!( "SDK key must be {} bytes or less", limits::SYNC_KEY_MAX ))); } if key.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) { return Err(AppError::validation( "SDK key contains invalid characters".to_string(), )); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_validate_sync_app_name() { assert!(validate_sync_app_name("GoingsOn").is_ok()); assert!(validate_sync_app_name("").is_err()); // empty assert!(validate_sync_app_name(&"a".repeat(100)).is_ok()); // at limit assert!(validate_sync_app_name(&"a".repeat(101)).is_err()); // over limit } #[test] fn test_validate_sync_device_name() { assert!(validate_sync_device_name("Max's MacBook").is_ok()); assert!(validate_sync_device_name("").is_err()); assert!(validate_sync_device_name(&"a".repeat(101)).is_err()); } #[test] fn test_validate_sync_table_name() { assert!(validate_sync_table_name("tasks").is_ok()); assert!(validate_sync_table_name("calendar_events").is_ok()); assert!(validate_sync_table_name("").is_err()); assert!(validate_sync_table_name("bad-name").is_err()); // hyphens assert!(validate_sync_table_name("bad name").is_err()); // spaces assert!(validate_sync_table_name(&"a".repeat(101)).is_err()); } #[test] fn test_validate_sync_row_id() { assert!(validate_sync_row_id("uuid-123").is_ok()); assert!(validate_sync_row_id("").is_err()); assert!(validate_sync_row_id(&"a".repeat(255)).is_ok()); assert!(validate_sync_row_id(&"a".repeat(256)).is_err()); } // ── Edge cases (test-fuzz) ── #[test] fn test_validate_slug_only_hyphens() { // A slug with NO alphanumeric chars is rejected by the // "must contain at least one letter or number" rule (added later). assert!(validate_slug("--").is_err()); } #[test] fn test_validate_slug_leading_trailing_hyphens() { assert!(validate_slug("-ab-").is_ok()); // hyphens at boundaries } #[test] fn test_validate_slug_with_unicode() { // Non-ASCII is rejected by is_ascii_alphanumeric assert!(validate_slug("caf\u{00e9}").is_err()); } #[test] fn test_validate_sync_blob_hash_uppercase() { let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; assert!(validate_sync_blob_hash(hash).is_err()); // must be lowercase } #[test] fn test_validate_sync_blob_hash_mixed_case() { let hash = "aAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaA"; assert!(validate_sync_blob_hash(hash).is_err()); } #[test] fn test_validate_sync_blob_hash_valid() { let hash = "a".repeat(64); assert!(validate_sync_blob_hash(&hash).is_ok()); } #[test] fn test_validate_sync_blob_hash_wrong_length() { assert!(validate_sync_blob_hash(&"a".repeat(63)).is_err()); assert!(validate_sync_blob_hash(&"a".repeat(65)).is_err()); } #[test] fn test_validate_sync_table_name_with_unicode() { assert!(validate_sync_table_name("\u{00e9}vents").is_err()); } // ── Adversarial tests (test-fuzz) ── #[test] fn test_validate_slug_null_bytes() { assert!(validate_slug("ab\0cd").is_err()); } #[test] fn test_validate_slug_zero_width_chars() { // Zero-width space (U+200B) should be rejected assert!(validate_slug("ab\u{200B}cd").is_err()); // Zero-width joiner assert!(validate_slug("ab\u{200D}cd").is_err()); } #[test] fn test_validate_slug_rtl_override() { // Right-to-left override (U+202E) should be rejected assert!(validate_slug("ab\u{202E}cd").is_err()); } #[test] fn test_validate_slug_at_exact_max() { assert!(validate_slug(&"a".repeat(100)).is_ok()); assert!(validate_slug(&"a".repeat(101)).is_err()); } #[test] fn test_validate_slug_single_char() { assert!(validate_slug("a").is_err()); // min is 2 } #[test] fn test_validate_slug_empty() { assert!(validate_slug("").is_err()); } #[test] fn test_validate_sync_blob_hash_non_hex() { // 64 chars but contains 'g' which is not hex let hash = format!("{}g", "a".repeat(63)); assert!(validate_sync_blob_hash(&hash).is_err()); } #[test] fn test_validate_sync_blob_hash_empty() { assert!(validate_sync_blob_hash("").is_err()); } #[test] fn test_validate_sync_table_name_sql_injection() { assert!(validate_sync_table_name("users; DROP TABLE users").is_err()); assert!(validate_sync_table_name("users'--").is_err()); } #[test] fn test_validate_sync_row_id_null_bytes() { // Null bytes and control characters are rejected assert!(validate_sync_row_id("a\0b").is_err()); assert!(validate_sync_row_id("a\x01b").is_err()); // Tabs are allowed (some ID schemes use them) assert!(validate_sync_row_id("a\tb").is_ok()); // Normal IDs pass assert!(validate_sync_row_id("row-123-abc").is_ok()); } // ── SDK key validation (test-fuzz) ── #[test] fn test_validate_synckit_key_basic() { assert!(validate_synckit_key("user-42").is_ok()); assert!(validate_synckit_key("workspace/team-1").is_ok()); assert!(validate_synckit_key("a").is_ok()); // single char is fine assert!(validate_synckit_key("").is_err()); } #[test] fn test_validate_synckit_key_length_boundaries() { let at_max = "k".repeat(limits::SYNC_KEY_MAX); let over_max = "k".repeat(limits::SYNC_KEY_MAX + 1); assert!(validate_synckit_key(&at_max).is_ok(), "exact max must pass"); assert!(validate_synckit_key(&over_max).is_err(), "over max must fail"); } #[test] fn test_validate_synckit_key_null_and_controls() { assert!(validate_synckit_key("a\0b").is_err()); assert!(validate_synckit_key("a\x01b").is_err()); assert!(validate_synckit_key("a\x1Fb").is_err()); // unit separator // Tabs are permitted, mirroring validate_sync_row_id. assert!(validate_synckit_key("a\tb").is_ok()); } #[test] fn test_validate_synckit_key_unicode_allowed() { // SDK keys are opaque — non-ASCII is fine as long as it's not a control char. assert!(validate_synckit_key("café").is_ok()); assert!(validate_synckit_key("ユーザー1").is_ok()); } #[test] fn test_validate_synckit_key_oversize_uses_byte_length() { // SYNC_KEY_MAX is in BYTES (key.len()), not chars. Multibyte chars eat // more budget. A 100-char emoji string easily exceeds 255 bytes. let many_emoji = "🦀".repeat(100); // 4 bytes per emoji → 400 bytes assert!(validate_synckit_key(&many_emoji).is_err()); } // ── Property-based tests (test-fuzz) ── proptest::proptest! { #[test] fn prop_slug_valid_inputs_never_panic(s in "[a-z0-9\\-]{0,200}") { let _ = validate_slug(&s); } #[test] fn prop_slug_valid_always_accepted(s in "[a-z0-9\\-]{2,100}") { // The regex doesn't ensure at least one alphanumeric char; the validator // (correctly) rejects hyphen-only strings, so filter to inputs that meet // both rules. if s.chars().any(|c| c.is_ascii_alphanumeric()) { proptest::prop_assert!(validate_slug(&s).is_ok(), "Valid slug rejected: {:?}", s); } } #[test] fn prop_slug_rejects_non_ascii(s in "[a-z]{2,10}\u{00e9}[a-z]{2,10}") { proptest::prop_assert!(validate_slug(&s).is_err(), "Non-ASCII slug accepted: {:?}", s); } #[test] fn prop_sync_table_name_valid_always_accepted(s in "[a-zA-Z_][a-zA-Z0-9_]{0,99}") { proptest::prop_assert!(validate_sync_table_name(&s).is_ok(), "Valid table name rejected: {:?}", s); } #[test] fn prop_blob_hash_valid_always_accepted(s in "[0-9a-f]{64}") { proptest::prop_assert!(validate_sync_blob_hash(&s).is_ok(), "Valid hash rejected: {:?}", s); } } }