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}