Skip to main content

bashkit/fs/
limits.rs

1//! Filesystem resource limits for sandboxed 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 path depth (directory nesting): 100
33pub const DEFAULT_MAX_PATH_DEPTH: usize = 100;
34
35/// Default maximum filename (single component) length: 255 bytes
36pub const DEFAULT_MAX_FILENAME_LENGTH: usize = 255;
37
38/// Default maximum total path length: 4096 bytes
39pub const DEFAULT_MAX_PATH_LENGTH: usize = 4096;
40
41/// Filesystem resource limits.
42///
43/// Controls maximum resource consumption for in-memory filesystems.
44/// Applied to both [`InMemoryFs`](crate::InMemoryFs) and [`OverlayFs`](crate::OverlayFs).
45///
46/// # Example
47///
48/// ```rust
49/// use bashkit::{Bash, FsLimits, InMemoryFs};
50/// use std::sync::Arc;
51///
52/// // Create filesystem with custom limits
53/// let limits = FsLimits::new()
54///     .max_total_bytes(50_000_000)  // 50MB total
55///     .max_file_size(5_000_000)     // 5MB per file
56///     .max_file_count(1000);        // 1000 files max
57///
58/// let fs = Arc::new(InMemoryFs::with_limits(limits));
59/// let bash = Bash::builder().fs(fs).build();
60/// ```
61///
62/// # Default Limits
63///
64/// | Limit | Default | Purpose |
65/// |-------|---------|---------|
66/// | `max_total_bytes` | 100MB | Total filesystem memory |
67/// | `max_file_size` | 10MB | Single file size |
68/// | `max_file_count` | 10,000 | Number of files |
69/// | `max_path_depth` | 100 | Directory nesting depth |
70/// | `max_filename_length` | 255 | Single path component |
71/// | `max_path_length` | 4096 | Total path length |
72#[derive(Debug, Clone)]
73pub struct FsLimits {
74    /// Maximum total bytes across all files.
75    /// Default: 100MB (100,000,000 bytes)
76    pub max_total_bytes: u64,
77
78    /// Maximum size of a single file in bytes.
79    /// Default: 10MB (10,000,000 bytes)
80    pub max_file_size: u64,
81
82    /// Maximum number of files (not including directories).
83    /// Default: 10,000
84    pub max_file_count: u64,
85
86    // THREAT[TM-DOS-012]: Deep directory nesting can cause stack/memory exhaustion
87    // Mitigation: Limit maximum path component count
88    /// Maximum directory nesting depth.
89    /// Default: 100
90    pub max_path_depth: usize,
91
92    // THREAT[TM-DOS-013]: Long filenames can cause memory exhaustion
93    // Mitigation: Limit filename and total path length
94    /// Maximum length of a single filename (path component) in bytes.
95    /// Default: 255 bytes
96    pub max_filename_length: usize,
97
98    /// Maximum total path length in bytes.
99    /// Default: 4096 bytes
100    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    /// Create new limits with defaults.
118    ///
119    /// # Example
120    ///
121    /// ```rust
122    /// use bashkit::FsLimits;
123    ///
124    /// let limits = FsLimits::new();
125    /// assert_eq!(limits.max_total_bytes, 100_000_000);
126    /// ```
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Create unlimited limits (no restrictions).
132    ///
133    /// # Warning
134    ///
135    /// Using unlimited limits removes protection against memory exhaustion.
136    /// Only use in trusted environments.
137    ///
138    /// # Example
139    ///
140    /// ```rust
141    /// use bashkit::FsLimits;
142    ///
143    /// let limits = FsLimits::unlimited();
144    /// assert_eq!(limits.max_total_bytes, u64::MAX);
145    /// ```
146    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    /// Set maximum total filesystem size.
158    ///
159    /// # Example
160    ///
161    /// ```rust
162    /// use bashkit::FsLimits;
163    ///
164    /// let limits = FsLimits::new().max_total_bytes(50_000_000); // 50MB
165    /// ```
166    pub fn max_total_bytes(mut self, bytes: u64) -> Self {
167        self.max_total_bytes = bytes;
168        self
169    }
170
171    /// Set maximum single file size.
172    ///
173    /// # Example
174    ///
175    /// ```rust
176    /// use bashkit::FsLimits;
177    ///
178    /// let limits = FsLimits::new().max_file_size(1_000_000); // 1MB
179    /// ```
180    pub fn max_file_size(mut self, bytes: u64) -> Self {
181        self.max_file_size = bytes;
182        self
183    }
184
185    /// Set maximum file count.
186    ///
187    /// # Example
188    ///
189    /// ```rust
190    /// use bashkit::FsLimits;
191    ///
192    /// let limits = FsLimits::new().max_file_count(100);
193    /// ```
194    pub fn max_file_count(mut self, count: u64) -> Self {
195        self.max_file_count = count;
196        self
197    }
198
199    /// Set maximum path depth (directory nesting).
200    pub fn max_path_depth(mut self, depth: usize) -> Self {
201        self.max_path_depth = depth;
202        self
203    }
204
205    /// Set maximum filename (single component) length.
206    pub fn max_filename_length(mut self, len: usize) -> Self {
207        self.max_filename_length = len;
208        self
209    }
210
211    /// Set maximum total path length.
212    pub fn max_path_length(mut self, len: usize) -> Self {
213        self.max_path_length = len;
214        self
215    }
216
217    // THREAT[TM-DOS-012]: Deep directory nesting can exhaust stack/memory
218    // THREAT[TM-DOS-013]: Long filenames/paths can exhaust memory
219    // THREAT[TM-DOS-015]: Unicode control chars can cause path confusion
220    // Mitigation: Validate all three properties before accepting a path
221    /// Validate a path against depth, length, and character safety limits.
222    ///
223    /// Returns `Err(FsLimitExceeded)` if the path violates any limit.
224    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        // TM-DOS-013: Check total path length
229        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                    // TM-DOS-013: Check individual filename length
243                    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                    // TM-DOS-015: Reject control characters and bidi overrides
251                    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        // TM-DOS-012: Check path depth
268        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    /// Check if adding bytes would exceed total limit.
279    ///
280    /// Returns `Ok(())` if within limits, `Err(FsLimitExceeded)` otherwise.
281    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    /// Check if a file size exceeds the limit.
294    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    /// Check if adding a file would exceed the count limit.
305    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
316// THREAT[TM-DOS-015]: Unicode control chars and bidi overrides can cause path confusion
317// Mitigation: Reject paths containing these characters
318/// Check if a path component contains unsafe characters.
319///
320/// Returns `Some(description)` for the first unsafe character found.
321/// Rejects: ASCII control chars (0x00-0x1F, 0x7F), C1 controls (0x80-0x9F),
322/// and Unicode bidi override characters (U+202A-U+202E, U+2066-U+2069).
323fn find_unsafe_path_char(name: &str) -> Option<String> {
324    for ch in name.chars() {
325        // ASCII control characters (except we allow nothing - null is already
326        // impossible in Rust strings)
327        if ch.is_ascii_control() {
328            return Some(format!("U+{:04X}", ch as u32));
329        }
330        // C1 control characters
331        if ('\u{0080}'..='\u{009F}').contains(&ch) {
332            return Some(format!("U+{:04X}", ch as u32));
333        }
334        // Bidi override characters - can cause visual path confusion
335        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/// Error returned when a filesystem limit is exceeded.
343#[derive(Debug, Clone)]
344pub enum FsLimitExceeded {
345    /// Total filesystem size would exceed limit.
346    TotalBytes {
347        current: u64,
348        additional: u64,
349        limit: u64,
350    },
351    /// Single file size exceeds limit.
352    FileSize { size: u64, limit: u64 },
353    /// File count would exceed limit.
354    FileCount { current: u64, limit: u64 },
355    /// Path depth (nesting) exceeds limit (TM-DOS-012).
356    PathTooDeep { depth: usize, limit: usize },
357    /// Single filename component exceeds length limit (TM-DOS-013).
358    FilenameTooLong { length: usize, limit: usize },
359    /// Total path exceeds length limit (TM-DOS-013).
360    PathTooLong { length: usize, limit: usize },
361    /// Path contains unsafe character (TM-DOS-015).
362    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/// Current filesystem usage statistics.
434///
435/// Returned by [`FileSystem::usage()`](crate::FileSystem::usage).
436#[derive(Debug, Clone, Default)]
437pub struct FsUsage {
438    /// Total bytes used by all files.
439    pub total_bytes: u64,
440    /// Number of files (not including directories).
441    pub file_count: u64,
442    /// Number of directories.
443    pub dir_count: u64,
444}
445
446impl FsUsage {
447    /// Create new usage stats.
448    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    // TM-DOS-012: Path depth validation
551    #[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        // /a/b/../c/d = /a/c/d which is depth 3 — OK
569        assert!(limits.validate_path(Path::new("/a/b/../c/d")).is_ok());
570    }
571
572    // TM-DOS-013: Filename length validation
573    #[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    // TM-DOS-013: Total path length validation
590    #[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    // TM-DOS-015: Unicode path safety
600    #[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        // Normal unicode (accented chars, CJK, emoji) should be fine
622        assert!(limits.validate_path(Path::new("/tmp/café")).is_ok());
623        assert!(limits.validate_path(Path::new("/tmp/文件")).is_ok());
624    }
625}