server/repository/znippy-maven/src/lib.rs
//! Maven repository backend over a read-only znippy archive: serves JARs/POMs and
//! synthesizes the native `maven-metadata.xml` GA version listing from the packages
//! inside, so a partial mirror (bare `.jar`s, no upstream metadata) is still
//! resolvable by a Maven client. Sibling of the other `znippy-{rpm,deb,npm,...}`
//! backends behind [`RepositoryBackendTrait`].
//!
//! Resolution order per request: exact archive-path passthrough (a mirrored repo
//! shipping its own metadata wins) → typed **maven view** lookup by GAV[+classifier]
//! → `maven-metadata.xml` synthesis (GA version listing AND version-level
//! `-SNAPSHOT` metadata) → `.sha1`/`.md5` checksum synthesis over the served bytes.
//! The typed view ([`ZnippyArchive::as_maven`],
//! built once behind the archive's `OnceLock`) reads coords from index columns the
//! packer extracted, so `-SNAPSHOT` versions and `-sources`/`-javadoc` classifiers
//! resolve by authoritative coords instead of a fragile filename split.
//!
//! Gotcha: the typed-view read path sizes its allocation from the archive INDEX
//! (`uncompressed_size`), which is untrusted input — the exact-path sibling
//! (`extract_file`) bounds every offset+length against the real archive length
//! before allocating, the view does not. A lying index therefore turned one
//! unauthenticated GET into a multi-terabyte `Vec::with_capacity` and an abort, so
//! every view read is gated on [`MavenRepoZnippy::max_artifact_bytes`] first.
//!
//! Gotcha: the typed view is only populated when the archive was packed WITH the
//! maven plugin. An archive tagged `maven` but packed without GAV columns (e.g. the
//! agent's directory→znippy packer) yields an EMPTY view — [`MavenRepoZnippy::list`]
//! must therefore fall through to the legacy filename parse rather than trusting an
//! empty view, and the `with_reader` (non-archive) path has no view at all, serving
//! by exact path only.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use traits::znippy_base::{not_found, ok};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;
/// Znippy-backed Maven repository.
///
/// Serves Maven artifacts (JARs, POMs, etc.) from a znippy archive. When backed
/// by a concrete [`ZnippyArchive`] the repo resolves requests through the typed
/// **maven view** ([`ZnippyArchive::as_maven`]) — GAV[+classifier] coords are read
/// from the index columns the plugin extracted, so `-SNAPSHOT` and classifiers
/// (`-sources`/`-javadoc`) resolve correctly instead of being mis-split from the
/// filename. The view is built ONCE (cached behind the archive's `OnceLock`) and
/// reused per request. A `with_reader` fallback keeps the generic path working for
/// non-archive readers (e.g. in-memory test readers) that have no typed view.
pub struct MavenRepoZnippy {
base: ZnippyBase,
/// Ceiling on the size an archive index may claim for an artifact served
/// through the typed view; see [`DEFAULT_MAX_ARTIFACT_BYTES`].
max_artifact_bytes: u64,
/// The archive's file table, taken once (lane HOL-PERF-2): a GA listing over a reader
/// without a typed view filtered a fresh copy of it per request.
paths: std::sync::OnceLock<Vec<String>>,
/// `maven-metadata.xml` and the checksum documents, built once each (lane HOL-PERF-2):
/// the archive is immutable, so a second request reads nothing and hashes nothing.
docs: traits::DocMemo<(&'static str, traits::Bytes)>,
}
/// Default ceiling on one artifact read through the typed maven view.
///
/// The view allocates `Vec::with_capacity(uncompressed_size)` straight from the
/// archive index — a number the archive, not holger, chose. The exact-path sibling
/// checks every offset+length against the real file length before allocating; the
/// view has no such check, so an index claiming petabytes aborted the process on a
/// single unauthenticated GET. Real Maven artifacts are megabytes (the largest
/// uber-jars in the low hundreds); anything past this ceiling is refused as a
/// 404 + a logged reason rather than allocated. Tune with
/// [`MavenRepoZnippy::with_max_artifact_bytes`].
pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
impl MavenRepoZnippy {
pub fn new(name: String) -> Self {
Self {
base: ZnippyBase::new(name),
max_artifact_bytes: DEFAULT_MAX_ARTIFACT_BYTES,
paths: std::sync::OnceLock::new(),
docs: traits::DocMemo::default(),
}
}
/// Override the typed-view read ceiling (default [`DEFAULT_MAX_ARTIFACT_BYTES`]).
pub fn with_max_artifact_bytes(mut self, max_artifact_bytes: u64) -> Self {
self.max_artifact_bytes = max_artifact_bytes;
self
}
/// The ceiling this repo applies to a typed-view read.
pub fn max_artifact_bytes(&self) -> u64 {
self.max_artifact_bytes
}
/// Read a typed-view artifact's bytes, refusing an index that claims more than
/// the ceiling. The size is checked BEFORE [`znippy_common`] allocates from it
/// — that allocation is the whole vulnerability, so it must never be reached
/// with an unchecked number.
fn view_bytes(&self, pkg: znippy_common::views::MavenPackage) -> Option<Vec<u8>> {
let declared = pkg.size();
if declared > self.max_artifact_bytes {
let (g, a, v, c) = pkg.coords();
log::warn!(
"maven repo '{}': refusing {g}:{a}:{v}{} — archive index claims {declared} bytes, \
ceiling is {} (a lying index must not size an allocation)",
self.base.name(),
c.map(|c| format!(":{c}")).unwrap_or_default(),
self.max_artifact_bytes,
);
return None;
}
pkg.into_bytes().ok()
}
/// Create a repo backed by a specific znippy archive file. Holds the open
/// archive so the typed maven view is built once and reused.
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Ok(Self {
base: ZnippyBase::with_archive(name, archive_path)?,
max_artifact_bytes: DEFAULT_MAX_ARTIFACT_BYTES,
paths: std::sync::OnceLock::new(),
docs: traits::DocMemo::default(),
})
}
/// Create a repo backed by any ZnippyReader implementation. This path has no
/// typed maven view; it serves by exact archive path only.
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self {
base: ZnippyBase::with_reader(name, reader),
max_artifact_bytes: DEFAULT_MAX_ARTIFACT_BYTES,
paths: std::sync::OnceLock::new(),
docs: traits::DocMemo::default(),
}
}
pub fn list_files(&self) -> Vec<String> {
self.base.list_files()
}
pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
self.base.get_file(relative_path)
}
/// Resolve the raw bytes a Maven request path serves, WITHOUT the checksum
/// layer: exact archive-path passthrough → typed-view GAV[+classifier] lookup
/// → synthesized `maven-metadata.xml` (version-level `-SNAPSHOT` first, else
/// the GA version listing). The single source of truth for "what does this
/// path serve", so a `.sha1`/`.md5` request can hash exactly those bytes.
/// `None` when nothing resolves.
fn serve_resource(&self, remainder: &str) -> Option<(Vec<u8>, &'static str)> {
// Exact archive-path passthrough — a mirrored repo shipping its own bytes
// (incl. metadata / checksum files) wins.
if let Ok(data) = self.get_file(remainder) {
return Some((data, maven_content_type(remainder)));
}
// A `maven-metadata.xml` request is metadata, NOT an artifact: resolve it by
// synthesis (version-level `-SNAPSHOT` first, else the GA version listing)
// and stop — never fall through to the view, which would mis-match
// `maven-metadata.xml` as the main artifact of the enclosing GAV.
if let Some(base) = remainder.strip_suffix("/maven-metadata.xml") {
if let Some(xml) = self.synthesize_snapshot_metadata(base) {
return Some((xml.into_bytes(), "application/xml"));
}
if let Some(xml) = self.synthesize_ga_metadata(base) {
return Some((xml.into_bytes(), "application/xml"));
}
return None;
}
// Typed-view GAV[+classifier] lookup: a `-SNAPSHOT`/classifier request
// whose stored relative_path differs from the wire path still resolves by
// its authoritative coords.
if let Some(archive) = self.base.typed() {
if let Some(maven) = archive.as_maven() {
if let Some((group, artifact, version, classifier)) =
parse_maven_wire_path(remainder)
{
if let Some(pkg) =
maven.get_classified(&group, &artifact, &version, classifier.as_deref())
{
if let Some(data) = self.view_bytes(pkg) {
return Some((data, maven_content_type(remainder)));
}
}
}
}
}
None
}
/// The answer for `remainder` (the path below the repository), rendered fresh.
fn answer(&self, body: &[u8], remainder: &str) -> anyhow::Result<traits::HttpReply> {
let _ = body;
log::debug!("Maven repo znippy request remainder={}", remainder);
if remainder.is_empty() {
return Ok(not_found("Not found"));
}
// Checksum request (`<path>.sha1` / `<path>.md5`): an archive that ships its
// own checksum sidecar wins (exact-path passthrough); otherwise synthesize
// the hash over the SAME bytes `<path>` would serve — artifact OR
// synthesized `maven-metadata.xml` — so a bare-`.jar` mirror still satisfies
// a Maven client that requests checksums.
for (suffix, is_sha1) in [(".sha1", true), (".md5", false)] {
if let Some(base) = remainder.strip_suffix(suffix) {
if let Ok(data) = self.get_file(remainder) {
return Ok(ok("text/plain", data));
}
if let Some((bytes, _)) = self.serve_resource(base) {
let hex = if is_sha1 {
sha1_hex(&bytes)
} else {
md5_hex(&bytes)
};
return Ok(ok("text/plain", hex.into_bytes()));
}
return Ok(not_found("Not found in archive"));
}
}
// Non-checksum: exact path → typed-view GAV → synthesized metadata.
match self.serve_resource(remainder) {
Some((data, ctype)) => Ok(ok(ctype, data)),
None => Ok(not_found("Not found in archive")),
}
}
/// Synthesize a **GA-level** `maven-metadata.xml` — the version listing Maven
/// fetches to discover an artifact's releases — from the typed maven view. This
/// closes the "passthrough / partial" gap: a repo that carries only the `.jar`s
/// (no upstream metadata) can still be resolved by a Maven client. `base` is the
/// wire path with `/maven-metadata.xml` already stripped, i.e.
/// `<group/as/dirs>/<artifact>`. `None` when the view has no versions for that GA.
fn synthesize_ga_metadata(&self, base: &str) -> Option<String> {
let (group_dirs, artifact) = base.rsplit_once('/')?;
let group = group_dirs.replace('/', ".");
if group.is_empty() || artifact.is_empty() {
return None;
}
// Every main-artifact version for this GA (classifier variants like
// `-sources` aren't distinct versions), sorted in Maven order, deduped.
let mut versions: Vec<String> = match self.base.typed().and_then(|t| t.as_maven()) {
Some(maven) => maven
.list()
.into_iter()
.filter(|(g, a, _v, c)| g == &group && a == artifact && c.is_none())
.map(|(_g, _a, v, _c)| v)
.collect(),
// No typed view (a reader over loose files: the intake chain's view, lane
// HOL-MVP-FORMATS): the versions are the `<base>/<v>/` directories that hold the
// main artifact `<artifact>-<v>.pom` or `.jar`.
None => self
.paths
.get_or_init(|| self.list_files())
.iter()
.filter_map(|f| {
let rest = f.strip_prefix(base)?.strip_prefix('/')?;
let (v, file) = rest.split_once('/')?;
let stem = format!("{artifact}-{v}.");
(!v.is_empty()
&& !file.contains('/')
&& (file == format!("{stem}pom") || file == format!("{stem}jar")))
.then(|| v.to_string())
})
.collect(),
};
if versions.is_empty() {
return None;
}
versions.sort_by(|a, b| maven_version_cmp(a, b));
versions.dedup();
Some(build_maven_metadata(&group, artifact, &versions))
}
/// Synthesize **version-level** `-SNAPSHOT` `maven-metadata.xml` — the document
/// a Maven client fetches from inside a `.../<version>/` dir to resolve which
/// build of a snapshot to download. `base` is the wire path with
/// `/maven-metadata.xml` stripped, i.e. `<group/as/dirs>/<artifact>/<version>`.
///
/// holger is an immutable airgap mirror: it stores snapshot artifacts under
/// their literal `-SNAPSHOT` name (no deploy timestamp), so it advertises a
/// **non-unique ("local") snapshot** — `<snapshot><localCopy>true</localCopy>`
/// with each `<snapshotVersion>`'s `<value>` being the `-SNAPSHOT` version
/// itself. Maven then requests `<artifact>-<version>.<ext>` directly, which the
/// exact-path / typed-view resolution serves. `None` unless `base` names a
/// SNAPSHOT version the view actually carries.
fn synthesize_snapshot_metadata(&self, base: &str) -> Option<String> {
let maven = self.base.typed()?.as_maven()?;
let (parent, version) = base.rsplit_once('/')?;
if !version.contains("SNAPSHOT") {
return None;
}
let (group_dirs, artifact) = parent.rsplit_once('/')?;
let group = group_dirs.replace('/', ".");
if group.is_empty() || artifact.is_empty() {
return None;
}
// Every artifact the view carries for this exact GAV, by classifier.
let mut classifiers: Vec<Option<String>> = maven
.list()
.into_iter()
.filter(|(g, a, v, _c)| g == &group && a == artifact && v == version)
.map(|(_g, _a, _v, c)| c)
.collect();
if classifiers.is_empty() {
return None;
}
classifiers.sort();
classifiers.dedup();
Some(build_snapshot_metadata(
&group,
artifact,
version,
&classifiers,
))
}
}
/// Best-effort Maven version ordering, sufficient for the common numeric-dotted +
/// `-SNAPSHOT`/qualifier cases: compare the numeric base token-by-token (missing
/// tokens count as 0, so `1.0` == `1.0.0`); on an equal base a release (no
/// qualifier) outranks any qualifier, and qualifiers compare lexically (so
/// `1.0-SNAPSHOT` < `1.0`). Not a full `ComparableVersion`, but correct for typical
/// repositories.
fn maven_version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let split = |v: &str| -> (Vec<u64>, String) {
let (base, qual) = v.split_once('-').unwrap_or((v, ""));
let nums = base
.split('.')
.map(|t| t.parse::<u64>().unwrap_or(0))
.collect();
(nums, qual.to_string())
};
let (na, qa) = split(a);
let (nb, qb) = split(b);
for i in 0..na.len().max(nb.len()) {
match na
.get(i)
.copied()
.unwrap_or(0)
.cmp(&nb.get(i).copied().unwrap_or(0))
{
Ordering::Equal => {}
o => return o,
}
}
match (qa.is_empty(), qb.is_empty()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater, // release > qualifier
(false, true) => Ordering::Less,
(false, false) => qa.cmp(&qb),
}
}
/// Build a GA-level `maven-metadata.xml` from the Maven-sorted `versions`. `latest`
/// is the greatest version (incl. snapshots); `release` is the greatest
/// non-SNAPSHOT (omitted if every version is a snapshot).
fn build_maven_metadata(group: &str, artifact: &str, versions: &[String]) -> String {
let latest = versions.last().cloned().unwrap_or_default();
let release = versions
.iter()
.rev()
.find(|v| !v.contains("SNAPSHOT"))
.cloned();
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n");
out.push_str(&format!(
" <groupId>{}</groupId>\n",
nornir_escape::xml(group)
));
out.push_str(&format!(
" <artifactId>{}</artifactId>\n",
nornir_escape::xml(artifact)
));
out.push_str(" <versioning>\n");
out.push_str(&format!(
" <latest>{}</latest>\n",
nornir_escape::xml(&latest)
));
if let Some(release) = release {
out.push_str(&format!(
" <release>{}</release>\n",
nornir_escape::xml(&release)
));
}
out.push_str(" <versions>\n");
for v in versions {
out.push_str(&format!(
" <version>{}</version>\n",
nornir_escape::xml(v)
));
}
out.push_str(" </versions>\n");
// Immutable airgap repo — no per-artifact publish time is tracked, so a fixed,
// deterministic value is emitted (clients use lastUpdated only for SNAPSHOT
// freshness, which release metadata doesn't drive).
out.push_str(" <lastUpdated>19700101000000</lastUpdated>\n");
out.push_str(" </versioning>\n</metadata>\n");
out
}
/// Build a **version-level** `-SNAPSHOT` `maven-metadata.xml` for a non-unique
/// ("local") snapshot: `<snapshot><localCopy>true</localCopy>` plus one
/// `<snapshotVersion>` per artifact present (by classifier), each with `<value>`
/// = the `-SNAPSHOT` version so Maven fetches the literal `-SNAPSHOT` files.
/// Extensions aren't in the typed coords, so the main + classifier artifacts are
/// advertised as `jar` and a `pom` sidecar is listed for the main artifact (the
/// near-universal Maven pairing); the actual served bytes come from exact-path /
/// view resolution regardless of what's advertised here.
fn build_snapshot_metadata(
group: &str,
artifact: &str,
version: &str,
classifiers: &[Option<String>],
) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n");
out.push_str(&format!(
" <groupId>{}</groupId>\n",
nornir_escape::xml(group)
));
out.push_str(&format!(
" <artifactId>{}</artifactId>\n",
nornir_escape::xml(artifact)
));
out.push_str(&format!(
" <version>{}</version>\n",
nornir_escape::xml(version)
));
out.push_str(" <versioning>\n");
out.push_str(" <snapshot>\n <localCopy>true</localCopy>\n </snapshot>\n");
out.push_str(" <lastUpdated>19700101000000</lastUpdated>\n");
out.push_str(" <snapshotVersions>\n");
let mut push_sv = |classifier: Option<&str>, ext: &str| {
out.push_str(" <snapshotVersion>\n");
if let Some(c) = classifier {
out.push_str(&format!(
" <classifier>{}</classifier>\n",
nornir_escape::xml(c)
));
}
out.push_str(&format!(" <extension>{}</extension>\n", ext));
out.push_str(&format!(
" <value>{}</value>\n",
nornir_escape::xml(version)
));
out.push_str(" <updated>19700101000000</updated>\n");
out.push_str(" </snapshotVersion>\n");
};
for c in classifiers {
push_sv(c.as_deref(), "jar");
}
// A `.pom` accompanies essentially every main Maven artifact.
if classifiers.iter().any(|c| c.is_none()) {
push_sv(None, "pom");
}
out.push_str(" </snapshotVersions>\n </versioning>\n</metadata>\n");
out
}
/// Content-Type for a Maven wire path by extension.
fn maven_content_type(p: &str) -> &'static str {
if p.ends_with(".pom") {
"application/xml"
} else if p.ends_with(".jar") {
"application/java-archive"
} else if p.ends_with(".sha1") || p.ends_with(".md5") {
"text/plain"
} else {
"application/octet-stream"
}
}
/// Lowercase-hex SHA-1 of `data` — the primary Maven checksum sidecar.
fn sha1_hex(data: &[u8]) -> String {
use sha1::{Digest, Sha1};
let mut h = Sha1::new();
h.update(data);
hex_lower(&h.finalize())
}
/// Lowercase-hex MD5 of `data` — the legacy Maven checksum sidecar.
fn md5_hex(data: &[u8]) -> String {
use md5::{Digest, Md5};
let mut h = Md5::new();
h.update(data);
hex_lower(&h.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{:02x}", b);
}
s
}
/// Parse a Maven wire path `<group/as/dirs>/<artifact>/<version>/<filename>` into
/// `(group, artifact, version, classifier?)`. The group dots are the path dirs
/// before the trailing `artifact/version/file`; the classifier is the suffix of
/// the filename after `{artifact}-{version}`. Returns `None` for non-Maven paths.
fn parse_maven_wire_path(remainder: &str) -> Option<(String, String, String, Option<String>)> {
let comps: Vec<&str> = remainder.split('/').filter(|c| !c.is_empty()).collect();
if comps.len() < 4 {
return None;
}
let filename = comps[comps.len() - 1];
let version = comps[comps.len() - 2];
let artifact = comps[comps.len() - 3];
let group = comps[..comps.len() - 3].join(".");
if group.is_empty() || artifact.is_empty() || version.is_empty() {
return None;
}
// classifier: `{artifact}-{version}[-{classifier}].{ext}`
let stem = filename
.rsplit_once('.')
.map(|(s, _)| s)
.unwrap_or(filename);
let prefix = format!("{artifact}-{version}");
let classifier = stem
.strip_prefix(&prefix)
.and_then(|r| r.strip_prefix('-'))
.filter(|r| !r.is_empty())
.map(|s| s.to_string());
Some((group, artifact.to_string(), version.to_string(), classifier))
}
impl RepositoryBackendTrait for MavenRepoZnippy {
traits::znippy_read_only!(base);
/// An artifact download in pieces (lane HOL-RSS): the exact archive path the
/// whole-body door answers first. A metadata or checksum document is never one.
fn stream_get(
&self,
suburl: &str,
piece_bytes: usize,
) -> anyhow::Result<Option<traits::StreamedGet>> {
let remainder = self.base.strip_repo(suburl);
let doc = remainder.ends_with("/maven-metadata.xml")
|| remainder.ends_with(".sha1")
|| remainder.ends_with(".md5");
if remainder.is_empty() || doc {
return Ok(None);
}
self.base
.stream_get(remainder, maven_content_type(remainder), piece_bytes)
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
self.handle_http2_request_bytes(method, suburl, body)
.map(|(s, h, b)| (s, h, b.to_vec()))
}
fn handle_http2_request_bytes(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<traits::HttpBytesReply> {
if method == "HEAD" {
// HOL-HEAD-1: the GET answer's status and headers, its length, no body.
return self
.handle_http2_request_bytes("GET", suburl, &[])
.map(traits::backend::head_of_bytes);
}
let remainder = self.base.strip_repo(suburl);
// A metadata or checksum document: built once (lane HOL-PERF-2), the same bytes after.
let doc = remainder.ends_with("/maven-metadata.xml")
|| remainder.ends_with(".sha1")
|| remainder.ends_with(".md5");
if doc && !remainder.is_empty() {
let built = self
.docs
.get_or(remainder, || match self.answer(body, remainder) {
Ok((200, headers, b)) => {
// The answer's own Content-Type, as one of the few this backend speaks.
let said = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.as_str());
let ctype = ["application/xml", "text/plain", "application/java-archive"]
.into_iter()
.find(|c| Some(*c) == said)
.unwrap_or("application/octet-stream");
Some((ctype, traits::Bytes::from(b)))
}
_ => None,
});
return Ok(match built {
Some((ctype, b)) => traits::znippy_base::ok_bytes(ctype, b),
None => traits::znippy_base::not_found_bytes("Not found in archive"),
});
}
self.answer(body, remainder)
.map(|(s, h, b)| (s, h, traits::Bytes::from(b)))
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Maven3
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
// Resolve through the typed maven view: GAV coords come from the index
// columns, so `-SNAPSHOT` versions and classifiers resolve correctly (the
// old `{a}-{v}.jar` path-build mangled both). `id.namespace` is the groupId.
let group = id.namespace.as_deref().unwrap_or("");
if let Some(archive) = self.base.typed() {
if let Some(maven) = archive.as_maven() {
if let Some(pkg) = maven.get(group, &id.name, &id.version) {
// Same ceiling as the serve path: the index's claimed size is
// untrusted input, and `fetch` is reachable from the same
// unauthenticated doors.
return Ok(self.view_bytes(pkg).map(traits::Bytes::from));
}
// No view match → not present (don't fall back to a path guess).
return Ok(None);
}
}
// Fallback for non-archive readers (no typed view): legacy path build.
let group_path = group.replace('.', "/");
let full_path = format!(
"{}/{}/{}/{}-{}.jar",
group_path, id.name, id.version, id.name, id.version
);
match self.get_file(&full_path) {
Ok(data) => Ok(Some(traits::Bytes::from(data))),
Err(_) => Ok(None),
}
}
/// List artifacts in the backing znippy archive. Maven paths are
/// `"{group/as/dirs}/{name}/{version}/{name}-{version}.jar"`, so the groupId
/// is reconstructed as the namespace. Size via the archive index; empty when
/// no archive is configured.
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
// ONE index read for the whole listing; every token after it is memory,
// and no chunk of any artifact is touched. See [`traits::ArchiveTokens`].
let tokens = self.base.tokens().open();
// View-driven: coords are authoritative (group_id/artifact_id/version from
// the index columns), so no `rsplit_once('-')` mis-split of -SNAPSHOT /
// classifier filenames. A classifier coord is reported with its classifier
// appended to the version (e.g. "3.12.0:sources") so it stays distinct.
if let Some(archive) = self.base.typed() {
if let Some(maven) = archive.as_maven() {
let mut out = Vec::new();
for (group, artifact, version, classifier) in maven.list() {
if let Some(f) = name_filter {
if !artifact.contains(f) {
continue;
}
}
let pkg_version = match &classifier {
Some(c) => format!("{version}:{c}"),
None => version.clone(),
};
// The package handle names its own archive row
// (`relative_path`), which is the key the lookup sub-index
// answers a change token for. Size and token come off the
// same handle; neither reads an artifact byte.
let (size_bytes, change_token) = maven
.get_classified(&group, &artifact, &version, classifier.as_deref())
.map(|p| {
let token = tokens.as_ref().and_then(|t| t.token(p.relative_path()));
(p.size() as i64, token)
})
.unwrap_or((0, None));
out.push(
traits::ArtifactEntry {
id: ArtifactId {
namespace: if group.is_empty() { None } else { Some(group) },
name: artifact,
version: pkg_version,
},
size_bytes,
content_type: "application/octet-stream".into(),
// A znippy file table publishes no mtime and no per-entry digest;
// answering either would mean extracting and hashing every
// artifact on the listing path. Unknown, never a fabricated zero.
// The CHANGE TOKEN below is what the index CAN answer.
..traits::ArtifactEntry::UNKNOWN_PROVENANCE
}
.with_change_token(change_token),
);
if limit != 0 && out.len() >= limit {
break;
}
}
// Only authoritative when the typed view actually carries coords.
// An archive packed without the maven plugin (e.g. the agent's
// directory→znippy packer tags `pkg_type` but writes no GAV
// columns) yields an *empty* view — fall through to the filename
// parse below so wire-path-laid `.jar`s still Browse.
if !out.is_empty() {
return Ok(out);
}
}
}
// Fallback: legacy filename parse over the raw file list. Uses `reader()`
// (archive-or-reader) so an archive whose typed view is empty still Browses.
let Some(reader) = self.base.reader() else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for path in reader.list_files()? {
let comps: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
if comps.len() < 3 {
continue;
}
let Some(stem) = comps[comps.len() - 1].strip_suffix(".jar") else {
continue;
};
let Some((name, version)) = stem.rsplit_once('-') else {
continue;
};
if name.is_empty() || version.is_empty() {
continue;
}
if let Some(f) = name_filter {
if !name.contains(f) {
continue;
}
}
let namespace = {
let g = comps[..comps.len() - 3].join(".");
if g.is_empty() {
None
} else {
Some(g)
}
};
out.push(
traits::ArtifactEntry {
id: ArtifactId {
namespace,
name: name.to_string(),
version: version.to_string(),
},
size_bytes: reader.file_size(&path).unwrap_or(0) as i64,
content_type: "application/octet-stream".into(),
// A znippy file table publishes no mtime and no per-entry digest;
// answering either would mean extracting and hashing every
// artifact on the listing path. Unknown, never a fabricated zero.
..traits::ArtifactEntry::UNKNOWN_PROVENANCE
}
.with_change_token(tokens.as_ref().and_then(|t| t.token(&path))),
);
if limit != 0 && out.len() >= limit {
break;
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
/// One file, nothing else — the smallest loaded reader.
struct OneFile;
impl znippy_common::ZnippyReader for OneFile {
fn list_files(&self) -> anyhow::Result<Vec<String>> {
Ok(vec!["a/b".into()])
}
fn extract_file(&self, _: &str) -> anyhow::Result<Vec<u8>> {
Ok(b"x".to_vec())
}
fn contains(&self, p: &str) -> bool {
p == "a/b"
}
fn file_size(&self, _: &str) -> Option<u64> {
Some(1)
}
}
/// RED drift bug: a backend with its archive loaded must SAY it has one —
/// metering counts archive bytes only for backends that do.
#[test]
fn a_loaded_reader_is_an_archive() {
let repo = MavenRepoZnippy::with_reader("r".to_string(), std::sync::Arc::new(OneFile));
assert!(
repo.caps().archive,
"a backend with its reader loaded must claim the archive cap"
);
assert_eq!(repo.archive().unwrap().info().file_count, 1);
}
use super::*;
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
/// **Maturity disclosure** (CLAUDE.md prod-vs-prototype: Maven *metadata*).
/// GA-level `maven-metadata.xml`, version-level `-SNAPSHOT` (non-unique/local)
/// metadata, AND `.sha1`/`.md5` checksum synthesis over the served bytes are
/// now all implemented (see `snapshot_metadata_*` / `checksum_*` tests). The
/// remaining partial: `-SNAPSHOT` metadata advertises a *non-unique* snapshot
/// (`localCopy=true`) — holger stores files under their literal `-SNAPSHOT`
/// name, so timestamped unique-build values are neither stored nor synthesized.
#[test]
fn maven_metadata_maturity_disclosure() {
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-maven",
"snapshot_and_checksum_metadata",
true,
"GA + version-level -SNAPSHOT (localCopy) maven-metadata.xml and .sha1/.md5 checksum synthesis implemented; timestamped unique-build snapshots not synthesized",
);
}
#[test]
fn checksum_hex_is_lowercase_and_known() {
// Known vectors for the empty input.
assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
}
#[test]
fn snapshot_metadata_declares_local_copy_and_lists_classifiers() {
let classifiers = vec![None, Some("sources".to_string())];
let xml = build_snapshot_metadata("org.example", "widget", "1.0-SNAPSHOT", &classifiers);
assert!(xml.contains("<version>1.0-SNAPSHOT</version>"));
assert!(
xml.contains("<localCopy>true</localCopy>"),
"non-unique snapshot signal"
);
// main jar + sources jar + main pom advertised, each valued at the SNAPSHOT.
assert!(xml.contains("<extension>jar</extension>"));
assert!(xml.contains("<classifier>sources</classifier>"));
assert!(
xml.contains("<extension>pom</extension>"),
"main pom sidecar listed"
);
assert_eq!(
xml.matches("<value>1.0-SNAPSHOT</value>").count(),
3,
"one snapshotVersion value per (main jar, sources jar, main pom)"
);
}
#[test]
fn maven_version_ordering() {
use std::cmp::Ordering;
assert_eq!(maven_version_cmp("1.0", "1.1"), Ordering::Less);
assert_eq!(maven_version_cmp("2.0", "1.9"), Ordering::Greater);
assert_eq!(
maven_version_cmp("1.0", "1.0.0"),
Ordering::Equal,
"trailing zeros equal"
);
assert_eq!(
maven_version_cmp("1.0.10", "1.0.9"),
Ordering::Greater,
"numeric, not lexical"
);
assert_eq!(
maven_version_cmp("1.0-SNAPSHOT", "1.0"),
Ordering::Less,
"snapshot < release"
);
assert_eq!(
maven_version_cmp("1.0-alpha", "1.0-beta"),
Ordering::Less,
"qualifier lexical"
);
}
#[test]
fn maven_metadata_lists_versions_latest_and_release() {
let versions = [
"1.0".to_string(),
"1.2".to_string(),
"2.0".to_string(),
"2.1-SNAPSHOT".to_string(),
];
let xml = build_maven_metadata("org.example", "widget", &versions);
assert!(xml.contains("<groupId>org.example</groupId>"));
assert!(xml.contains("<artifactId>widget</artifactId>"));
// latest includes the snapshot; release is the greatest non-SNAPSHOT.
assert!(xml.contains("<latest>2.1-SNAPSHOT</latest>"));
assert!(xml.contains("<release>2.0</release>"));
for v in &versions {
assert!(
xml.contains(&format!("<version>{v}</version>")),
"missing {v}"
);
}
}
#[test]
fn maven_metadata_all_snapshots_has_no_release() {
let versions = ["1.0-SNAPSHOT".to_string(), "1.1-SNAPSHOT".to_string()];
let xml = build_maven_metadata("g", "a", &versions);
assert!(xml.contains("<latest>1.1-SNAPSHOT</latest>"));
assert!(
!xml.contains("<release>"),
"no release when every version is a snapshot"
);
}
/// RED-when-broken guard on the shared `nornir_escape::xml` consumed here (the
/// deleted local `xml_escape` twin). The three original entities (`&`/`<`/`>`)
/// must render EXACTLY as before, and `"` is now ALSO escaped — the deliberate,
/// safe widening from switching the maven metadata over to the shared primitive
/// (a `"` inside XML text is always fine to escape). The group/artifact tokens
/// flow through `xml` verbatim, so a malicious coord can't break the document.
#[test]
fn maven_metadata_xml_escapes_entities() {
// A version token carrying every sensitive char; the group is attacker-shaped.
let versions = ["1.0-<b>&\"x\"".to_string()];
let xml = build_maven_metadata("a&b<c>d\"e", "wid<g>et", &versions);
// The three legacy entities render byte-for-byte as the old twin did.
assert!(
xml.contains("<groupId>a&b<c>d"e</groupId>"),
"{xml}"
);
assert!(
xml.contains("<artifactId>wid<g>et</artifactId>"),
"{xml}"
);
// The widened `"`→`"` now applies in the version listing too.
assert!(
xml.contains("<version>1.0-<b>&"x"</version>"),
"{xml}"
);
// No raw sensitive char leaks past the escaper into the emitted text.
assert!(
!xml.contains("a&b"),
"raw ampersand must not survive: {xml}"
);
assert!(
!xml.contains("wid<g>et"),
"raw angle brackets must not survive: {xml}"
);
}
#[test]
fn test_new_maven_repo() {
let repo = MavenRepoZnippy::new("maven-test".to_string());
assert_eq!(repo.name(), "maven-test");
assert!(repo.list_files().is_empty());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-maven",
"new_repo_named_and_empty",
repo.name() == "maven-test" && repo.list_files().is_empty(),
&format!("name={} files={}", repo.name(), repo.list_files().len()),
);
}
#[test]
fn test_readonly() {
let repo = MavenRepoZnippy::new("maven-test".to_string());
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-maven",
"is_readonly",
!repo.is_writable(),
&format!("writable={}", repo.is_writable()),
);
}
#[test]
fn test_format() {
let repo = MavenRepoZnippy::new("maven-test".to_string());
assert_eq!(repo.format(), ArtifactFormat::Maven3);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-maven",
"format_is_maven3",
repo.format() == ArtifactFormat::Maven3,
&format!("format = {:?}", repo.format()),
);
}
/// Caps and methods agree, with and without an archive loaded.
#[test]
fn caps_match_methods() {
struct One;
impl znippy_common::ZnippyReader for One {
fn list_files(&self) -> anyhow::Result<Vec<String>> {
Ok(vec!["x".into()])
}
fn extract_file(&self, _: &str) -> anyhow::Result<Vec<u8>> {
Ok(Vec::new())
}
fn contains(&self, p: &str) -> bool {
p == "x"
}
fn file_size(&self, _: &str) -> Option<u64> {
Some(0)
}
}
traits::backend::conformance::check(&MavenRepoZnippy::new("r".into())).unwrap();
traits::backend::conformance::check(&MavenRepoZnippy::with_reader(
"r".into(),
std::sync::Arc::new(One),
))
.unwrap();
}
/// **A metadata or checksum document is built once** (lane HOL-PERF-2, step 6): a
/// second `maven-metadata.xml`, `.sha1` or `.md5` request reads neither the file
/// table nor a member again, and answers the same bytes.
#[test]
fn a_second_metadata_or_checksum_request_reads_nothing_again() {
use traits::test_support::{CountingReader, MemReader};
let c = CountingReader::new(MemReader::new([
("org/demo/lib/1.0/lib-1.0.jar", b"jar one".to_vec()),
("org/demo/lib/1.0/lib-1.0.pom", b"<project/>".to_vec()),
("org/demo/lib/2.0/lib-2.0.jar", b"jar two".to_vec()),
]));
let repo = MavenRepoZnippy::with_reader("m".into(), c.clone());
for url in [
"/m/org/demo/lib/maven-metadata.xml",
"/m/org/demo/lib/maven-metadata.xml.sha1",
"/m/org/demo/lib/1.0/lib-1.0.jar.sha1",
"/m/org/demo/lib/1.0/lib-1.0.jar.md5",
] {
let a = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
assert_eq!(a.0, 200, "{url}");
let seen = (c.table_reads(), c.member_reads());
let b = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
assert_eq!(a.2.as_ptr(), b.2.as_ptr(), "{url}: the same bytes");
assert_eq!(
(c.table_reads(), c.member_reads()),
seen,
"{url}: nothing read again"
);
}
let meta = repo
.handle_http2_request("GET", "/m/org/demo/lib/maven-metadata.xml", &[])
.unwrap()
.2;
assert!(String::from_utf8_lossy(&meta).contains("<version>2.0</version>"));
}
}