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

nordisk/znippy-zoomies

lgz/tests/lying_isize_cli.rs

//! The `lgz` CLI must not size an allocation from an ISIZE trailer it has not
//! bounded.
//!
//! `speculative::plan_member_layout` bounds each member's claimed output by its
//! own compressed span (1032:1). The binary carried its own copy of that
//! arithmetic in two places — the `-o` pre-size loop and the `hints`/`max_hint`
//! loop that feeds `Vec::reserve` on every worker — and neither had the bound.
//!
//! # Seen red
//!
//! With those two loops reading the trailer raw, on the fixture below (1344
//! bytes: 64 genuine one-byte gzip members whose ISIZE is patched to
//! 0xFFFF_FFFF), under this test's `ulimit -v 3145728` (3 GiB):
//!
//! ```text
//! memory allocation of 4294967295 bytes failed
//! Aborted (core dumped)   exit=134
//! ```
//!
//! At `ulimit -v 25165824` (24 GiB) it did not abort — it wedged for over two
//! minutes instead, one 4 GiB reservation per worker on a 32-core box.
//!
//! Bounded, the fast path refuses the layout and falls through to the
//! authoritative streaming decoder, which rejects the file on its merits:
//! `lgz: decompression failed: corrupt gzip stream does not have a matching
//! checksum`.
//!
//! The 3 GiB cap is set by the *fallback* pipeline's fixed 200 MiB chunk buffer,
//! not by anything attacker-controlled; it is still three-quarters of one
//! believed `ISIZE = 0xFFFF_FFFF`, so the red above is unaffected by it.
//!
//! The honest control below is what keeps this test from passing on a fix that
//! merely refuses everything: the same 64 members with truthful trailers must
//! still decode to their 64 bytes.

use std::process::Command;

/// One genuine gzip member holding a single byte `x`, with its 4-byte ISIZE
/// trailer overwritten by `claim`.
fn member_claiming(claim: u32) -> Vec<u8> {
    use std::io::Write;
    // A fixed, minimal gzip member: header, one stored-then-deflated byte, trailer.
    let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    enc.write_all(b"x").unwrap();
    let mut m = enc.finish().unwrap();
    let n = m.len();
    m[n - 4..].copy_from_slice(&claim.to_le_bytes());
    m
}

/// `n` copies of that member, concatenated — the shape `cat *.gz` produces, and
/// the shape the CLI's multi-member fast path is written for.
fn concatenated(n: usize, claim: u32) -> Vec<u8> {
    member_claiming(claim).repeat(n)
}

/// Run the built `lgz` binary on `path` with its address space capped, and
/// return `(exit code, stdout len, stderr)`.
///
/// The cap is 1 GiB — three orders of magnitude more than this fixture's honest
/// 64-byte output needs, and three orders of magnitude less than one believed
/// `ISIZE = 0xFFFF_FFFF` asks for. The `ulimit` is what makes the red safe to
/// run: an unbounded reservation dies in its own process instead of on the box.
fn run_capped(path: &std::path::Path) -> (Option<i32>, usize, String) {
    let out = Command::new("sh")
        .arg("-c")
        .arg(format!(
            "ulimit -v 3145728; exec '{}' '{}'",
            env!("CARGO_BIN_EXE_lgz"),
            path.display()
        ))
        .output()
        .expect("failed to run lgz");
    (
        out.status.code(),
        out.stdout.len(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

#[test]
fn a_lying_isize_trailer_cannot_size_the_cli_decode_buffers() {
    let dir = tempfile::tempdir().unwrap();

    // ── The control. Truthful trailers: must still decode, byte for byte. ──
    let honest = dir.path().join("honest.gz");
    std::fs::write(&honest, concatenated(64, 1)).unwrap();
    let (code, stdout_len, stderr) = run_capped(&honest);
    assert_eq!(
        (code, stdout_len),
        (Some(0), 64),
        "an honest 64-member stream must still decode to its 64 bytes; stderr: {stderr}"
    );

    // ── The hostile input. 64 real members, each claiming 4 GiB. ──
    let lying = dir.path().join("lying.gz");
    let bytes = concatenated(64, u32::MAX);
    assert!(
        bytes.len() < 4096,
        "fixture must stay tiny: {} bytes",
        bytes.len()
    );
    std::fs::write(&lying, &bytes).unwrap();

    let (code, _stdout_len, stderr) = run_capped(&lying);

    // The specific failure this exists for: the allocator was asked for the
    // claim. Assert the *message*, not just the exit code — a process that dies
    // for some other reason must not be able to pass this.
    assert!(
        !stderr.contains("memory allocation of"),
        "the CLI sized an allocation from the lying trailer: {stderr}"
    );
    // 134 = 128 + SIGABRT, what a failed Rust allocation exits with.
    assert_ne!(
        code,
        Some(134),
        "the CLI aborted on the lying trailer: {stderr}"
    );
    assert!(
        code.is_some(),
        "the CLI died on a signal rather than refusing the trailer: {stderr}"
    );
}