Skip to main content

bashkit/fs/
limits.rs

1//! Filesystem resource limits for virtual execution.
2//!
3//! These limits prevent scripts from exhausting memory via filesystem operations.
4//!
5//! # Security Mitigations
6//!
7//! This module mitigates the following threats (see `specs/006-threat-model.md`):
8//!
9//! - **TM-DOS-005**: Large file creation → `max_file_size`
10//! - **TM-DOS-006**: Many small files → `max_file_count`
11//! - **TM-DOS-007**: Zip bomb decompression → limits checked during extraction
12//! - **TM-DOS-008**: Tar bomb extraction → `max_total_bytes`, `max_file_count`
13//! - **TM-DOS-009**: Recursive copy → `max_total_bytes`
14//! - **TM-DOS-010**: Append flood → `max_total_bytes`, `max_file_size`
15//! - **TM-DOS-012**: Deep directory nesting → `max_path_depth`
16//! - **TM-DOS-013**: Long filenames → `max_filename_length`, `max_path_length`
17//! - **TM-DOS-014**: Many directory entries → `max_file_count`
18//! - **TM-DOS-015**: Unicode path attacks → `validate_path()` control char rejection
19
20use std::fmt;
21use std::path::Path;
22
23/// Default maximum total filesystem size: 100MB
24pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 100_000_000;
25
26/// Default maximum single file size: 10MB
27pub const DEFAULT_MAX_FILE_SIZE: u64 = 10_000_000;
28
29/// Default maximum file count: 10,000
30pub const DEFAULT_MAX_FILE_COUNT: u64 = 10_000;
31
32/// Default maximum directory count: 10,000
33pub const DEFAULT_MAX_DIR_COUNT: u64 = 10_000;
34
35/// Default maximum path depth (directory nesting): 100
36pub const DEFAULT_MAX_PATH_DEPTH: usize = 100;
37
38/// Default maximum filename (single component) length: 255 bytes
39pub const DEFAULT_MAX_FILENAME_LENGTH: usize = 255;
40
41/// Default maximum total path length: 4096 bytes
42pub const DEFAULT_MAX_PATH_LENGTH: usize = 4096;
43
44/// Filesystem resource limits.
45///
46/// Controls maximum resource consumption for in-memory filesystems.
47/// Applied to both [`InMemoryFs`](crate::InMemoryFs) and [`OverlayFs`](crate::OverlayFs).
48///
49/// # Example
50///
51/// ```rust
52/// use bashkit::{Bash, FsLimits, InMemoryFs};
53/// use std::sync::Arc;
54///
55/// // Create filesystem with custom limits
56/// let limits = FsLimits::new()
57///     .max_total_bytes(50_000_000)  // 50MB total
58///     .max_file_size(5_000_000)     // 5MB per file
59///     .max_file_count(1000);        // 1000 files max
60///
61/// let fs = Arc::new(InMemoryFs::with_limits(limits));
62/// let bash = Bash::builder().fs(fs).build();
63/// ```
64///
65/// # Default Limits
66///
67/// | Limit | Default | Purpose |
68/// |-------|---------|---------|
69/// | `max_total_bytes` | 100MB | Total filesystem memory |
70/// | `max_file_size` | 10MB | Single file size |
71/// | `max_file_count` | 10,000 | Number of files |
72/// | `max_dir_count` | 10,000 | Number of directories |
73/// | `max_path_depth` | 100 | Directory nesting depth |
74/// | `max_filename_length` | 255 | Single path component |
75/// | `max_path_length` | 4096 | Total path length |
76#[derive(Debug, Clone)]
77pub struct FsLimits {
78    /// Maximum total bytes across all files.
79    /// Default: 100MB (100,000,000 bytes)
80    pub max_total_bytes: u64,
81
82    /// Maximum size of a single file in bytes.
83    /// Default: 10MB (10,000,000 bytes)
84    pub max_file_size: u64,
85
86    /// Maximum number of files (not including directories).
87    /// Default: 10,000
88    pub max_file_count: u64,
89
90    // THREAT[TM-DOS-037]: Unbounded directory creation via chmod CoW
91    // Mitigation: Limit maximum directory count
92    /// Maximum number of directories.
93    /// Default: 10,000
94    pub max_dir_count: u64,
95
96    // THREAT[TM-DOS-012]: Deep directory nesting can cause stack/memory exhaustion
97    // Mitigation: Limit maximum path component count
98    /// Maximum directory nesting depth.
99    /// Default: 100
100    pub max_path_depth: usize,
101
102    // THREAT[TM-DOS-013]: Long filenames can cause memory exhaustion
103    // Mitigation: Limit filename and total path length
104    /// Maximum length of a single filename (path component) in bytes.
105    /// Default: 255 bytes
106    pub max_filename_length: usize,
107
108    /// Maximum total path length in bytes.
109    /// Default: 4096 bytes
110    pub max_path_length: usize,
111}
112
113impl Default for FsLimits {
114    fn default() -> Self {
115        Self {
116            max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
117            max_file_size: DEFAULT_MAX_FILE_SIZE,
118            max_file_count: DEFAULT_MAX_FILE_COUNT,
119            max_dir_count: DEFAULT_MAX_DIR_COUNT,
120            max_path_depth: DEFAULT_MAX_PATH_DEPTH,
121            max_filename_length: DEFAULT_MAX_FILENAME_LENGTH,
122            max_path_length: DEFAULT_MAX_PATH_LENGTH,
123        }
124    }
125}
126
127impl FsLimits {
128    /// Create new limits with defaults.
129    ///
130    /// # Example
131    ///
132    /// ```rust
133    /// use bashkit::FsLimits;
134    ///
135    /// let limits = FsLimits::new();
136    /// assert_eq!(limits.max_total_bytes, 100_000_000);
137    /// ```
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Create unlimited limits (no restrictions).
143    ///
144    /// # Warning
145    ///
146    /// Using unlimited limits removes protection against memory exhaustion.
147    /// Only use in trusted environments.
148    ///
149    /// # Example
150    ///
151    /// ```rust
152    /// use bashkit::FsLimits;
153    ///
154    /// let limits = FsLimits::unlimited();
155    /// assert_eq!(limits.max_total_bytes, u64::MAX);
156    /// ```
157    pub fn unlimited() -> Self {
158        Self {
159            max_total_bytes: u64::MAX,
160            max_file_size: u64::MAX,
161            max_file_count: u64::MAX,
162            max_dir_count: u64::MAX,
163            max_path_depth: usize::MAX,
164            max_filename_length: usize::MAX,
165            max_path_length: usize::MAX,
166        }
167    }
168
169    /// Set maximum total filesystem size.
170    ///
171    /// # Example
172    ///
173    /// ```rust
174    /// use bashkit::FsLimits;
175    ///
176    /// let limits = FsLimits::new().max_total_bytes(50_000_000); // 50MB
177    /// ```
178    pub fn max_total_bytes(mut self, bytes: u64) -> Self {
179        self.max_total_bytes = bytes;
180        self
181    }
182
183    /// Set maximum single file size.
184    ///
185    /// # Example
186    ///
187    /// ```rust
188    /// use bashkit::FsLimits;
189    ///
190    /// let limits = FsLimits::new().max_file_size(1_000_000); // 1MB
191    /// ```
192    pub fn max_file_size(mut self, bytes: u64) -> Self {
193        self.max_file_size = bytes;
194        self
195    }
196
197    /// Set maximum file count.
198    ///
199    /// # Example
200    ///
201    /// ```rust
202    /// use bashkit::FsLimits;
203    ///
204    /// let limits = FsLimits::new().max_file_count(100);
205    /// ```
206    pub fn max_file_count(mut self, count: u64) -> Self {
207        self.max_file_count = count;
208        self
209    }
210
211    /// Set maximum directory count.
212    pub fn max_dir_count(mut self, count: u64) -> Self {
213        self.max_dir_count = count;
214        self
215    }
216
217    /// Set maximum path depth (directory nesting).
218    pub fn max_path_depth(mut self, depth: usize) -> Self {
219        self.max_path_depth = depth;
220        self
221    }
222
223    /// Set maximum filename (single component) length.
224    pub fn max_filename_length(mut self, len: usize) -> Self {
225        self.max_filename_length = len;
226        self
227    }
228
229    /// Set maximum total path length.
230    pub fn max_path_length(mut self, len: usize) -> Self {
231        self.max_path_length = len;
232        self
233    }
234
235    // THREAT[TM-DOS-012]: Deep directory nesting can exhaust stack/memory
236    // THREAT[TM-DOS-013]: Long filenames/paths can exhaust memory
237    // THREAT[TM-DOS-015]: Unicode control chars can cause path confusion
238    // Mitigation: Validate all three properties before accepting a path
239    /// Validate a path against depth, length, and character safety limits.
240    ///
241    /// Returns `Err(FsLimitExceeded)` if the path violates any limit.
242    pub fn validate_path(&self, path: &Path) -> Result<(), FsLimitExceeded> {
243        let path_str = path.to_string_lossy();
244        let path_len = path_str.len();
245
246        // TM-DOS-013: Check total path length
247        if path_len > self.max_path_length {
248            return Err(FsLimitExceeded::PathTooLong {
249                length: path_len,
250                limit: self.max_path_length,
251            });
252        }
253
254        let mut depth: usize = 0;
255        for component in path.components() {
256            match component {
257                std::path::Component::Normal(name) => {
258                    let name_str = name.to_string_lossy();
259
260                    // TM-DOS-013: Check individual filename length
261                    if name_str.len() > self.max_filename_length {
262                        return Err(FsLimitExceeded::FilenameTooLong {
263                            length: name_str.len(),
264                            limit: self.max_filename_length,
265                        });
266                    }
267
268                    // TM-DOS-015: Reject control characters and bidi overrides
269                    if let Some(bad_char) = find_unsafe_path_char(&name_str) {
270                        return Err(FsLimitExceeded::UnsafePathChar {
271                            character: bad_char,
272                            component: name_str.to_string(),
273                        });
274                    }
275
276                    depth += 1;
277                }
278                std::path::Component::ParentDir => {
279                    depth = depth.saturating_sub(1);
280                }
281                _ => {}
282            }
283        }
284
285        // TM-DOS-012: Check path depth
286        if depth > self.max_path_depth {
287            return Err(FsLimitExceeded::PathTooDeep {
288                depth,
289                limit: self.max_path_depth,
290            });
291        }
292
293        Ok(())
294    }
295
296    /// Check if adding bytes would exceed total limit.
297    ///
298    /// Returns `Ok(())` if within limits, `Err(FsLimitExceeded)` otherwise.
299    pub fn check_total_bytes(&self, current: u64, additional: u64) -> Result<(), FsLimitExceeded> {
300        let new_total = current.saturating_add(additional);
301        if new_total > self.max_total_bytes {
302            return Err(FsLimitExceeded::TotalBytes {
303                current,
304                additional,
305                limit: self.max_total_bytes,
306            });
307        }
308        Ok(())
309    }
310
311    /// Check if a file size exceeds the limit.
312    pub fn check_file_size(&self, size: u64) -> Result<(), FsLimitExceeded> {
313        if size > self.max_file_size {
314            return Err(FsLimitExceeded::FileSize {
315                size,
316                limit: self.max_file_size,
317            });
318        }
319        Ok(())
320    }
321
322    /// Check if adding a file would exceed the count limit.
323    pub fn check_file_count(&self, current: u64) -> Result<(), FsLimitExceeded> {
324        if current >= self.max_file_count {
325            return Err(FsLimitExceeded::FileCount {
326                current,
327                limit: self.max_file_count,
328            });
329        }
330        Ok(())
331    }
332
333    /// Check if adding a directory would exceed the directory count limit.
334    pub fn check_dir_count(&self, current: u64) -> Result<(), FsLimitExceeded> {
335        if current >= self.max_dir_count {
336            return Err(FsLimitExceeded::DirCount {
337                current,
338                limit: self.max_dir_count,
339            });
340        }
341        Ok(())
342    }
343}
344
345// THREAT[TM-DOS-015]: Unicode control chars and bidi overrides can cause path confusion
346// Mitigation: Reject paths containing these characters
347/// Check if a path component contains unsafe characters.
348///
349/// Returns `Some(description)` for the first unsafe character found.
350/// Rejects: ASCII control chars (0x00-0x1F, 0x7F), C1 controls (0x80-0x9F),
351/// and Unicode bidi override characters (U+202A-U+202E, U+2066-U+2069).
352fn find_unsafe_path_char(name: &str) -> Option<String> {
353    for ch in name.chars() {
354        // ASCII control characters (except we allow nothing - null is already
355        // impossible in Rust strings)
356        if ch.is_ascii_control() {
357            return Some(format!("U+{:04X}", ch as u32));
358        }
359        // C1 control characters
360        if ('\u{0080}'..='\u{009F}').contains(&ch) {
361            return Some(format!("U+{:04X}", ch as u32));
362        }
363        // Bidi override characters - can cause visual path confusion
364        if ('\u{202A}'..='\u{202E}').contains(&ch) || ('\u{2066}'..='\u{2069}').contains(&ch) {
365            return Some(format!("U+{:04X} (bidi override)", ch as u32));
366        }
367    }
368    None
369}
370
371/// Error returned when a filesystem limit is exceeded.
372#[derive(Debug, Clone)]
373pub enum FsLimitExceeded {
374    /// Total filesystem size would exceed limit.
375    TotalBytes {
376        current: u64,
377        additional: u64,
378        limit: u64,
379    },
380    /// Single file size exceeds limit.
381    FileSize { size: u64, limit: u64 },
382    /// File count would exceed limit.
383    FileCount { current: u64, limit: u64 },
384    /// Directory count would exceed limit (TM-DOS-037).
385    DirCount { current: u64, limit: u64 },
386    /// Path depth (nesting) exceeds limit (TM-DOS-012).
387    PathTooDeep { depth: usize, limit: usize },
388    /// Single filename component exceeds length limit (TM-DOS-013).
389    FilenameTooLong { length: usize, limit: usize },
390    /// Total path exceeds length limit (TM-DOS-013).
391    PathTooLong { length: usize, limit: usize },
392    /// Path contains unsafe character (TM-DOS-015).
393    UnsafePathChar {
394        character: String,
395        component: String,
396    },
397}
398
399impl fmt::Display for FsLimitExceeded {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        match self {
402            FsLimitExceeded::TotalBytes {
403                current,
404                additional,
405                limit,
406            } => {
407                write!(
408                    f,
409                    "filesystem full: {} + {} bytes exceeds {} byte limit",
410                    current, additional, limit
411                )
412            }
413            FsLimitExceeded::FileSize { size, limit } => {
414                write!(
415                    f,
416                    "file too large: {} bytes exceeds {} byte limit",
417                    size, limit
418                )
419            }
420            FsLimitExceeded::FileCount { current, limit } => {
421                write!(
422                    f,
423                    "too many files: {} files at {} file limit",
424                    current, limit
425                )
426            }
427            FsLimitExceeded::DirCount { current, limit } => {
428                write!(
429                    f,
430                    "too many directories: {} directories at {} directory limit",
431                    current, limit
432                )
433            }
434            FsLimitExceeded::PathTooDeep { depth, limit } => {
435                write!(
436                    f,
437                    "path too deep: {} levels exceeds {} level limit",
438                    depth, limit
439                )
440            }
441            FsLimitExceeded::FilenameTooLong { length, limit } => {
442                write!(
443                    f,
444                    "filename too long: {} bytes exceeds {} byte limit",
445                    length, limit
446                )
447            }
448            FsLimitExceeded::PathTooLong { length, limit } => {
449                write!(
450                    f,
451                    "path too long: {} bytes exceeds {} byte limit",
452                    length, limit
453                )
454            }
455            FsLimitExceeded::UnsafePathChar {
456                character,
457                component,
458            } => {
459                write!(
460                    f,
461                    "unsafe character {} in path component '{}'",
462                    character, component
463                )
464            }
465        }
466    }
467}
468
469impl std::error::Error for FsLimitExceeded {}
470
471/// Current filesystem usage statistics.
472///
473/// Returned by [`FileSystemExt::usage()`](crate::FileSystemExt::usage).
474#[derive(Debug, Clone, Default)]
475pub struct FsUsage {
476    /// Total bytes used by all files.
477    pub total_bytes: u64,
478    /// Number of files (not including directories).
479    pub file_count: u64,
480    /// Number of directories.
481    pub dir_count: u64,
482}
483
484impl FsUsage {
485    /// Create new usage stats.
486    pub fn new(total_bytes: u64, file_count: u64, dir_count: u64) -> Self {
487        Self {
488            total_bytes,
489            file_count,
490            dir_count,
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use std::path::PathBuf;
499
500    #[test]
501    fn test_default_limits() {
502        let limits = FsLimits::default();
503        assert_eq!(limits.max_total_bytes, 100_000_000);
504        assert_eq!(limits.max_file_size, 10_000_000);
505        assert_eq!(limits.max_file_count, 10_000);
506        assert_eq!(limits.max_dir_count, 10_000);
507        assert_eq!(limits.max_path_depth, 100);
508        assert_eq!(limits.max_filename_length, 255);
509        assert_eq!(limits.max_path_length, 4096);
510    }
511
512    #[test]
513    fn test_unlimited() {
514        let limits = FsLimits::unlimited();
515        assert_eq!(limits.max_total_bytes, u64::MAX);
516        assert_eq!(limits.max_file_size, u64::MAX);
517        assert_eq!(limits.max_file_count, u64::MAX);
518        assert_eq!(limits.max_dir_count, u64::MAX);
519        assert_eq!(limits.max_path_depth, usize::MAX);
520        assert_eq!(limits.max_filename_length, usize::MAX);
521        assert_eq!(limits.max_path_length, usize::MAX);
522    }
523
524    #[test]
525    fn test_builder() {
526        let limits = FsLimits::new()
527            .max_total_bytes(50_000_000)
528            .max_file_size(1_000_000)
529            .max_file_count(100);
530
531        assert_eq!(limits.max_total_bytes, 50_000_000);
532        assert_eq!(limits.max_file_size, 1_000_000);
533        assert_eq!(limits.max_file_count, 100);
534    }
535
536    #[test]
537    fn test_check_total_bytes() {
538        let limits = FsLimits::new().max_total_bytes(1000);
539
540        assert!(limits.check_total_bytes(500, 400).is_ok());
541        assert!(limits.check_total_bytes(500, 500).is_ok());
542        assert!(limits.check_total_bytes(500, 501).is_err());
543        assert!(limits.check_total_bytes(1000, 1).is_err());
544    }
545
546    #[test]
547    fn test_check_file_size() {
548        let limits = FsLimits::new().max_file_size(1000);
549
550        assert!(limits.check_file_size(999).is_ok());
551        assert!(limits.check_file_size(1000).is_ok());
552        assert!(limits.check_file_size(1001).is_err());
553    }
554
555    #[test]
556    fn test_check_file_count() {
557        let limits = FsLimits::new().max_file_count(10);
558
559        assert!(limits.check_file_count(9).is_ok());
560        assert!(limits.check_file_count(10).is_err());
561        assert!(limits.check_file_count(11).is_err());
562    }
563
564    #[test]
565    fn test_error_display() {
566        let err = FsLimitExceeded::TotalBytes {
567            current: 90,
568            additional: 20,
569            limit: 100,
570        };
571        assert!(err.to_string().contains("90"));
572        assert!(err.to_string().contains("20"));
573        assert!(err.to_string().contains("100"));
574
575        let err = FsLimitExceeded::FileSize {
576            size: 200,
577            limit: 100,
578        };
579        assert!(err.to_string().contains("200"));
580        assert!(err.to_string().contains("100"));
581
582        let err = FsLimitExceeded::FileCount {
583            current: 10,
584            limit: 10,
585        };
586        assert!(err.to_string().contains("10"));
587    }
588
589    // TM-DOS-012: Path depth validation
590    #[test]
591    fn test_validate_path_depth_ok() {
592        let limits = FsLimits::new().max_path_depth(3);
593        assert!(limits.validate_path(Path::new("/a/b/c")).is_ok());
594    }
595
596    #[test]
597    fn test_validate_path_depth_exceeded() {
598        let limits = FsLimits::new().max_path_depth(3);
599        assert!(limits.validate_path(Path::new("/a/b/c/d")).is_err());
600        let err = limits.validate_path(Path::new("/a/b/c/d")).unwrap_err();
601        assert!(err.to_string().contains("path too deep"));
602    }
603
604    #[test]
605    fn test_validate_path_depth_with_parent_refs() {
606        let limits = FsLimits::new().max_path_depth(3);
607        // /a/b/../c/d = /a/c/d which is depth 3 — OK
608        assert!(limits.validate_path(Path::new("/a/b/../c/d")).is_ok());
609    }
610
611    // TM-DOS-013: Filename length validation
612    #[test]
613    fn test_validate_filename_length_ok() {
614        let limits = FsLimits::new().max_filename_length(10);
615        assert!(limits.validate_path(Path::new("/tmp/short.txt")).is_ok());
616    }
617
618    #[test]
619    fn test_validate_filename_length_exceeded() {
620        let limits = FsLimits::new().max_filename_length(10);
621        let long_name = "a".repeat(11);
622        let path = PathBuf::from(format!("/tmp/{}", long_name));
623        assert!(limits.validate_path(&path).is_err());
624        let err = limits.validate_path(&path).unwrap_err();
625        assert!(err.to_string().contains("filename too long"));
626    }
627
628    // TM-DOS-013: Total path length validation
629    #[test]
630    fn test_validate_path_length_exceeded() {
631        let limits = FsLimits::new().max_path_length(20);
632        let path = PathBuf::from("/this/is/a/very/long/path/that/exceeds");
633        assert!(limits.validate_path(&path).is_err());
634        let err = limits.validate_path(&path).unwrap_err();
635        assert!(err.to_string().contains("path too long"));
636    }
637
638    // TM-DOS-015: Unicode path safety
639    #[test]
640    fn test_validate_path_control_char_rejected() {
641        let limits = FsLimits::new();
642        let path = PathBuf::from("/tmp/file\x01name");
643        assert!(limits.validate_path(&path).is_err());
644        let err = limits.validate_path(&path).unwrap_err();
645        assert!(err.to_string().contains("unsafe character"));
646    }
647
648    #[test]
649    fn test_validate_path_bidi_override_rejected() {
650        let limits = FsLimits::new();
651        let path = PathBuf::from("/tmp/file\u{202E}name");
652        assert!(limits.validate_path(&path).is_err());
653        let err = limits.validate_path(&path).unwrap_err();
654        assert!(err.to_string().contains("bidi override"));
655    }
656
657    #[test]
658    fn test_validate_path_normal_unicode_ok() {
659        let limits = FsLimits::new();
660        // Normal unicode (accented chars, CJK, emoji) should be fine
661        assert!(limits.validate_path(Path::new("/tmp/café")).is_ok());
662        assert!(limits.validate_path(Path::new("/tmp/文件")).is_ok());
663    }
664}