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-composer/src/lib.rs

//! Composer (PHP / Packagist) repository backend served from a read-only znippy
//! archive — the Composer sibling to `znippy-{npm,maven,rpm,...}`, one crate per
//! ecosystem, all implementing `RepositoryBackendTrait`.
//!
//! **Finished 2026-09-20 (?23).** Until then this backend was, alone among its
//! siblings, pure passthrough: the archive had to already carry Composer v2
//! metadata verbatim at `p2/{vendor}/{package}.json`. `packages.json` was
//! correct and pointed a client at `metadata-url` → `/{repo}/p2/%package%.json`,
//! and from a bare set of dist zips there was NOTHING at that URL — so every
//! `composer install` 404ed on p2 and failed. It now synthesizes that metadata
//! from the packages present, the way deb builds `Release` and rpm builds
//! `repomd`: a Composer dist is a **zip**, the `composer.json` inside it is the
//! package definition, and `p2.rs` turns those into p2 documents.
//!
//! **Passthrough still wins.** When the archive really does carry
//! `p2/{vendor}/{package}.json`, that document is served verbatim, exactly as
//! before: a curated mirror of real Packagist metadata knows more than any
//! synthesis can reconstruct. Synthesis is the FALLBACK.
//!
//! `handle_http2_request` strips the repo name and routes three shapes —
//! `packages.json` (the root), `p2/…` metadata (archive first, then
//! synthesized, `~dev` included), and `dist/…` zips straight out of the
//! archive. `fetch()` is the in-process dist path (namespace = vendor);
//! `put()` always errors, read-only.
//!
//! Gotcha: the emitted `packages.json` `metadata-url` is `/{name}/p2/%package%.json`,
//! and `%package%` is Composer's own literal placeholder the client substitutes —
//! it is emitted through `serde_json`, never interpolated, so it survives.
//!
//! Cost: the catalog — one read of every dist zip for its `composer.json` — is
//! built ONCE, lazily, on the first p2 request that has to synthesize, held in
//! a `OnceLock` like deb's `Release`. The fan-out is `znippy_zoomies::gatling`
//! (ROOT LAW #0 — never rayon). The `dist/…` and passthrough-`p2/…` arms never
//! touch it.

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};

use anyhow::Result;

use traits::znippy_base::{not_found, ok};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;

pub mod p2;

use p2::ComposerPackage;

/// A dist's `composer.json`, when it has one: its member name and bytes.
type Manifest = Option<(String, Vec<u8>)>;

/// The catalog: `vendor/package` → its versions.
type Catalog = BTreeMap<String, Vec<ComposerPackage>>;

/// The public base URL the synthesized `dist.url` is built from.
///
/// Composer resolves `dist.url` exactly as written, so it must be the
/// externally-reachable holger base (set from `ron_base_url`) — a relative or
/// loopback URL sends every remote client to the wrong host. Defaults to the
/// dev loopback when unconfigured, the same default `znippy-nuget` uses.
const DEFAULT_BASE_URL: &str = "https://127.0.0.1:8443";

/// znippy-backed Composer (PHP / Packagist) repository implementing the
/// Composer v2 metadata protocol.
///
///   * `GET /{repo}/packages.json`               → repository root (points at p2)
///   * `GET /{repo}/p2/{vendor}/{package}.json`  → per-package version metadata
///   * `GET /{repo}/dist/{vendor}/{package}/{ref}.zip` → distributable zip
///
/// Metadata is stored in the archive as `p2/{vendor}/{package}.json`, dists as
/// `dist/{vendor}/{package}/{ref}.zip`. When the former is absent it is
/// synthesized from the latter; see [`p2`].
pub struct ComposerRepoZnippy {
    base: ZnippyBase,
    /// The archive's file table, taken ONCE — the same reasoning as
    /// `znippy-nuget`'s: `ZnippyArchive::list_files()` allocates one String per
    /// file in the archive on every call, and the root and p2 arms scan it per
    /// request. This backend is read-only and holds its reader for the process
    /// lifetime, so the table cannot change underneath.
    paths: OnceLock<Vec<String>>,
    /// Public base URL the synthesized `dist.url` points at. See
    /// [`DEFAULT_BASE_URL`].
    base_url: String,
    /// **The `composer.json` of every dist in the archive, taken ONCE.**
    ///
    /// A p2 document is an answer ABOUT the dists, so building one means
    /// opening every dist zip. Per request that would make `composer install`
    /// — which asks for one p2 document per required package — an O(archive)
    /// read each time. Held for the process lifetime, like deb's `Release`
    /// `OnceLock` and conda's repodata cache.
    catalog: OnceLock<Catalog>,
}

