//! 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; /// Reject control characters in a single-line field. /// /// Single-line titles/labels (item, chapter, blog-post, collection, section, /// project, issue, link) must not contain newlines, carriage returns, tabs, or /// other control characters. Besides being malformed, a `\r`/`\n` in a value /// that reaches an email subject makes Postmark reject the send (the JSON API /// refuses CRLF in headers). Multi-line fields (bio, description, body) use /// their own looser validators that permit `\n`/`\r`/`\t`. pub fn reject_control_chars(field: &str, value: &str) -> Result<(), AppError> { if value.chars().any(char::is_control) { return Err(AppError::validation(format!( "{field} cannot contain line breaks or control characters" ))); } Ok(()) } /// Reject control characters in a multi-line field while permitting normal /// whitespace (`\n`, `\r`, `\t`). Descriptions, bodies, and changelogs are /// legitimately multi-line but should never carry NUL/escape/etc., those /// corrupt logs and tooling and can smuggle bytes into downstream contexts. /// Mirrors `validate_bio`. pub fn reject_control_chars_multiline(field: &str, value: &str) -> Result<(), AppError> { if value .chars() .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) { return Err(AppError::validation(format!( "{field} cannot contain control characters" ))); } Ok(()) } /// 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; // Git notes. A note is prose about one commit, so the cap is generous // rather than tight; the reason to have one at all is that every note in a // namespace is read into memory whenever the tree is flattened. pub const NOTE_CONTENT_MAX: usize = 50_000; pub const NOTE_NAMESPACE_MAX: usize = 64; // 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; // Passwords. Measured in Unicode scalar values (`chars().count()`), NOT // bytes. Every password path, signup, reset, profile change, login, OAuth // login, and SyncKit auth, MUST cap by this same metric and value; a // byte-based cap on the login side rejects (permanently locks out) any // account whose password is <=128 chars but >128 bytes, which multibyte // passphrases routinely are. Route the upper-bound check through // `super::password_too_long`. pub const PASSWORD_MIN: usize = 8; pub const PASSWORD_MAX: usize = 128; } /// True when a password exceeds the maximum length. /// /// Measured in Unicode scalar values (`chars().count()`), never bytes, see /// [`limits::PASSWORD_MAX`]. This is the single upper-bound check shared by the /// set-side paths (signup / reset / profile) and the credential-verification /// paths (login / OAuth / SyncKit) so the metric can never drift back apart. pub fn password_too_long(password: &str) -> bool { password.chars().count() > limits::PASSWORD_MAX } /// 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 ── 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 ))); } // Reject control characters (NUL, newlines, bidi/RTL overrides) so the one // user-controlled string with no other content rules can't carry a payload // into any future non-escaping sink. Mirrors `validate_synckit_key`. if name.chars().any(char::is_control) { return Err(AppError::validation( "App name must not contain control characters".to_string(), )); } Ok(()) } 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 ))); } // Reject control characters, mirroring `validate_sync_app_name`. if name.chars().any(char::is_control) { return Err(AppError::validation( "Device name must not contain control characters".to_string(), )); } Ok(()) } /// Validate a sync group name. Same shape as an app/device name: non-empty, /// bounded by the `VARCHAR(100)` column, no control characters. pub fn validate_sync_group_name(name: &str) -> Result<(), AppError> { if name.is_empty() { return Err(AppError::validation("Group name is required".to_string())); } if name.chars().count() > limits::SYNC_APP_NAME_MAX { return Err(AppError::validation(format!( "Group name must be {} characters or less", limits::SYNC_APP_NAME_MAX ))); } if name.chars().any(char::is_control) { return Err(AppError::validation( "Group name must not contain control characters".to_string(), )); } Ok(()) } 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(()) } 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 base64-encoded SHA-256 digest, the form S3 wants for /// `x-amz-checksum-sha256`. /// /// Bound into a presigned URL, so a malformed value would produce a URL that /// fails at S3 with an opaque error long after the client committed to it. /// Reject it here, where the message can say what was wrong. pub fn validate_sha256_base64(value: &str) -> Result<(), AppError> { use base64::Engine; let decoded = base64::engine::general_purpose::STANDARD .decode(value) .map_err(|_| AppError::validation("Checksum must be base64".to_string()))?; if decoded.len() != 32 { return Err(AppError::validation(format!( "Checksum must decode to 32 bytes (SHA-256), got {}", decoded.len() ))); } 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 assert!(validate_sync_app_name("bad\nname").is_err()); // newline (control) assert!(validate_sync_app_name("bad\0name").is_err()); // NUL assert!(validate_sync_app_name("ok name 123").is_ok()); // spaces/digits fine } #[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()); // Control characters (NUL, escape) are rejected. assert!(validate_sync_device_name("laptop\u{0}").is_err()); assert!(validate_sync_device_name("laptop\u{1b}evil").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()); } #[test] fn test_password_too_long_measures_chars_not_bytes() { // Regression (audit Run 22): the cap is Unicode scalar count, not bytes. // A 128-char password is accepted even when its UTF-8 encoding exceeds // 128 bytes, otherwise multibyte passphrases that pass signup would be // rejected at login (permanent self-lockout). assert!(!password_too_long(&"a".repeat(limits::PASSWORD_MAX))); assert!(password_too_long(&"a".repeat(limits::PASSWORD_MAX + 1))); // 128 CJK chars = 128 chars (at the cap) but 384 bytes, must NOT be // flagged too long. let cjk = "\u{4f60}".repeat(limits::PASSWORD_MAX); assert_eq!(cjk.chars().count(), limits::PASSWORD_MAX); assert!( cjk.len() > limits::PASSWORD_MAX, "test string must exceed the byte cap" ); assert!(!password_too_long(&cjk)); // One char over the cap is rejected regardless of byte width. assert!(password_too_long( &"\u{4f60}".repeat(limits::PASSWORD_MAX + 1) )); } // ── 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); } } }