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

nordisk/znippy-zoomies

ldeflate/tests/stock_zip.rs

//! **The acceptance test for the ZIP/JAR container.** Real tools judge it.
//!
//! Two independent readers, because neither shares a line of code with this
//! crate or with each other:
//!   * **`unzip`** (Info-ZIP) — `-t` verifies every entry's CRC-32, `-p` streams
//!     one entry to stdout for a byte comparison, `-l` lists the directory.
//!   * **Python's `zipfile`** — a completely separate implementation, and the
//!     only one here that exercises the *central directory* as the primary index
//!     (`unzip` will happily fall back to scanning local headers, so a broken
//!     central directory can slip past it). `testzip()` re-checks every CRC.
//!
//! `jar` and `java` are **not installed on this host**, so the JAR arm verifies
//! the structural properties `java.util.jar.JarInputStream` depends on —
//! manifest first, correctly terminated, parseable — rather than claiming a JVM
//! read it. That limitation is stated rather than papered over.
//!
//! Every arm asserts the tool **saw the entries** (a count, and the bytes), not
//! merely that it exited 0 — `unzip -t` on an empty archive succeeds. Every arm
//! has a negative control proving the check can fail.
//!
//! Fixtures are small and degenerate parameters are never crossed with large
//! inputs; see the OOM note in `src/limits.rs`.

use std::path::PathBuf;
use std::process::{Command, Stdio};

use ldeflate::{Config, ZipEntry, jar_write, zip_write};