impl ComposerRepoZnippy {
    pub fn new(name: String) -> Self {
        Self {
            base: ZnippyBase::new(name),
            paths: OnceLock::new(),
            base_url: DEFAULT_BASE_URL.to_string(),
            catalog: OnceLock::new(),
        }
    }

    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        Ok(Self {
            base: ZnippyBase::with_archive(name, &archive_path)?,
            paths: OnceLock::new(),
            base_url: DEFAULT_BASE_URL.to_string(),
            catalog: OnceLock::new(),
        })
    }

    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self {
            base: ZnippyBase::with_reader(name, reader),
            paths: OnceLock::new(),
            base_url: DEFAULT_BASE_URL.to_string(),
            catalog: OnceLock::new(),
        }
    }

    /// Override the public base URL the synthesized `dist.url` is built from.
    /// An empty string resets to the default loopback; a trailing slash is
    /// normalized. Same seam and same wording as `znippy-nuget`'s.
    pub fn with_base_url(mut self, url: String) -> Self {
        let trimmed = url.trim_end_matches('/');
        self.base_url = if trimmed.is_empty() {
            DEFAULT_BASE_URL.to_string()
        } else {
            trimmed.to_string()
        };
        self
    }

    /// The archive's file table, filled on first use. See the `paths` field.
    fn paths(&self) -> &[String] {
        self.paths.get_or_init(|| match self.base.reader() {
            Some(r) => r.list_files().unwrap_or_default(),
            None => Vec::new(),
        })
    }

    pub fn list_files(&self) -> Vec<String> {
        self.paths().to_vec()
    }

    pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
        self.base.get_file(relative_path)
    }

    /// Every `vendor/package` this repository holds, from the archive PATHS
    /// alone — no dist is opened. Both layouts count: a package the archive
    /// carries p2 metadata for, and a package it only carries dists for.
    fn available_packages(&self) -> Vec<String> {
        let mut out: std::collections::BTreeSet<String> = Default::default();
        for path in self.paths() {
            let mut seg = path.split('/');
            match (seg.next(), seg.next(), seg.next()) {
                (Some("dist"), Some(vendor), Some(pkg))
                    if !vendor.is_empty() && !pkg.is_empty() =>
                {
                    out.insert(format!("{vendor}/{pkg}"));
                }
                (Some("p2"), Some(vendor), Some(file)) if !vendor.is_empty() => {
                    if let Some(pkg) = file.strip_suffix(".json") {
                        let pkg = pkg.strip_suffix("~dev").unwrap_or(pkg);
                        if !pkg.is_empty() {
                            out.insert(format!("{vendor}/{pkg}"));
                        }
                    }
                }
                _ => {}
            }
        }
        out.into_iter().collect()
    }

    /// The `dist/{vendor}/{package}/{reference}.zip` coordinates.
    fn dist_coords(&self) -> Vec<DistCoord> {
        let mut out = Vec::new();
        for path in self.paths() {
            let Some(rest) = path.strip_prefix("dist/") else {
                continue;
            };
            let mut seg = rest.split('/');
            let (Some(vendor), Some(pkg), Some(file), None) =
                (seg.next(), seg.next(), seg.next(), seg.next())
            else {
                continue;
            };
            let Some(reference) = file.strip_suffix(".zip") else {
                continue;
            };
            if vendor.is_empty() || pkg.is_empty() || reference.is_empty() {
                continue;
            }
            out.push(DistCoord {
                path: path.clone(),
                vendor: vendor.to_string(),
                package: pkg.to_string(),
                reference: reference.to_string(),
            });
        }
        out
    }

    /// Open every dist zip and read its `composer.json`. Called ONCE; see the
    /// `catalog` field.
    ///
    /// A dist that is not a readable zip, or that carries no `composer.json`,
    /// is **skipped and logged** — never fatal. One hostile dist must not make
    /// the whole repository unresolvable, which is the same ruling
    /// `znippy-helm` makes about one bomb chart in an `index.yaml` build. Its
    /// bytes are still downloadable at their exact `dist/…` URL.
    fn build_catalog(&self) -> Catalog {
        let Some(reader) = self.base.reader() else {
            return Catalog::new();
        };
        let coords = self.dist_coords();
        if coords.is_empty() {
            return Catalog::new();
        }

        // ROOT LAW #0: gatling, never rayon. One unit per dist, weighted by the
        // archive's own stored size so the big dists are dealt first. At most
        // CATALOG_WORKERS at once (lane HOL-RSS-3): each holds a zip's directory, its
        // `composer.json` and one piece of the dist being hashed, never the dist.
        let parsed: Vec<Option<ComposerPackage>> =
            znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
                &coords,
                catalog_workers(),
                1,
                |c: &DistCoord| reader.file_size(&c.path).unwrap_or(0),
                |_i, c: &DistCoord| {
                    let (manifest, shasum) = match self.read_dist(reader.as_ref(), &c.path) {
                        Ok((Some((_name, b)), shasum)) => match p2::parse_manifest(&b) {
                            Some(m) => (m, shasum),
                            None => {
                                log::warn!("composer catalog: bad composer.json in {}", c.path);
                                return None;
                            }
                        },
                        Ok((None, _)) => {
                            log::warn!("composer catalog: no composer.json in {}", c.path);
                            return None;
                        }
                        Err(e) => {
                            log::warn!("composer catalog: refused {} : {e}", c.path);
                            return None;
                        }
                    };
                    Some(p2::package_from_dist(
                        &c.vendor,
                        &c.package,
                        &c.reference,
                        manifest,
                        shasum,
                    ))
                },
            );

        p2::group(parsed.into_iter().flatten().collect())
    }

    /// The `composer.json` of the dist zip at `rel` and the dist's SHA-1 (lane
    /// HOL-RSS-3). The manifest is read where it lies in the archive — the zip's end, its
    /// central directory and the one member — and the SHA-1 a piece at a time: the dist
    /// is never held whole. Composer checks a download against `dist.shasum` with sha1,
    /// so the digest covers every byte. A backend over a plain reader reads the dist
    /// whole, once, for both.
    fn read_dist(&self, reader: &dyn ZnippyReader, rel: &str) -> Result<(Manifest, String)> {
        if let Some(mut f) = self.base.file_at(rel) {
            let mut src =
                zip_entry::ReadAt::new(f.len(), |off, buf: &mut [u8]| f.read_at(off, buf));
            let manifest = zip_entry::find_member_at(
                &mut src,
                zip_entry::MAX_MEMBER_DECOMPRESSED_BYTES,
                p2::is_dist_manifest,
            )?;
            if manifest.is_none() {
                return Ok((None, String::new()));
            }
            use sha1::{Digest, Sha1};
            let mut h = Sha1::new();
            self.base.for_each_piece(rel, |p| h.update(p))?;
            return Ok((manifest, hex(&h.finalize())));
        }
        let bytes = reader.extract_file(rel)?;
        let manifest = zip_entry::find_member(&bytes, p2::is_dist_manifest)?;
        Ok((manifest, sha1_hex(&bytes)))
    }

    /// The catalog, filled on first use.
    fn catalog(&self) -> &Catalog {
        self.catalog.get_or_init(|| self.build_catalog())
    }
}

