Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions pkg/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
20 changes: 20 additions & 0 deletions src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
78 changes: 78 additions & 0 deletions src/models/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down
Loading