server/repository/znippy-gem/tests/plugin_read_view.rs
//! Holger gem backend serve test, through a REAL compressed archive built with the
//! gem plugin (so the authoritative name/version/platform columns from
//! `metadata.gz` are present). Proves the backend resolves a **platform-suffixed**
//! native gem via the typed view — the case the filename loses (a native gem
//! `nokogiri-1.15.0-java.gem` folds the platform into the "version" on a naive
//! parse). Also proves R1: `info/{name}` serves a REAL compact-index line built
//! from the view's coords, not the old dummy `---\n` stub.
use std::fs;
use std::path::Path;
use holger_znippy_gem_repository::GemRepoZnippy;
use traits::{ArtifactId, RepositoryBackendTrait};
use znippy_common::plugin::PluginRegistry;
use znippy_common::plugins::gem_native::GemPlugin;
use znippy_compress::compress_dir;
/// A real `.gem`: a plain (uncompressed) ustar tar of `metadata.gz` (gzipped YAML
/// carrying the authoritative name/version/platform) + `data.tar.gz`. The platform
/// + clean version live ONLY in metadata.gz.
fn make_gem(name: &str, version: &str, platform: &str, contents: &[u8]) -> Vec<u8> {
let mut data_tar_gz = Vec::new();
{
let enc = flate2::write::GzEncoder::new(&mut data_tar_gz, flate2::Compression::fast());
let mut tar = tar::Builder::new(enc);
let body = contents;
let mut h = tar::Header::new_gnu();
h.set_path("lib/thing.rb").unwrap();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_cksum();
tar.append(&h, body).unwrap();
tar.finish().unwrap();
}
let yaml = format!(
"--- !ruby/object:Gem::Specification\n\
name: {name}\n\
version: !ruby/object:Gem::Version\n version: {version}\n\
platform: {platform}\n"
);
let mut metadata_gz = Vec::new();
{
use std::io::Write;
let mut enc = flate2::write::GzEncoder::new(&mut metadata_gz, flate2::Compression::fast());
enc.write_all(yaml.as_bytes()).unwrap();
enc.finish().unwrap();
}
let mut gem = Vec::new();
{
let mut tar = tar::Builder::new(&mut gem);
let mut mh = tar::Header::new_gnu();
mh.set_path("metadata.gz").unwrap();
mh.set_size(metadata_gz.len() as u64);
mh.set_mode(0o644);
mh.set_cksum();
tar.append(&mh, &metadata_gz[..]).unwrap();
let mut dh = tar::Header::new_gnu();
dh.set_path("data.tar.gz").unwrap();
dh.set_size(data_tar_gz.len() as u64);
dh.set_mode(0o644);
dh.set_cksum();
tar.append(&dh, &data_tar_gz[..]).unwrap();
tar.finish().unwrap();
}
gem
}
fn build_archive(dir: &Path, out: &Path) {
compress_dir(
&dir.to_path_buf(),
&out.to_path_buf(),
false,
Some(&PluginRegistry::with_plugin(Box::new(GemPlugin))),
None,
None,
)
.expect("compress_dir with gem plugin");
}
/// `info/{name}` must be MEMOIZED, not rebuilt per request: building it
/// decompresses and SHA-256s every version of the gem, and `/versions` tells an
/// unauthenticated client exactly which name has the most of them.
///
/// Proven without a timer: after the first request the archive's bytes on disk are
/// scribbled over. Anything that goes back to disk for the answer now produces a
/// different body (asserted directly, with a freshly-opened repo); a memoized
/// backend keeps answering correctly because it never reads again.
#[test]
fn info_is_memoized_and_never_re_reads_the_gems() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
fs::create_dir_all(src.path().join("gems")).unwrap();
for version in ["1.0.0", "2.0.0", "3.0.0"] {
let gem = make_gem("mygem", version, "ruby", b"# gem contents\n");
fs::write(src.path().join(format!("gems/mygem-{version}.gem")), &gem).unwrap();
}
let archive = out.path().join("gem.znippy");
build_archive(src.path(), &archive);
let repo = GemRepoZnippy::with_archive("gem".into(), archive.clone()).unwrap();
let (status, _h, first) = repo
.handle_http2_request("GET", "/gem/info/mygem", &[])
.unwrap();
assert_eq!(status, 200);
let first_text = String::from_utf8(first.clone()).unwrap();
assert_eq!(
first_text.matches("|checksum:").count(),
3,
"all three versions must be indexed with a checksum: {first_text}"
);
assert!(
!first_text.contains("|checksum:\n"),
"every checksum must be a real digest, not an empty one: {first_text}"
);
// Destroy the source of those checksums, in place (same path, same length, the
// already-open fd keeps pointing at it).
let len = fs::metadata(&archive).unwrap().len();
{
use std::io::Write;
let mut f = fs::OpenOptions::new().write(true).open(&archive).unwrap();
f.write_all(&vec![0u8; len as usize]).unwrap();
f.sync_all().unwrap();
}
// Control: the corruption really is fatal to anything that reads the archive —
// a fresh repo over the same path cannot reproduce the answer.
let from_disk = GemRepoZnippy::with_archive("gem".into(), archive.clone())
.ok()
.map(|r| {
r.handle_http2_request("GET", "/gem/info/mygem", &[])
.unwrap()
.2
});
assert!(
from_disk.as_deref() != Some(first.as_slice()),
"the fixture must be destroyed — otherwise this test proves nothing"
);
// The memoized backend still answers, byte-identically, without touching disk.
for _ in 0..5 {
let (s, _h, again) = repo
.handle_http2_request("GET", "/gem/info/mygem", &[])
.unwrap();
assert_eq!(s, 200);
assert_eq!(
again, first,
"info/{{name}} must be served from the memoized index, not rebuilt per request"
);
}
// A name the archive does not carry is still answered (empty compact index),
// and cannot be used to grow the cache.
let (unknown_status, _h, unknown) = repo
.handle_http2_request("GET", "/gem/info/does-not-exist", &[])
.unwrap();
assert_eq!(unknown_status, 200);
assert_eq!(
unknown, b"---\n",
"unknown gem name → the empty compact index"
);
}
#[test]
fn gem_backend_serves_platform_gem_via_view_and_real_compact_index() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
// Platform-suffixed native gem: filename folds `-java` into the version, but
// metadata.gz keeps version `1.15.0` + platform `java`.
let java_gem = make_gem("nokogiri", "1.15.0", "java", b"# gem contents\n");
fs::create_dir_all(src.path().join("gems")).unwrap();
fs::write(src.path().join("gems/nokogiri-1.15.0-java.gem"), &java_gem).unwrap();
let archive = out.path().join("gem.znippy");
build_archive(src.path(), &archive);
let repo = GemRepoZnippy::with_archive("gem".into(), archive).unwrap();
// fetch() resolves the platform gem via the view. The platform rides in the
// namespace; version is the authoritative `1.15.0` (NOT `1.15.0-java`).
let id = ArtifactId {
namespace: Some("java".into()),
name: "nokogiri".into(),
version: "1.15.0".into(),
};
let got = repo.fetch(&id).unwrap();
assert_eq!(
got.as_deref(),
Some(java_gem.as_slice()),
"platform gem must serve via the view"
);
// Gem-file wire path resolves via the view (basename rebuilt from coords).
let url = "/gem/gems/nokogiri-1.15.0-java.gem";
let (status, _h, body) = repo.handle_http2_request("GET", url, &[]).unwrap();
assert_eq!(status, 200, "platform gem wire path must serve 200");
assert_eq!(body, java_gem, "gem serve must return the exact bytes");
// R1: `info/{name}` is a REAL compact index built from the view's coords. The
// version token carries the platform suffix; the line is NOT the old dummy.
let (istatus, _ih, ibody) = repo
.handle_http2_request("GET", "/gem/info/nokogiri", &[])
.unwrap();
assert_eq!(istatus, 200, "info endpoint must resolve");
let info = String::from_utf8(ibody).unwrap();
assert!(
info.starts_with("---\n"),
"compact-index info starts with the --- separator: {info}"
);
assert!(
info.contains("1.15.0-java |checksum:"),
"info must carry a real compact-index line for the platform version (R1): {info}"
);
// The checksum is a real SHA-256 of the served gem bytes (bundler verifies it).
let expected = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&java_gem);
format!(
"checksum:{}",
h.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
)
};
assert!(
info.contains(&expected),
"info checksum must be the SHA-256 of the served gem bytes (R1): want {expected} in {info}"
);
// `versions` lists the name with the platform-suffixed version (real index).
let (vstatus, _vh, vbody) = repo
.handle_http2_request("GET", "/gem/versions", &[])
.unwrap();
assert_eq!(vstatus, 200);
let versions = String::from_utf8(vbody).unwrap();
assert!(
versions.contains("nokogiri 1.15.0-java"),
"versions must list the authoritative coords (R1): {versions}"
);
// list() reports the authoritative platform coords.
let listed = repo.list(None, 0).unwrap();
assert!(
listed.iter().any(|e| e.id.name == "nokogiri"
&& e.id.version == "1.15.0"
&& e.id.namespace.as_deref() == Some("java")),
"list must carry the authoritative platform coords, got: {:?}",
listed
.iter()
.map(|e| (&e.id.namespace, &e.id.name, &e.id.version))
.collect::<Vec<_>>()
);
// Absent coord → None (not a wrong file). The java gem must NOT resolve as the
// default ruby platform either.
let absent = ArtifactId {
namespace: None,
name: "nokogiri".into(),
version: "1.15.0".into(),
};
assert_eq!(
repo.fetch(&absent).unwrap(),
None,
"java gem must not resolve as ruby platform"
);
}
// ───────────────────────── the change token ─────────────────────────
/// `coordinate -> change_token` for one listing, refusing a row without one.
fn token_map(entries: &[traits::ArtifactEntry]) -> std::collections::BTreeMap<String, String> {
entries
.iter()
.map(|e| {
let key = format!(
"{}/{}@{}",
e.id.namespace.clone().unwrap_or_default(),
e.id.name,
e.id.version
);
let tok = e
.change_token
.clone()
.unwrap_or_else(|| panic!("{key}: no change token"));
(key, tok)
})
.collect()
}
/// **Every gem row carries a change token, and it moves only when the bytes do.**
///
/// Two independent seals of the same three gems, the second with ONE rewritten
/// (same name, version and platform, same archive path, a different
/// `data.tar.gz`). The token is folded from the per-chunk checksums the archive
/// index carries for that row, so the two unchanged gems fold to the same token
/// across the seals and the rewritten one does not. Before
/// `GemPackage::relative_path` this backend listed through the typed view and
/// could not name its row, so it stamped nothing.
#[test]
fn gem_entries_carry_a_change_token_that_moves_only_when_the_bytes_do() {
let seal = |rewrite: bool| -> (tempfile::TempDir, std::path::PathBuf) {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
fs::create_dir_all(src.path().join("gems")).unwrap();
let lay = |file: &str, bytes: &[u8]| {
fs::write(src.path().join("gems").join(file), bytes).unwrap()
};
lay(
"nokogiri-1.15.0-java.gem",
&make_gem("nokogiri", "1.15.0", "java", b"# gem contents\n"),
);
lay(
"rake-13.0.6.gem",
&make_gem("rake", "13.0.6", "ruby", b"# gem contents\n"),
);
let contents: &[u8] = if rewrite {
b"# rewritten contents\n"
} else {
b"# gem contents\n"
};
lay(
"rack-3.0.8.gem",
&make_gem("rack", "3.0.8", "ruby", contents),
);
let archive = out.path().join("gems.znippy");
build_archive(src.path(), &archive);
(out, archive)
};
let (_a, first) = seal(false);
let (_b, second) = seal(true);
let before = token_map(
&GemRepoZnippy::with_archive("gems".into(), first)
.unwrap()
.list(None, 0)
.unwrap(),
);
let after = token_map(
&GemRepoZnippy::with_archive("gems".into(), second)
.unwrap()
.list(None, 0)
.unwrap(),
);
assert_eq!(
before.len(),
3,
"three rows, every one with a token: {before:?}"
);
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"the same coordinates on both sides"
);
let moved = "/rack@3.0.8";
assert!(
before.contains_key(moved),
"the rewritten row is listed: {before:?}"
);
for (k, t) in &before {
if k == moved {
assert_ne!(
t, &after[k],
"{k}: the bytes changed, so the token must move"
);
} else {
assert_eq!(
t, &after[k],
"{k}: the bytes did not change, so the token must not move"
);
}
}
}