/// One dist as the archive path spells it.
struct DistCoord {
    path: String,
    vendor: String,
    package: String,
    reference: String,
}

/// SHA-1 of a dist, hex. See [`p2::ComposerPackage::shasum`] for why it is
/// SHA-1 and not the house SHA-256.
fn sha1_hex(bytes: &[u8]) -> String {
    use sha1::{Digest, Sha1};
    let mut h = Sha1::new();
    h.update(bytes);
    hex(&h.finalize())
}

/// Lowercase hex of a digest.
fn hex(digest: &[u8]) -> String {
    let mut s = String::with_capacity(digest.len() * 2);
    for b in digest {
        use std::fmt::Write as _;
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// How many dists the catalog reads at once (lane HOL-RSS-3): bounded, not one per
/// core. Each worker holds one zip's central directory, one `composer.json` and one
/// piece of the dist it hashes (and, out of a compressed chunk, that chunk), so the
/// build's memory no longer grows with the core count or the dist size.
const CATALOG_WORKERS: usize = 4;

fn catalog_workers() -> usize {
    std::thread::available_parallelism()
        .map_or(1, |n| n.get())
        .min(CATALOG_WORKERS)
}

impl RepositoryBackendTrait for ComposerRepoZnippy {
    traits::znippy_read_only!(base);

    /// A dist download in pieces (lane HOL-RSS part 2): `dist/{vendor}/{package}/{file}`, the
    /// archive file the whole-body door reads.
    fn stream_get(
        &self,
        suburl: &str,
        piece_bytes: usize,
    ) -> anyhow::Result<Option<traits::StreamedGet>> {
        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
        let ["dist", vendor, pkg, file] = parts.as_slice() else {
            return Ok(None);
        };
        self.base.stream_get(
            &format!("dist/{vendor}/{pkg}/{file}"),
            "application/zip",
            piece_bytes,
        )
    }

    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        if method == "HEAD" {
            // HOL-HEAD-1: the GET answer's status and headers, its length, no body.
            return self
                .handle_http2_request("GET", suburl, &[])
                .map(traits::backend::head_of);
        }
        let _ = body;
        log::debug!(
            "composer repo znippy handle_http2_request.suburl={}",
            suburl
        );

        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();

        match parts.as_slice() {
            ["packages.json"] => Ok(ok(
                "application/json",
                p2::packages_json(self.base.name(), &self.available_packages()),
            )),
            // /p2/{vendor}/{package}.json — and `{package}~dev.json`, which
            // Composer asks for beside every stable one.
            ["p2", vendor, pkg_json] => {
                // 1. Passthrough: a curated archive's own metadata always wins.
                //    PROBED with `contains`, not with a speculative
                //    `extract_file`: on a synthesizing archive the passthrough
                //    key is absent on every single p2 request, and an
                //    extract-and-discard would decompress nothing but still
                //    cost an archive lookup and an Err allocation per request.
                let archive_path = format!("p2/{}/{}", vendor, pkg_json);
                let curated = self
                    .base
                    .reader()
                    .is_some_and(|r| r.contains(&archive_path));
                if curated {
                    if let Ok(data) = self.get_file(&archive_path) {
                        return Ok(ok("application/json", data));
                    }
                }
                // 2. Synthesis, from the dists present. This is the arm that
                //    was missing: without it every `composer install` 404s.
                let Some(stem) = pkg_json.strip_suffix(".json") else {
                    return Ok(not_found("Package metadata not found"));
                };
                let (pkg, dev) = match stem.strip_suffix("~dev") {
                    Some(p) => (p, true),
                    None => (stem, false),
                };
                let name = format!("{vendor}/{pkg}");
                match self.catalog().get(&name) {
                    Some(versions) => Ok(ok(
                        "application/json",
                        p2::p2_document(&self.base_url, self.base.name(), &name, versions, dev),
                    )),
                    None => Ok(not_found("Package metadata not found")),
                }
            }
            // /dist/{vendor}/{package}/{ref}.zip
            ["dist", vendor, pkg, file] => {
                let archive_path = format!("dist/{}/{}/{}", vendor, pkg, file);
                match self.get_file(&archive_path) {
                    Ok(data) => Ok(ok("application/zip", data)),
                    Err(_) => Ok(not_found("Dist not found in archive")),
                }
            }
            _ => Ok(not_found("Not found")),
        }
    }

    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Composer
    }

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        // namespace = vendor, name = package
        let vendor = id.namespace.as_deref().unwrap_or("");
        let path = format!("dist/{}/{}/{}.zip", vendor, id.name, id.version);
        match self.get_file(&path) {
            Ok(data) => Ok(Some(traits::Bytes::from(data))),
            Err(_) => Ok(None),
        }
    }

    /// Package coordinates from the archive's `dist/{vendor}/{package}/{ref}.zip`
    /// entries.
    ///
    /// Was absent, so `list()` fell through to the trait default and reported an
    /// empty Vec — which `/metrics` and the front page cannot tell apart from a
    /// repository that really holds nothing. The p2 metadata entries are
    /// deliberately skipped: they describe artifacts, they are not artifacts.
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        let Some(reader) = self.base.reader() else {
            return Ok(Vec::new());
        };
        // ONE index read for the whole listing; every token after it is memory.
        let tokens = self.base.tokens().open();

        traits::list_archive_entries(
            reader.as_ref(),
            tokens.as_ref(),
            name_filter,
            limit,
            "application/zip",
            |path| {
                let rest = path.strip_prefix("dist/")?;
                let mut seg = rest.split('/');
                let vendor = seg.next()?;
                let pkg = seg.next()?;
                let file = seg.next()?;
                if seg.next().is_some() {
                    return None;
                }
                let reference = file.strip_suffix(".zip")?;
                if vendor.is_empty() || pkg.is_empty() || reference.is_empty() {
                    return None;
                }
                Some(ArtifactId {
                    namespace: Some(vendor.to_string()),
                    name: pkg.to_string(),
                    version: reference.to_string(),
                })
            },
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use traits::test_support::{CountingReader, MemReader};

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    #[test]
    fn test_new() {
        let repo = ComposerRepoZnippy::new("composer-test".to_string());
        assert_eq!(repo.name(), "composer-test");
        assert!(!repo.is_writable());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-composer",
            "new_name_and_readonly",
            repo.name() == "composer-test" && !repo.is_writable(),
            &format!("name={} writable={}", repo.name(), repo.is_writable()),
        );
    }

    #[test]
    fn test_format() {
        let f = ComposerRepoZnippy::new("x".into()).format();
        assert_eq!(f, ArtifactFormat::Composer);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-composer",
            "format_is_composer",
            f == ArtifactFormat::Composer,
            &format!("format = {f:?}"),
        );
    }

    #[test]
    fn serves_injected_archive() {
        // Known dist artifact: a (fake) Composer dist zip, stored at the exact
        // key fetch()/get_file() build → dist/{vendor}/{package}/{ref}.zip.
        let zip_bytes: Vec<u8> = b"PK\x03\x04injected-composer-dist-zip".to_vec();
        // Per-package metadata, served verbatim from p2/{vendor}/{package}.json.
        let p2_json: Vec<u8> = br#"{"packages":{"acme/widget":[]}}"#.to_vec();

        let reader = MemReader::new([
            ("dist/acme/widget/1.0.0.zip", zip_bytes.clone()),
            ("p2/acme/widget.json", p2_json.clone()),
        ]);
        let repo = ComposerRepoZnippy::with_reader("composer-test".to_string(), reader);

        // --- fetch(): hit returns the exact injected zip bytes ---
        let id = ArtifactId {
            namespace: Some("acme".to_string()),
            name: "widget".to_string(),
            version: "1.0.0".to_string(),
        };
        let fetched = repo.fetch(&id).expect("fetch ok");
        assert_eq!(fetched.as_deref(), Some(zip_bytes.as_slice()));

        // --- fetch(): miss returns None ---
        let miss_id = ArtifactId {
            namespace: Some("acme".to_string()),
            name: "widget".to_string(),
            version: "9.9.9".to_string(),
        };
        let fetched_miss = repo.fetch(&miss_id).expect("fetch ok");
        assert_eq!(fetched_miss, None);

        // --- handle_http2_request: dist hit → 200 + application/zip + bytes ---
        let (dist_status, dist_headers, dist_body) = repo
            .handle_http2_request("GET", "/composer-test/dist/acme/widget/1.0.0.zip", b"")
            .expect("dist request ok");
        let dist_ct = dist_headers
            .iter()
            .find(|(k, _)| k == "Content-Type")
            .map(|(_, v)| v.as_str());
        assert_eq!(dist_status, 200);
        assert_eq!(dist_ct, Some("application/zip"));
        assert_eq!(dist_body, zip_bytes);

        // --- handle_http2_request: dist miss → 404 ---
        let (dist_miss_status, _, _) = repo
            .handle_http2_request("GET", "/composer-test/dist/acme/widget/9.9.9.zip", b"")
            .expect("dist miss request ok");
        assert_eq!(dist_miss_status, 404);

        // --- handle_http2_request: p2 metadata hit → 200 + JSON + injected name ---
        let (p2_status, p2_headers, p2_body) = repo
            .handle_http2_request("GET", "/composer-test/p2/acme/widget.json", b"")
            .expect("p2 request ok");
        let p2_ct = p2_headers
            .iter()
            .find(|(k, _)| k == "Content-Type")
            .map(|(_, v)| v.as_str());
        assert_eq!(p2_status, 200);
        assert_eq!(p2_ct, Some("application/json"));
        assert_eq!(p2_body, p2_json);
        assert!(String::from_utf8_lossy(&p2_body).contains("acme/widget"));

        // --- handle_http2_request: packages.json root → 200 + JSON ---
        let (root_status, root_headers, root_body) = repo
            .handle_http2_request("GET", "/composer-test/packages.json", b"")
            .expect("packages.json request ok");
        let root_ct = root_headers
            .iter()
            .find(|(k, _)| k == "Content-Type")
            .map(|(_, v)| v.as_str());
        let root_str = String::from_utf8_lossy(&root_body);
        assert_eq!(root_status, 200);
        assert_eq!(root_ct, Some("application/json"));
        assert!(root_str.contains("/composer-test/p2/%package%.json"));

        #[cfg(feature = "testmatrix")]
        {
            fstatus(
                "znippy-composer",
                "fetch_serves_injected_dist",
                fetched.as_deref() == Some(zip_bytes.as_slice()) && fetched_miss.is_none(),
                &format!(
                    "fetch hit_len={:?} miss={:?}",
                    fetched.as_ref().map(|b| b.len()),
                    fetched_miss
                ),
            );
            fstatus(
                "znippy-composer",
                "http_dist_route_serves_zip",
                dist_status == 200
                    && dist_ct == Some("application/zip")
                    && dist_body == zip_bytes
                    && dist_miss_status == 404,
                &format!(
                    "hit={} ct={:?} body_match={} miss={}",
                    dist_status,
                    dist_ct,
                    dist_body == zip_bytes,
                    dist_miss_status
                ),
            );
            fstatus(
                "znippy-composer",
                "http_p2_route_serves_metadata",
                p2_status == 200
                    && p2_ct == Some("application/json")
                    && String::from_utf8_lossy(&p2_body).contains("acme/widget"),
                &format!("status={} ct={:?}", p2_status, p2_ct),
            );
            fstatus(
                "znippy-composer",
                "http_packages_json_root",
                root_status == 200
                    && root_ct == Some("application/json")
                    && root_str.contains("/composer-test/p2/%package%.json"),
                &format!("status={} ct={:?}", root_status, root_ct),
            );
        }
    }
    #[test]
    fn list_reports_dists_not_a_false_zero() {
        let reader = MemReader::new([
            ("dist/acme/widget/1.0.0.zip", b"PK-one".to_vec()),
            ("dist/acme/widget/2.0.0.zip", b"PK-two-longer".to_vec()),
            ("dist/other/thing/1.0.0.zip", b"PK".to_vec()),
            // metadata describes artifacts; it is not one
            ("p2/acme/widget.json", br#"{"packages":{}}"#.to_vec()),
        ]);
        let repo = ComposerRepoZnippy::with_reader("composer-test".to_string(), reader);

        let all = repo.list(None, 0).expect("list ok");
        assert_eq!(all.len(), 3, "three dists, p2 metadata excluded");

        for e in &all {
            assert!(
                repo.fetch(&e.id).expect("fetch ok").is_some(),
                "unfetchable: {:?}",
                e.id
            );
        }

        let widget = repo.list(Some("widget"), 0).expect("list ok");
        assert_eq!(widget.len(), 2);
        assert_eq!(widget[0].id.namespace.as_deref(), Some("acme"));
        assert_eq!(widget[0].content_type, "application/zip");
        assert!(widget.iter().any(|e| e.size_bytes == 6), "real size, not 0");

        assert_eq!(
            repo.list(None, 2).expect("list ok").len(),
            2,
            "limit honoured"
        );
        assert!(ComposerRepoZnippy::new("empty".into())
            .list(None, 0)
            .unwrap()
            .is_empty());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-composer",
            "list_reports_dists",
            all.len() == 3 && widget.len() == 2,
            &format!("all={} filtered={}", all.len(), widget.len()),
        );
    }
    /// The Archive tab was told there was nothing to browse on a repository
    /// that was serving dists: the archive verbs
    /// were left at the trait default. The same false zero
    /// as `list()`, on three more verbs.
    #[test]
    fn the_archive_verbs_report_the_real_archive() {
        let reader = MemReader::new([
            ("dist/acme/widget/1.0.0.zip", b"PK-one".to_vec()),
            ("dist/acme/widget/2.0.0.zip", b"PK-two".to_vec()),
            ("p2/acme/widget.json", br#"{"packages":{}}"#.to_vec()),
        ]);
        let repo = ComposerRepoZnippy::with_reader("composer-test".to_string(), reader);

        assert!(repo.caps().archive, "a loaded reader IS an archive");
        assert_eq!(repo.archive().unwrap().paths(None).len(), 3);
        assert_eq!(
            repo.archive().unwrap().paths(Some("dist/")).len(),
            2,
            "prefix narrows"
        );

        let info = repo.archive().unwrap().info();
        assert_eq!(info.file_count, 3);
        assert_eq!(
            info.total_uncompressed_bytes,
            6 + 6 + 15,
            "real bytes, not zero"
        );
        assert_eq!(info.archive_path, "composer-test");

        let empty = ComposerRepoZnippy::new("empty".into());
        assert!(!empty.caps().archive);
        assert!(traits::is_unsupported(&empty.archive().unwrap_err()));
    }

    // ── ?23: the metadata this backend had no synthesiser for ────────────────

    /// Build a real Composer dist: a zip whose root holds the package's own
    /// `composer.json`, exactly as `composer archive` lays one out.
    fn dist(name: &str, requires: &str) -> Vec<u8> {
        let manifest = format!(
            r#"{{"name":"{name}","description":"a package","type":"library",
                "license":["MIT"],"require":{{{requires}}},
                "autoload":{{"psr-4":{{"Acme\\Widget\\":"src/"}}}}}}"#
        );
        zip_entry::fixture::zip(&[
            ("composer.json", manifest.as_bytes()),
            ("src/Widget.php", b"<?php class Widget {}".as_slice()),
        ])
    }

    fn json_of(repo: &ComposerRepoZnippy, url: &str) -> (u16, serde_json::Value) {
        let (status, _h, body) = repo.handle_http2_request("GET", url, b"").expect("request");
        let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
        (status, v)
    }

    /// ★ **?23, the composer half.** Before: the archive had to already carry
    /// `p2/{vendor}/{package}.json`, and from a bare set of dist zips every
    /// `composer install` 404ed on p2 — the repository served bytes nobody
    /// could find. After: the p2 document is synthesized from the
    /// `composer.json` inside each dist.
    ///
    /// This walks the exact sequence `composer install` walks.
    #[test]
    fn a_composer_install_now_resolves_from_bare_dists() {
        let reader = MemReader::new([
            (
                "dist/acme/widget/1.0.0.zip",
                dist("acme/widget", r#""php":">=8.1""#),
            ),
            (
                "dist/acme/widget/1.2.0.zip",
                dist("acme/widget", r#""php":">=8.1","acme/other":"^2.0""#),
            ),
            ("dist/acme/widget/main.zip", dist("acme/widget", "")),
            ("dist/acme/other/2.1.0.zip", dist("acme/other", "")),
        ]);
        let repo = ComposerRepoZnippy::with_reader("composer".into(), reader)
            .with_base_url("https://holger.example.com/".into());

        // 1. The root. `metadata-url` is unchanged — it was always right — and
        //    the catalog is now advertised beside it.
        let (status, root) = json_of(&repo, "/composer/packages.json");
        assert_eq!(status, 200);
        assert_eq!(root["metadata-url"], "/composer/p2/%package%.json");
        let avail: Vec<&str> = root["available-packages"]
            .as_array()
            .expect("catalog")
            .iter()
            .map(|v| v.as_str().unwrap_or(""))
            .collect();
        assert_eq!(avail, vec!["acme/other", "acme/widget"]);

        // 2. Follow it. THE request that used to 404.
        let (status, meta) = json_of(&repo, "/composer/p2/acme/widget.json");
        assert_eq!(status, 200, "a bare dist archive must answer p2, not 404");
        let items = meta["packages"]["acme/widget"]
            .as_array()
            .expect("versions");
        assert_eq!(
            items.len(),
            2,
            "two stable versions; the branch is not one of them"
        );
        assert_eq!(items[0]["version"], "1.2.0", "newest first");
        assert_eq!(items[0]["version_normalized"], "1.2.0.0");

        // 3. The requirements — the whole reason a client reads this document,
        //    and what a passthrough archive without p2 could never supply.
        assert_eq!(items[0]["require"]["acme/other"], "^2.0");
        assert_eq!(items[0]["autoload"]["psr-4"]["Acme\\Widget\\"], "src/");

        // 4. The download. `dist.url` must be absolute and must resolve
        //    against this backend's own dist arm.
        let url = items[0]["dist"]["url"].as_str().expect("dist url");
        assert_eq!(
            url,
            "https://holger.example.com/composer/dist/acme/widget/1.2.0.zip"
        );
        let suburl = url.trim_start_matches("https://holger.example.com");
        let (dstatus, dheaders, dbody) = repo
            .handle_http2_request("GET", suburl, b"")
            .expect("download");
        assert_eq!(
            dstatus, 200,
            "the URL the p2 document advertised really serves bytes"
        );
        assert!(dheaders
            .iter()
            .any(|(k, v)| k == "Content-Type" && v == "application/zip"));
        assert!(dbody.starts_with(b"PK"));

        // 5. …and the shasum in the document is the one Composer will check
        //    the downloaded bytes against. sha1, because that is what
        //    Composer's own verifier uses.
        let shasum = items[0]["dist"]["shasum"].as_str().expect("shasum");
        assert_eq!(shasum.len(), 40, "sha1 hex, not sha256: {shasum}");
        assert_eq!(
            shasum,
            sha1_hex(&dbody),
            "the digest is OF the bytes that are served"
        );

        // 6. The `~dev` document Composer asks for beside every stable one.
        let (status, dev) = json_of(&repo, "/composer/p2/acme/widget~dev.json");
        assert_eq!(status, 200);
        assert_eq!(dev["packages"]["acme/widget"][0]["version"], "dev-main");

        // A package that is not here is a 404, not an empty document a client
        // would read as "exists, has no versions".
        assert_eq!(json_of(&repo, "/composer/p2/acme/ghost.json").0, 404);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-composer",
            "p2_synthesized_from_bare_dists",
            status == 200,
            "p2 built from composer.json inside each dist: require, version_normalized, \
             absolute dist url that downloads, sha1 shasum over the served bytes, ~dev split",
        );
    }

    /// A curated archive that packed REAL Packagist metadata keeps serving it
    /// verbatim. Synthesis is the fallback, never a replacement: the upstream
    /// document carries `time`, `source` and upstream digests no synthesis can
    /// reconstruct.
    #[test]
    fn curated_p2_metadata_still_wins_over_synthesis() {
        let curated = br#"{"packages":{"acme/widget":[{"version":"0.0.1","curated":true}]}}"#;
        let reader = MemReader::new([
            ("dist/acme/widget/1.0.0.zip", dist("acme/widget", "")),
            ("p2/acme/widget.json", curated.to_vec()),
        ]);
        let repo = ComposerRepoZnippy::with_reader("composer".into(), reader);

        let (status, doc) = json_of(&repo, "/composer/p2/acme/widget.json");
        assert_eq!(status, 200);
        assert_eq!(doc["packages"]["acme/widget"][0]["curated"], true);
        assert_eq!(
            doc["packages"]["acme/widget"][0]["version"], "0.0.1",
            "the archive's own document is served verbatim, not the synthesized 1.0.0"
        );
    }

    /// ★ **The memo, counted.** A p2 document is an answer ABOUT the dists, so
    /// building one opens every dist zip. `composer install` asks for one p2
    /// document per required package, so per request that is O(archive) every
    /// time. Measured: four dists, twelve p2 requests. Without the memo that is
    /// 48 member reads; with it, 4 — and the dist arm pays 1 for its own bytes.
    #[test]
    fn the_composer_json_catalog_is_built_once_not_per_request() {
        let mem = MemReader::new([
            ("dist/acme/widget/1.0.0.zip", dist("acme/widget", "")),
            ("dist/acme/widget/1.2.0.zip", dist("acme/widget", "")),
            ("dist/acme/other/1.0.0.zip", dist("acme/other", "")),
            ("dist/acme/other/2.0.0.zip", dist("acme/other", "")),
        ]);
        let counting = CountingReader::new(mem);
        let repo = ComposerRepoZnippy::with_reader("composer".into(), counting.clone());

        assert_eq!(
            counting.member_reads(),
            0,
            "nothing is opened before the first request"
        );

        // The root is built from PATHS alone: it must not pay for the catalog.
        assert_eq!(json_of(&repo, "/composer/packages.json").0, 200);
        assert_eq!(
            counting.member_reads(),
            0,
            "packages.json lists the catalog from the file table, opening no dist"
        );

        for _ in 0..6 {
            assert_eq!(json_of(&repo, "/composer/p2/acme/widget.json").0, 200);
            assert_eq!(json_of(&repo, "/composer/p2/acme/other.json").0, 200);
        }
        assert_eq!(
            counting.member_reads(),
            4,
            "twelve p2 requests over a 4-dist archive must cost FOUR member reads \
             (one per dist, once), not 48"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-composer",
            "composer_json_catalog_built_once",
            counting.member_reads() == 4,
            "12 p2 requests over 4 dists -> 4 member reads (was 48); packages.json pays 0",
        );
    }

    /// One hostile dist must not make the whole repository unresolvable, and
    /// its bytes must stay downloadable.
    #[test]
    fn a_dist_that_is_not_a_readable_zip_is_skipped_not_fatal() {
        let reader = MemReader::new([
            ("dist/acme/widget/1.0.0.zip", dist("acme/widget", "")),
            (
                "dist/acme/junk/1.0.0.zip",
                b"PK\x03\x04 not really a zip".to_vec(),
            ),
            // A zip with no composer.json at all.
            (
                "dist/acme/empty/1.0.0.zip",
                zip_entry::fixture::zip(&[("src/x.php", b"<?php".as_slice())]),
            ),
        ]);
        let repo = ComposerRepoZnippy::with_reader("composer".into(), reader);

        assert_eq!(json_of(&repo, "/composer/p2/acme/widget.json").0, 200);
        assert_eq!(json_of(&repo, "/composer/p2/acme/junk.json").0, 404);
        assert_eq!(json_of(&repo, "/composer/p2/acme/empty.json").0, 404);

        let (s, _h, b) = repo
            .handle_http2_request("GET", "/composer/dist/acme/junk/1.0.0.zip", b"")
            .expect("request");
        assert_eq!(
            s, 200,
            "a manifest holger cannot parse does not hide the bytes"
        );
        assert_eq!(b, b"PK\x03\x04 not really a zip".to_vec());
    }

    /// 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(&ComposerRepoZnippy::new("r".into())).unwrap();
        traits::backend::conformance::check(&ComposerRepoZnippy::with_reader(
            "r".into(),
            std::sync::Arc::new(One),
        ))
        .unwrap();
    }
}