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

nordisk/holger

server/repository/znippy-rust/tests/format_version_gate.rs

//! **RED-when-broken cover for the znippy format-version pin.**
//!
//! The defect this guards (`.nornir/artifactory-parity-design.md`, P0): a znippy
//! archive written by a *newer* znippy than the reader linked into this holger
//! crashes that reader — and, on the serve path, takes the server down with it.
//! An artifact registry any client can kill by uploading is not a registry.
//!
//! What is asserted here, end to end, against **real** archives (packed by
//! `znippy-compress`, not mocks):
//!
//! 1. A crafted archive recording a *future* format version is **refused at open**
//!    with holger's own clear error — it names the archive, what it recorded, and
//!    what this build reads.
//! 2. A writable repo whose live `.znippy` turns future underneath it **fails the
//!    request** (503 + the reason) and returns `Err` from `get_file` — it does not
//!    panic, does not abort, and does not silently answer 404.
//! 3. Through all of that, an unrelated repo on a good archive **keeps serving
//!    byte-identical crates** — the blast radius is one repo, not the process.
//! 4. The *same* archive left unpatched opens and serves fine, which is what makes
//!    (1) and (2) evidence about the version pin rather than about corruption.
//!
//! RED-when-broken: the assertions match on `"holger refuses"`, the marker of
//! holger's own gate (`traits::znippy_format`). Delete or bypass the gate and these
//! tests fail even though the linked znippy may raise an error of its own — which
//! is the point: holger pins the format it reads itself, because the znippy it is
//! built against (published crates in release, a local `[patch.crates-io]` in dev)
//! may have no such check.

use std::path::{Path, PathBuf};

use repository_znippy_rust::RustRepoZnippy;
use traits::{ArtifactId, RepositoryBackendTrait};
use znippy_compress::{compress_stream, ArchiveEntry};

#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;

/// Deterministic, non-trivial `.crate` payload — a few KB so packing exercises
/// real chunking rather than a degenerate empty file. Contents are opaque to znippy.
fn synth(name: &str, version: &str) -> Vec<u8> {
    let mut data = format!("synthetic-crate {name} {version}\n").into_bytes();
    let seed = name.bytes().fold(0u8, |a, b| a.wrapping_add(b));
    for i in 0..4096u32 {
        data.push(seed.wrapping_add((i % 251) as u8));
    }
    data
}

/// Pack `crates` into a real znippy archive under `base` and return the `.znippy`
/// index path.
fn pack(base: &PathBuf, crates: &[(&str, &str)]) -> PathBuf {
    let compressor = compress_stream(base, true).unwrap();
    let sender = compressor.sender().clone();
    for (name, version) in crates {
        sender
            .send(ArchiveEntry {
                relative_path: format!("{name}-{version}.crate"),
                data: synth(name, version),
                pkg_type: None,
                repo: None,
            })
            .unwrap();
    }
    drop(sender);
    compressor.finish().unwrap();
    base.with_extension("znippy")
}

/// The format version an archive currently records, verbatim — read through the
/// gate's own reader, which reports what it finds rather than refusing (so it can
/// still read an archive that has already been sent to the future).
fn recorded_version(index: &Path) -> String {
    traits::recorded_format_version(index)
        .expect("a znippy-compress-packed archive must record its format version")
}

