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