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
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ through configurable limits.
|--------|---------------|------------|--------|
| Large input (TM-DOS-001) | 1GB script | `max_input_bytes` limit (10MB) | MITIGATED |
| Output flooding (TM-DOS-002) | `yes \| head -n 1000000000` | Command limit stops loop | MITIGATED |
| Width-derived allocation (TM-DOS-103) | `printf x \| od -w1152921504606846976` | Reject widths above 65,536 bytes; stack-pad one numeric field; budget output capacity before fallible growth | MITIGATED |
| Variable explosion (TM-DOS-003) | `x=$(cat /dev/urandom)` | /dev/urandom returns bounded 8KB | MITIGATED |
| Array growth (TM-DOS-004) | `arr+=(element)` in loop | Command limit | MITIGATED |

Expand Down
88 changes: 65 additions & 23 deletions crates/bashkit/src/builtins/hextools.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
//! od, xxd, and hexdump builtins - byte-level inspection tools
//!
//! Decision: od caps row width before layout arithmetic, pads only one field
//! on the stack, and admits output growth through the shared request budget.

use super::clap_cache::cached_command;
use async_trait::async_trait;
Expand All @@ -7,6 +10,7 @@ use super::{Builtin, Context};
use crate::error::Result;
use crate::fs::vfs_join;
use crate::interpreter::ExecResult;
use crate::limits::{BudgetedString, ExecutionBudget};

/// The od builtin - dump files in octal and other formats.
///
Expand Down Expand Up @@ -50,6 +54,9 @@ pub struct Hexdump;
// full blocks collapse to `*` unless `-v`. Floating-point types (`f`, `-e`,
// `-f`, `-F`) are rejected with an explicit error (not yet ported).

// THREAT[TM-DOS-103]: Bound caller-selected dimensions even without a budget.
const OD_MAX_WIDTH: usize = 65_536;