fn have(tool: &str, probe: &[&str]) -> bool {
    Command::new(tool)
        .args(probe)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn have_unzip() -> bool {
    // `unzip -v` exits 0; `--version` does not exist on Info-ZIP.
    have("unzip", &["-v"])
}
fn have_python() -> bool {
    have("python3", &["-c", "import zipfile"])
}

fn scratch(name: &str) -> PathBuf {
    let base = std::env::var("LDEFLATE_TEST_SCRATCH")
        .unwrap_or_else(|_| format!("{}/scratch/cargo/zoomies-deflate/ziptest", std::env::var("HOME").unwrap_or_default()));
    let dir = PathBuf::from(base).join(format!("{name}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create scratch dir");
    dir
}

/// Compressible but distinct per entry, so a mis-assembled directory cannot
/// decode to the right bytes by luck.
fn payload(tag: &str, n: usize) -> Vec<u8> {
    let mut v = Vec::with_capacity(n);
    while v.len() < n {
        v.extend_from_slice(tag.as_bytes());
        v.extend_from_slice(b" pub fn run() -> usize { let x = 1; x + 2 }\n");
    }
    v.truncate(n);
    v
}

fn noise(n: usize) -> Vec<u8> {
    let mut v = Vec::with_capacity(n);
    let mut s: u64 = 0x243F6A8885A308D3;
    while v.len() < n {
        s ^= s << 13;
        s ^= s >> 7;
        s ^= s << 17;
        v.extend_from_slice(&s.to_le_bytes());
    }
    v.truncate(n);
    v
}

/// A fixture spanning every path the writer has: coalesced tiny entries, a
/// mid-sized one, one large enough to take the internal split, an
/// incompressible one (must be STORED), an empty one, and a nested path.
struct Fixture {
    names: Vec<String>,
    datas: Vec<Vec<u8>>,
}

impl Fixture {
    fn build() -> Self {
        let mut names = Vec::new();
        let mut datas = Vec::new();
        for i in 0..300 {
            names.push(format!("small/f{i}.txt"));
            datas.push(payload(&format!("e{i}"), 40 + (i * 7) % 900));
        }
        names.push("mid.txt".into());
        datas.push(payload("mid", 300_000));
        names.push("big.bin".into());
        datas.push(payload("big", 9 << 20)); // crosses split_above (4 MiB)
        names.push("noise.bin".into());
        datas.push(noise(200_000));
        names.push("empty.txt".into());
        datas.push(Vec::new());
        names.push("deep/nested/path/x.txt".into());
        datas.push(payload("deep", 1234));
        Fixture { names, datas }
    }
    fn entries(&self) -> Vec<ZipEntry<'_>> {
        self.names
            .iter()
            .zip(&self.datas)
            .map(|(n, d)| ZipEntry::new(n.as_str(), d.as_slice()))
            .collect()
    }
}

/// ARM 1 — `unzip -t` accepts, `unzip -l` sees every entry, `unzip -p` returns
/// the exact bytes.
#[test]
fn stock_unzip_accepts_an_archive_we_wrote() {
    if !have_unzip() {
        eprintln!("SKIP unzip arm: `unzip` not on PATH");
        return;
    }
    let dir = scratch("unzip");
    let fx = Fixture::build();
    let entries = fx.entries();
    let zip = zip_write(&entries, &Config::default()).unwrap();
    let p = dir.join("ours.zip");
    std::fs::write(&p, &zip).unwrap();

    // -t verifies every entry's CRC-32.
    let out = Command::new("unzip").arg("-t").arg(&p).output().unwrap();
    assert!(
        out.status.success(),
        "unzip -t REJECTED our archive.\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let text = String::from_utf8_lossy(&out.stdout);
    // It must have TESTED every entry — not walked an empty archive and exited 0.
    let tested = text.lines().filter(|l| l.contains("testing:")).count();
    assert_eq!(
        tested,
        entries.len(),
        "unzip -t reported {tested} tested entries but we wrote {} — it did not \
         actually visit the archive, so its clean verdict means nothing.\n{text}",
        entries.len()
    );
    assert!(
        text.contains("No errors detected"),
        "unzip -t did not confirm success:\n{text}"
    );

    // Every entry's bytes, straight out of unzip.
    for (name, want) in fx.names.iter().zip(&fx.datas) {
        let out = Command::new("unzip")
            .arg("-p")
            .arg(&p)
            .arg(name)
            .output()
            .unwrap();
        assert!(out.status.success(), "unzip -p failed for {name}");
        assert_eq!(
            out.stdout.len(),
            want.len(),
            "{name}: unzip returned {} bytes, entry is {}",
            out.stdout.len(),
            want.len()
        );
        assert_eq!(&out.stdout, want, "{name}: unzip returned different bytes");
    }
    let _ = std::fs::remove_dir_all(&dir);
}

/// ARM 2 — Python's `zipfile`: an entirely separate implementation, and the one
/// that reads the **central directory** as its index rather than scanning local
/// headers. A broken central directory passes `unzip` and fails here.
#[test]
fn python_zipfile_reads_the_central_directory_we_wrote() {
    if !have_python() {
        eprintln!("SKIP python zipfile arm: python3 without zipfile");
        return;
    }
    let dir = scratch("py");
    let fx = Fixture::build();
    let entries = fx.entries();
    let zip = zip_write(&entries, &Config::default()).unwrap();
    let p = dir.join("ours.zip");
    std::fs::write(&p, &zip).unwrap();

    // Dump name/size/crc/method per entry, plus testzip()'s verdict.
    let script = r#"
import sys, zipfile, hashlib
path = sys.argv[1]
z = zipfile.ZipFile(path)
bad = z.testzip()
if bad is not None:
    print("BADCRC", bad); sys.exit(1)
names = z.namelist()
print("COUNT", len(names))
for i in z.infolist():
    d = z.read(i.filename)
    print("E", i.filename, i.file_size, i.compress_type, hashlib.sha256(d).hexdigest())
"#;
    let out = Command::new("python3")
        .arg("-c")
        .arg(script)
        .arg(&p)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "python zipfile REJECTED our archive.\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let text = String::from_utf8_lossy(&out.stdout);

    let count: usize = text
        .lines()
        .find_map(|l| l.strip_prefix("COUNT "))
        .and_then(|n| n.parse().ok())
        .expect("python did not report a count");
    assert_eq!(
        count,
        entries.len(),
        "python read {count} entries from the central directory, we wrote {}",
        entries.len()
    );

    // Byte-exact per entry, via sha256 so the comparison does not stream MBs
    // through the process boundary.
    let mut seen = 0usize;
    for line in text.lines().filter_map(|l| l.strip_prefix("E ")) {
        let f: Vec<&str> = line.rsplitn(4, ' ').collect();
        // rsplitn gives [sha, method, size, name] reversed
        let (sha, method, size, name) = (f[0], f[1], f[2], f[3]);
        let idx = fx
            .names
            .iter()
            .position(|n| n == name)
            .unwrap_or_else(|| panic!("python reported an entry we never wrote: {name:?}"));
        let want = &fx.datas[idx];
        assert_eq!(
            size.parse::<usize>().unwrap(),
            want.len(),
            "{name}: size differs"
        );
        let want_sha = sha256_hex(want);
        assert_eq!(sha, want_sha, "{name}: content differs");
        // The incompressible entry must be STORED (method 0) even through a
        // third-party reader — the LFS finding, visible from outside.
        if name == "noise.bin" {
            assert_eq!(
                method, "0",
                "noise.bin should be stored, python saw method {method}"
            );
        }
        if name == "mid.txt" {
            assert_eq!(
                method, "8",
                "mid.txt should be deflated, python saw method {method}"
            );
        }
        seen += 1;
    }
    assert_eq!(seen, entries.len(), "python did not report every entry");
    let _ = std::fs::remove_dir_all(&dir);
}

/// Minimal SHA-256 so the Python arm can compare hashes without a new dep.
fn sha256_hex(data: &[u8]) -> String {
    // Tiny, from FIPS 180-4. Only used by tests.
    const K: [u32; 64] = [
        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
        0xc67178f2,
    ];
    let mut h: [u32; 8] = [
        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
        0x5be0cd19,
    ];
    let mut msg = data.to_vec();
    let bitlen = (data.len() as u64) * 8;
    msg.push(0x80);
    while msg.len() % 64 != 56 {
        msg.push(0);
    }
    msg.extend_from_slice(&bitlen.to_be_bytes());
    for block in msg.chunks(64) {
        let mut w = [0u32; 64];
        for i in 0..16 {
            w[i] = u32::from_be_bytes([
                block[i * 4],
                block[i * 4 + 1],
                block[i * 4 + 2],
                block[i * 4 + 3],
            ]);
        }
        for i in 16..64 {
            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
            w[i] = w[i - 16]
                .wrapping_add(s0)
                .wrapping_add(w[i - 7])
                .wrapping_add(s1);
        }
        let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
            (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
        for i in 0..64 {
            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
            let ch = (e & f) ^ ((!e) & g);
            let t1 = hh
                .wrapping_add(s1)
                .wrapping_add(ch)
                .wrapping_add(K[i])
                .wrapping_add(w[i]);
            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
            let maj = (a & b) ^ (a & c) ^ (b & c);
            let t2 = s0.wrapping_add(maj);
            hh = g;
            g = f;
            f = e;
            e = d.wrapping_add(t1);
            d = c;
            c = b;
            b = a;
            a = t1.wrapping_add(t2);
        }
        for (i, v) in [a, b, c, d, e, f, g, hh].iter().enumerate() {
            h[i] = h[i].wrapping_add(*v);
        }
    }
    h.iter().map(|x| format!("{x:08x}")).collect()
}

/// **RED-when-broken control.** Both readers must actually reject a corrupted
/// archive. Without this, the arms above would pass even if the tools were
/// ignoring the payload — and `unzip -t` on an EMPTY archive exits 0, so an
/// exit code is never sufficient.
#[test]
fn both_readers_reject_a_corrupted_archive() {
    if !have_unzip() && !have_python() {
        eprintln!("SKIP zip negative control: no reader on PATH");
        return;
    }
    let dir = scratch("zipneg");
    let data = payload("victim", 200_000);
    let entries = [ZipEntry::new("victim.txt", &data)];
    let clean = zip_write(&entries, &Config::default()).unwrap();
    let p = dir.join("clean.zip");
    std::fs::write(&p, &clean).unwrap();

    if have_unzip() {
        assert!(
            Command::new("unzip")
                .arg("-t")
                .arg(&p)
                .status()
                .unwrap()
                .success(),
            "fixture must be clean before corruption"
        );
    }

    // Corrupt a byte in the middle of the compressed payload — the CRC-32 in the
    // headers is the only thing that can catch it.
    let mut bad = clean.clone();
    let mid = bad.len() / 2;
    bad[mid] ^= 0x5a;
    let pb = dir.join("bad.zip");
    std::fs::write(&pb, &bad).unwrap();

    if have_unzip() {
        let out = Command::new("unzip").arg("-t").arg(&pb).output().unwrap();
        assert!(
            !out.status.success(),
            "unzip -t ACCEPTED a corrupted payload — the acceptance arm proves nothing"
        );
    }
    if have_python() {
        let script = "import sys,zipfile\n\
                      z=zipfile.ZipFile(sys.argv[1])\n\
                      sys.exit(1 if z.testzip() is not None else 0)\n";
        let st = Command::new("python3")
            .arg("-c")
            .arg(script)
            .arg(&pb)
            .output()
            .unwrap();
        assert!(
            !st.status.success(),
            "python zipfile.testzip() ACCEPTED a corrupted payload"
        );
    }

    // And the empty-archive trap, stated explicitly.
    let empty = zip_write(&[], &Config::default()).unwrap();
    let pe = dir.join("empty.zip");
    std::fs::write(&pe, &empty).unwrap();
    if have_python() {
        let script = "import sys,zipfile\nprint(len(zipfile.ZipFile(sys.argv[1]).namelist()))\n";
        let out = Command::new("python3")
            .arg("-c")
            .arg(script)
            .arg(&pe)
            .output()
            .unwrap();
        assert!(
            out.status.success(),
            "an empty archive must still be a VALID zip"
        );
        assert_eq!(
            String::from_utf8_lossy(&out.stdout).trim(),
            "0",
            "an empty archive must contain zero entries — and note it VALIDATES, \
             which is why entry counts, not exit codes, are the assertion above"
        );
    }
    let _ = std::fs::remove_dir_all(&dir);
}

/// The JAR arm. `jar` and `java` are not installed here, so this checks the
/// structural contract `java.util.jar.JarInputStream` actually depends on —
/// through Python, i.e. not through our own parser — rather than claiming a JVM
/// validated it.
#[test]
fn jar_is_a_valid_zip_with_the_manifest_first() {
    if !have_python() {
        eprintln!("SKIP jar arm: python3 without zipfile");
        return;
    }
    if have("jar", &["--version"]) || have("java", &["-version"]) {
        eprintln!("NOTE: a JVM appears to be present; consider adding a real `jar tf` arm");
    }
    let dir = scratch("jar");
    let cls = payload("class", 5_000);
    let res = payload("resource", 900);
    let jar = jar_write(
        "Manifest-Version: 1.0\nCreated-By: ldeflate\nMain-Class: com.example.App",
        &[
            ZipEntry::new("com/example/App.class", &cls),
            ZipEntry::new("resources/app.properties", &res),
        ],
        &Config::default(),
    )
    .unwrap();
    let p = dir.join("ours.jar");
    std::fs::write(&p, &jar).unwrap();

    let script = r#"
import sys, zipfile
z = zipfile.ZipFile(sys.argv[1])
assert z.testzip() is None, "CRC failure"
names = z.namelist()
print("FIRST", names[0])
print("COUNT", len(names))
m = z.read("META-INF/MANIFEST.MF").decode()
print("MANIFEST_OK", int(m.startswith("Manifest-Version: 1.0") and m.endswith("\n\n")))
print("MAIN", "Main-Class: com.example.App" in m)
"#;
    let out = Command::new("python3")
        .arg("-c")
        .arg(script)
        .arg(&p)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "python zipfile rejected our JAR.\nstderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let text = String::from_utf8_lossy(&out.stdout);
    assert!(
        text.contains("FIRST META-INF/MANIFEST.MF"),
        "the manifest is not the first entry — JarInputStream would return null:\n{text}"
    );
    assert!(text.contains("COUNT 3"), "expected 3 entries:\n{text}");
    assert!(
        text.contains("MANIFEST_OK 1"),
        "manifest is not correctly formed/terminated:\n{text}"
    );
    assert!(
        text.contains("MAIN True"),
        "Main-Class missing from the manifest:\n{text}"
    );

    if have_unzip() {
        let out = Command::new("unzip").arg("-t").arg(&p).output().unwrap();
        assert!(out.status.success(), "unzip -t rejected our JAR");
    }
    let _ = std::fs::remove_dir_all(&dir);
}

/// The SHA-256 helper is hand-written, so pin it against known answers — a
/// broken hash would make the Python arm compare garbage against garbage.
#[test]
fn sha256_helper_known_answers() {
    assert_eq!(
        sha256_hex(b""),
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    );
    assert_eq!(
        sha256_hex(b"abc"),
        "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
    );
    assert_eq!(
        sha256_hex(&vec![b'a'; 1_000_000])[..16],
        *"cdc76e5c9914fb92"
    );
}