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

//! Conda repository backend: serves a real per-subdir `repodata.json` index plus
//! the packages, synthesized from a read-only znippy archive (sibling to the
//! deb/rpm/npm/gem backends). The coord-authoritative path resolves through the
//! archive's typed **conda view** ([`ZnippyArchive::as_conda`]) — name/version/
//! build/subdir come from each package's parsed `info/index.json`, not a filename
//! heuristic — so `repodata` and `package_via_view` serve classic
//! `{name}-{version}-{build}.tar.bz2` coords exactly. A `with_reader` fallback
//! has no view and serves by exact path only (empty `packages` map). Read-only:
//! `put`/`is_writable` refuse writes.
//!
//! Two invariants that bite: `repodata.json` is cached per-subdir behind a Mutex
//! because building it decompresses and re-hashes every package — without the
//! cache each request is a DoS-amplification vector (mamba hits repodata first).
//! The cache key is the subdir RESOLVED against the set the archive actually
//! carries ([`CondaRepoZnippy::known_subdirs`]); anything else 404s before a byte
//! of work. Keyed on the raw first path segment it was no cache at all — every
//! invented `/{subdir}/repodata.json` missed, re-hashed the whole channel, and
//! grew the map, so one unauthenticated client could loop on it.
//! And the `sha256` key MUST carry a bare 64-hex SHA-256 (conda/mamba abort on a
//! checksum mismatch); the znippy blake3 rides in a separate non-standard `blake3`
//! key, never in `sha256`.

use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};

use anyhow::Result;

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

/// znippy-backed Conda channel.
///
/// conda/mamba fetch the per-subdir index then the packages:
///   * `GET /{repo}/{subdir}/repodata.json`     → package index
///   * `GET /{repo}/{subdir}/{pkg}.tar.bz2`     → package (classic)
///   * `GET /{repo}/{subdir}/{pkg}.conda`       → package (new format)
///
/// When backed by a concrete [`ZnippyArchive`] the repo resolves through the typed
/// **conda view** ([`ZnippyArchive::as_conda`]): the package `name`, `version`,
/// `build`, and `subdir` are read from the index columns the plugin parsed out of
/// each package's `info/index.json`. The view is built once (cached behind the
/// archive's `OnceLock`) and reused.
///
/// **R4 fix:** `repodata.json` is now a REAL index built from the view's
/// authoritative coords — a `packages` map keyed by filename with
/// `{name, version, build, subdir, size, sha256}` per the conda repodata schema —
/// not the old dummy `{"packages":{},"packages.conda":{}}` stub. (See
/// [`CondaRepoZnippy::repodata`].)
pub struct CondaRepoZnippy {
    base: ZnippyBase,
    /// Per-subdir `repodata.json` cache (M8). The archive is immutable, so the
    /// index for a given subdir is computed once (decompress + blake3 over every
    /// package) and reused — without this each `repodata.json` request re-hashes
    /// the whole channel, a DoS-amplification vector (mamba hits it first).
    ///
    /// Keyed by a subdir that [`CondaRepoZnippy::known_subdirs`] has already
    /// accepted, so it cannot be grown (or bypassed) by inventing subdir names.
    repodata_cache: Mutex<HashMap<String, Arc<Vec<u8>>>>,
    /// The subdirs this channel actually serves — derived once from the archive
    /// (the typed view's `subdir` column plus the top-level directories), and the
    /// only keys `repodata_cache` can ever hold.
    subdirs_cache: OnceLock<BTreeSet<String>>,
}

impl CondaRepoZnippy {
    pub fn new(name: String) -> Self {
        Self {
            base: ZnippyBase::new(name),
            repodata_cache: Mutex::new(HashMap::new()),
            subdirs_cache: OnceLock::new(),
        }
    }

