From c2c13dbe30880d01722781e32b147e96216ea676 Mon Sep 17 00:00:00 2001 From: relative23 <62724298+relative23@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:04:32 +0200 Subject: [PATCH 1/5] dd: derive the count_bytes read budget from bytes read calc_loop_bsize computed the remaining byte budget for count=N iflag=count_bytes from the bytes written so far, while below_count_limit gates the copy loop on bytes read. A conversion that writes more than it reads (conv=block record expansion, conv=sync padding of partial reads) pushes the written total past the limit while the read total is still below it, and the u128 subtraction underflowed: a panic in debug builds ('attempt to subtract with overflow' in calc_loop_bsize), and a wrapped huge value in release builds that unlocked a full-sized read on every following iteration, silently overshooting the count limit. Derive the remaining budget from the bytes read instead, matching below_count_limit and the documented semantics of count_bytes (saturating, so an inconsistency degrades to a zero-sized read instead of wrapping), and drop the now-unused WriteStat parameter. --- src/uu/dd/src/dd.rs | 66 ++++++++++++++++++++++++++++++++++------ tests/by-util/test_dd.rs | 31 +++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 0599963e6e3..b789856e762 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore fname, ftype, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, behaviour, bmax, bremain, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rremain, rsofar, rstat, sigusr, wlen, wstat oconv canonicalized FADV DONTNEED ESPIPE SPIPE bufferedoutput, SETFL +// spell-checker:ignore fname, ftype, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, behaviour, bmax, bremain, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rremain, rsofar, rstat, sigusr, underflowed, wlen, wstat oconv canonicalized FADV DONTNEED ESPIPE SPIPE bufferedoutput, SETFL mod blocks; mod bufferedoutput; @@ -1224,7 +1224,7 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { // As an optimization, make an educated guess about the // best buffer size for reading based on the number of // blocks already read and the number of blocks remaining. - let loop_bsize = calc_loop_bsize(i.settings.count, &rstat, &wstat, i.settings.ibs, bsize); + let loop_bsize = calc_loop_bsize(i.settings.count, &rstat, i.settings.ibs, bsize); let rstat_update = read_helper(&mut i, &mut buf, loop_bsize)?; if rstat_update.is_empty() { if input_nocache { @@ -1418,13 +1418,7 @@ fn calc_bsize(ibs: usize, obs: usize) -> usize { /// Calculate the buffer size appropriate for this loop iteration, respecting /// a `count=N` if present. -fn calc_loop_bsize( - count: Option, - rstat: &ReadStat, - wstat: &WriteStat, - ibs: usize, - ideal_bsize: usize, -) -> usize { +fn calc_loop_bsize(count: Option, rstat: &ReadStat, ibs: usize, ideal_bsize: usize) -> usize { match count { Some(Num::Blocks(rmax)) => { let rsofar = rstat.reads_complete + rstat.reads_partial; @@ -1432,8 +1426,17 @@ fn calc_loop_bsize( cmp::min(ideal_bsize as u64, rremain * ibs as u64) as usize } Some(Num::Bytes(bmax)) => { + // `count=N iflag=count_bytes` limits the *input*, and + // `below_count_limit` gates the copy loop on bytes read, so the + // remaining budget must be derived from bytes read as well. + // Deriving it from bytes written underflowed whenever a + // conversion wrote more than `bmax` bytes (`conv=sync` padding, + // `conv=block` record expansion): a panic in debug builds, and a + // wrapped result in release builds that unlocked a full-sized + // read on every following iteration, overshooting the count + // limit. let bmax: u128 = bmax.into(); - let bremain: u128 = bmax - wstat.bytes_total; + let bremain: u128 = bmax.saturating_sub(rstat.bytes_total.into()); cmp::min(ideal_bsize as u128, bremain) as usize } None => ideal_bsize, @@ -1672,4 +1675,47 @@ mod tests { Output::new_file(Path::new(settings.outfile.as_ref().unwrap()), &settings).is_err() ); } + + #[test] + fn test_calc_loop_bsize_count_bytes_remaining_from_bytes_read() { + use crate::progress::ReadStat; + use crate::{Num, calc_loop_bsize}; + + let rstat = ReadStat { + reads_complete: 1, + reads_partial: 0, + records_truncated: 0, + bytes_total: 512, + }; + // 488 input bytes of the 1000-byte budget remain, so the next read + // must be capped to them. + assert_eq!( + calc_loop_bsize(Some(Num::Bytes(1000)), &rstat, 512, 512), + 488 + ); + + let exhausted = ReadStat { + reads_complete: 2, + reads_partial: 0, + records_truncated: 0, + bytes_total: 1000, + }; + assert_eq!( + calc_loop_bsize(Some(Num::Bytes(1000)), &exhausted, 512, 512), + 0 + ); + + // If the accounting ever overshoots the budget, the saturating + // subtraction must degrade to a zero-sized read instead of wrapping. + let exceeded = ReadStat { + reads_complete: 1, + reads_partial: 1, + records_truncated: 0, + bytes_total: 1001, + }; + assert_eq!( + calc_loop_bsize(Some(Num::Bytes(1000)), &exceeded, 512, 512), + 0 + ); + } } diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 246b0a29242..622fe15e9e9 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -2157,3 +2157,34 @@ fn test_bs_not_positive() { } } } + +/// `count=N iflag=count_bytes` limits the number of *input* bytes, but the +/// per-iteration read size used to be derived from the bytes *written*. +/// A conversion that writes more than N bytes (`conv=block` record +/// expansion, `conv=sync` padding of partial reads) made that subtraction +/// underflow: a panic in debug builds, and a wrapped value in release +/// builds that unlocked full-sized reads and bypassed the count limit. +#[test] +fn test_count_bytes_with_expanding_block_conv() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("input.txt", &"aaaaaaaaaa\n".repeat(182)); + ucmd.args(&[ + "if=input.txt", + "of=output.bin", + "conv=block", + "cbs=1024", + "count=1000", + "iflag=count_bytes", + ]) + .succeeds() + // Exactly 1000 input bytes are read, in two loop iterations (one full + // 512-byte read, then a partial 488-byte read), independent of what + // the conversion emits. + .stderr_contains("1+1 records in"); + // Chunk-local block conversion turns the 512 + 488 input bytes into + // 47 + 45 records of cbs bytes. GNU emits 91 records here because it + // converts records across read boundaries (a pre-existing divergence, + // see #13434), so this legitimately becomes 91 * 1024 if that is ever + // unified. + assert_eq!(at.read_bytes("output.bin").len(), 92 * 1024); +} From 0a07694daa52e5f5ee304e98de6d78b8f00cec7d Mon Sep 17 00:00:00 2001 From: relative23 <62724298+relative23@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:40:47 +0200 Subject: [PATCH 2/5] dd: shorten the count_bytes comments --- src/uu/dd/src/dd.rs | 11 ++--------- tests/by-util/test_dd.rs | 8 ++------ 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index b789856e762..abdab8fa72f 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -1426,15 +1426,8 @@ fn calc_loop_bsize(count: Option, rstat: &ReadStat, ibs: usize, ideal_bsize cmp::min(ideal_bsize as u64, rremain * ibs as u64) as usize } Some(Num::Bytes(bmax)) => { - // `count=N iflag=count_bytes` limits the *input*, and - // `below_count_limit` gates the copy loop on bytes read, so the - // remaining budget must be derived from bytes read as well. - // Deriving it from bytes written underflowed whenever a - // conversion wrote more than `bmax` bytes (`conv=sync` padding, - // `conv=block` record expansion): a panic in debug builds, and a - // wrapped result in release builds that unlocked a full-sized - // read on every following iteration, overshooting the count - // limit. + // The budget is on the *input*: derive it from bytes read, not + // written (which can exceed it and underflow), like below_count_limit. let bmax: u128 = bmax.into(); let bremain: u128 = bmax.saturating_sub(rstat.bytes_total.into()); cmp::min(ideal_bsize as u128, bremain) as usize diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 6b9aafc2ef5..b30bb0062e2 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -2143,12 +2143,8 @@ fn test_bs_not_positive() { } } -/// `count=N iflag=count_bytes` limits the number of *input* bytes, but the -/// per-iteration read size used to be derived from the bytes *written*. -/// A conversion that writes more than N bytes (`conv=block` record -/// expansion, `conv=sync` padding of partial reads) made that subtraction -/// underflow: a panic in debug builds, and a wrapped value in release -/// builds that unlocked full-sized reads and bypassed the count limit. +/// Regression test for #13434: with `count=N iflag=count_bytes`, a conversion +/// writing more than N bytes made the per-iteration read-size math underflow. #[test] fn test_count_bytes_with_expanding_block_conv() { let (at, mut ucmd) = at_and_ucmd!(); From a946821eb18a925b529b53eb86a13e611925419e Mon Sep 17 00:00:00 2001 From: relative23 <62724298+relative23@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:02:34 +0200 Subject: [PATCH 3/5] tests/dd: shorten count_bytes comment --- tests/by-util/test_dd.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index b30bb0062e2..99681971e51 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -2162,10 +2162,6 @@ fn test_count_bytes_with_expanding_block_conv() { // 512-byte read, then a partial 488-byte read), independent of what // the conversion emits. .stderr_contains("1+1 records in"); - // Chunk-local block conversion turns the 512 + 488 input bytes into - // 47 + 45 records of cbs bytes. GNU emits 91 records here because it - // converts records across read boundaries (a pre-existing divergence, - // see #13434), so this legitimately becomes 91 * 1024 if that is ever - // unified. + // conv=block currently emits 92 chunk-local records; see #13434. assert_eq!(at.read_bytes("output.bin").len(), 92 * 1024); } From 24e12974ee10cafda46d1931b5b9ecca5fc444b7 Mon Sep 17 00:00:00 2001 From: relative23 <62724298+relative23@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:23:13 +0200 Subject: [PATCH 4/5] dd: remove stale spelling exception --- src/uu/dd/src/dd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index abdab8fa72f..9c1ea81944c 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore fname, ftype, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, behaviour, bmax, bremain, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rremain, rsofar, rstat, sigusr, underflowed, wlen, wstat oconv canonicalized FADV DONTNEED ESPIPE SPIPE bufferedoutput, SETFL +// spell-checker:ignore fname, ftype, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, behaviour, bmax, bremain, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rremain, rsofar, rstat, sigusr, wlen, wstat oconv canonicalized FADV DONTNEED ESPIPE SPIPE bufferedoutput, SETFL mod blocks; mod bufferedoutput; From d19666cce935847a5f283a08768bbba30c77411c Mon Sep 17 00:00:00 2001 From: relative23 <62724298+relative23@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:44:39 +0200 Subject: [PATCH 5/5] dd: simplify count_bytes accounting --- src/uu/dd/src/dd.rs | 56 ++++++++++------------------------------ tests/by-util/test_dd.rs | 17 +++++------- 2 files changed, 21 insertions(+), 52 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 9c1ea81944c..b3ef9607878 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -1426,11 +1426,9 @@ fn calc_loop_bsize(count: Option, rstat: &ReadStat, ibs: usize, ideal_bsize cmp::min(ideal_bsize as u64, rremain * ibs as u64) as usize } Some(Num::Bytes(bmax)) => { - // The budget is on the *input*: derive it from bytes read, not - // written (which can exceed it and underflow), like below_count_limit. - let bmax: u128 = bmax.into(); - let bremain: u128 = bmax.saturating_sub(rstat.bytes_total.into()); - cmp::min(ideal_bsize as u128, bremain) as usize + // `iflag=count_bytes` limits input, so use bytes read. + let bremain = bmax.saturating_sub(rstat.bytes_total); + cmp::min(ideal_bsize as u64, bremain) as usize } None => ideal_bsize, } @@ -1670,45 +1668,19 @@ mod tests { } #[test] - fn test_calc_loop_bsize_count_bytes_remaining_from_bytes_read() { + fn test_calc_loop_bsize_count_bytes() { use crate::progress::ReadStat; use crate::{Num, calc_loop_bsize}; - let rstat = ReadStat { - reads_complete: 1, - reads_partial: 0, - records_truncated: 0, - bytes_total: 512, - }; - // 488 input bytes of the 1000-byte budget remain, so the next read - // must be capped to them. - assert_eq!( - calc_loop_bsize(Some(Num::Bytes(1000)), &rstat, 512, 512), - 488 - ); - - let exhausted = ReadStat { - reads_complete: 2, - reads_partial: 0, - records_truncated: 0, - bytes_total: 1000, - }; - assert_eq!( - calc_loop_bsize(Some(Num::Bytes(1000)), &exhausted, 512, 512), - 0 - ); - - // If the accounting ever overshoots the budget, the saturating - // subtraction must degrade to a zero-sized read instead of wrapping. - let exceeded = ReadStat { - reads_complete: 1, - reads_partial: 1, - records_truncated: 0, - bytes_total: 1001, - }; - assert_eq!( - calc_loop_bsize(Some(Num::Bytes(1000)), &exceeded, 512, 512), - 0 - ); + for (bytes_read, expected) in [(512, 488), (1000, 0), (1001, 0)] { + let rstat = ReadStat { + bytes_total: bytes_read, + ..ReadStat::default() + }; + assert_eq!( + calc_loop_bsize(Some(Num::Bytes(1000)), &rstat, 512, 512), + expected + ); + } } } diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 99681971e51..a7bdaabd362 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -2143,12 +2143,12 @@ fn test_bs_not_positive() { } } -/// Regression test for #13434: with `count=N iflag=count_bytes`, a conversion -/// writing more than N bytes made the per-iteration read-size math underflow. #[test] fn test_count_bytes_with_expanding_block_conv() { let (at, mut ucmd) = at_and_ucmd!(); - at.write("input.txt", &"aaaaaaaaaa\n".repeat(182)); + let mut input = vec![b'a'; 1000]; + input.extend([b'Z'; 24]); + at.write_bytes("input.txt", &input); ucmd.args(&[ "if=input.txt", "of=output.bin", @@ -2157,11 +2157,8 @@ fn test_count_bytes_with_expanding_block_conv() { "count=1000", "iflag=count_bytes", ]) - .succeeds() - // Exactly 1000 input bytes are read, in two loop iterations (one full - // 512-byte read, then a partial 488-byte read), independent of what - // the conversion emits. - .stderr_contains("1+1 records in"); - // conv=block currently emits 92 chunk-local records; see #13434. - assert_eq!(at.read_bytes("output.bin").len(), 92 * 1024); + .succeeds(); + let output = at.read_bytes("output.bin"); + assert_eq!(bytecount::count(&output, b'a'), 1000); + assert!(!output.contains(&b'Z')); }