diff --git a/pkg/test.js b/pkg/test.js index 994642c..8f986a9 100644 --- a/pkg/test.js +++ b/pkg/test.js @@ -474,6 +474,46 @@ describe("PubkySpecs Example Objects Tests", () => { ); }); + it("should expose complete file name invalid characters", () => { + assert.ok( + Array.isArray(validationLimits.fileNameInvalidChars), + "fileNameInvalidChars should be an array" + ); + + for (const invalidChar of ['"', "\\", "\n", "\r", "\0"]) { + assert.ok( + validationLimits.fileNameInvalidChars.includes(invalidChar), + `fileNameInvalidChars should include ${JSON.stringify(invalidChar)}` + ); + } + + for (let codePoint = 0; codePoint <= 0x9f; codePoint += 1) { + const isControl = + codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); + if (!isControl) { + continue; + } + + const controlChar = String.fromCodePoint(codePoint); + assert.ok( + validationLimits.fileNameInvalidChars.includes(controlChar), + `fileNameInvalidChars should include control character U+${codePoint + .toString(16) + .toUpperCase() + .padStart(4, "0")}` + ); + } + + assert.ok( + !validationLimits.fileNameInvalidChars.includes("é"), + "fileNameInvalidChars should not reject Unicode letters" + ); + assert.ok( + !validationLimits.fileNameInvalidChars.includes("文"), + "fileNameInvalidChars should not reject CJK characters" + ); + }); + it("getValidationLimits should return a copy that matches validationLimits", () => { const limitsCopy = getValidationLimits(); diff --git a/src/limits.rs b/src/limits.rs index c99ace9..fb4bd8a 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -13,6 +13,23 @@ use serde::Serialize; +/// Disallowed file name characters. +/// +/// Includes the quoted-string characters that break `Content-Disposition` +/// filenames plus all Unicode control characters (`char::is_control()`), so +/// serialized validation limits can represent the complete character rule. +pub const FILE_NAME_INVALID_CHARS: &[char] = &[ + '"', '\\', '\u{0000}', '\u{0001}', '\u{0002}', '\u{0003}', '\u{0004}', '\u{0005}', '\u{0006}', + '\u{0007}', '\u{0008}', '\u{0009}', '\u{000A}', '\u{000B}', '\u{000C}', '\u{000D}', '\u{000E}', + '\u{000F}', '\u{0010}', '\u{0011}', '\u{0012}', '\u{0013}', '\u{0014}', '\u{0015}', '\u{0016}', + '\u{0017}', '\u{0018}', '\u{0019}', '\u{001A}', '\u{001B}', '\u{001C}', '\u{001D}', '\u{001E}', + '\u{001F}', '\u{007F}', '\u{0080}', '\u{0081}', '\u{0082}', '\u{0083}', '\u{0084}', '\u{0085}', + '\u{0086}', '\u{0087}', '\u{0088}', '\u{0089}', '\u{008A}', '\u{008B}', '\u{008C}', '\u{008D}', + '\u{008E}', '\u{008F}', '\u{0090}', '\u{0091}', '\u{0092}', '\u{0093}', '\u{0094}', '\u{0095}', + '\u{0096}', '\u{0097}', '\u{0098}', '\u{0099}', '\u{009A}', '\u{009B}', '\u{009C}', '\u{009D}', + '\u{009E}', '\u{009F}', +]; + /// Bundled validation limits for quick consumption. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] @@ -77,6 +94,8 @@ pub struct ValidationLimits { pub file_name_min_length: usize, /// Maximum file name length in characters. pub file_name_max_length: usize, + /// Complete set of disallowed file name characters. + pub file_name_invalid_chars: &'static [char], /// Maximum file src length in characters. pub file_src_max_length: usize, /// Maximum number of tags allowed in a feed. @@ -110,6 +129,7 @@ pub const VALIDATION_LIMITS: ValidationLimits = ValidationLimits { collection_items_max_count: 100, file_name_min_length: 1, file_name_max_length: 255, + file_name_invalid_chars: FILE_NAME_INVALID_CHARS, file_src_max_length: 1024, feed_tags_max_count: 5, }; diff --git a/src/models/file.rs b/src/models/file.rs index 480488a..d919393 100644 --- a/src/models/file.rs +++ b/src/models/file.rs @@ -162,6 +162,21 @@ impl Validatable for PubkyAppFile { return Err("Validation Error: Invalid name length".into()); } + // Keep file-name validation data-driven so client validation built from + // exported limits cannot drift from Rust validation. The list includes + // the control characters that an inline `char::is_control()` check + // would catch. + if let Some(c) = self + .name + .chars() + .find(|c| VALIDATION_LIMITS.file_name_invalid_chars.contains(c)) + { + return Err(format!( + "Validation Error: File name '{}' contains invalid character: {}", + self.name, c + )); + } + // Validate src if self.src.chars().count() == 0 { return Err("Validation Error: Invalid src".into()); @@ -303,6 +318,69 @@ mod tests { } } + fn valid_file_with_name(name: &str) -> PubkyAppFile { + PubkyAppFile::new( + name.to_string(), + blob_uri_builder("user_id".into(), "id".into()), + "image/png".to_string(), + 1024, + ) + } + + #[test] + fn test_validate_file_name_chars() { + for c in (0..=char::MAX as u32) + .filter_map(char::from_u32) + .filter(|c| c.is_control()) + { + assert!( + VALIDATION_LIMITS.file_name_invalid_chars.contains(&c), + "Expected control character U+{:04X} to be exported as invalid", + c as u32 + ); + } + + let valid_names = ["example.png", "résumé.pdf", "文档.pdf"]; + for name in valid_names { + let file = valid_file_with_name(name); + let id = file.create_id(); + assert!( + file.validate(Some(&id)).is_ok(), + "Expected '{}' to be valid", + name + ); + } + + let invalid_cases: Vec<(&str, char)> = vec![ + ("my\"file.txt", '"'), + ("path\\file.txt", '\\'), + ("file\n.txt", '\n'), + ("file\r.txt", '\r'), + ("file\0.txt", '\0'), + ]; + + for (name, expected_char) in invalid_cases { + let file = valid_file_with_name(name); + let id = file.create_id(); + let result = file.validate(Some(&id)); + assert!(result.is_err(), "Expected '{}' to be rejected", name); + let error = result.unwrap_err(); + assert!( + error.contains("contains invalid character:"), + "Expected invalid character error for '{}', got: {}", + name, + error + ); + assert!( + error.contains(expected_char), + "Expected error for '{}' to contain '{}', got: {}", + name, + expected_char, + error + ); + } + } + #[test] fn test_validate_invalid_src() { // Create file directly without sanitization to test validation logic