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(¤t);
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
),
);
}