struct OdOptions {
addr_radix: AddrRadix,
specs: Vec<OdSpec>,
Expand Down Expand Up @@ -285,7 +292,11 @@ fn od_options_from_matches(
opts.skip = parse_od_num(s, "-j")?;
}
if let Some(s) = matches.get_one::<String>("width") {
opts.width = Some(parse_od_num(s, "-w")?);
let width = parse_od_num(s, "-w")?;
if width > OD_MAX_WIDTH {
return Err(format!("od: width exceeds maximum of {OD_MAX_WIDTH} bytes"));
}
opts.width = Some(width);
}
if let Some(e) = matches.get_one::<String>("endian") {
opts.big_endian = e == "big";
Expand Down Expand Up @@ -361,7 +372,18 @@ fn format_od_field(spec: &OdSpec, bytes: &[u8], big_endian: bool) -> String {
}
}

fn od_dump(data: &[u8], opts: &OdOptions) -> String {
// Avoid allocating padding or formatted rows outside the budgeted owner.
fn od_spaces(output: &mut BudgetedString, mut count: usize) -> Result<()> {
const SPACES: &str = " ";
while count > 0 {
let n = count.min(SPACES.len());
output.try_push_str(&SPACES[..n])?;
count -= n;
}
Ok(())
}

fn od_dump(data: &[u8], opts: &OdOptions, budget: Option<&ExecutionBudget>) -> Result<String> {
let lcm = opts.specs.iter().fold(1usize, |acc, s| {
let (mut a, mut b) = (acc, s.size);
while b != 0 {
Expand All @@ -388,28 +410,30 @@ fn od_dump(data: &[u8], opts: &OdOptions) -> String {
None => data,
};

let mut output = String::new();
// THREAT[TM-DOS-103]: lease capacity before fallible allocation.
let mut output = BudgetedString::new(budget)?;
let mut prev: Option<&[u8]> = None;
let mut in_dup_run = false;
for (chunk_idx, chunk) in data.chunks(bpb).enumerate() {
if let Some(budget) = budget {
budget.consume_work(chunk.len() as u64)?;
}
let offset = opts.skip + chunk_idx * bpb;
if !opts.show_duplicates && chunk.len() == bpb && prev == Some(chunk) {
if !in_dup_run {
output.push_str("*\n");
output.try_push_str("*\n")?;
in_dup_run = true;
}
continue;
}
in_dup_run = false;
prev = Some(chunk);

let mut block = chunk.to_vec();
block.resize(bpb, 0);
for (si, spec) in opts.specs.iter().enumerate() {
if si == 0 {
output.push_str(&format_od_addr(offset, opts.addr_radix));
output.try_push_str(&format_od_addr(offset, opts.addr_radix))?;
} else {
output.push_str(&" ".repeat(addr_pad));
od_spaces(&mut output, addr_pad)?;
}
let fields = bpb / spec.size;
let blank = (bpb - chunk.len()) / spec.size;
Expand All @@ -418,38 +442,48 @@ fn od_dump(data: &[u8], opts: &OdOptions) -> String {
let mut pad_remaining = pad;
for i in (blank + 1..=fields).rev() {
let idx = fields - i;
let next_pad = pad * (i - 1) / fields;
// Width is capped; u64 also keeps the product safe on 32-bit hosts.
let next_pad = (pad as u64 * (i - 1) as u64 / fields as u64) as usize;
let adjusted = pad_remaining - next_pad + width;
let field = &block[idx * spec.size..(idx + 1) * spec.size];
let text = format_od_field(spec, field, opts.big_endian);
output.push_str(&format!("{text:>adjusted$}"));
if let Some(budget) = budget {
budget.consume_work(1)?;
}
// Only an incomplete numeric field needs zero padding (max 8 bytes).
let mut field = [0u8; 8];
let start = idx * spec.size;
let available = spec.size.min(chunk.len() - start);
field[..available].copy_from_slice(&chunk[start..start + available]);
let text = format_od_field(spec, &field[..spec.size], opts.big_endian);
od_spaces(&mut output, adjusted.saturating_sub(text.len()))?;
output.try_push_str(&text)?;
pad_remaining = next_pad;
}
if spec.trailer {
let trailer_pad = blank * width + pad * blank / fields;
output.push_str(&" ".repeat(trailer_pad));
output.push_str(" >");
let trailer_pad =
blank * width + (pad as u64 * blank as u64 / fields as u64) as usize;
od_spaces(&mut output, trailer_pad)?;
output.try_push_str(" >")?;
for &b in chunk {
output.push(if (0x20..0x7f).contains(&b) {
output.try_push(if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
});
})?;
}
output.push('<');
output.try_push('<')?;
}
output.push('\n');
output.try_push('\n')?;
}
}

// GNU always ends with the final address (nothing for `-An`).
let addr = format_od_addr(opts.skip + data.len(), opts.addr_radix);
if !addr.is_empty() {
output.push_str(&addr);
output.push('\n');
output.try_push_str(&addr)?;
output.try_push('\n')?;
}

output
Ok(output.into_inner())
}

// Cached `od` arg surface: pre-built once, cloned per invocation.
Expand Down Expand Up @@ -489,7 +523,15 @@ impl Builtin for Od {
Ok(data) => data,
Err(failure) => return Ok(failure),
};
let output = od_dump(&data, &opts);
let budget = ctx
.execution_budget()
.map(|budget| {
budget
.try_with(Clone::clone)
.map_err(|_| crate::Error::Cancelled)
})
.transpose()?;
let output = od_dump(&data, &opts, budget.as_ref())?;

Ok(ExecResult::ok(output))
}
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ pub mod mkfifo_tests;
pub mod namespace_fs_tests;
pub mod nested_subscript_tests;
pub mod network_security_tests;
pub mod od_resource_tests;
pub mod output_truncation_tests;
pub mod parallel_sessions_tests;
pub mod partial_parse_tests;
Expand Down
185 changes: 185 additions & 0 deletions crates/bashkit/tests/integration/od_resource_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! od dimensions and output must be bounded before allocation.

use bashkit::{Bash, Error, ExecutionLimits, LimitExceeded};

#[tokio::test]
async fn od_rejects_width_above_ceiling() {
let mut bash = Bash::new();
let result = bash.exec("printf x | od -w65538").await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr, "od: width exceeds maximum of 65536 bytes\n");
assert!(result.stdout.is_empty());
}

#[tokio::test]
async fn od_trailer_output_respects_live_budget() {
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_live_intermediate_bytes(4096))
.build();
let result = bash.exec("printf x | od -An -tx1z -w65536").await;
assert!(matches!(
result,
Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_)))
));
let recovery = bash.exec("printf x | od -An -tx1").await.unwrap();
assert_eq!(recovery.stdout, " 78\n");
}

