Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions crates/bashkit/src/builtins/truncate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ExecResult> {
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Expand Down Expand Up @@ -253,6 +270,56 @@ fn parse_size_number(raw: &str) -> Option<u64> {
#[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<InMemoryFs>) -> ExecResult {
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = 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() {
Expand Down
2 changes: 1 addition & 1 deletion specs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |
Expand Down
Loading