/// Rewrite the archive at `index` so it claims a format version from the FUTURE,
/// changing nothing else. Returns the version it now claims.
///
/// The version lives in the Arrow schema metadata of each sub-index, as a
/// flatbuffers `KeyValue`: the value string's slot sits immediately before the key
/// string's, laid out `[len:u32-le][ascii][NUL][pad → 8 bytes]` followed by
/// `[21:u32-le]["znippy_format_version"]`. A 1-, 2-, or 3-character value all
/// occupy the same 8-byte slot (4 length + bytes + NUL, padded to alignment), so
/// appending one digit — `"3"` → `"39"` — is a **length-preserving** in-place edit:
/// every offset in the manifest, the footer and the surrounding flatbuffer stays
/// valid, and the archive differs from the original in exactly one thing, its
/// declared format version. That is what makes this a version test.
fn craft_future_version(index: &Path) -> String {
    const KEY: &str = "znippy_format_version";
    let current = recorded_version(index);
    let future = format!("{current}9");
    assert!(
        future.parse::<u32>().unwrap() > traits::MAX_SUPPORTED_ZNIPPY_FORMAT,
        "the crafted version must actually be in the future"
    );

    // `[len][ascii][NUL][pad → 8]` + `[21][key]`.
    let slot = |value: &str| -> Vec<u8> {
        let mut v = Vec::new();
        v.extend_from_slice(&(value.len() as u32).to_le_bytes());
        v.extend_from_slice(value.as_bytes());
        while v.len() % 8 != 0 {
            v.push(0);
        }
        v.extend_from_slice(&(KEY.len() as u32).to_le_bytes());
        v.extend_from_slice(KEY.as_bytes());
        v
    };
    let needle = slot(&current);
    let patch = slot(&future);
    assert_eq!(
        needle.len(),
        patch.len(),
        "the edit must be length-preserving or every archive offset shifts"
    );

    let mut bytes = std::fs::read(index).unwrap();
    let mut hits = 0usize;
    let mut i = 0usize;
    while i + needle.len() <= bytes.len() {
        if bytes[i..i + needle.len()] == needle[..] {
            bytes[i..i + patch.len()].copy_from_slice(&patch);
            hits += 1;
            i += needle.len();
        } else {
            i += 1;
        }
    }
    assert!(
        hits > 0,
        "no recorded format version found to patch — the on-disk metadata layout \
         changed; update this crafting recipe"
    );
    std::fs::write(index, &bytes).unwrap();
    assert_eq!(
        traits::recorded_format_version(index).as_deref(),
        Some(future.as_str()),
        "the archive must now claim the future version"
    );
    future
}

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

    // A good archive, and a second one that will be sent to the future. Separate
    // bases so each has its own blob file.
    let good_index = pack(
        &dir.path().join("good"),
        &[("serde", "1.0.200"), ("log", "0.4.22")],
    );
    let future_index = pack(&dir.path().join("future"), &[("anyhow", "1.0.86")]);

    // CONTROL — before crafting, this very archive opens and serves. Without this
    // the refusal below would only prove "holger rejects something broken".
    {
        let ok = RustRepoZnippy::with_archive("future".into(), future_index.clone())
            .expect("an unpatched archive must open");
        assert_eq!(
            ok.get_file("anyhow-1.0.86.crate").unwrap(),
            synth("anyhow", "1.0.86"),
            "control: the archive serves its bytes before the version is touched"
        );
    }

    let future = craft_future_version(&future_index);

    // THE GATE — refused, with holger's own message naming archive, version, pin.
    let err = match RustRepoZnippy::with_archive("future".into(), future_index.clone()) {
        Ok(_) => panic!("an archive from a future znippy format must be refused, not parsed"),
        Err(e) => e,
    };
    let msg = err.to_string();
    assert!(
        msg.contains("holger refuses"),
        "not holger's own gate: {msg}"
    );
    assert!(
        msg.contains(&future),
        "the refusal must name the recorded version: {msg}"
    );
    assert!(
        msg.contains(&format!("v{}", traits::MAX_SUPPORTED_ZNIPPY_FORMAT)),
        "the refusal must name the supported ceiling: {msg}"
    );
    // Typed, so a request handler can answer the request instead of guessing.
    assert!(
        err.downcast_ref::<traits::ArchiveFormatError>().is_some(),
        "the refusal must be a typed ArchiveFormatError: {msg}"
    );

    // AND THE SERVER KEEPS SERVING — an unrelated repo is untouched, over both the
    // in-process read and the HTTP door cargo actually hits.
    let good = RustRepoZnippy::with_archive("good".into(), good_index)
        .expect("the good archive must still open");
    assert_eq!(
        good.get_file("serde-1.0.200.crate").unwrap(),
        synth("serde", "1.0.200")
    );
    let (status, _h, body) = good
        .handle_http2_request("GET", "/good/crates/log/0.4.22/download", b"")
        .unwrap();
    assert_eq!(status, 200, "the good repo must still serve over HTTP");
    assert_eq!(body, synth("log", "0.4.22"));

    #[cfg(feature = "testmatrix")]
    fstatus(
        "znippy-rust/format-gate",
        "future_version_archive_refused_at_open",
        msg.contains("holger refuses") && status == 200,
        &format!("crafted v{future} archive refused (max v{}); sibling repo still served 200 with {} bytes", traits::MAX_SUPPORTED_ZNIPPY_FORMAT, body.len()),
    );
}

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

    // A live writable repo — the `/sparring` + `/cache`-primary shape, which
    // re-opens its `.znippy` on every read and is therefore the path where a
    // swapped-in future archive reaches a *request* rather than boot.
    let live = dir.path().join("live").join("sparring.znippy");
    let repo = RustRepoZnippy::with_writable_archive("sparring".into(), live.clone());
    let id = ArtifactId {
        namespace: None,
        name: "serde".into(),
        version: "1.0.200".into(),
    };
    let bytes = synth("serde", "1.0.200");
    repo.put(&id, &bytes).unwrap();

    // CONTROL — it serves right now, over the HTTP download door.
    let (status, _h, body) = repo
        .handle_http2_request("GET", "/sparring/crates/serde/1.0.200/download", b"")
        .unwrap();
    assert_eq!(
        status, 200,
        "control: the live repo serves before the archive goes future"
    );
    assert_eq!(body, bytes);

    // A second, unrelated repo that must be unaffected by what happens next.
    let bystander_index = pack(&dir.path().join("bystander"), &[("log", "0.4.22")]);
    let bystander = RustRepoZnippy::with_archive("bystander".into(), bystander_index).unwrap();

    // WHAT A LIVE WRITABLE ARCHIVE IS NOW. `put` appends through znippy's
    // `HotArchive`, so between publishes the file is blobs plus a journal and
    // carries NO sealed manifest — its journal IS its index. It therefore
    // records no format version while hot, and the gate reads "undetermined" as
    // "read as before", which is the fallback it has always honoured for legacy
    // archives. The self-describing property has not been dropped, it has moved
    // to the seal: assert BOTH halves so either one breaking is loud.
    assert!(
        znippy_common::HotArchive::is_hot(&live),
        "a writable repo that has taken a put is hot — that is what makes the \
         append O(1) instead of a whole-index re-seal"
    );
    {
        // Seal a COPY (the live repo keeps its handle) and the version is there
        // again, and within what this build reads.
        let sealed = dir.path().join("sealed.znippy");
        std::fs::copy(&live, &sealed).unwrap();
        for seg in std::fs::read_dir(live.parent().unwrap()).unwrap().flatten() {
            let name = seg.file_name().to_string_lossy().to_string();
            if let Some(rest) = name.strip_prefix("sparring.znippy.hot.") {
                std::fs::copy(
                    seg.path(),
                    dir.path().join(format!("sealed.znippy.hot.{rest}")),
                )
                .unwrap();
            }
        }
        znippy_common::seal_hot(&sealed, 3, znippy_common::SkipPolicy::resolve()).unwrap();
        let recorded = traits::recorded_format_version(&sealed)
            .expect("a SEALED archive holger wrote records its format version");
        let recorded_n: u32 = recorded.parse().unwrap_or_else(|e| {
            panic!("recorded format version {recorded:?} must be numeric: {e}")
        });
        assert!(
            recorded_n <= traits::MAX_SUPPORTED_ZNIPPY_FORMAT,
            "holger writes archives it can itself read: recorded v{recorded_n} exceeds the \
             supported max v{}",
            traits::MAX_SUPPORTED_ZNIPPY_FORMAT
        );
    }

    // The live archive is now REPLACED by one from a newer znippy — the exact
    // "a client pushed an archive this build cannot read" scenario, arriving at a
    // repo that holds its `.znippy` open, i.e. after boot. The handle stamps the
    // file it wrote with `(len, mtime)`, so a replacement is noticed on the next
    // request and sent back through the gate.
    let intruder_index = pack(&dir.path().join("intruder"), &[("serde", "1.0.200")]);
    let future = craft_future_version(&intruder_index);
    std::thread::sleep(std::time::Duration::from_millis(20));
    std::fs::copy(&intruder_index, &live).unwrap();

    // THE REQUEST FAILS, CLEARLY — 503 carrying the reason, and `Ok(..)` all the
    // way out: no panic escaped into the handler, no abort.
    let (status, _h, body) = repo
        .handle_http2_request("GET", "/sparring/crates/serde/1.0.200/download", b"")
        .expect("a future-format archive must fail the request, not the handler");
    assert_eq!(
        status, 503,
        "a refused archive must be a service error, not a silent 404"
    );
    let refusal = String::from_utf8(body).unwrap();
    assert!(
        refusal.contains("holger refuses"),
        "the 503 must say why: {refusal}"
    );
    assert!(
        refusal.contains(&future),
        "the 503 must name the recorded version: {refusal}"
    );

    // The in-process read path refuses too — with the reason, not "No reader
    // configured" (the old behaviour, which swallowed the open error entirely).
    let err = repo
        .get_file("serde-1.0.200.crate")
        .expect_err("a future-format archive must not be read");
    let msg = err.to_string();
    assert!(
        msg.contains("holger refuses"),
        "get_file must surface the gate's reason: {msg}"
    );

    // AND THE SERVER KEEPS SERVING — the bystander repo is byte-identical.
    let (bystatus, _h, bybody) = bystander
        .handle_http2_request("GET", "/bystander/crates/log/0.4.22/download", b"")
        .unwrap();
    assert_eq!(bystatus, 200, "one refused repo must not stop the others");
    assert_eq!(bybody, synth("log", "0.4.22"));

    #[cfg(feature = "testmatrix")]
    fstatus(
        "znippy-rust/format-gate",
        "future_version_archive_fails_the_request_only",
        status == 503 && msg.contains("holger refuses") && bystatus == 200,
        &format!("live archive swapped to v{future}: request 503'd with the reason, bystander repo still served {} bytes", bybody.len()),
    );
}

