use std::fmt;
use std::path::Path;
pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 100_000_000;
pub const DEFAULT_MAX_FILE_SIZE: u64 = 10_000_000;
pub const DEFAULT_MAX_FILE_COUNT: u64 = 10_000;
pub const DEFAULT_MAX_DIR_COUNT: u64 = 10_000;
pub const DEFAULT_MAX_PATH_DEPTH: usize = 100;
pub const DEFAULT_MAX_FILENAME_LENGTH: usize = 255;
pub const DEFAULT_MAX_PATH_LENGTH: usize = 4096;
#[derive(Debug, Clone)]
pub struct FsLimits {
pub max_total_bytes: u64,
pub max_file_size: u64,
pub max_file_count: u64,
pub max_dir_count: u64,
pub max_path_depth: usize,
pub max_filename_length: usize,
pub max_path_length: usize,
}
impl Default for FsLimits {
fn default() -> Self {
Self {
max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
max_file_size: DEFAULT_MAX_FILE_SIZE,
max_file_count: DEFAULT_MAX_FILE_COUNT,
max_dir_count: DEFAULT_MAX_DIR_COUNT,
max_path_depth: DEFAULT_MAX_PATH_DEPTH,
max_filename_length: DEFAULT_MAX_FILENAME_LENGTH,
max_path_length: DEFAULT_MAX_PATH_LENGTH,
}
}
}
impl FsLimits {
pub fn new() -> Self {
Self::default()
}
pub fn unlimited() -> Self {
Self {
max_total_bytes: u64::MAX,
max_file_size: u64::MAX,
max_file_count: u64::MAX,
max_dir_count: u64::MAX,
max_path_depth: usize::MAX,
max_filename_length: usize::MAX,
max_path_length: usize::MAX,
}
}
pub fn max_total_bytes(mut self, bytes: u64) -> Self {
self.max_total_bytes = bytes;
self
}
pub fn max_file_size(mut self, bytes: u64) -> Self {
self.max_file_size = bytes;
self
}
pub fn max_file_count(mut self, count: u64) -> Self {
self.max_file_count = count;
self
}
pub fn max_dir_count(mut self, count: u64) -> Self {
self.max_dir_count = count;
self
}
pub fn max_path_depth(mut self, depth: usize) -> Self {
self.max_path_depth = depth;
self
}
pub fn max_filename_length(mut self, len: usize) -> Self {
self.max_filename_length = len;
self
}
pub fn max_path_length(mut self, len: usize) -> Self {
self.max_path_length = len;
self
}
pub fn validate_path(&self, path: &Path) -> Result<(), FsLimitExceeded> {
let path_str = path.to_string_lossy();
let path_len = path_str.len();
if path_len > self.max_path_length {
return Err(FsLimitExceeded::PathTooLong {
length: path_len,
limit: self.max_path_length,
});
}
let mut depth: usize = 0;
for component in path.components() {
match component {
std::path::Component::Normal(name) => {
let name_str = name.to_string_lossy();
if name_str.len() > self.max_filename_length {
return Err(FsLimitExceeded::FilenameTooLong {
length: name_str.len(),
limit: self.max_filename_length,
});
}
if let Some(bad_char) = find_unsafe_path_char(&name_str) {
return Err(FsLimitExceeded::UnsafePathChar {
character: bad_char,
component: name_str.to_string(),
});
}
depth += 1;
}
std::path::Component::ParentDir => {
depth = depth.saturating_sub(1);
}
_ => {}
}
}
if depth > self.max_path_depth {
return Err(FsLimitExceeded::PathTooDeep {
depth,
limit: self.max_path_depth,
});
}
Ok(())
}
pub fn check_total_bytes(&self, current: u64, additional: u64) -> Result<(), FsLimitExceeded> {
let new_total = current.saturating_add(additional);
if new_total > self.max_total_bytes {
return Err(FsLimitExceeded::TotalBytes {
current,
additional,
limit: self.max_total_bytes,
});
}
Ok(())
}
pub fn check_file_size(&self, size: u64) -> Result<(), FsLimitExceeded> {
if size > self.max_file_size {
return Err(FsLimitExceeded::FileSize {
size,
limit: self.max_file_size,
});
}
Ok(())
}
pub fn check_file_count(&self, current: u64) -> Result<(), FsLimitExceeded> {
if current >= self.max_file_count {
return Err(FsLimitExceeded::FileCount {
current,
limit: self.max_file_count,
});
}
Ok(())
}
pub fn check_dir_count(&self, current: u64) -> Result<(), FsLimitExceeded> {
if current >= self.max_dir_count {
return Err(FsLimitExceeded::DirCount {
current,
limit: self.max_dir_count,
});
}
Ok(())
}
}
fn find_unsafe_path_char(name: &str) -> Option<String> {
for ch in name.chars() {
if ch.is_ascii_control() {
return Some(format!("U+{:04X}", ch as u32));
}
if ('\u{0080}'..='\u{009F}').contains(&ch) {
return Some(format!("U+{:04X}", ch as u32));
}
if ('\u{202A}'..='\u{202E}').contains(&ch) || ('\u{2066}'..='\u{2069}').contains(&ch) {
return Some(format!("U+{:04X} (bidi override)", ch as u32));
}
}
None
}
#[derive(Debug, Clone)]
pub enum FsLimitExceeded {
TotalBytes {
current: u64,
additional: u64,
limit: u64,
},
FileSize { size: u64, limit: u64 },
FileCount { current: u64, limit: u64 },
DirCount { current: u64, limit: u64 },
PathTooDeep { depth: usize, limit: usize },
FilenameTooLong { length: usize, limit: usize },
PathTooLong { length: usize, limit: usize },
UnsafePathChar {
character: String,
component: String,
},
}
impl fmt::Display for FsLimitExceeded {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FsLimitExceeded::TotalBytes {
current,
additional,
limit,
} => {
write!(
f,
"filesystem full: {} + {} bytes exceeds {} byte limit",
current, additional, limit
)
}
FsLimitExceeded::FileSize { size, limit } => {
write!(
f,
"file too large: {} bytes exceeds {} byte limit",
size, limit
)
}
FsLimitExceeded::FileCount { current, limit } => {
write!(
f,
"too many files: {} files at {} file limit",
current, limit
)
}
FsLimitExceeded::DirCount { current, limit } => {
write!(
f,
"too many directories: {} directories at {} directory limit",
current, limit
)
}
FsLimitExceeded::PathTooDeep { depth, limit } => {
write!(
f,
"path too deep: {} levels exceeds {} level limit",
depth, limit
)
}
FsLimitExceeded::FilenameTooLong { length, limit } => {
write!(
f,
"filename too long: {} bytes exceeds {} byte limit",
length, limit
)
}
FsLimitExceeded::PathTooLong { length, limit } => {
write!(
f,
"path too long: {} bytes exceeds {} byte limit",
length, limit
)
}
FsLimitExceeded::UnsafePathChar {
character,
component,
} => {
write!(
f,
"unsafe character {} in path component '{}'",
character, component
)
}
}
}
}
impl std::error::Error for FsLimitExceeded {}
#[derive(Debug, Clone, Default)]
pub struct FsUsage {
pub total_bytes: u64,
pub file_count: u64,
pub dir_count: u64,
}
impl FsUsage {
pub fn new(total_bytes: u64, file_count: u64, dir_count: u64) -> Self {
Self {
total_bytes,
file_count,
dir_count,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_default_limits() {
let limits = FsLimits::default();
assert_eq!(limits.max_total_bytes, 100_000_000);
assert_eq!(limits.max_file_size, 10_000_000);
assert_eq!(limits.max_file_count, 10_000);
assert_eq!(limits.max_dir_count, 10_000);
assert_eq!(limits.max_path_depth, 100);
assert_eq!(limits.max_filename_length, 255);
assert_eq!(limits.max_path_length, 4096);
}
#[test]
fn test_unlimited() {
let limits = FsLimits::unlimited();
assert_eq!(limits.max_total_bytes, u64::MAX);
assert_eq!(limits.max_file_size, u64::MAX);
assert_eq!(limits.max_file_count, u64::MAX);
assert_eq!(limits.max_dir_count, u64::MAX);
assert_eq!(limits.max_path_depth, usize::MAX);
assert_eq!(limits.max_filename_length, usize::MAX);
assert_eq!(limits.max_path_length, usize::MAX);
}
#[test]
fn test_builder() {
let limits = FsLimits::new()
.max_total_bytes(50_000_000)
.max_file_size(1_000_000)
.max_file_count(100);
assert_eq!(limits.max_total_bytes, 50_000_000);
assert_eq!(limits.max_file_size, 1_000_000);
assert_eq!(limits.max_file_count, 100);
}
#[test]
fn test_check_total_bytes() {
let limits = FsLimits::new().max_total_bytes(1000);
assert!(limits.check_total_bytes(500, 400).is_ok());
assert!(limits.check_total_bytes(500, 500).is_ok());
assert!(limits.check_total_bytes(500, 501).is_err());
assert!(limits.check_total_bytes(1000, 1).is_err());
}
#[test]
fn test_check_file_size() {
let limits = FsLimits::new().max_file_size(1000);
assert!(limits.check_file_size(999).is_ok());
assert!(limits.check_file_size(1000).is_ok());
assert!(limits.check_file_size(1001).is_err());
}
#[test]
fn test_check_file_count() {
let limits = FsLimits::new().max_file_count(10);
assert!(limits.check_file_count(9).is_ok());
assert!(limits.check_file_count(10).is_err());
assert!(limits.check_file_count(11).is_err());
}
#[test]
fn test_error_display() {
let err = FsLimitExceeded::TotalBytes {
current: 90,
additional: 20,
limit: 100,
};
assert!(err.to_string().contains("90"));
assert!(err.to_string().contains("20"));
assert!(err.to_string().contains("100"));
let err = FsLimitExceeded::FileSize {
size: 200,
limit: 100,
};
assert!(err.to_string().contains("200"));
assert!(err.to_string().contains("100"));
let err = FsLimitExceeded::FileCount {
current: 10,
limit: 10,
};
assert!(err.to_string().contains("10"));
}
#[test]
fn test_validate_path_depth_ok() {
let limits = FsLimits::new().max_path_depth(3);
assert!(limits.validate_path(Path::new("/a/b/c")).is_ok());
}
#[test]
fn test_validate_path_depth_exceeded() {
let limits = FsLimits::new().max_path_depth(3);
assert!(limits.validate_path(Path::new("/a/b/c/d")).is_err());
let err = limits.validate_path(Path::new("/a/b/c/d")).unwrap_err();
assert!(err.to_string().contains("path too deep"));
}
#[test]
fn test_validate_path_depth_with_parent_refs() {
let limits = FsLimits::new().max_path_depth(3);
assert!(limits.validate_path(Path::new("/a/b/../c/d")).is_ok());
}
#[test]
fn test_validate_filename_length_ok() {
let limits = FsLimits::new().max_filename_length(10);
assert!(limits.validate_path(Path::new("/tmp/short.txt")).is_ok());
}
#[test]
fn test_validate_filename_length_exceeded() {
let limits = FsLimits::new().max_filename_length(10);
let long_name = "a".repeat(11);
let path = PathBuf::from(format!("/tmp/{}", long_name));
assert!(limits.validate_path(&path).is_err());
let err = limits.validate_path(&path).unwrap_err();
assert!(err.to_string().contains("filename too long"));
}
#[test]
fn test_validate_path_length_exceeded() {
let limits = FsLimits::new().max_path_length(20);
let path = PathBuf::from("/this/is/a/very/long/path/that/exceeds");
assert!(limits.validate_path(&path).is_err());
let err = limits.validate_path(&path).unwrap_err();
assert!(err.to_string().contains("path too long"));
}
#[test]
fn test_validate_path_control_char_rejected() {
let limits = FsLimits::new();
let path = PathBuf::from("/tmp/file\x01name");
assert!(limits.validate_path(&path).is_err());
let err = limits.validate_path(&path).unwrap_err();
assert!(err.to_string().contains("unsafe character"));
}
#[test]
fn test_validate_path_bidi_override_rejected() {
let limits = FsLimits::new();
let path = PathBuf::from("/tmp/file\u{202E}name");
assert!(limits.validate_path(&path).is_err());
let err = limits.validate_path(&path).unwrap_err();
assert!(err.to_string().contains("bidi override"));
}
#[test]
fn test_validate_path_normal_unicode_ok() {
let limits = FsLimits::new();
assert!(limits.validate_path(Path::new("/tmp/café")).is_ok());
assert!(limits.validate_path(Path::new("/tmp/文件")).is_ok());
}
}