    /// Backed by a specific znippy archive file. Holds the open archive so the
    /// typed conda 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)?,
            repodata_cache: Mutex::new(HashMap::new()),
            subdirs_cache: OnceLock::new(),
        })
    }

    /// Backed by any ZnippyReader. No typed conda view; serves by exact path only.
    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self {
            base: ZnippyBase::with_reader(name, reader),
            repodata_cache: Mutex::new(HashMap::new()),
            subdirs_cache: OnceLock::new(),
        }
    }

    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)
    }

    /// The package filename for a coord. Classic conda packages compress to
    /// `{name}-{version}-{build}.tar.bz2`; that's the form this view serves.
    fn package_filename(name: &str, version: &str, build: &str) -> String {
        format!("{name}-{version}-{build}.tar.bz2")
    }

    /// The subdirs this channel serves: every `subdir` the typed view reports,
    /// every top-level directory in the archive, and always `noarch` (conda and
    /// mamba fetch `noarch/repodata.json` unconditionally, so a channel that
    /// carries no noarch package still owes them a valid empty index). Derived
    /// once — the archive is immutable — and the ONLY keys `repodata` will accept.
    fn known_subdirs(&self) -> &BTreeSet<String> {
        self.subdirs_cache.get_or_init(|| {
            let mut out = BTreeSet::new();
            out.insert("noarch".to_string());
            if let Some(archive) = self.base.typed() {
                if let Some(conda) = archive.as_conda() {
                    for (_n, _v, _b, subdir) in conda.list() {
                        if !subdir.is_empty() {
                            out.insert(subdir);
                        }
                    }
                }
            }
            for path in self.list_files() {
                if let Some((top, _rest)) = path.split_once('/') {
                    if !top.is_empty() {
                        out.insert(top.to_string());
                    }
                }
            }
            out
        })
    }

    /// Cached `repodata.json` for `subdir` (M8). Computes once per subdir and
    /// serves clones thereafter.
    ///
    /// `None` for a subdir this channel does not carry — the caller 404s it. That
    /// resolution is what bounds the cache: the key is one of the archive's own
    /// subdirs, never whatever first path segment happened to arrive.
    fn repodata(&self, subdir: &str) -> Option<Arc<Vec<u8>>> {
        if !self.known_subdirs().contains(subdir) {
            return None;
        }
        if let Ok(cache) = self.repodata_cache.lock() {
            if let Some(hit) = cache.get(subdir) {
                return Some(Arc::clone(hit));
            }
        }
        let built = Arc::new(self.build_repodata(subdir));
        if let Ok(mut cache) = self.repodata_cache.lock() {
            cache.insert(subdir.to_string(), Arc::clone(&built));
        }
        Some(built)
    }

    /// Build the conda **repodata.json** for `subdir` from the view's authoritative
    /// coords (R4). Shape (conda repodata schema):
    ///
    /// ```json
    /// {
    ///   "info": {"subdir": "linux-64"},
    ///   "packages": {
    ///     "numpy-1.26.0-py311h1234567_0.tar.bz2": {
    ///       "name": "numpy", "version": "1.26.0", "build": "py311h1234567_0",
    ///       "subdir": "linux-64", "size": 12345, "sha256": "blake3:…"
    ///     }
    ///   },
    ///   "packages.conda": {}
    /// }
    /// ```
    ///
    /// `size` is the uncompressed size from the index. A real channel publishes the
    /// package's SHA-256; with no sha column in the index we publish the znippy
    /// **blake3** of the served bytes, prefixed `blake3:` so the value is
    /// unambiguous and verifiable against the served bytes (documented divergence).
    fn build_repodata(&self, subdir: &str) -> Vec<u8> {
        // Built with `serde_json` so the (attacker-controlled) name/version/build/
        // subdir from `info/index.json` are JSON-escaped, never interpolated raw.
        let mut packages = serde_json::Map::new();
        if let Some(archive) = self.base.typed() {
            if let Some(conda) = archive.as_conda() {
                for (name, version, build, pkg_subdir) in conda.list() {
                    // Filter to the requested subdir; treat an empty stored subdir
                    // (older/unknown) as matching any request.
                    if !pkg_subdir.is_empty() && pkg_subdir != subdir {
                        continue;
                    }
                    let Some(pkg) = conda.get_exact(&name, &version, &build, &pkg_subdir) else {
                        continue;
                    };
                    let filename = Self::package_filename(&name, &version, &build);
                    let size = pkg.size();
                    // The `sha256` key MUST carry a real SHA-256 (bare 64-hex):
                    // conda/mamba abort on a checksum mismatch. blake3 is kept
                    // under a separate non-standard key for content-addressing.
                    let bytes = pkg.bytes().ok();
                    // REUSE-law: the ONE shared content-hash primitive (edda
                    // `nornir_hash`) — conda/mamba abort on a `sha256` mismatch, so
                    // this must be the byte-identical bare 64-hex of the served bytes.
                    let sha256 = bytes
                        .as_ref()
                        .map(|b| nornir_hash::sha256_hex(b))
                        .unwrap_or_default();
                    let blake3_hex = bytes
                        .as_ref()
                        .map(|b| blake3::hash(b).to_hex().to_string())
                        .unwrap_or_default();
                    let effective_subdir = if pkg_subdir.is_empty() {
                        subdir
                    } else {
                        pkg_subdir.as_str()
                    };
                    packages.insert(
                        filename,
                        serde_json::json!({
                            "name": name,
                            "version": version,
                            "build": build,
                            "subdir": effective_subdir,
                            "size": size,
                            "sha256": sha256,
                            "blake3": blake3_hex,
                        }),
                    );
                }
            }
        }
        // serde_json's `Map` is key-sorted (no `preserve_order` feature), so the
        // packages stay in filename order — and every value is escaped.
        let doc = serde_json::json!({
            "info": { "subdir": subdir },
            "packages": packages,
            "packages.conda": {},
        });
        serde_json::to_vec(&doc).unwrap_or_default()
    }

    /// Resolve a package request via the view: `subdir` + `filename`
    /// (`{name}-{version}-{build}.tar.bz2`). Matches against the view's
    /// authoritative coords (so the build string resolves without a filename
    /// heuristic). `None` if not a `.tar.bz2` coord in the view.
    fn package_via_view(&self, subdir: &str, filename: &str) -> Option<Vec<u8>> {
        let archive = self.base.typed()?;
        let conda = archive.as_conda()?;
        for (name, version, build, pkg_subdir) in conda.list() {
            if !pkg_subdir.is_empty() && pkg_subdir != subdir {
                continue;
            }
            if Self::package_filename(&name, &version, &build) == filename {
                return conda
                    .get_exact(&name, &version, &build, &pkg_subdir)
                    .and_then(|p| p.into_bytes().ok());
            }
        }
        None
    }
}