#[tokio::test]
async fn od_enormous_widths_fail_safely_and_leave_host_usable() {
let mut bash = Bash::new();
for width in [
"1152921504606846976",
"0x1000000000000000",
"01000000000000000000000",
"1125899906842624k",
"1099511627776m",
"18446744073709551615",
] {
for input in ["printf x |", "printf '' |"] {
for flags in ["", "-tx1z", "-tu8", "-tc"] {
let script = format!("{input} od {flags} --width={width}");
let result = bash.exec(&script).await.unwrap();
assert_eq!(result.exit_code, 1, "{script}");
assert!(result.stdout.is_empty(), "{script}");
assert!(result.stderr.starts_with("od:"), "{script}");
assert!(result.stderr.len() < 1024);
}
}
}
let result = bash.exec("printf x | od -An -tx1").await.unwrap();
assert_eq!(result.stdout, " 78\n");
}

#[tokio::test]
async fn od_width_boundary_needs_only_actual_field_storage() {
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_live_intermediate_bytes(4096))
.build();
for width in ["65536", "0x10000", "0200000", "64k"] {
let result = bash
.exec(&format!("printf x | od -An -tx8 --width={width}"))
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 0000000000000078\n");
}
}

#[tokio::test]
async fn od_work_budget_bounds_rendering() {
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_work_units(1_000))
.build();
bash.fs()
.write_file(std::path::Path::new("/input"), &[b'x'; 4096])
.await
.unwrap();
// The same file with one selected byte reaches and completes the builtin;
// the refusal below therefore measures scalable rendering work.
let small = bash.exec("od -An -tx1 -N1 /input").await.unwrap();
assert_eq!(small.stdout, " 78\n");
assert!(matches!(
bash.exec("od -An -tx1 /input").await,
Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_)))
));
}

#[tokio::test]
async fn od_normal_layout_matches_gnu() {
use std::io::Write;
use std::process::{Command, Stdio};
let Some(program) = ["god", "od"].into_iter().find(|program| {
Command::new(program)
.arg("--version")
.output()
.is_ok_and(|out| {
out.status.success()
&& String::from_utf8_lossy(&out.stdout).contains("GNU coreutils")
})
}) else {
assert!(
!(cfg!(target_os = "linux") && std::env::var_os("CI").is_some()),
"GNU od is required for Linux CI"
);
eprintln!("skip: GNU od is not installed");
return;
};
// Include partial numeric fields, mixed-size alignment, endian, trailers,
// and duplicate suppression. Invalid widths are tested separately: some
// GNU versions abort on -w0, so they cannot be an oracle for that case.
let mut bash = Bash::new();
for args in [
"-An -tx1 -w4",
"-tx2z -w16",
"-An -tx8 --endian=big",
"-An -tc -tx8 -w32",
"-An -v -tx1 -w4",
"-An -tx8 -w65536",
"-An -ta -td1 -w65536",
] {
for input in ["abc", "aaaaaaaaaaaa", ""] {
let mut host = Command::new(program)
.args(args.split_whitespace())
.env("LC_ALL", "C")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
host.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let expected = host.wait_with_output().unwrap();
let script = format!("printf '{input}' | od {args}");
let actual = bash.exec(&script).await.unwrap();
assert_eq!(
actual.exit_code,
expected.status.code().unwrap_or_else(|| {
panic!(
"{script}: GNU od terminated as {}: {}",
expected.status,
String::from_utf8_lossy(&expected.stderr)
)
}),
"{script}"
);
assert_eq!(actual.stdout.as_bytes(), expected.stdout, "{script}");
}
}
}

