server/repository/znippy-maven/tests/plugin_read_view.rs
//! Holger maven backend serve test, through a REAL compressed archive built with
//! the maven plugin (so the GAV columns are present). Proves the backend resolves
//! a classifier / `-SNAPSHOT` GAV via the typed view — the exact cases the old
//! filename-regex (`rsplit_once('-')`, `{a}-{v}.jar` path build) mis-split.
use std::fs;
use std::path::Path;
use holger_znippy_maven_repository::MavenRepoZnippy;
use traits::{ArtifactId, RepositoryBackendTrait};
use znippy_common::plugin::PluginRegistry;
use znippy_compress::compress_dir;
use znippy_plugin_maven::NativeMavenPlugin;
// Minimal stored-entry ZIP/JAR carrying a pom.xml at the maven path.
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
}
}
!crc
}
fn build_zip_stored(name: &str, data: &[u8]) -> Vec<u8> {
let nb = name.as_bytes();
let crc = crc32(data);
let size = data.len() as u32;
let mut out = Vec::new();
let local = out.len() as u32;
out.extend_from_slice(b"PK\x03\x04");
out.extend_from_slice(&20u16.to_le_bytes());
for _ in 0..3 {
out.extend_from_slice(&0u16.to_le_bytes());
}
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&(nb.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(nb);
out.extend_from_slice(data);
let cd = out.len() as u32;
out.extend_from_slice(b"PK\x01\x02");
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
for _ in 0..4 {
out.extend_from_slice(&0u16.to_le_bytes());
}
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&(nb.len() as u16).to_le_bytes());
for _ in 0..3 {
out.extend_from_slice(&0u16.to_le_bytes());
}
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(&local.to_le_bytes());
out.extend_from_slice(nb);
let cd_size = out.len() as u32 - cd;
out.extend_from_slice(b"PK\x05\x06");
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
fn make_jar(group: &str, artifact: &str, version: &str) -> Vec<u8> {
let pom = format!(
"<?xml version=\"1.0\"?><project>\
<groupId>{group}</groupId><artifactId>{artifact}</artifactId>\
<version>{version}</version><packaging>jar</packaging></project>"
);
build_zip_stored(
&format!("META-INF/maven/{group}/{artifact}/pom.xml"),
pom.as_bytes(),
)
}
fn build_archive(dir: &Path, out: &Path) {
compress_dir(
&dir.to_path_buf(),
&out.to_path_buf(),
false,
Some(&PluginRegistry::with_plugin(Box::new(NativeMavenPlugin))),
None,
None,
)
.expect("compress_dir with maven plugin");
}
#[test]
fn maven_backend_serves_snapshot_and_classifier_via_view() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let gid = "org.apache.commons";
let aid = "commons-lang3";
// -SNAPSHOT primary jar (old `{a}-{v}.jar` path build + `rsplit_once('-')`
// would mis-derive version="SNAPSHOT").
let snap = make_jar(gid, aid, "3.20.0-SNAPSHOT");
let snap_dir = src.path().join(format!("{gid}/{aid}/3.20.0-SNAPSHOT"));
fs::create_dir_all(&snap_dir).unwrap();
fs::write(snap_dir.join(format!("{aid}-3.20.0-SNAPSHOT.jar")), &snap).unwrap();
// release + a -sources classifier (old regex would split the classifier wrong).
let rel = make_jar(gid, aid, "3.12.0");
let rel_dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
fs::create_dir_all(&rel_dir).unwrap();
fs::write(rel_dir.join(format!("{aid}-3.12.0.jar")), &rel).unwrap();
fs::write(rel_dir.join(format!("{aid}-3.12.0-sources.jar")), &rel).unwrap();
let archive = out.path().join("maven.znippy");
build_archive(src.path(), &archive);
let repo = MavenRepoZnippy::with_archive("maven".into(), archive).unwrap();
// fetch() resolves the -SNAPSHOT GAV through the view → exact bytes.
let id = ArtifactId {
namespace: Some(gid.into()),
name: aid.into(),
version: "3.20.0-SNAPSHOT".into(),
};
let got = repo.fetch(&id).unwrap();
assert_eq!(
got.as_deref(),
Some(snap.as_slice()),
"SNAPSHOT GAV must serve the right jar"
);
// HTTP wire path for the -sources classifier resolves via the view.
let url = format!("/maven/{gid}/{aid}/3.12.0/{aid}-3.12.0-sources.jar");
let (status, _h, body) = repo.handle_http2_request("GET", &url, &[]).unwrap();
assert_eq!(status, 200, "classifier wire path must serve 200");
assert_eq!(
body, rel,
"classifier serve must return the sources jar bytes"
);
// HTTP wire path for the -SNAPSHOT primary jar resolves too.
let snap_url = format!("/maven/{gid}/{aid}/3.20.0-SNAPSHOT/{aid}-3.20.0-SNAPSHOT.jar");
let (s, _h, b) = repo.handle_http2_request("GET", &snap_url, &[]).unwrap();
assert_eq!(s, 200);
assert_eq!(b, snap);
// list() reports authoritative coords (group/artifact/version from columns),
// with the classifier appended — no `rsplit_once('-')` mis-split.
let listed = repo.list(None, 0).unwrap();
assert!(
listed
.iter()
.any(|e| e.id.name == aid && e.id.version == "3.20.0-SNAPSHOT"),
"list must carry the authoritative SNAPSHOT version, got: {:?}",
listed
.iter()
.map(|e| (&e.id.name, &e.id.version))
.collect::<Vec<_>>()
);
assert!(
listed.iter().any(|e| e.id.version == "3.12.0:sources"),
"list must carry the classifier coord distinctly"
);
// Absent GAV → None (not a wrong file).
let absent = ArtifactId {
namespace: Some(gid.into()),
name: aid.into(),
version: "9.9.9".into(),
};
assert_eq!(repo.fetch(&absent).unwrap(), None);
}
fn sha1_hex(data: &[u8]) -> String {
use sha1::{Digest, Sha1};
let mut h = Sha1::new();
h.update(data);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
/// The Maven backend synthesizes `.sha1`/`.md5` sidecars over the SAME bytes the
/// underlying path serves (artifact AND synthesized `maven-metadata.xml`), so a
/// bare-`.jar` mirror still satisfies a checksum-requesting Maven client; and it
/// synthesizes version-level `-SNAPSHOT` metadata (`localCopy=true`).
#[test]
fn maven_backend_synthesizes_checksums_and_snapshot_metadata() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let gid = "org.apache.commons";
let aid = "commons-lang3";
let snap = make_jar(gid, aid, "3.20.0-SNAPSHOT");
let snap_dir = src.path().join(format!("{gid}/{aid}/3.20.0-SNAPSHOT"));
fs::create_dir_all(&snap_dir).unwrap();
fs::write(snap_dir.join(format!("{aid}-3.20.0-SNAPSHOT.jar")), &snap).unwrap();
let rel = make_jar(gid, aid, "3.12.0");
let rel_dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
fs::create_dir_all(&rel_dir).unwrap();
fs::write(rel_dir.join(format!("{aid}-3.12.0.jar")), &rel).unwrap();
let archive = out.path().join("maven.znippy");
build_archive(src.path(), &archive);
let repo = MavenRepoZnippy::with_archive("maven".into(), archive).unwrap();
// .sha1 of a served jar = sha1 of exactly the bytes the .jar path serves.
let jar_url = format!("/maven/{gid}/{aid}/3.12.0/{aid}-3.12.0.jar");
let sha_url = format!("{jar_url}.sha1");
let (s, h, body) = repo.handle_http2_request("GET", &sha_url, &[]).unwrap();
assert_eq!(s, 200, "synthesized .sha1 must serve 200");
assert!(h
.iter()
.any(|(k, v)| k == "Content-Type" && v == "text/plain"));
assert_eq!(
String::from_utf8(body).unwrap(),
sha1_hex(&rel),
"sha1 must hash the served jar bytes"
);
// .md5 also synthesizes (32 lowercase hex chars).
let (s, _h, body) = repo
.handle_http2_request("GET", &format!("{jar_url}.md5"), &[])
.unwrap();
assert_eq!(s, 200);
let md5 = String::from_utf8(body).unwrap();
assert_eq!(md5.len(), 32, "md5 hex is 32 chars, got {md5:?}");
assert!(md5
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
// Checksum of a SYNTHESIZED maven-metadata.xml resolves too (hash of the XML).
let meta_url = format!("/maven/{gid}/{aid}/maven-metadata.xml");
let (s, _h, meta) = repo.handle_http2_request("GET", &meta_url, &[]).unwrap();
assert_eq!(s, 200, "GA metadata synthesizes");
let (s, _h, meta_sha) = repo
.handle_http2_request("GET", &format!("{meta_url}.sha1"), &[])
.unwrap();
assert_eq!(s, 200);
assert_eq!(
String::from_utf8(meta_sha).unwrap(),
sha1_hex(&meta),
"metadata .sha1 hashes the metadata"
);
// Version-level -SNAPSHOT metadata: non-unique (localCopy) snapshot.
let snap_meta = format!("/maven/{gid}/{aid}/3.20.0-SNAPSHOT/maven-metadata.xml");
let (s, _h, body) = repo.handle_http2_request("GET", &snap_meta, &[]).unwrap();
assert_eq!(s, 200, "version-level SNAPSHOT metadata synthesizes");
let xml = String::from_utf8(body).unwrap();
assert!(
xml.contains("<version>3.20.0-SNAPSHOT</version>"),
"got: {xml}"
);
assert!(xml.contains("<localCopy>true</localCopy>"), "got: {xml}");
// A release version has NO version-level snapshot metadata (only GA-level).
let rel_meta = format!("/maven/{gid}/{aid}/3.12.0/maven-metadata.xml");
let (s, _h, _b) = repo.handle_http2_request("GET", &rel_meta, &[]).unwrap();
assert_eq!(
s, 404,
"release version has no version-level snapshot metadata"
);
}
/// The typed-view read path sizes its allocation from the archive index's
/// `uncompressed_size` — a number the ARCHIVE chose, not holger. The exact-path
/// sibling bounds every offset+length against the real file length before it
/// allocates; the view does not, so an index claiming a petabyte turned one
/// unauthenticated GET into a `Vec::with_capacity` that aborts the process.
///
/// Proven by pinning the ceiling below the fixture: the same request that serves
/// under the ceiling must be refused above it, on BOTH view doors (`fetch` and the
/// wire path), and the refusal must be a 404, never an allocation.
#[test]
fn typed_view_read_is_bounded_by_the_declared_size() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let gid = "org.apache.commons";
let aid = "commons-lang3";
let jar = make_jar(gid, aid, "3.12.0");
let dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{aid}-3.12.0.jar")), &jar).unwrap();
let archive = out.path().join("maven.znippy");
build_archive(src.path(), &archive);
let id = ArtifactId {
namespace: Some(gid.into()),
name: aid.into(),
version: "3.12.0".into(),
};
// The wire path uses group-as-DIRS, which is not the archive's stored path, so
// it can only resolve through the typed view (no exact-path passthrough).
let view_url = format!(
"/maven/{}/{aid}/3.12.0/{aid}-3.12.0.jar",
gid.replace('.', "/")
);
// Under the ceiling: both view doors serve the artifact.
let ok_repo = MavenRepoZnippy::with_archive("maven".into(), archive.clone()).unwrap();
assert!(
ok_repo.max_artifact_bytes() >= jar.len() as u64,
"the default ceiling must not refuse an ordinary jar"
);
assert_eq!(ok_repo.fetch(&id).unwrap().as_deref(), Some(jar.as_slice()));
let (s, _h, body) = ok_repo.handle_http2_request("GET", &view_url, &[]).unwrap();
assert_eq!(s, 200, "the view wire path must serve under the ceiling");
assert_eq!(body, jar);
// Ceiling below the declared size: refused, both doors, no allocation.
let capped = MavenRepoZnippy::with_archive("maven".into(), archive)
.unwrap()
.with_max_artifact_bytes(jar.len() as u64 - 1);
assert_eq!(
capped.fetch(&id).unwrap(),
None,
"fetch must refuse an artifact whose declared size is over the ceiling"
);
let (s, _h, _b) = capped.handle_http2_request("GET", &view_url, &[]).unwrap();
assert_eq!(
s, 404,
"the view wire path must refuse an over-ceiling declared size with 404"
);
}
// ───────────────────────── 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 maven row carries a change token, and it moves only when the bytes do.**
///
/// Two independent seals of the same three artifacts, the second with the
/// `-sources` jar rewritten (same GAV, same classifier, same archive path, a
/// different pom body). The token is folded from the per-chunk checksums the
/// archive index carries for that row, so the two unchanged jars fold to the
/// same token across the two seals and the rewritten one does not. Before
/// `MavenPackage::relative_path` this backend listed through the typed view
/// and could not name its row, so it stamped nothing and a mirror re-fetched
/// every jar on every tick.
#[test]
fn maven_entries_carry_a_change_token_that_moves_only_when_the_bytes_do() {
let gid = "org.apache.commons";
let aid = "commons-lang3";
// The rewritten pom: same GAV, one extra element, so the plugin still reads
// the same coordinates out of different bytes.
let rewritten_sources = build_zip_stored(
&format!("META-INF/maven/{gid}/{aid}/pom.xml"),
format!(
"<?xml version=\"1.0\"?><project>\
<groupId>{gid}</groupId><artifactId>{aid}</artifactId>\
<version>3.12.0</version><packaging>jar</packaging>\
<description>rewritten</description></project>"
)
.as_bytes(),
);
assert_ne!(
rewritten_sources,
make_jar(gid, aid, "3.12.0"),
"the fixture must actually differ"
);
let seal = |rewrite: bool| -> (tempfile::TempDir, std::path::PathBuf) {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let snap_dir = src.path().join(format!("{gid}/{aid}/3.20.0-SNAPSHOT"));
fs::create_dir_all(&snap_dir).unwrap();
fs::write(
snap_dir.join(format!("{aid}-3.20.0-SNAPSHOT.jar")),
make_jar(gid, aid, "3.20.0-SNAPSHOT"),
)
.unwrap();
let rel_dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
fs::create_dir_all(&rel_dir).unwrap();
fs::write(
rel_dir.join(format!("{aid}-3.12.0.jar")),
make_jar(gid, aid, "3.12.0"),
)
.unwrap();
let sources = if rewrite {
rewritten_sources.clone()
} else {
make_jar(gid, aid, "3.12.0")
};
fs::write(rel_dir.join(format!("{aid}-3.12.0-sources.jar")), sources).unwrap();
let archive = out.path().join("maven.znippy");
build_archive(src.path(), &archive);
(out, archive)
};
let (_a, first) = seal(false);
let (_b, second) = seal(true);
let before = token_map(
&MavenRepoZnippy::with_archive("maven".into(), first)
.unwrap()
.list(None, 0)
.unwrap(),
);
let after = token_map(
&MavenRepoZnippy::with_archive("maven".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 = format!("{gid}/{aid}@3.12.0:sources");
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"
);
}
}
}