From 802053ceb56e2b8f646d030a5a597d3054d592e8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 12 Oct 2026 23:53:46 -0500 Subject: [PATCH 1/6] fix(dd): admit padded copy capacity before allocation --- crates/bashkit/src/builtins/dd.rs | 128 ++++++++++++++++--- crates/bashkit/tests/integration/dd_tests.rs | 108 ++++++++++++++++ crates/bashkit/tests/integration/main.rs | 1 + 3 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 crates/bashkit/tests/integration/dd_tests.rs diff --git a/crates/bashkit/src/builtins/dd.rs b/crates/bashkit/src/builtins/dd.rs index 3b6af50b7..6aca8a0bb 100644 --- a/crates/bashkit/src/builtins/dd.rs +++ b/crates/bashkit/src/builtins/dd.rs @@ -6,6 +6,8 @@ //! files. Every invocation moves at most `DD_MAX_BYTES` //! (THREAT[TM-DOS-003]); an unbounded device read without `count=` stops at //! the cap and exits 1, like dd running out of space. +//! Sync padding is admitted by checked length and live-capacity budget before +//! allocation; the lease remains with the copy buffer through output handling. use async_trait::async_trait; @@ -13,6 +15,7 @@ use super::limits::DD_MAX_BYTES; use super::{Builtin, Context}; use crate::error::Result; use crate::interpreter::ExecResult; +use crate::limits::{BudgetedBytes, ExecutionBudget, ExecutionBudgetLease}; /// `dd` builtin. pub struct Dd; @@ -143,6 +146,34 @@ fn parse_opts(args: &[String]) -> std::result::Result { Ok(o) } +// THREAT[TM-DOS-003] THREAT[TM-DOS-103]: Padding must fit both limits before growth. +fn padded_len(len: usize, ibs: usize, limit: usize) -> Option { + let remainder = len % ibs; + let padding = if remainder == 0 { 0 } else { ibs - remainder }; + len.checked_add(padding).filter(|&size| size <= limit) +} + +fn copy_data( + input: &[u8], + ibs: usize, + sync: bool, + budget: Option<&ExecutionBudget>, +) -> Result<(Vec, Option)> { + let len = if sync { + padded_len(input.len(), ibs, DD_MAX_BYTES).ok_or_else(|| { + crate::LimitExceeded::Memory("dd padded data exceeds byte limit".into()) + })? + } else { + input.len() + }; + let mut buffer = BudgetedBytes::try_with_capacity(budget, len)?; + buffer.try_extend_from_slice(input)?; + let (mut data, lease) = buffer.into_parts(); + // Capacity for the entire padded copy is already admitted and allocated. + data.resize(len, 0); + Ok((data, lease)) +} + fn device_bytes(path: &str, len: usize) -> Option, String>> { match path { "/dev/zero" => Some(Ok(vec![0; len])), @@ -211,21 +242,21 @@ impl Builtin for Dd { } }; let start = skip_bytes.min(input.len()); - let mut data = input[start..].to_vec(); - if let Some(w) = want { - data.truncate(w); - } - if data.len() > DD_MAX_BYTES { - data.truncate(DD_MAX_BYTES); - capped = true; - } - let (full_in, partial_in) = ( - data.len() / o.ibs, - usize::from(!data.len().is_multiple_of(o.ibs)), - ); - if o.sync && partial_in == 1 { - data.resize((full_in + 1) * o.ibs, 0); - } + let available = input.len() - start; + let requested = want.unwrap_or(available).min(available); + capped |= requested > DD_MAX_BYTES; + let len = requested.min(DD_MAX_BYTES); + let budget = ctx + .execution_budget() + .map(|budget| { + budget + .try_with(Clone::clone) + .map_err(|_| crate::Error::Cancelled) + }) + .transpose()?; + let (full_in, partial_in) = (len / o.ibs, usize::from(!len.is_multiple_of(o.ibs))); + let (mut data, _data_lease) = + copy_data(&input[start..start + len], o.ibs, o.sync, budget.as_ref())?; if o.ucase { data.make_ascii_uppercase(); } else if o.lcase { @@ -325,6 +356,73 @@ impl Builtin for Dd { mod tests { use super::*; + fn tiny_budget(bytes: u64) -> ExecutionBudget { + ExecutionBudget::new( + &crate::ExecutionLimits::new().max_live_intermediate_bytes(bytes), + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + } + + #[test] + fn padding_size_admission_is_checked_without_allocation() { + assert_eq!(padded_len(0, 4, 8), Some(0)); + assert_eq!(padded_len(0, usize::MAX, 8), Some(0)); + assert_eq!(padded_len(1, usize::MAX, 8), None); + assert_eq!(padded_len(4, 4, 4), Some(4)); + assert_eq!(padded_len(5, 4, 8), Some(8)); + assert_eq!(padded_len(5, 4, 7), None); + assert_eq!(padded_len(usize::MAX - 1, 4, usize::MAX), None); + } + + #[test] + fn padded_copy_requires_live_capacity_admission() { + let budget = tiny_budget(4); + assert!(matches!( + copy_data(b"x", 8, true, Some(&budget)), + Err(crate::Error::ResourceLimit( + crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::LiveBytes { .. } + ) + )) + )); + } + + #[test] + fn copy_capacity_lease_survives_until_owner_drops() { + let budget = tiny_budget(8); + let (data, lease) = copy_data(b"ab", 4, true, Some(&budget)).unwrap(); + assert_eq!(data, b"ab\0\0"); + assert!(lease.is_some()); + let remaining = budget.lease_bytes(8 - data.capacity()).unwrap(); + drop(remaining); + drop(data); + drop(lease); + assert!(budget.lease_bytes(8).is_ok()); + + let budget = tiny_budget(4); + let (_data, _lease) = copy_data(b"ab", 4, true, Some(&budget)).unwrap(); + assert!(matches!( + budget.lease_bytes(1), + Err(crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::LiveBytes { .. } + )) + )); + } + + #[test] + fn ordinary_copy_and_complete_blocks_do_not_pad() { + for (input, ibs, sync, expected) in [ + (&b""[..], 8, true, &b""[..]), + (&b"abcd"[..], 4, true, &b"abcd"[..]), + (&b"abc"[..], 8, false, &b"abc"[..]), + (&b"abcde"[..], 4, true, &b"abcde\0\0\0"[..]), + ] { + let budget = tiny_budget(8); + let (data, _lease) = copy_data(input, ibs, sync, Some(&budget)).unwrap(); + assert_eq!(data, expected); + } + } + #[test] fn parses_sizes_like_gnu() { assert_eq!(parse_size("512"), Some(512)); diff --git a/crates/bashkit/tests/integration/dd_tests.rs b/crates/bashkit/tests/integration/dd_tests.rs new file mode 100644 index 000000000..4f3ef835a --- /dev/null +++ b/crates/bashkit/tests/integration/dd_tests.rs @@ -0,0 +1,108 @@ +//! Tiny ordinary dd copy contracts; admission arithmetic lives in dd unit tests. + +use bashkit::{Bash, ExecOptions}; + +#[tokio::test] +async fn dd_normal_copies_match_gnu() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let Some(program) = ["gdd", "dd"].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 dd is required for Linux CI" + ); + eprintln!("skip: GNU dd is not installed"); + return; + }; + + for (input, args) in [ + ("abcde", "ibs=4 obs=2 conv=sync status=noxfer"), + ("abcd", "ibs=4 obs=2 conv=sync status=noxfer"), + ("abcde", "bs=2 count=2 skip=1 status=noxfer"), + ( + "abcde", + "ibs=4 count=3 iflag=count_bytes conv=sync status=noxfer", + ), + ("ab", "ibs=4 conv=sync,ucase status=none"), + ("abcde", "ibs=4 count=0 conv=sync status=none"), + ("", "ibs=4 conv=sync status=noxfer"), + ("ab", "ibs=4 conv=sync of=/dev/null status=noxfer"), + ] { + 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 actual = Bash::new() + .exec_with_options(&format!("dd {args}"), ExecOptions::new().stdin(input)) + .await + .unwrap(); + assert_eq!(actual.stdout.as_bytes(), expected.stdout, "{args}"); + assert_eq!(actual.stderr.as_bytes(), expected.stderr, "{args}"); + assert_eq!(actual.exit_code, expected.status.code().unwrap(), "{args}"); + } +} + +#[tokio::test] +async fn dd_padding_keeps_seek_and_file_errors() { + let mut bash = Bash::new(); + bash.fs() + .write_file(std::path::Path::new("/copy"), b"abcdef") + .await + .unwrap(); + let result = bash + .exec("printf XY | dd of=/copy ibs=4 obs=1 seek=1 conv=sync,notrunc status=none") + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.is_empty()); + assert!(result.stderr.is_empty()); + assert_eq!( + bash.fs() + .read_file(std::path::Path::new("/copy")) + .await + .unwrap(), + b"aXY\0\0f" + ); + + for (script, stderr) in [ + ( + "printf XY | dd of=/copy ibs=4 conv=sync,excl status=none", + "dd: failed to open '/copy': File exists\n", + ), + ( + "printf XY | dd of=/missing ibs=4 conv=sync,nocreat status=none", + "dd: failed to open '/missing': No such file or directory\n", + ), + ] { + let result = bash.exec(script).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert_eq!(result.stderr, stderr); + assert!(result.stdout.is_empty()); + } + assert_eq!( + bash.fs() + .read_file(std::path::Path::new("/copy")) + .await + .unwrap(), + b"aXY\0\0f" + ); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 69640799a..820a046ea 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -68,6 +68,7 @@ pub mod date_timezone_differential_tests; pub mod date_timezone_no_tzdata_tests; #[cfg(feature = "tzdata")] pub mod date_timezone_tests; +pub mod dd_tests; pub mod dev_fd_path_tests; pub mod dev_null_tests; pub mod diagnostic_prefix_tests; From 733120e3009edce3a76dab8edbbcb7ac46419e81 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 12 Oct 2026 23:53:46 -0500 Subject: [PATCH 2/6] chore(knowledge): document dd padded copy admission --- crates/bashkit/docs/threat-model.md | 5 +++++ docs/security.md | 5 +++++ knowledge/foundations/builtins.md | 13 +++++++++++++ knowledge/security/threat-model.md | 2 +- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index f44c2a411..2f89f921f 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -178,6 +178,11 @@ let bash = Bash::builder() # } ``` +`dd conv=sync` uses checked padding arithmetic and rejects padded lengths above +its 64 MiB copy cap before allocation. Copied payload capacity is reserved through +`BudgetedBytes` against the shared live-memory budget and retained through output +handling, including discarded output (TM-DOS-003, TM-DOS-103). + **Additional DoS hardening:** | Threat | Attack Example | Mitigation | Status | diff --git a/docs/security.md b/docs/security.md index 3b0f37f99..022ae03eb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -18,6 +18,11 @@ 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). | +`dd conv=sync` checks its padded block length against the 64 MiB copy cap +before allocating or zero-filling. Copied payload capacity also counts against +`max_live_intermediate_bytes` until output handling completes, including when +output is discarded. Exceeding either limit returns a resource-limit error. + `pr` checks its 16 MiB output cap before appending headers, rows, margins, newlines or form feeds. Repeated long headers cannot grow the output buffer past that cap. Input dates are retained as timestamps, so long date formats are not copied diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index d984080f6..8c6cc818e 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -613,6 +613,19 @@ probe of the 879 BRE/ERE regex cases except two whose pattern holds a raw Gaps are L-GREP-001..003 in [Known Limitations](../operations/limitations.md); regex caps are TM-DOS-023/025 in the [Threat Model](../security/threat-model.md). +### dd + +`dd` keeps its whole-buffer copy model. The selected input slice is capped before +copying. `conv=sync` computes the rounded block length with checked arithmetic +and rejects lengths above `DD_MAX_BYTES` before allocation. The entire copied +payload capacity is admitted through `BudgetedBytes` against the shared live +memory budget; its lease remains through conversion and output handling, +including discarded output. Budget failures propagate as resource-limit errors. +Ordinary count/skip, padding, conversion, record reports, seek, and file-error +behavior is preserved. Unit tests use tiny admission budgets and allocation-free +overflow arithmetic; `dd_tests` compares small copies with GNU dd and checks VFS +seek/error behavior (TM-DOS-003, TM-DOS-103). + ### fmt `fmt` vendors the uutils/coreutils `fmt` engine (MIT): `builtins/fmt/ diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index c474a41f3..d4eaa5462 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -116,7 +116,7 @@ embedded in rustdoc. It contains: |----|--------|--------------|------------|--------| | TM-DOS-001 | Large script input | `Bash::exec(huge_string)` | `max_input_bytes` limit (10MB) | **MITIGATED** | | TM-DOS-002 | Output flooding | `yes \| head -n 1000000000` | Command limit stops loop | Mitigated | -| TM-DOS-003 | Variable explosion | `x=$(cat /dev/urandom)`, `dd if=/dev/zero` | /dev/urandom returns bounded 8KB; `dd` generates `/dev/zero`/`/dev/urandom` only up to `DD_MAX_BYTES` (64 MiB) per invocation | Mitigated | +| TM-DOS-003 | Variable explosion | `x=$(cat /dev/urandom)`, `dd if=/dev/zero` | /dev/urandom returns bounded 8KB; `dd` selects at most `DD_MAX_BYTES` (64 MiB) per copy; checked `conv=sync` padding must also fit that cap before allocation, and copy capacity retains a shared live-memory lease through output handling (`dd` unit tests, `dd_tests`) | Mitigated | | TM-DOS-004 | Array growth | `arr+=(element)` in loop | Command limit | Mitigated | **Current Risk**: LOW. Implementation: `ExecutionLimits` in `limits.rs`, `max_input_bytes` 10MB (TM-DOS-001), `max_commands` 10K per `exec()` (TM-DOS-002, TM-DOS-004). From 6e3723e0cc21d12c070edffe82d233bf60b65b2c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 12 Oct 2026 23:58:34 -0500 Subject: [PATCH 3/6] fix(dd): retain admitted storage through stdout conversion --- crates/bashkit/src/builtins/dd.rs | 64 ++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/bashkit/src/builtins/dd.rs b/crates/bashkit/src/builtins/dd.rs index 6aca8a0bb..46862fe9f 100644 --- a/crates/bashkit/src/builtins/dd.rs +++ b/crates/bashkit/src/builtins/dd.rs @@ -15,7 +15,7 @@ use super::limits::DD_MAX_BYTES; use super::{Builtin, Context}; use crate::error::Result; use crate::interpreter::ExecResult; -use crate::limits::{BudgetedBytes, ExecutionBudget, ExecutionBudgetLease}; +use crate::limits::{BudgetedBytes, ExecutionBudget}; /// `dd` builtin. pub struct Dd; @@ -158,7 +158,7 @@ fn copy_data( ibs: usize, sync: bool, budget: Option<&ExecutionBudget>, -) -> Result<(Vec, Option)> { +) -> Result { let len = if sync { padded_len(input.len(), ibs, DD_MAX_BYTES).ok_or_else(|| { crate::LimitExceeded::Memory("dd padded data exceeds byte limit".into()) @@ -168,10 +168,13 @@ fn copy_data( }; let mut buffer = BudgetedBytes::try_with_capacity(budget, len)?; buffer.try_extend_from_slice(input)?; - let (mut data, lease) = buffer.into_parts(); - // Capacity for the entire padded copy is already admitted and allocated. - data.resize(len, 0); - Ok((data, lease)) + // Every append stays within the already admitted full padded capacity. + const ZEROS: [u8; 1024] = [0; 1024]; + while buffer.len() < len { + let padding = (len - buffer.len()).min(ZEROS.len()); + buffer.try_extend_from_slice(&ZEROS[..padding])?; + } + Ok(buffer) } fn device_bytes(path: &str, len: usize) -> Option, String>> { @@ -255,8 +258,7 @@ impl Builtin for Dd { }) .transpose()?; let (full_in, partial_in) = (len / o.ibs, usize::from(!len.is_multiple_of(o.ibs))); - let (mut data, _data_lease) = - copy_data(&input[start..start + len], o.ibs, o.sync, budget.as_ref())?; + let mut data = copy_data(&input[start..start + len], o.ibs, o.sync, budget.as_ref())?; if o.ucase { data.make_ascii_uppercase(); } else if o.lcase { @@ -269,8 +271,18 @@ impl Builtin for Dd { let copied = data.len(); let mut result = ExecResult::ok(String::new()); + let mut _output_storage = [None, None]; match o.output.as_deref() { - None | Some("-") | Some("/dev/stdout") => result = ExecResult::ok_bytes(data), + None | Some("-") | Some("/dev/stdout") => { + let stdout = if let Some(budget) = &budget { + let (stdout, storage) = crate::StreamData::from_budgeted_bytes(data, budget)?; + _output_storage = storage; + stdout + } else { + crate::StreamData::from(data.into_parts().0) + }; + result = ExecResult::ok(stdout); + } Some("/dev/null") => {} Some(path) => { let p = super::resolve_path(ctx.cwd, path); @@ -390,7 +402,9 @@ mod tests { #[test] fn copy_capacity_lease_survives_until_owner_drops() { let budget = tiny_budget(8); - let (data, lease) = copy_data(b"ab", 4, true, Some(&budget)).unwrap(); + let (data, lease) = copy_data(b"ab", 4, true, Some(&budget)) + .unwrap() + .into_parts(); assert_eq!(data, b"ab\0\0"); assert!(lease.is_some()); let remaining = budget.lease_bytes(8 - data.capacity()).unwrap(); @@ -400,7 +414,9 @@ mod tests { assert!(budget.lease_bytes(8).is_ok()); let budget = tiny_budget(4); - let (_data, _lease) = copy_data(b"ab", 4, true, Some(&budget)).unwrap(); + let (_data, _lease) = copy_data(b"ab", 4, true, Some(&budget)) + .unwrap() + .into_parts(); assert!(matches!( budget.lease_bytes(1), Err(crate::LimitExceeded::ExecutionBudget( @@ -418,8 +434,30 @@ mod tests { (&b"abcde"[..], 4, true, &b"abcde\0\0\0"[..]), ] { let budget = tiny_budget(8); - let (data, _lease) = copy_data(input, ibs, sync, Some(&budget)).unwrap(); - assert_eq!(data, expected); + let data = copy_data(input, ibs, sync, Some(&budget)).unwrap(); + assert_eq!(&*data, expected); + } + } + + #[test] + fn stdout_padding_admits_cached_text() { + for limit in [7, 8] { + let budget = tiny_budget(limit); + let data = copy_data(b"ab", 4, true, Some(&budget)).unwrap(); + let result = crate::StreamData::from_budgeted_bytes(data, &budget); + if limit == 7 { + assert!(matches!( + result, + Err(crate::LimitExceeded::ExecutionBudget( + crate::ExecutionBudgetExceeded::LiveBytes { .. } + )) + )); + } else { + let (stream, _storage) = result.unwrap(); + assert_eq!(stream.as_bytes(), b"ab\0\0"); + assert_eq!(stream.text_lossy(), "ab\0\0"); + assert!(budget.lease_bytes(1).is_err()); + } } } From 0ef7e681bb95aa42dc1676145e4002c93b0199f9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 12 Oct 2026 23:58:34 -0500 Subject: [PATCH 4/6] chore(knowledge): document dd cached text admission --- crates/bashkit/docs/threat-model.md | 3 ++- docs/security.md | 3 ++- knowledge/foundations/builtins.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 2f89f921f..27ea37ddf 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -181,7 +181,8 @@ let bash = Bash::builder() `dd conv=sync` uses checked padding arithmetic and rejects padded lengths above its 64 MiB copy cap before allocation. Copied payload capacity is reserved through `BudgetedBytes` against the shared live-memory budget and retained through output -handling, including discarded output (TM-DOS-003, TM-DOS-103). +handling, including discarded output. Stdout cached text shares that budget +(TM-DOS-003, TM-DOS-103). **Additional DoS hardening:** diff --git a/docs/security.md b/docs/security.md index 022ae03eb..08f1894ca 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,8 @@ individual threat IDs and mitigation status, see the `dd conv=sync` checks its padded block length against the 64 MiB copy cap before allocating or zero-filling. Copied payload capacity also counts against `max_live_intermediate_bytes` until output handling completes, including when -output is discarded. Exceeding either limit returns a resource-limit error. +output is discarded. Stdout cached text is admitted under the same budget. +Exceeding either limit returns a resource-limit error. `pr` checks its 16 MiB output cap before appending headers, rows, margins, newlines or form feeds. Repeated long headers cannot grow the output buffer past that cap. diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 8c6cc818e..2fe97a2e9 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -620,7 +620,8 @@ copying. `conv=sync` computes the rounded block length with checked arithmetic and rejects lengths above `DD_MAX_BYTES` before allocation. The entire copied payload capacity is admitted through `BudgetedBytes` against the shared live memory budget; its lease remains through conversion and output handling, -including discarded output. Budget failures propagate as resource-limit errors. +including discarded output. Stdout conversion also admits cached text while +retaining both storage guards. Budget failures propagate as resource-limit errors. Ordinary count/skip, padding, conversion, record reports, seek, and file-error behavior is preserved. Unit tests use tiny admission budgets and allocation-free overflow arithmetic; `dd_tests` compares small copies with GNU dd and checks VFS From 1d37c14d60efa2d78f6a23519a778a964593cb75 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 13 Oct 2026 00:42:22 -0500 Subject: [PATCH 5/6] test(dd): recognize GNU package version banners --- crates/bashkit/tests/integration/dd_tests.rs | 21 ++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/tests/integration/dd_tests.rs b/crates/bashkit/tests/integration/dd_tests.rs index 4f3ef835a..a45c63ee5 100644 --- a/crates/bashkit/tests/integration/dd_tests.rs +++ b/crates/bashkit/tests/integration/dd_tests.rs @@ -2,6 +2,22 @@ use bashkit::{Bash, ExecOptions}; +fn is_gnu_dd_version(stdout: &[u8]) -> bool { + // GNU dd prints PACKAGE (`coreutils`), not PACKAGE_NAME (`GNU coreutils`). + stdout.starts_with(b"dd (coreutils) ") || stdout.starts_with(b"dd (GNU coreutils) ") +} + +#[test] +fn dd_oracle_recognizes_gnu_package_banner() { + assert!(is_gnu_dd_version( + b"dd (coreutils) 9.4\nCopyright (C) 2023 Free Software Foundation, Inc.\n" + )); + assert!(is_gnu_dd_version(b"dd (GNU coreutils) 9.12\n")); + assert!(!is_gnu_dd_version(b"dd (uutils coreutils) 0.12.0\n")); + assert!(!is_gnu_dd_version(b"dd (BSD)\n")); + assert!(!is_gnu_dd_version(b"other program GNU coreutils\n")); +} + #[tokio::test] async fn dd_normal_copies_match_gnu() { use std::io::Write; @@ -11,10 +27,7 @@ async fn dd_normal_copies_match_gnu() { Command::new(program) .arg("--version") .output() - .is_ok_and(|out| { - out.status.success() - && String::from_utf8_lossy(&out.stdout).contains("GNU coreutils") - }) + .is_ok_and(|out| out.status.success() && is_gnu_dd_version(&out.stdout)) }) else { assert!( !(cfg!(target_os = "linux") && std::env::var_os("CI").is_some()), From 6a9ffbfde3511ae7d2808ed75088b1c4f7dd0ed4 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 13 Oct 2026 00:42:23 -0500 Subject: [PATCH 6/6] chore(knowledge): document dd GNU oracle discovery --- knowledge/security/security-testing.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/knowledge/security/security-testing.md b/knowledge/security/security-testing.md index efa2e4547..9fa527358 100644 --- a/knowledge/security/security-testing.md +++ b/knowledge/security/security-testing.md @@ -73,6 +73,17 @@ 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 +## dd GNU Regression Oracle + +`dd_tests` compares only tiny ordinary copies against a GNU dd binary. Discovery +requires a successful `--version` and recognizes the `dd (coreutils)` package +banner as well as `dd (GNU coreutils)`. GNU dd's +[version-option code](https://github.com/coreutils/coreutils/blob/v9.4/src/dd.c#L2288) +uses the package name; requiring the longer phrase anywhere in the output rejects +GNU versions without a matching footer. The inert banner fixture also rejects +uutils, BSD, and unrelated programs. Linux CI must find GNU dd; all byte, stderr, +and exit-status assertions remain mandatory. See [Builtins](../foundations/builtins.md). + ## od Width Regression Oracle `od_resource_tests` compares valid-width layouts with GNU `od` when available