#[tokio::test]
async fn od_pipeline_output_is_not_bounded_by_capture_cap() {
let mut bash = Bash::builder()
.limits(
ExecutionLimits::new()
.max_stdout_bytes(32)
.max_live_intermediate_bytes(32_768),
)
.build();
let result = bash
.exec("printf x | od -An -tx1z -w2048 | wc -c")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "6150");
assert!(result.stderr.is_empty());
}

#[tokio::test]
async fn od_zero_and_misaligned_widths_use_type_size() {
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_live_intermediate_bytes(4096))
.build();
for (format, expected) in [("x2", " 6261\n 0063\n"), ("x8", " 0000000000636261\n")] {
for width in [0, 3] {
let script = format!("printf abc | od -An -t{format} -w{width}");
let result = bash.exec(&script).await.unwrap();
assert_eq!(result.exit_code, 0, "{script}");
assert_eq!(result.stdout, expected, "{script}");
}
}
}
6 changes: 6 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ individual threat IDs and mitigation status, see the
| **Resource limits** | Configurable caps on commands, loop iterations, recursion depth, AST depth, timeouts, and parser operations prevent denial-of-service from malicious scripts. |
| **Filesystem limits** | Total bytes, per-file size, file count, path depth, and filename length are all capped to prevent storage exhaustion (zip bombs, tar bombs, recursive copies). |

`od -w` / `od --width` accepts at most 65,536 bytes per row. Larger widths
fail before rendering, even with empty input. Numeric fields use at most eight
bytes of stack padding; output capacity is admitted against
`ExecutionLimits::max_live_intermediate_bytes` before fallible allocation.
This memory budget applies to pipelines and redirected output too.

Recursive `grep -R` skips directory symlinks that resolve to an ancestor and
reports `warning: recursive directory loop` (suppressed by `-s`). Separate
aliases of a non-ancestor directory remain searchable. Traversal uses the shared
Expand Down
1 change: 1 addition & 0 deletions knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ pass in CI); only divergences and boundaries are recorded here.
| L-CURL-001 | curl | Spec-test coverage for methods/headers/auth/redirects not ported (needs `http_client` + allowlist in harness); payload behavior has integration and real-curl differential coverage | stance |
| L-CURL-002 | curl/wget | Unknown options are ignored for compatibility, not rejected (real curl/wget error); deliberate leniency | `curl.rs` |
| L-PRINTF-001 | printf | `%(fmt)T` argument `-2` (bash: shell start time) formats the current time; the shell start instant is not tracked. GNU-only `%N` in the time format is expanded to nanoseconds, bash prints it literally | `printf.rs::expand_time_directives` |
| L-OD-002 | od | `-w`/`--width` above 65,536 bytes is rejected, including empty input; zero or type-misaligned widths retain the type-size fallback; output growth is constrained by the shared live-intermediate budget | `od_resource_tests` |
| L-FACTOR-001 | factor | Numbers above 2^64-1 are rejected ("too large"); GNU factor accepts arbitrary precision | `factor.rs::rejects_bad_tokens` |
| L-ENV-001 | umask, ulimit, enable | `umask` is stored and reported (subshell-scoped) but does not yet change VFS file creation modes; `ulimit` reports fixed sandbox values (not host limits) and stores lowered values without enforcing them, real caps are `ExecutionLimits`; `enable -n/-d/-f` (disable or load builtins) are refused | `shellenv.rs`, `process-env-builtins.test.sh` |
| L-DD-001 | dd | Transfer-statistics line reports `0 s, 0 B/s` (no wall-clock rate); each invocation moves at most 64 MiB (`DD_MAX_BYTES`) and an uncounted `/dev/zero`/`/dev/urandom` read stops there with exit 1 | `dd.rs` |
Expand Down
Loading
Loading