fn content_type_for(path: &str) -> &'static str {
    if path.ends_with(".json") {
        "application/json"
    } else if path.ends_with(".tar.bz2") {
        "application/x-bzip2"
    } else {
        "application/octet-stream"
    }
}

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

    /// A package download in pieces (lane HOL-RSS part 2): the archive file the whole-body
    /// door reads (the view's, else the exact path). `repodata.json` 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 parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
        if remainder.is_empty() || matches!(parts.as_slice(), [_, "repodata.json"]) {
            return Ok(None);
        }
        // the view's package first, then the exact path, as the whole-body door reads them
        if let [subdir, file] = parts.as_slice() {
            let via_view = self
                .base
                .typed()
                .and_then(|a| a.as_conda())
                .and_then(|conda| {
                    conda
                        .list()
                        .into_iter()
                        .filter(|(_, _, _, s)| s.is_empty() || s == subdir)
                        .find(|(n, v, b, _)| Self::package_filename(n, v, b) == *file)
                        .and_then(|(n, v, b, s)| conda.get_exact(&n, &v, &b, &s))
                        .map(|pkg| pkg.relative_path().to_string())
                });
            if let Some(rel) = via_view {
                return self
                    .base
                    .stream_get(&rel, content_type_for(file), piece_bytes);
            }
        }
        self.base
            .stream_get(remainder, content_type_for(remainder), 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!("conda repo znippy handle_http2_request.suburl={}", suburl);

        let remainder = self.base.strip_repo(suburl);
        if remainder.is_empty() {
            return Ok(not_found("Not found"));
        }

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

        // `{subdir}/repodata.json` → REAL index built from the view (R4), but only
        // for a subdir the channel actually carries; an invented one 404s here,
        // before any decompress/hash work and without touching the cache.
        if let [subdir, "repodata.json"] = parts.as_slice() {
            return match self.repodata(subdir) {
                Some(body) => Ok((
                    200,
                    vec![("Content-Type".into(), "application/json".into())],
                    body.as_ref().clone(),
                )),
                None => Ok(not_found("Unknown subdir")),
            };
        }

        // `{subdir}/{pkg}.tar.bz2` → view-driven serve, then exact-path fallback.
        if let [subdir, file] = parts.as_slice() {
            if let Some(data) = self.package_via_view(subdir, file) {
                return Ok(ok(content_type_for(file), data));
            }
        }

        match self.get_file(remainder) {
            Ok(data) => Ok(ok(content_type_for(remainder), data)),
            Err(_) => Ok(not_found("Not found in archive")),
        }
    }

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

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        // Resolve through the typed conda view: name/version come from the index
        // columns (info/index.json). The subdir rides in `id.namespace`
        // (e.g. "linux-64"); default "noarch". `get` returns the first build match.
        if let Some(archive) = self.base.typed() {
            if let Some(conda) = archive.as_conda() {
                let subdir = id.namespace.as_deref().filter(|s| !s.is_empty());
                let pkg = match subdir {
                    Some(s) => conda
                        .list()
                        .into_iter()
                        .find(|(n, v, _b, sd)| n == &id.name && v == &id.version && sd == s)
                        .and_then(|(n, v, b, sd)| conda.get_exact(&n, &v, &b, &sd)),
                    None => conda.get(&id.name, &id.version),
                };
                if let Some(pkg) = pkg {
                    return Ok(Some(traits::Bytes::from(pkg.into_bytes()?)));
                }
                return Ok(None);
            }
        }

        // Fallback for non-archive readers: legacy filename path build.
        let subdir = id.namespace.as_deref().unwrap_or("noarch");
        for ext in [".conda", ".tar.bz2"] {
            let path = format!("{}/{}-{}{}", subdir, id.name, id.version, ext);
            if let Ok(data) = self.get_file(&path) {
                return Ok(Some(traits::Bytes::from(data)));
            }
        }
        Ok(None)
    }

    /// List packages in the archive via the view — authoritative `(name, version,
    /// build, subdir)` coords. The subdir rides in the entry's `namespace`. Empty
    /// when no archive configured.
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        if let Some(archive) = self.base.typed() {
            if let Some(conda) = archive.as_conda() {
                // 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();
                let mut out = Vec::new();
                for (name, version, build, subdir) in conda.list() {
                    if let Some(f) = name_filter {
                        if !name.contains(f) {
                            continue;
                        }
                    }
                    // 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) = conda
                        .get_exact(&name, &version, &build, &subdir)
                        .map(|p| {
                            let token = tokens.as_ref().and_then(|t| t.token(p.relative_path()));
                            (p.size() as i64, token)
                        })
                        .unwrap_or((0, None));
                    let namespace = if subdir.is_empty() {
                        None
                    } else {
                        Some(subdir)
                    };
                    out.push(
                        traits::ArtifactEntry {
                            id: ArtifactId {
                                namespace,
                                name,
                                version,
                            },
                            size_bytes,
                            content_type: "application/x-bzip2".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;
                    }
                }
                return Ok(out);
            }
        }
        Ok(Vec::new())
    }
}

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

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

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

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

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

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

    /// The `repodata.json` cache must be keyed on a subdir the channel actually
    /// carries — not on whatever first path segment arrived. Keyed on the raw
    /// segment, `GET /{repo}/{anything}/repodata.json` was a guaranteed cache MISS
    /// that rebuilt the index (decompress + hash every package) and then GREW the
    /// map, so one unauthenticated client could both burn CPU and grow the heap
    /// without bound. Unknown subdirs must 404 and leave the cache alone.
    #[test]
    fn repodata_subdir_is_resolved_against_the_archive() {
        let repo = CondaRepoZnippy::with_reader(
            "conda-test".to_string(),
            MemReader::new([("linux-64/numpy-1.26.0.tar.bz2", b"PAYLOAD".to_vec())]),
        );

        // The subdir the archive carries resolves.
        let (ok_status, _h, ok_body) = repo
            .handle_http2_request("GET", "/conda-test/linux-64/repodata.json", &[])
            .expect("serve ok");
        assert_eq!(ok_status, 200, "a real subdir must still serve");
        let parsed: serde_json::Value = serde_json::from_slice(&ok_body).expect("json");
        assert_eq!(parsed["info"]["subdir"], serde_json::json!("linux-64"));

        // `noarch` is always served (conda/mamba fetch it unconditionally) even
        // though this channel carries no noarch package.
        let (noarch_status, _h, _b) = repo
            .handle_http2_request("GET", "/conda-test/noarch/repodata.json", &[])
            .expect("serve ok");
        assert_eq!(noarch_status, 200, "noarch must always resolve");

        // The attack: a fresh subdir name every time.
        let mut invented_statuses = Vec::new();
        for i in 0..25 {
            let (s, _h, _b) = repo
                .handle_http2_request("GET", &format!("/conda-test/subdir-{i}/repodata.json"), &[])
                .expect("serve ok");
            invented_statuses.push(s);
        }
        assert!(
            invented_statuses.iter().all(|s| *s == 404),
            "every invented subdir must 404, got {invented_statuses:?}"
        );

        // …and the cache holds only the real subdirs that were actually asked for.
        let cached = repo.repodata_cache.lock().unwrap();
        assert!(
            cached.len() <= 2 && cached.keys().all(|k| k == "linux-64" || k == "noarch"),
            "cache must be bounded by the archive's own subdirs, got {:?}",
            cached.keys().collect::<Vec<_>>()
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-conda",
            "repodata_subdir_resolved",
            invented_statuses.iter().all(|s| *s == 404) && cached.len() <= 2,
            &format!(
                "25 invented subdirs -> 404, cache bounded to {} real subdir(s)",
                cached.len()
            ),
        );
    }

    #[test]
    fn package_filename_is_name_version_build_tar_bz2() {
        assert_eq!(
            CondaRepoZnippy::package_filename("numpy", "1.26.0", "py311h1234567_0"),
            "numpy-1.26.0-py311h1234567_0.tar.bz2"
        );
    }

    /// End-to-end serve/fetch over a `with_reader`-backed repo (generic reader
    /// fallback, no typed conda view). Injects one classic package at the exact
    /// archive key the reader-fallback `fetch()` builds
    /// (`{subdir}/{name}-{version}.tar.bz2`) and the path `get_file()`/
    /// `handle_http2_request` resolve, then asserts: `fetch()` hit/miss, the
    /// HTTP/2 package serve (200 + `application/x-bzip2` + bytes) and miss (404),
    /// and the real `repodata.json` route (200 + `application/json`, echoing the
    /// requested subdir — the packages map is empty here because the generic
    /// reader carries no authoritative coord view).
    #[test]
    fn serves_injected_archive() {
        // Exact key the reader-fallback fetch() builds and the HTTP path resolves.
        const KEY: &str = "linux-64/numpy-1.26.0.tar.bz2";
        let pkg_bytes = b"CONDA-NUMPY-1.26.0-PAYLOAD".to_vec();
        let repo = CondaRepoZnippy::with_reader(
            "conda-test".to_string(),
            MemReader::new([(KEY, pkg_bytes.clone())]),
        );

        // --- fetch(): hit resolves name+version+subdir → injected bytes.
        let hit_id = ArtifactId {
            namespace: Some("linux-64".to_string()),
            name: "numpy".to_string(),
            version: "1.26.0".to_string(),
        };
        let fetched = repo.fetch(&hit_id).expect("fetch ok");
        assert_eq!(fetched.as_deref(), Some(pkg_bytes.as_slice()));

        // --- fetch(): miss returns None.
        let miss_id = ArtifactId {
            namespace: Some("linux-64".to_string()),
            name: "nope".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(): package hit → 200 + content-type + bytes.
        let (status, headers, body) = repo
            .handle_http2_request("GET", "/conda-test/linux-64/numpy-1.26.0.tar.bz2", &[])
            .expect("serve ok");
        let ctype = headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
            .map(|(_, v)| v.as_str());
        assert_eq!(status, 200);
        assert_eq!(ctype, Some("application/x-bzip2"));
        assert_eq!(body, pkg_bytes);

        // --- handle_http2_request(): package miss → 404.
        let (miss_status, _miss_headers, _miss_body) = repo
            .handle_http2_request("GET", "/conda-test/linux-64/absent-0.0.0.tar.bz2", &[])
            .expect("serve ok");
        assert_eq!(miss_status, 404);

        // --- handle_http2_request(): real repodata.json route → 200 + JSON.
        let (rd_status, rd_headers, rd_body) = repo
            .handle_http2_request("GET", "/conda-test/linux-64/repodata.json", &[])
            .expect("serve ok");
        let rd_ctype = rd_headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
            .map(|(_, v)| v.as_str());
        assert_eq!(rd_status, 200);
        assert_eq!(rd_ctype, Some("application/json"));
        let rd_json: serde_json::Value =
            serde_json::from_slice(&rd_body).expect("repodata is valid json");
        assert_eq!(rd_json["info"]["subdir"], serde_json::json!("linux-64"));
        assert!(rd_json["packages"].is_object());

        #[cfg(feature = "testmatrix")]
        {
            fstatus(
                "znippy-conda",
                "fetch_injected_package",
                fetched.as_deref() == Some(pkg_bytes.as_slice()) && fetched_miss.is_none(),
                &format!("key={KEY} hit_bytes={} miss=None", pkg_bytes.len()),
            );
            fstatus(
                "znippy-conda",
                "serve_injected_package",
                status == 200 && ctype == Some("application/x-bzip2") && body == pkg_bytes,
                &format!("status={status} ctype={ctype:?} bytes={}", body.len()),
            );
            fstatus(
                "znippy-conda",
                "serve_missing_package_404",
                miss_status == 404,
                &format!("status={miss_status}"),
            );
            fstatus(
                "znippy-conda",
                "serve_repodata_json",
                rd_status == 200
                    && rd_ctype == Some("application/json")
                    && rd_json["info"]["subdir"] == serde_json::json!("linux-64"),
                &format!(
                    "status={rd_status} ctype={rd_ctype:?} bytes={}",
                    rd_body.len()
                ),
            );
        }
    }

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