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

nordisk/znippy-zoomies

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