1use std::fmt;
21use std::path::Path;
22
23pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 100_000_000;
25
26pub const DEFAULT_MAX_FILE_SIZE: u64 = 10_000_000;
28
29pub const DEFAULT_MAX_FILE_COUNT: u64 = 10_000;
31
32pub const DEFAULT_MAX_PATH_DEPTH: usize = 100;
34
35pub const DEFAULT_MAX_FILENAME_LENGTH: usize = 255;
37
38pub const DEFAULT_MAX_PATH_LENGTH: usize = 4096;
40
41#[derive(Debug, Clone)]
73pub struct FsLimits {
74 pub max_total_bytes: u64,
77
78 pub max_file_size: u64,
81
82 pub max_file_count: u64,
85
86 pub max_path_depth: usize,
91
92 pub max_filename_length: usize,
97
98 pub max_path_length: usize,
101}
102
103impl Default for FsLimits {
104 fn default() -> Self {
105 Self {
106 max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
107 max_file_size: DEFAULT_MAX_FILE_SIZE,
108 max_file_count: DEFAULT_MAX_FILE_COUNT,
109 max_path_depth: DEFAULT_MAX_PATH_DEPTH,
110 max_filename_length: DEFAULT_MAX_FILENAME_LENGTH,
111 max_path_length: DEFAULT_MAX_PATH_LENGTH,
112 }
113 }
114}
115
116impl FsLimits {
117 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn unlimited() -> Self {
147 Self {
148 max_total_bytes: u64::MAX,
149 max_file_size: u64::MAX,
150 max_file_count: u64::MAX,
151 max_path_depth: usize::MAX,
152 max_filename_length: usize::MAX,
153 max_path_length: usize::MAX,
154 }
155 }
156
157 pub fn max_total_bytes(mut self, bytes: u64) -> Self {
167 self.max_total_bytes = bytes;
168 self
169 }
170
171 pub fn max_file_size(mut self, bytes: u64) -> Self {
181 self.max_file_size = bytes;
182 self
183 }
184
185 pub fn max_file_count(mut self, count: u64) -> Self {
195 self.max_file_count = count;
196 self
197 }
198
199 pub fn max_path_depth(mut self, depth: usize) -> Self {
201 self.max_path_depth = depth;
202 self
203 }
204
205 pub fn max_filename_length(mut self, len: usize) -> Self {
207 self.max_filename_length = len;
208 self
209 }
210
211 pub fn max_path_length(mut self, len: usize) -> Self {
213 self.max_path_length = len;
214 self
215 }
216
217 pub fn validate_path(&self, path: &Path) -> Result<(), FsLimitExceeded> {
225 let path_str = path.to_string_lossy();
226 let path_len = path_str.len();
227
228 if path_len > self.max_path_length {
230 return Err(FsLimitExceeded::PathTooLong {
231 length: path_len,
232 limit: self.max_path_length,
233 });
234 }
235
236 let mut depth: usize = 0;
237 for component in path.components() {
238 match component {
239 std::path::Component::Normal(name) => {
240 let name_str = name.to_string_lossy();
241
242 if name_str.len() > self.max_filename_length {
244 return Err(FsLimitExceeded::FilenameTooLong {
245 length: name_str.len(),
246 limit: self.max_filename_length,
247 });
248 }
249
250 if let Some(bad_char) = find_unsafe_path_char(&name_str) {
252 return Err(FsLimitExceeded::UnsafePathChar {
253 character: bad_char,
254 component: name_str.to_string(),
255 });
256 }
257
258 depth += 1;
259 }
260 std::path::Component::ParentDir => {
261 depth = depth.saturating_sub(1);
262 }
263 _ => {}
264 }
265 }
266
267 if depth > self.max_path_depth {
269 return Err(FsLimitExceeded::PathTooDeep {
270 depth,
271 limit: self.max_path_depth,
272 });
273 }
274
275 Ok(())
276 }
277
278 pub fn check_total_bytes(&self, current: u64, additional: u64) -> Result<(), FsLimitExceeded> {
282 let new_total = current.saturating_add(additional);
283 if new_total > self.max_total_bytes {
284 return Err(FsLimitExceeded::TotalBytes {
285 current,
286 additional,
287 limit: self.max_total_bytes,
288 });
289 }
290 Ok(())
291 }
292
293 pub fn check_file_size(&self, size: u64) -> Result<(), FsLimitExceeded> {
295 if size > self.max_file_size {
296 return Err(FsLimitExceeded::FileSize {
297 size,
298 limit: self.max_file_size,
299 });
300 }
301 Ok(())
302 }
303
304 pub fn check_file_count(&self, current: u64) -> Result<(), FsLimitExceeded> {
306 if current >= self.max_file_count {
307 return Err(FsLimitExceeded::FileCount {
308 current,
309 limit: self.max_file_count,
310 });
311 }
312 Ok(())
313 }
314}
315
316fn find_unsafe_path_char(name: &str) -> Option<String> {
324 for ch in name.chars() {
325 if ch.is_ascii_control() {
328 return Some(format!("U+{:04X}", ch as u32));
329 }
330 if ('\u{0080}'..='\u{009F}').contains(&ch) {
332 return Some(format!("U+{:04X}", ch as u32));
333 }
334 if ('\u{202A}'..='\u{202E}').contains(&ch) || ('\u{2066}'..='\u{2069}').contains(&ch) {
336 return Some(format!("U+{:04X} (bidi override)", ch as u32));
337 }
338 }
339 None
340}
341
342#[derive(Debug, Clone)]
344pub enum FsLimitExceeded {
345 TotalBytes {
347 current: u64,
348 additional: u64,
349 limit: u64,
350 },
351 FileSize { size: u64, limit: u64 },
353 FileCount { current: u64, limit: u64 },
355 PathTooDeep { depth: usize, limit: usize },
357 FilenameTooLong { length: usize, limit: usize },
359 PathTooLong { length: usize, limit: usize },
361 UnsafePathChar {
363 character: String,
364 component: String,
365 },
366}
367
368impl fmt::Display for FsLimitExceeded {
369 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370 match self {
371 FsLimitExceeded::TotalBytes {
372 current,
373 additional,
374 limit,
375 } => {
376 write!(
377 f,
378 "filesystem full: {} + {} bytes exceeds {} byte limit",
379 current, additional, limit
380 )
381 }
382 FsLimitExceeded::FileSize { size, limit } => {
383 write!(
384 f,
385 "file too large: {} bytes exceeds {} byte limit",
386 size, limit
387 )
388 }
389 FsLimitExceeded::FileCount { current, limit } => {
390 write!(
391 f,
392 "too many files: {} files at {} file limit",
393 current, limit
394 )
395 }
396 FsLimitExceeded::PathTooDeep { depth, limit } => {
397 write!(
398 f,
399 "path too deep: {} levels exceeds {} level limit",
400 depth, limit
401 )
402 }
403 FsLimitExceeded::FilenameTooLong { length, limit } => {
404 write!(
405 f,
406 "filename too long: {} bytes exceeds {} byte limit",
407 length, limit
408 )
409 }
410 FsLimitExceeded::PathTooLong { length, limit } => {
411 write!(
412 f,
413 "path too long: {} bytes exceeds {} byte limit",
414 length, limit
415 )
416 }
417 FsLimitExceeded::UnsafePathChar {
418 character,
419 component,
420 } => {
421 write!(
422 f,
423 "unsafe character {} in path component '{}'",
424 character, component
425 )
426 }
427 }
428 }
429}
430
431impl std::error::Error for FsLimitExceeded {}
432
433#[derive(Debug, Clone, Default)]
437pub struct FsUsage {
438 pub total_bytes: u64,
440 pub file_count: u64,
442 pub dir_count: u64,
444}
445
446impl FsUsage {
447 pub fn new(total_bytes: u64, file_count: u64, dir_count: u64) -> Self {
449 Self {
450 total_bytes,
451 file_count,
452 dir_count,
453 }
454 }
455}
456
457#[cfg(test)]
458#[allow(clippy::unwrap_used)]
459mod tests {
460 use super::*;
461 use std::path::PathBuf;
462
463 #[test]
464 fn test_default_limits() {
465 let limits = FsLimits::default();
466 assert_eq!(limits.max_total_bytes, 100_000_000);
467 assert_eq!(limits.max_file_size, 10_000_000);
468 assert_eq!(limits.max_file_count, 10_000);
469 assert_eq!(limits.max_path_depth, 100);
470 assert_eq!(limits.max_filename_length, 255);
471 assert_eq!(limits.max_path_length, 4096);
472 }
473
474 #[test]
475 fn test_unlimited() {
476 let limits = FsLimits::unlimited();
477 assert_eq!(limits.max_total_bytes, u64::MAX);
478 assert_eq!(limits.max_file_size, u64::MAX);
479 assert_eq!(limits.max_file_count, u64::MAX);
480 assert_eq!(limits.max_path_depth, usize::MAX);
481 assert_eq!(limits.max_filename_length, usize::MAX);
482 assert_eq!(limits.max_path_length, usize::MAX);
483 }
484
485 #[test]
486 fn test_builder() {
487 let limits = FsLimits::new()
488 .max_total_bytes(50_000_000)
489 .max_file_size(1_000_000)
490 .max_file_count(100);
491
492 assert_eq!(limits.max_total_bytes, 50_000_000);
493 assert_eq!(limits.max_file_size, 1_000_000);
494 assert_eq!(limits.max_file_count, 100);
495 }
496
497 #[test]
498 fn test_check_total_bytes() {
499 let limits = FsLimits::new().max_total_bytes(1000);
500
501 assert!(limits.check_total_bytes(500, 400).is_ok());
502 assert!(limits.check_total_bytes(500, 500).is_ok());
503 assert!(limits.check_total_bytes(500, 501).is_err());
504 assert!(limits.check_total_bytes(1000, 1).is_err());
505 }
506
507 #[test]
508 fn test_check_file_size() {
509 let limits = FsLimits::new().max_file_size(1000);
510
511 assert!(limits.check_file_size(999).is_ok());
512 assert!(limits.check_file_size(1000).is_ok());
513 assert!(limits.check_file_size(1001).is_err());
514 }
515
516 #[test]
517 fn test_check_file_count() {
518 let limits = FsLimits::new().max_file_count(10);
519
520 assert!(limits.check_file_count(9).is_ok());
521 assert!(limits.check_file_count(10).is_err());
522 assert!(limits.check_file_count(11).is_err());
523 }
524
525 #[test]
526 fn test_error_display() {
527 let err = FsLimitExceeded::TotalBytes {
528 current: 90,
529 additional: 20,
530 limit: 100,
531 };
532 assert!(err.to_string().contains("90"));
533 assert!(err.to_string().contains("20"));
534 assert!(err.to_string().contains("100"));
535
536 let err = FsLimitExceeded::FileSize {
537 size: 200,
538 limit: 100,
539 };
540 assert!(err.to_string().contains("200"));
541 assert!(err.to_string().contains("100"));
542
543 let err = FsLimitExceeded::FileCount {
544 current: 10,
545 limit: 10,
546 };
547 assert!(err.to_string().contains("10"));
548 }
549
550 #[test]
552 fn test_validate_path_depth_ok() {
553 let limits = FsLimits::new().max_path_depth(3);
554 assert!(limits.validate_path(Path::new("/a/b/c")).is_ok());
555 }
556
557 #[test]
558 fn test_validate_path_depth_exceeded() {
559 let limits = FsLimits::new().max_path_depth(3);
560 assert!(limits.validate_path(Path::new("/a/b/c/d")).is_err());
561 let err = limits.validate_path(Path::new("/a/b/c/d")).unwrap_err();
562 assert!(err.to_string().contains("path too deep"));
563 }
564
565 #[test]
566 fn test_validate_path_depth_with_parent_refs() {
567 let limits = FsLimits::new().max_path_depth(3);
568 assert!(limits.validate_path(Path::new("/a/b/../c/d")).is_ok());
570 }
571
572 #[test]
574 fn test_validate_filename_length_ok() {
575 let limits = FsLimits::new().max_filename_length(10);
576 assert!(limits.validate_path(Path::new("/tmp/short.txt")).is_ok());
577 }
578
579 #[test]
580 fn test_validate_filename_length_exceeded() {
581 let limits = FsLimits::new().max_filename_length(10);
582 let long_name = "a".repeat(11);
583 let path = PathBuf::from(format!("/tmp/{}", long_name));
584 assert!(limits.validate_path(&path).is_err());
585 let err = limits.validate_path(&path).unwrap_err();
586 assert!(err.to_string().contains("filename too long"));
587 }
588
589 #[test]
591 fn test_validate_path_length_exceeded() {
592 let limits = FsLimits::new().max_path_length(20);
593 let path = PathBuf::from("/this/is/a/very/long/path/that/exceeds");
594 assert!(limits.validate_path(&path).is_err());
595 let err = limits.validate_path(&path).unwrap_err();
596 assert!(err.to_string().contains("path too long"));
597 }
598
599 #[test]
601 fn test_validate_path_control_char_rejected() {
602 let limits = FsLimits::new();
603 let path = PathBuf::from("/tmp/file\x01name");
604 assert!(limits.validate_path(&path).is_err());
605 let err = limits.validate_path(&path).unwrap_err();
606 assert!(err.to_string().contains("unsafe character"));
607 }
608
609 #[test]
610 fn test_validate_path_bidi_override_rejected() {
611 let limits = FsLimits::new();
612 let path = PathBuf::from("/tmp/file\u{202E}name");
613 assert!(limits.validate_path(&path).is_err());
614 let err = limits.validate_path(&path).unwrap_err();
615 assert!(err.to_string().contains("bidi override"));
616 }
617
618 #[test]
619 fn test_validate_path_normal_unicode_ok() {
620 let limits = FsLimits::new();
621 assert!(limits.validate_path(Path::new("/tmp/café")).is_ok());
623 assert!(limits.validate_path(Path::new("/tmp/文件")).is_ok());
624 }
625}