ljar/tests/lying_uncompressed_size_cli.rs
//! A Central Directory must not be able to size the extractor's zero-filled
//! decode buffer.
//!
//! `znippy_zip_core::central_dir` already bounds every published size against the
//! archive: the compressed bytes must physically fit in the file, and
//! `uncompressed_size` may not exceed what those bytes could produce at DEFLATE's
//! documented 1032:1 worst case. That is a real bound and it is not enough — a
//! 4.2 MB archive may legally declare a 4.0 GiB entry and still pass it, because
//! 4.2 MB of DEFLATE genuinely could expand that far. `decode_entry` then did
//! `out.resize(that + headroom, 0)`, zero-filled, on every worker at once.
//!
//! # Seen red
//!
//! The fixture below, run through the extractor built from the shipped tree,
//! under `ulimit -v 2097152` (2 GiB):
//!
//! ```text
//! ljar: 1 entries, 32 parallel writers
//! memory allocation of 4294967584 bytes failed
//! Aborted (core dumped) exit=134
//! ```
//!
//! Zero files extracted. One entry did that; a directory full of them and 32
//! workers multiply it.
//!
//! Clamped, the same archive extracts its real 4_200_000 bytes under the same
//! cap. The clamp is lossless because `decode_entry` falls back to a growing
//! decoder on `OutputOverflow` — an archive that really does hold an entry above
//! `MAX_PRESIZE` still extracts byte-for-byte, which is what the honest control
//! here is for.
//!
//! `lzip-parallel`'s extractor is the same function over the same shared
//! `presize_for`; it was verified by hand against this same fixture (`lzip
//! lying.zip out` → 4_200_000 bytes, no allocation failure) rather than by a
//! second 90-line copy of this file.
use std::process::Command;
/// A real single-entry DEFLATE archive whose Central Directory's
/// uncompressed-size field is then overwritten with `claim`.
///
/// Only the directory is rewritten: the local header, the compressed bytes and
/// the CRC are untouched, so everything except the one number is honest. That is
/// the field every consumer in this workspace sizes a buffer from.
fn archive_claiming(payload: &[u8], claim: Option<u32>) -> Vec<u8> {
use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf));
zw.start_file(
"payload.bin",
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated),
)
.unwrap();
zw.write_all(payload).unwrap();
zw.finish().unwrap();
}
let Some(claim) = claim else { return buf };
// EOCD is at the tail (no comment): +16 is the Central Directory offset.
let eocd = buf
.windows(4)
.rposition(|w| w == [0x50, 0x4b, 0x05, 0x06])
.expect("no EOCD");
let cd_off = u32::from_le_bytes(buf[eocd + 16..eocd + 20].try_into().unwrap()) as usize;
assert_eq!(
&buf[cd_off..cd_off + 4],
b"PK\x01\x02",
"CD not where EOCD says"
);
// CD header +20 is compressed size, +24 uncompressed size.
let csize = u32::from_le_bytes(buf[cd_off + 20..cd_off + 24].try_into().unwrap()) as u64;
assert!(
claim as u64 <= csize * linflate::MAX_DEFLATE_RATIO as u64,
"the fixture must survive the 1032:1 directory bound, or it proves nothing \
about the pre-size: claim {claim} vs csize {csize}"
);
buf[cd_off + 24..cd_off + 28].copy_from_slice(&claim.to_le_bytes());
buf
}
/// Run the built extractor with its address space capped, returning
/// `(exit code, combined output)`.
fn extract_capped(archive: &std::path::Path, out_dir: &std::path::Path) -> (Option<i32>, String) {
let out = Command::new("sh")
.arg("-c")
.arg(format!(
"ulimit -v 2097152; exec '{}' '{}' '{}'",
env!("CARGO_BIN_EXE_ljar"),
archive.display(),
out_dir.display()
))
.output()
.expect("failed to run ljar");
let mut both = String::from_utf8_lossy(&out.stdout).into_owned();
both.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.code(), both)
}
#[test]
fn a_lying_uncompressed_size_cannot_size_the_extractor_buffer() {
// Incompressible, so `compressed_size` is large enough that a 4 GiB claim
// clears the directory's own 1032:1 bound and reaches the pre-size.
let payload: Vec<u8> = {
let mut s: u64 = 0x2545_F491_4F6C_DD1D;
(0..4_200_000u32)
.map(|_| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s as u8
})
.collect()
};
let dir = tempfile::tempdir().unwrap();
// ── Control: the same archive, telling the truth, must extract exactly. ──
let honest = dir.path().join("honest.zip");
let honest_out = dir.path().join("honest-out");
std::fs::create_dir(&honest_out).unwrap();
std::fs::write(&honest, archive_claiming(&payload, None)).unwrap();
let (code, log) = extract_capped(&honest, &honest_out);
assert_eq!(code, Some(0), "an honest archive failed to extract: {log}");
assert_eq!(
std::fs::read(honest_out.join("payload.bin")).unwrap(),
payload,
"the honest extraction is not byte-identical"
);
// ── The hostile archive: 4.2 MB on disk, directory claims 4.0 GiB. ──
let lying = dir.path().join("lying.zip");
let lying_out = dir.path().join("lying-out");
std::fs::create_dir(&lying_out).unwrap();
let bytes = archive_claiming(&payload, Some(0xFFFF_FFFE));
assert!(
bytes.len() < 8 << 20,
"fixture must stay small: {} bytes",
bytes.len()
);
std::fs::write(&lying, &bytes).unwrap();
let (code, log) = extract_capped(&lying, &lying_out);
assert!(
!log.contains("memory allocation of"),
"the extractor sized an allocation from the lying directory: {log}"
);
assert_ne!(
code,
Some(134),
"the extractor aborted on the lying directory: {log}"
);
// And it must still get the entry's real bytes out — a "cap" that refused the
// archive outright would pass the two assertions above and be wrong.
assert_eq!(
std::fs::read(lying_out.join("payload.bin")).unwrap(),
payload,
"the clamped pre-size lost or corrupted the entry's real bytes"
);
}