Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy-zoomies

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
    );
}