From 5fb262c7b915051d8e22cd1c1288ce3c137ed794 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:17:22 -0500 Subject: [PATCH 1/5] fix(od): bound row widths and budget output growth --- crates/bashkit/docs/threat-model.md | 1 + crates/bashkit/src/builtins/hextools.rs | 88 +++++++--- crates/bashkit/tests/integration/main.rs | 1 + .../tests/integration/od_resource_tests.rs | 157 ++++++++++++++++++ docs/security.md | 6 + 5 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 crates/bashkit/tests/integration/od_resource_tests.rs diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index e33382e6a..80b49abfc 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -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 | diff --git a/crates/bashkit/src/builtins/hextools.rs b/crates/bashkit/src/builtins/hextools.rs index a6d5874dd..6edf52478 100644 --- a/crates/bashkit/src/builtins/hextools.rs +++ b/crates/bashkit/src/builtins/hextools.rs @@ -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; @@ -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. /// @@ -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, @@ -285,7 +292,11 @@ fn od_options_from_matches( opts.skip = parse_od_num(s, "-j")?; } if let Some(s) = matches.get_one::("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::("endian") { opts.big_endian = e == "big"; @@ -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 { let lcm = opts.specs.iter().fold(1usize, |acc, s| { let (mut a, mut b) = (acc, s.size); while b != 0 { @@ -388,14 +410,18 @@ 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; @@ -403,13 +429,11 @@ fn od_dump(data: &[u8], opts: &OdOptions) -> String { 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; @@ -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. @@ -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)) } diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 551d53f61..b5dda1cda 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -107,6 +107,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; diff --git a/crates/bashkit/tests/integration/od_resource_tests.rs b/crates/bashkit/tests/integration/od_resource_tests.rs new file mode 100644 index 000000000..1cd1f6f1a --- /dev/null +++ b/crates/bashkit/tests/integration/od_resource_tests.rs @@ -0,0 +1,157 @@ +//! 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(50)) + .build(); + bash.fs() + .write_file(std::path::Path::new("/input"), &[b'x'; 64]) + .await + .unwrap(); + 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 { + eprintln!("skip: GNU od is not installed"); + return; + }; + // Include partial numeric fields, mixed-size alignment, endian, trailers, + // duplicate suppression, and GNU's zero/misaligned-width fallback. + let mut bash = Bash::new(); + for args in [ + "-An -tx1 -w4", + "-tx2z -w16", + "-An -tx8 --endian=big", + "-An -tc -tx8 -w32", + "-An -tx2 -w0", + "-An -tx2 -w3", + "-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(), + "{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()); +} diff --git a/docs/security.md b/docs/security.md index 37aa7a3e0..a076003d5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. + ## Threat model Bashkit maintains a living threat model in [`knowledge/security/threat-model.md`](../knowledge/security/threat-model.md) From b017766b7b5ba9a79a7ed43f3c21231ee982f4db Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:17:22 -0500 Subject: [PATCH 2/5] chore(knowledge): document od resource bounds --- knowledge/operations/limitations.md | 1 + knowledge/security/threat-model.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 01622d433..5a72f15fe 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -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; 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` | diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index e83f7396d..91e595c49 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1381,7 +1381,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | TM-DOS-100 | jq control-character normalization amplification | A jq JSON string consisting of literal controls expands sixfold when each byte becomes `\u00XX`; an unmetered compatibility copy can exhaust memory or CPU before strict parsing | The jq-only normalizer charges input-length work before its single pass, borrows unchanged input, and acquires/grows a shared live-intermediate lease before every allocation growth (`builtins/jq/input.rs`), **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases, YAML/JSON nesting, multi-document floods, filters, and output format expansion are all attacker-controlled | A pre-deserialization lexical pass rejects aliases without misclassifying quoted or block scalar content; real parsers with recursion/depth caps, aggregate budgets, shared jaq work/deadline/output controls, and a final rendered-output cap; integration tests cover alias bombs plus YAML+JSON depth/document/input/output bounds, proptest composes arbitrary YAML+filters, and `yq_fuzz` covers stdin/in-place format paths, **MITIGATED** | | TM-DOS-102 | Archive decoder allocation-before-check | A small gzip/bzip2 stream makes the decoder output buffer allocate beyond live-memory or filesystem quotas before the post-growth size check runs | Decoder chunks validate absolute/ratio bounds and acquire a shared execution-budget lease before `try_reserve_exact` and copy; corrupt, truncated, CRC-invalid, ratio-bomb, and live-budget tests fail closed, **MITIGATED** | -| TM-DOS-103 | Post-allocation resource charging | Archive/compression and other growing buffers could call `reserve`/`extend` before taking a live-byte lease; concurrent `fetch_add` accounting could also wrap or transiently overcommit the ceiling | `BudgetedVec`/`BudgetedBytes` and `BudgetedString` own their `ExecutionBudgetLease`, charge planned capacity before `try_reserve_exact`, roll back on allocation/error, and release on drop. CAS admission rejects overflow and concurrent overcommit. Archive creation, extraction/list output, gzip/bzip2 encoders, and decompression use the builders, **MITIGATED** | +| TM-DOS-103 | Post-allocation resource charging | Archive/compression and other growing buffers could call `reserve`/`extend` before taking a live-byte lease; concurrent `fetch_add` accounting could also wrap or transiently overcommit the ceiling | `BudgetedVec`/`BudgetedBytes` and `BudgetedString` own their `ExecutionBudgetLease`, charge planned capacity before `try_reserve_exact`, roll back on allocation/error, and release on drop. CAS admission rejects overflow and concurrent overcommit. Archive creation, extraction/list output, gzip/bzip2 encoders, decompression, and `od` output use the builders. `od` rejects widths above 65,536 bytes before layout arithmetic (including empty input), pads only a single <= 8-byte field on the stack, uses u64 padding products for 32-bit safety, and charges input/field rendering work; `od_resource_tests` cover huge-width rejection, boundary formatting, live/work budgets, and subsequent execution, **MITIGATED** | | TM-DOS-104 | Pipeline stderr aggregation exceeds the configured output cap before final truncation | A pipeline with many stderr-producing stages can accumulate their combined diagnostics in memory | The pipeline aggregate stops appending at `max_stderr_bytes` and propagates its truncation signal to the final result, **MITIGATED** | | TM-DOS-106 | Zip creation allocation-before-check | `zip` recursively collected complete file contents and encoded its output before acquiring live-byte leases, allowing transient allocations above the configured intermediate-memory ceiling | Zip traversal collects paths only; each VFS file size is leased before `read_file`, leases remain live with the entries, and archive encoding uses `BudgetedBytes` to charge capacity before growth — **MITIGATED** | | TM-DOS-108 | CLI host-stdin read can stall before execution | A producer keeps the one-shot CLI's stdin pipe open while idle or trickles fewer than 10 MiB, so the pre-execution read never reaches EOF or the byte cap | Read stdin on a dedicated thread and bound the wait with the selected execution timeout; timeout exits with a diagnostic suggesting `--no-stdin`, while the existing byte cap remains enforced — **MITIGATED** | From 074bc4f5c42372ff2c7cc72e04ab9db0f42066af Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:19:59 -0500 Subject: [PATCH 3/5] test(od): isolate rendering work budget regression --- crates/bashkit/tests/integration/od_resource_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/bashkit/tests/integration/od_resource_tests.rs b/crates/bashkit/tests/integration/od_resource_tests.rs index 1cd1f6f1a..93aa37084 100644 --- a/crates/bashkit/tests/integration/od_resource_tests.rs +++ b/crates/bashkit/tests/integration/od_resource_tests.rs @@ -69,12 +69,16 @@ async fn od_width_boundary_needs_only_actual_field_storage() { #[tokio::test] async fn od_work_budget_bounds_rendering() { let mut bash = Bash::builder() - .limits(ExecutionLimits::new().max_work_units(50)) + .limits(ExecutionLimits::new().max_work_units(1_000)) .build(); bash.fs() - .write_file(std::path::Path::new("/input"), &[b'x'; 64]) + .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(_))) From 3b0471f22d349a5d7a23c0e5fc8f894ad818c2d2 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:49:16 -0500 Subject: [PATCH 4/5] test(od): use a valid-width GNU differential oracle --- .../tests/integration/od_resource_tests.rs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/tests/integration/od_resource_tests.rs b/crates/bashkit/tests/integration/od_resource_tests.rs index 93aa37084..8bc6b4804 100644 --- a/crates/bashkit/tests/integration/od_resource_tests.rs +++ b/crates/bashkit/tests/integration/od_resource_tests.rs @@ -98,19 +98,22 @@ async fn od_normal_layout_matches_gnu() { && 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, - // duplicate suppression, and GNU's zero/misaligned-width fallback. + // 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 -tx2 -w0", - "-An -tx2 -w3", "-An -v -tx1 -w4", "-An -tx8 -w65536", "-An -ta -td1 -w65536", @@ -134,7 +137,13 @@ async fn od_normal_layout_matches_gnu() { let actual = bash.exec(&script).await.unwrap(); assert_eq!( actual.exit_code, - expected.status.code().unwrap(), + 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}"); @@ -159,3 +168,18 @@ async fn od_pipeline_output_is_not_bounded_by_capture_cap() { 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}"); + } + } +} From 5b5dc779bc523773f56476edb3ede667f8c5f664 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:49:16 -0500 Subject: [PATCH 5/5] chore(knowledge): record od oracle and fallback contracts --- knowledge/operations/limitations.md | 2 +- knowledge/security/security-testing.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 5a72f15fe..35db72204 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -172,7 +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; output growth is constrained by the shared live-intermediate budget | `od_resource_tests` | +| 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` | diff --git a/knowledge/security/security-testing.md b/knowledge/security/security-testing.md index ff6b15610..3a85f456f 100644 --- a/knowledge/security/security-testing.md +++ b/knowledge/security/security-testing.md @@ -73,6 +73,16 @@ probability `10%`, count `5*`): see [fail crate docs](https://docs.rs/fail). 2. **Filesystem failure**: I/O errors, permission denied, disk full, partial writes, data corruption 3. **Interpreter failure**: execution errors, panic recovery, unexpected exit codes +## od Width Regression Oracle + +`od_resource_tests` compares valid-width layouts with GNU `od` when available +(required in Linux CI). Zero and type-misaligned widths are asserted directly +against Bashkit's existing type-size fallback. Some GNU versions abort on +`-w0`, so an invalid-width GNU subprocess cannot serve as a compatibility +oracle; see the [GNU report](https://lists.gnu.org/archive/html/bug-coreutils/2025-06/msg00014.html). +The enormous-width tests invoke only the fixed builtin, after safe bounded +regressions established the allocation path. + ## Adding New Fail Points Add `fail_point!("module::function", |action| ...)` under