lbzip2/tests/output_ceiling.rs
//! A bzip2 stream must not be able to make the whole-stream decoders grow
//! without limit.
//!
//! `parallel::decompress_parallel` and `stream::decompress` both materialise the
//! entire output as one `Vec<u8>`, and neither had a ceiling. bzip2's
//! amplification is the largest in this workspace and — unlike DEFLATE's 1032:1
//! — it is not usefully bounded by the input length: measured on oden on
//! 2026-08-03, 1 GiB of zeros is **785 bytes** of `bzip2 -9` output, a ratio of
//! 1_367_824:1 that is still climbing at that size. The parallel path was the
//! worse of the two, because it holds every decoded segment resident *and then*
//! copies them all into an assembled output — it asks for the claimed size twice.
//!
//! # Seen red
//!
//! The fixture below is 393_216 bytes: 8192 concatenated all-zero bzip2 streams
//! of 8 MiB each, so it claims **64 GiB**. Decoded by the uncapped
//! `decompress_parallel` under this test's `ulimit -v 6291456` (6 GiB):
//!
//! ```text
//! memory allocation of 268435456 bytes failed
//! child exited with 134 (SIGABRT)
//! ```
//!
//! Capped, the same bytes come back as an ordinary error — `decompressed output
//! exceeds max_out cap` — with the process peaking near the 1 GiB ceiling.
//!
//! The subprocess is not decoration: the red case allocates until the box says
//! no, so it has to be a `ulimit`ed child rather than this test's own process.
//! That is the lesson from the OOM of 2026-08-03, where a test matrix crossing a
//! degenerate size with a real input took out unrelated services.
use std::io::Write;
use std::process::Command;
/// Set in the child that performs the actual decode.
const INNER: &str = "LBZIP2_OUTPUT_CEILING_INNER";
/// Address space the child is allowed, in KiB. Six times the 1 GiB ceiling the
/// green path stops at; one hundredth of what the fixture claims.
const CHILD_AS_KIB: u64 = 6 * 1024 * 1024;
/// One complete bzip2 stream holding `n` zero bytes.
fn zeros_stream(n: usize) -> Vec<u8> {
let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::best());
let chunk = vec![0u8; 1 << 20];
let mut left = n;
while left > 0 {
let take = left.min(chunk.len());
enc.write_all(&chunk[..take]).unwrap();
left -= take;
}
enc.finish().unwrap()
}
/// The bomb: `copies` concatenated all-zero streams of `unit` bytes each. The
/// pbzip2 concatenation the parallel decoder is written to handle, so every byte
/// of it is a stream this decoder will genuinely try to produce.
fn bomb(unit: usize, copies: usize) -> (Vec<u8>, usize) {
let one = zeros_stream(unit);
(one.repeat(copies), unit * copies)
}
#[test]
fn a_concatenated_all_zero_bomb_cannot_grow_past_the_default_ceiling() {
if std::env::var(INNER).is_ok() {
inner_decode();
return;
}
let out = Command::new("sh")
.arg("-c")
.arg(format!(
"ulimit -v {CHILD_AS_KIB}; exec '{}' --exact \
a_concatenated_all_zero_bomb_cannot_grow_past_the_default_ceiling --nocapture",
std::env::current_exe().unwrap().display()
))
.env(INNER, "1")
.output()
.expect("failed to re-exec the test binary");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let both = format!("{stdout}{stderr}");
// The failure this exists for: the allocator was asked for the claim.
assert!(
!both.contains("memory allocation of"),
"the decode sized an allocation from the bomb's claim:\n{both}"
);
assert_ne!(
out.status.code(),
Some(134),
"the decode aborted (SIGABRT) on the bomb:\n{both}"
);
// And the specific things the child asserted, so a child that died quietly
// cannot be mistaken for a child that passed.
for marker in ["CONTROL-OK", "REFUSED-OK"] {
assert!(
both.contains(marker),
"child never reported {marker}:\n{both}"
);
}
}
/// What runs inside the address-space-capped child.
fn inner_decode() {
// ── Control. An honest stream must still decode, byte for byte, through the
// now-capped default entry points. Without this the test would pass on a
// "cap" that simply refused everything. ────────────────────────────────
let honest_len = 3 * (1 << 20);
let honest = zeros_stream(honest_len);
let par = lbzip2::parallel::decompress_parallel(&honest).expect("honest stream refused");
let seq = lbzip2::stream::decompress(&honest).expect("honest stream refused (sequential)");
assert_eq!(par.len(), honest_len, "capped parallel decode lost bytes");
assert_eq!(seq.len(), honest_len, "capped sequential decode lost bytes");
assert!(
par.iter().all(|&b| b == 0) && par == seq,
"capped decode corrupted the output"
);
println!("CONTROL-OK {honest_len} bytes through both capped paths");
// ── The bomb. 393 KB claiming 64 GiB. ──────────────────────────────────
let (bytes, claim) = bomb(8 << 20, 8192);
println!(
"BOMB {} compressed bytes claiming {claim} bytes ({}:1)",
bytes.len(),
claim / bytes.len()
);
assert!(
bytes.len() < 1 << 20,
"fixture must stay tiny: {} bytes",
bytes.len()
);
assert!(
claim > 60 * (1 << 30),
"fixture must claim far more than the ceiling: {claim}"
);
let err = lbzip2::parallel::decompress_parallel(&bytes)
.expect_err("the default-capped parallel decode accepted a 64 GiB claim");
assert_eq!(
err.0,
lbzip2::limits::OUTPUT_TOO_LARGE,
"refused, but for the wrong reason"
);
println!("REFUSED-OK {}", err.0);
// The explicitly-uncapped door still exists and is the only way to the old
// behaviour — call it on the *honest* stream, never on the bomb.
let un = lbzip2::parallel::decompress_parallel_uncapped(&honest)
.expect("the uncapped door refused an honest stream");
assert_eq!(un.len(), honest_len);
}
/// The ceiling is a shared budget, so a stream whose output straddles it must be
/// refused by the *aggregate*, not by any one segment. Cheap enough to run
/// in-process: the explicit cap is 4 MiB.
#[test]
fn the_ceiling_is_charged_across_segments_not_per_segment() {
let len = 24 << 20; // 24 MiB of zeros → ~28 bzip2 blocks across many segments
let data = zeros_stream(len);
// Under the total, every segment included: accepted.
let ok = lbzip2::parallel::decompress_parallel_capped(&data, len)
.expect("a budget equal to the real output must be enough");
assert_eq!(ok.len(), len);
// One byte under the total: refused. No single segment is that big, so only
// a shared budget can see it.
let err = lbzip2::parallel::decompress_parallel_capped(&data, len - 1)
.expect_err("a budget one byte short must refuse");
assert_eq!(err.0, lbzip2::limits::OUTPUT_TOO_LARGE);
// And the uncapped door is genuinely uncapped at that same size.
assert_eq!(
lbzip2::parallel::decompress_parallel_uncapped(&data)
.unwrap()
.len(),
len
);
}