#[test]
fn the_gate_admits_the_current_format_and_stays_out_of_the_way() {
    let dir = tempfile::tempdir().unwrap();
    let index = pack(&dir.path().join("current"), &[("bytes", "1.7.1")]);

    // The version this znippy writes today is, by construction, one holger reads —
    // if that ever stops being true the pin needs bumping WITH a reader that
    // understands the new layout, and this fails loudly to say so.
    let current: u32 = recorded_version(&index).parse().unwrap();
    assert!(
        current <= traits::MAX_SUPPORTED_ZNIPPY_FORMAT,
        "the linked znippy writes format v{current} but this holger pins v{} — bump \
         traits::MAX_SUPPORTED_ZNIPPY_FORMAT together with a reader that handles it",
        traits::MAX_SUPPORTED_ZNIPPY_FORMAT
    );
    assert!(traits::ensure_supported_format(&index).is_ok());

    let repo = RustRepoZnippy::with_archive("current".into(), index).unwrap();
    assert_eq!(
        repo.get_file("bytes-1.7.1.crate").unwrap(),
        synth("bytes", "1.7.1")
    );

    #[cfg(feature = "testmatrix")]
    fstatus(
        "znippy-rust/format-gate",
        "current_format_is_admitted_unchanged",
        true,
        &format!(
            "znippy writes format v{current}; holger pins v{}",
            traits::MAX_SUPPORTED_ZNIPPY_FORMAT
        ),
    );
}