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-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&amp;b&lt;c&gt;d&quot;e</groupId>"),
            "{xml}"
        );
        assert!(
            xml.contains("<artifactId>wid&lt;g&gt;et</artifactId>"),
            "{xml}"
        );
        // The widened `"`→`&quot;` now applies in the version listing too.
        assert!(
            xml.contains("<version>1.0-&lt;b&gt;&amp;&quot;x&quot;</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>"));
    }
}