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-014**: Many directory entries → `max_file_count`
16
17use std::fmt;
18
19/// Default maximum total filesystem size: 100MB
20pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 100_000_000;
21
22/// Default maximum single file size: 10MB
23pub const DEFAULT_MAX_FILE_SIZE: u64 = 10_000_000;
24
25/// Default maximum file count: 10,000
26pub const DEFAULT_MAX_FILE_COUNT: u64 = 10_000;
27
28/// Filesystem resource limits.
29///
30/// Controls maximum resource consumption for in-memory filesystems.
31/// Applied to both [`InMemoryFs`](crate::InMemoryFs) and [`OverlayFs`](crate::OverlayFs).
32///
33/// # Example
34///
35/// ```rust
36/// use bashkit::{Bash, FsLimits, InMemoryFs};
37/// use std::sync::Arc;
38///
39/// // Create filesystem with custom limits
40/// let limits = FsLimits::new()
41///     .max_total_bytes(50_000_000)  // 50MB total
42///     .max_file_size(5_000_000)     // 5MB per file
43///     .max_file_count(1000);        // 1000 files max
44///
45/// let fs = Arc::new(InMemoryFs::with_limits(limits));
46/// let bash = Bash::builder().fs(fs).build();
47/// ```
48///
49/// # Default Limits
50///
51/// | Limit | Default | Purpose |
52/// |-------|---------|---------|
53/// | `max_total_bytes` | 100MB | Total filesystem memory |
54/// | `max_file_size` | 10MB | Single file size |
55/// | `max_file_count` | 10,000 | Number of files |
56#[derive(Debug, Clone)]
57pub struct FsLimits {
58    /// Maximum total bytes across all files.
59    /// Default: 100MB (100,000,000 bytes)
60    pub max_total_bytes: u64,
61
62    /// Maximum size of a single file in bytes.
63    /// Default: 10MB (10,000,000 bytes)
64    pub max_file_size: u64,
65
66    /// Maximum number of files (not including directories).
67    /// Default: 10,000
68    pub max_file_count: u64,
69}
70
71impl Default for FsLimits {
72    fn default() -> Self {
73        Self {
74            max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
75            max_file_size: DEFAULT_MAX_FILE_SIZE,
76            max_file_count: DEFAULT_MAX_FILE_COUNT,
77        }
78    }
79}
80
81impl FsLimits {
82    /// Create new limits with defaults.
83    ///
84    /// # Example
85    ///
86    /// ```rust
87    /// use bashkit::FsLimits;
88    ///
89    /// let limits = FsLimits::new();
90    /// assert_eq!(limits.max_total_bytes, 100_000_000);
91    /// ```
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Create unlimited limits (no restrictions).
97    ///
98    /// # Warning
99    ///
100    /// Using unlimited limits removes protection against memory exhaustion.
101    /// Only use in trusted environments.
102    ///
103    /// # Example
104    ///
105    /// ```rust
106    /// use bashkit::FsLimits;
107    ///
108    /// let limits = FsLimits::unlimited();
109    /// assert_eq!(limits.max_total_bytes, u64::MAX);
110    /// ```
111    pub fn unlimited() -> Self {
112        Self {
113            max_total_bytes: u64::MAX,
114            max_file_size: u64::MAX,
115            max_file_count: u64::MAX,
116        }
117    }
118
119    /// Set maximum total filesystem size.
120    ///
121    /// # Example
122    ///
123    /// ```rust
124    /// use bashkit::FsLimits;
125    ///
126    /// let limits = FsLimits::new().max_total_bytes(50_000_000); // 50MB
127    /// ```
128    pub fn max_total_bytes(mut self, bytes: u64) -> Self {
129        self.max_total_bytes = bytes;
130        self
131    }
132
133    /// Set maximum single file size.
134    ///
135    /// # Example
136    ///
137    /// ```rust
138    /// use bashkit::FsLimits;
139    ///
140    /// let limits = FsLimits::new().max_file_size(1_000_000); // 1MB
141    /// ```
142    pub fn max_file_size(mut self, bytes: u64) -> Self {
143        self.max_file_size = bytes;
144        self
145    }
146
147    /// Set maximum file count.
148    ///
149    /// # Example
150    ///
151    /// ```rust
152    /// use bashkit::FsLimits;
153    ///
154    /// let limits = FsLimits::new().max_file_count(100);
155    /// ```
156    pub fn max_file_count(mut self, count: u64) -> Self {
157        self.max_file_count = count;
158        self
159    }
160
161    /// Check if adding bytes would exceed total limit.
162    ///
163    /// Returns `Ok(())` if within limits, `Err(FsLimitExceeded)` otherwise.
164    pub fn check_total_bytes(&self, current: u64, additional: u64) -> Result<(), FsLimitExceeded> {
165        let new_total = current.saturating_add(additional);
166        if new_total > self.max_total_bytes {
167            return Err(FsLimitExceeded::TotalBytes {
168                current,
169                additional,
170                limit: self.max_total_bytes,
171            });
172        }
173        Ok(())
174    }
175
176    /// Check if a file size exceeds the limit.
177    pub fn check_file_size(&self, size: u64) -> Result<(), FsLimitExceeded> {
178        if size > self.max_file_size {
179            return Err(FsLimitExceeded::FileSize {
180                size,
181                limit: self.max_file_size,
182            });
183        }
184        Ok(())
185    }
186
187    /// Check if adding a file would exceed the count limit.
188    pub fn check_file_count(&self, current: u64) -> Result<(), FsLimitExceeded> {
189        if current >= self.max_file_count {
190            return Err(FsLimitExceeded::FileCount {
191                current,
192                limit: self.max_file_count,
193            });
194        }
195        Ok(())
196    }
197}
198
199/// Error returned when a filesystem limit is exceeded.
200#[derive(Debug, Clone)]
201pub enum FsLimitExceeded {
202    /// Total filesystem size would exceed limit.
203    TotalBytes {
204        current: u64,
205        additional: u64,
206        limit: u64,
207    },
208    /// Single file size exceeds limit.
209    FileSize { size: u64, limit: u64 },
210    /// File count would exceed limit.
211    FileCount { current: u64, limit: u64 },
212}
213
214impl fmt::Display for FsLimitExceeded {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            FsLimitExceeded::TotalBytes {
218                current,
219                additional,
220                limit,
221            } => {
222                write!(
223                    f,
224                    "filesystem full: {} + {} bytes exceeds {} byte limit",
225                    current, additional, limit
226                )
227            }
228            FsLimitExceeded::FileSize { size, limit } => {
229                write!(
230                    f,
231                    "file too large: {} bytes exceeds {} byte limit",
232                    size, limit
233                )
234            }
235            FsLimitExceeded::FileCount { current, limit } => {
236                write!(
237                    f,
238                    "too many files: {} files at {} file limit",
239                    current, limit
240                )
241            }
242        }
243    }
244}
245
246impl std::error::Error for FsLimitExceeded {}
247
248/// Current filesystem usage statistics.
249///
250/// Returned by [`FileSystem::usage()`](crate::FileSystem::usage).
251#[derive(Debug, Clone, Default)]
252pub struct FsUsage {
253    /// Total bytes used by all files.
254    pub total_bytes: u64,
255    /// Number of files (not including directories).
256    pub file_count: u64,
257    /// Number of directories.
258    pub dir_count: u64,
259}
260
261impl FsUsage {
262    /// Create new usage stats.
263    pub fn new(total_bytes: u64, file_count: u64, dir_count: u64) -> Self {
264        Self {
265            total_bytes,
266            file_count,
267            dir_count,
268        }
269    }
270}
271
272#[cfg(test)]
273#[allow(clippy::unwrap_used)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_default_limits() {
279        let limits = FsLimits::default();
280        assert_eq!(limits.max_total_bytes, 100_000_000);
281        assert_eq!(limits.max_file_size, 10_000_000);
282        assert_eq!(limits.max_file_count, 10_000);
283    }
284
285    #[test]
286    fn test_unlimited() {
287        let limits = FsLimits::unlimited();
288        assert_eq!(limits.max_total_bytes, u64::MAX);
289        assert_eq!(limits.max_file_size, u64::MAX);
290        assert_eq!(limits.max_file_count, u64::MAX);
291    }
292
293    #[test]
294    fn test_builder() {
295        let limits = FsLimits::new()
296            .max_total_bytes(50_000_000)
297            .max_file_size(1_000_000)
298            .max_file_count(100);
299
300        assert_eq!(limits.max_total_bytes, 50_000_000);
301        assert_eq!(limits.max_file_size, 1_000_000);
302        assert_eq!(limits.max_file_count, 100);
303    }
304
305    #[test]
306    fn test_check_total_bytes() {
307        let limits = FsLimits::new().max_total_bytes(1000);
308
309        assert!(limits.check_total_bytes(500, 400).is_ok());
310        assert!(limits.check_total_bytes(500, 500).is_ok());
311        assert!(limits.check_total_bytes(500, 501).is_err());
312        assert!(limits.check_total_bytes(1000, 1).is_err());
313    }
314
315    #[test]
316    fn test_check_file_size() {
317        let limits = FsLimits::new().max_file_size(1000);
318
319        assert!(limits.check_file_size(999).is_ok());
320        assert!(limits.check_file_size(1000).is_ok());
321        assert!(limits.check_file_size(1001).is_err());
322    }
323
324    #[test]
325    fn test_check_file_count() {
326        let limits = FsLimits::new().max_file_count(10);
327
328        assert!(limits.check_file_count(9).is_ok());
329        assert!(limits.check_file_count(10).is_err());
330        assert!(limits.check_file_count(11).is_err());
331    }
332
333    #[test]
334    fn test_error_display() {
335        let err = FsLimitExceeded::TotalBytes {
336            current: 90,
337            additional: 20,
338            limit: 100,
339        };
340        assert!(err.to_string().contains("90"));
341        assert!(err.to_string().contains("20"));
342        assert!(err.to_string().contains("100"));
343
344        let err = FsLimitExceeded::FileSize {
345            size: 200,
346            limit: 100,
347        };
348        assert!(err.to_string().contains("200"));
349        assert!(err.to_string().contains("100"));
350
351        let err = FsLimitExceeded::FileCount {
352            current: 10,
353            limit: 10,
354        };
355        assert!(err.to_string().contains("10"));
356    }
357}