use crate::constants::{DEFAULT_MAX_FILE_LEN, DOT, DOTDOT};
use crate::error::{SafeNameError, SafeNameResult};
use crate::lookup::{BLOCKED_ALWAYS, BLOCKED_FINAL, BLOCKED_INITIAL};
#[derive(Debug, Clone)]
pub struct FileValidationOptions {
pub max_len: usize,
pub allow_trailing_nul: bool,
}
impl Default for FileValidationOptions {
fn default() -> Self {
Self {
max_len: DEFAULT_MAX_FILE_LEN,
allow_trailing_nul: false,
}
}
}
pub fn validate_file(filename: impl AsRef<[u8]>) -> SafeNameResult<()> {
validate_file_with_options(filename, &FileValidationOptions::default())
}
pub fn validate_file_with_options(
filename: impl AsRef<[u8]>,
options: &FileValidationOptions,
) -> SafeNameResult<()> {
let original = filename.as_ref();
let filename = if options.allow_trailing_nul {
let end = original.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
&original[..end]
} else {
original
};
let len = filename.len();
if len == 0 || len > options.max_len {
return Err(SafeNameError::InvalidLength {
len,
max: options.max_len,
});
}
if filename == DOT || filename == DOTDOT {
return Ok(());
}
if len == 1 {
let byte = filename[0];
let byte_index = byte as usize;
if BLOCKED_ALWAYS[byte_index] || BLOCKED_INITIAL[byte_index] || BLOCKED_FINAL[byte_index] {
return Err(SafeNameError::InvalidByte { index: 0, byte });
}
#[cfg(feature = "require-utf8")]
if std::str::from_utf8(filename).is_err() {
return Err(SafeNameError::InvalidByte {
index: 0,
byte: filename[0],
});
}
#[cfg(feature = "require-ascii")]
if byte >= 0x80 {
return Err(SafeNameError::InvalidByte { index: 0, byte });
}
return Ok(());
}
let last_idx = len - 1;
for (idx, &byte) in filename.iter().enumerate() {
let byte_index = byte as usize;
let is_blocked = BLOCKED_ALWAYS[byte_index]
|| (idx == 0 && BLOCKED_INITIAL[byte_index])
|| (idx == last_idx && BLOCKED_FINAL[byte_index]);
if is_blocked {
return Err(SafeNameError::InvalidByte { index: idx, byte });
}
#[cfg(feature = "require-ascii")]
if byte >= 0x80 {
return Err(SafeNameError::InvalidByte { index: idx, byte });
}
}
#[cfg(feature = "require-utf8")]
{
if let Err(e) = std::str::from_utf8(filename) {
return Err(SafeNameError::InvalidByte {
index: e.valid_up_to(),
byte: filename[e.valid_up_to()],
});
}
}
Ok(())
}
pub fn is_file_safe(filename: impl AsRef<[u8]>) -> bool {
validate_file(filename).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_valid_filenames() {
assert!(is_file_safe("file.txt"));
assert!(is_file_safe("my_document.pdf"));
assert!(is_file_safe("image-001.png"));
assert!(is_file_safe("CamelCase"));
assert!(is_file_safe("file.with.dots"));
#[cfg(not(feature = "require-ascii"))]
assert!(is_file_safe("unicode_αβγ.txt"));
assert!(is_file_safe(".hidden"));
assert!(is_file_safe("..dotdot"));
}
#[test]
fn test_special_dotfiles() {
assert!(is_file_safe("."));
assert!(is_file_safe(".."));
}
#[test]
fn test_empty_filename() {
assert_eq!(
validate_file(b""),
Err(SafeNameError::InvalidLength { len: 0, max: 255 })
);
assert!(!is_file_safe(""));
}
#[test]
fn test_too_long() {
let long_name = [b'a'; 300];
assert_eq!(
validate_file(long_name),
Err(SafeNameError::InvalidLength { len: 300, max: 255 })
);
let max_name = [b'a'; 255];
assert!(validate_file(max_name).is_ok());
}
#[test]
fn test_control_characters() {
assert_eq!(
validate_file(b"file\x00name"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0x00
})
);
assert_eq!(
validate_file(b"file\nname"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0x0A
})
);
assert_eq!(
validate_file(b"file\tname"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0x09
})
);
assert_eq!(
validate_file(b"file\x7Fname"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0x7F
})
);
}
#[test]
fn test_forward_slash_blocked() {
assert_eq!(
validate_file(b"foo/bar"),
Err(SafeNameError::InvalidByte {
index: 3,
byte: b'/'
})
);
assert_eq!(
validate_file(b"/leading"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: b'/'
})
);
}
#[test]
fn test_leading_dash() {
assert_eq!(
validate_file(b"-rf"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: b'-'
})
);
assert_eq!(
validate_file(b"--help"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: b'-'
})
);
assert!(is_file_safe("file-name"));
}
#[test]
fn test_leading_tilde() {
assert_eq!(
validate_file(b"~user"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: b'~'
})
);
assert!(is_file_safe("file~backup"));
}
#[test]
fn test_leading_space() {
assert_eq!(
validate_file(b" file"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: b' '
})
);
}
#[test]
fn test_trailing_space() {
assert_eq!(
validate_file(b"file "),
Err(SafeNameError::InvalidByte {
index: 4,
byte: b' '
})
);
#[cfg(not(feature = "block-space"))]
assert!(is_file_safe("file name"));
#[cfg(feature = "block-space")]
assert!(!is_file_safe("file name"));
}
#[test]
fn test_single_char() {
assert!(validate_file(b" ").is_err());
assert!(validate_file(b"-").is_err());
assert!(validate_file(b"a").is_ok());
}
#[test]
fn test_0xff() {
assert_eq!(
validate_file(b"file\xFFname"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0xFF
})
);
}
#[test]
#[cfg(feature = "block-colon")]
fn test_colon_blocked() {
assert_eq!(
validate_file(b"file:name"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: b':'
})
);
}
#[test]
#[cfg(not(feature = "block-colon"))]
fn test_colon_allowed() {
assert!(is_file_safe("file:name"));
}
#[test]
#[cfg(feature = "block-backslash")]
fn test_backslash_blocked() {
assert_eq!(
validate_file(b"file\\name"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: b'\\'
})
);
}
#[test]
#[cfg(not(feature = "block-backslash"))]
fn test_backslash_allowed() {
assert!(is_file_safe("file\\name"));
}
#[test]
#[cfg(feature = "require-utf8")]
fn test_invalid_utf8() {
assert_eq!(
validate_file(b"file\x80name"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0x80
})
);
assert!(is_file_safe("valid_utf8_αβγ"));
}
#[test]
#[cfg(feature = "require-ascii")]
fn test_non_ascii_rejected() {
assert_eq!(
validate_file(b"file\x80name"),
Err(SafeNameError::InvalidByte {
index: 4,
byte: 0x80
})
);
assert_eq!(
validate_file(b"\xFFstart"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: 0xFF
})
);
assert!(!is_file_safe("unicode_αβγ.txt"));
assert!(is_file_safe("ascii_only.txt"));
}
#[test]
fn test_asref_accepts_various_types() {
assert!(is_file_safe("file.txt"));
assert!(is_file_safe(b"file.txt"));
assert!(is_file_safe(String::from("file.txt")));
assert!(is_file_safe(vec![b'f', b'i', b'l', b'e']));
}
#[test]
fn test_trailing_nul_disabled_by_default() {
assert!(validate_file(b"file\x00").is_err());
}
#[test]
#[cfg(feature = "require-utf8")]
fn test_single_byte_non_ascii_utf8_error() {
assert_eq!(
validate_file(b"\x80"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: 0x80
})
);
assert_eq!(
validate_file(b"\xFF"),
Err(SafeNameError::InvalidByte {
index: 0,
byte: 0xFF
})
);
}
proptest! {
#[test]
fn prop_length_limit_enforced(len in 256usize..512) {
let input = vec![b'a'; len];
prop_assert!(validate_file(&input).is_err(), "Length {} not rejected", len);
}
#[test]
fn prop_alphanumeric_always_valid(input in "[a-zA-Z0-9]{1,100}") {
prop_assert!(validate_file(input.as_bytes()).is_ok(), "Rejected: {:?}", input);
}
#[test]
fn prop_control_chars_blocked(
prefix in "[a-z]{1,5}",
ctrl in 0u8..=0x1F,
suffix in "[a-z]{1,5}"
) {
let mut input = prefix.into_bytes();
input.push(ctrl);
input.extend_from_slice(suffix.as_bytes());
prop_assert!(validate_file(&input).is_err(), "Control char 0x{:02X} not blocked", ctrl);
}
#[test]
fn prop_middle_position_allowed(
prefix in "[a-z]{1,5}",
ch in prop_oneof![Just('-'), Just('~')],
suffix in "[a-z]{1,5}"
) {
let input = format!("{}{}{}", prefix, ch, suffix);
prop_assert!(validate_file(input.as_bytes()).is_ok(), "Rejected: {:?}", input);
}
#[test]
fn prop_leading_blocked(
ch in prop_oneof![Just('-'), Just('~'), Just(' ')],
suffix in "[a-z]{1,10}"
) {
let input = format!("{}{}", ch, suffix);
prop_assert!(validate_file(input.as_bytes()).is_err(), "Not blocked: {:?}", input);
}
#[test]
fn prop_trailing_space_blocked(prefix in "[a-z]{1,10}") {
let input = format!("{} ", prefix);
prop_assert!(validate_file(input.as_bytes()).is_err(), "Not blocked: {:?}", input);
}
#[test]
fn prop_trailing_nuls_trimmed(nul_count in 1usize..100) {
let opts = FileValidationOptions {
allow_trailing_nul: true,
..Default::default()
};
let mut filename = b"file".to_vec();
filename.extend(vec![0u8; nul_count]);
prop_assert!(
validate_file_with_options(&filename, &opts).is_ok(),
"Filename with {} trailing NULs rejected", nul_count
);
}
#[test]
fn prop_only_nuls_empty(nul_count in 1usize..100) {
let opts = FileValidationOptions {
allow_trailing_nul: true,
..Default::default()
};
let filename = vec![0u8; nul_count];
prop_assert!(
validate_file_with_options(&filename, &opts).is_err(),
"Filename with only {} NULs should be empty error", nul_count
);
}
#[test]
fn prop_embedded_nul_blocked(
prefix in "[a-z]{1,5}",
suffix in "[a-z]{1,5}",
trailing_nuls in 0usize..10
) {
let opts = FileValidationOptions {
allow_trailing_nul: true,
..Default::default()
};
let mut filename = prefix.into_bytes();
filename.push(0);
filename.extend_from_slice(suffix.as_bytes());
filename.extend(vec![0u8; trailing_nuls]);
prop_assert!(
validate_file_with_options(&filename, &opts).is_err(),
"Embedded NUL not blocked: {:?}", filename
);
}
}
}