diff --git a/crates/bashkit/src/builtins/truncate.rs b/crates/bashkit/src/builtins/truncate.rs index dcf64ae5..8eda06cb 100644 --- a/crates/bashkit/src/builtins/truncate.rs +++ b/crates/bashkit/src/builtins/truncate.rs @@ -5,6 +5,10 @@ //! and `crates/bashkit-coreutils-port/`. Behaviour is implemented locally //! against the bashkit VFS (read/resize/write — the trait has no dedicated //! truncate primitive yet). +//! +//! Security decision: validate target lengths against the active filesystem's +//! configured limits before resizing buffers. `write_file` enforces those +//! limits too late to guard this built-in's in-memory zero-fill path. use async_trait::async_trait; use std::ffi::OsString; @@ -16,11 +20,6 @@ use crate::interpreter::ExecResult; pub struct Truncate; -/// Maximum target size in bytes. Bounds the in-memory zero-fill that -/// extending a small file to a huge target would otherwise allocate. -/// Matches the order of magnitude of bashkit's other VFS limits. -const MAX_SIZE_BYTES: u64 = 1 << 32; // 4 GiB - #[async_trait] impl Builtin for Truncate { async fn execute(&self, ctx: Context<'_>) -> Result { @@ -122,15 +121,28 @@ impl Builtin for Truncate { Err(e) => return Ok(ExecResult::err(format!("truncate: {e}\n"), 1)), }; - if new_len > MAX_SIZE_BYTES { + let vfs_limit = target_size_limit(&ctx); + if new_len > vfs_limit { return Ok(ExecResult::err( - format!("truncate: target size {new_len} exceeds VFS limit\n"), + format!( + "truncate: target size {new_len} exceeds VFS limit ({vfs_limit} bytes)\n" + ), 1, )); } let mut next = current; - let new_len_usize = new_len as usize; + // THREAT[TM-DOS-005, TM-DOS-040]: fail before allocation if the + // requested virtual file length cannot be represented locally. + let new_len_usize = match usize::try_from(new_len) { + Ok(n) => n, + Err(_) => { + return Ok(ExecResult::err( + format!("truncate: target size {new_len} exceeds addressable memory\n"), + 1, + )); + } + }; if next.len() > new_len_usize { next.truncate(new_len_usize); } else { @@ -153,6 +165,11 @@ fn error_message(e: &crate::error::Error) -> String { e.to_string() } +fn target_size_limit(ctx: &Context<'_>) -> u64 { + let limits = ctx.fs.limits(); + limits.max_file_size.min(limits.max_total_bytes) +} + #[derive(Debug, Clone, Copy)] enum TargetSize { Absolute(u64), @@ -253,6 +270,56 @@ fn parse_size_number(raw: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::{FileSystem, FsLimits, InMemoryFs}; + + async fn run_truncate_with_fs(args: &[&str], fs: Arc) -> ExecResult { + let mut variables = HashMap::new(); + let env = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + Truncate.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn rejects_target_above_vfs_limit_before_write() { + let fs = Arc::new(InMemoryFs::with_limits( + FsLimits::new().max_file_size(10).max_total_bytes(10), + )); + let result = run_truncate_with_fs(&["-s", "11", "/tmp/too-large"], fs.clone()).await; + + assert_eq!(result.exit_code, 1); + assert!( + result.stderr.contains("exceeds VFS limit"), + "stderr was: {}", + result.stderr + ); + assert!( + !fs.exists(std::path::Path::new("/tmp/too-large")) + .await + .unwrap(), + "oversized truncate must not create a file" + ); + } #[test] fn parse_size_plain_number() { diff --git a/specs/threat-model.md b/specs/threat-model.md index 6ee46edb..a24ba919 100644 --- a/specs/threat-model.md +++ b/specs/threat-model.md @@ -122,7 +122,7 @@ the session-level backstop. | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| -| TM-DOS-005 | Large file creation | `dd if=/dev/zero bs=1G count=100` | `max_file_size` limit | **MITIGATED** | +| TM-DOS-005 | Large file creation | `dd if=/dev/zero bs=1G count=100`; `truncate -s 4G /tmp/p` | `max_file_size` limit; builtins that resize VFS file buffers validate target lengths before allocation | **MITIGATED** | | TM-DOS-006 | Many small files | `for i in $(seq 1 1000000); do touch $i; done` | `max_file_count` limit | **MITIGATED** | | TM-DOS-007 | Zip bomb | `gunzip bomb.gz` (small file → huge output) | Decompression limit | **MITIGATED** | | TM-DOS-008 | Tar bomb | `tar -xf bomb.tar` (many files / large files) | FS limits | **MITIGATED** |