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

//! NuGet (.NET) repository backend: serves the NuGet V3 flat-container protocol
//! synthesized from `.nupkg` files inside a read-only znippy archive. Sibling to
//! the other `znippy-<eco>` backends (npm packument, maven metadata, rpm repomd),
//! implementing `RepositoryBackendTrait` over a `ZnippyReader`.
//!
//! Flow: `handle_http2_request` routes the three V3 endpoints — service index,
//! `v3-flatcontainer/{id}/index.json` version list, and the `.nupkg` fetch — off
//! the archive layout `{id-lowercased}/{ver}/{id}.{ver}.nupkg`. Ids are lowercased
//! per the flat-container convention; `versions_json` scans the archive and
//! sorts+dedups. `base_url` (from `ron_base_url`) MUST be the externally-reachable
//! holger base, since NuGet follows the absolute `PackageBaseAddress` `@id` for
//! every subsequent fetch — leak the loopback default and remote clients break.
//!
//! **Finished 2026-09-20 (?23).** Until then the service index advertised ONLY
//! `PackageBaseAddress`, so a client could ask for "`Thing` at exactly 1.2.3"
//! and nothing else: `dotnet restore` of any project with a dependency graph or
//! a version RANGE had nowhere to look and failed. The backend now also serves
//! `RegistrationsBaseUrl` and `SearchQueryService`, synthesized — like deb's
//! `Release` and rpm's `repomd` — from the packages actually present: the
//! `.nuspec` is read out of each `.nupkg` (a zip; see `holger-zip-entry`),
//! parsed by `nuspec.rs`, and turned into registration documents and search
//! hits by `registration.rs`.
//!
//! Cost, stated because it is the one thing that could surprise an operator:
//! the catalog is built ONCE, lazily, on the first registration or search
//! request, and reading it means opening every `.nupkg` in the archive for its
//! manifest. That fan-out runs on `znippy_zoomies::gatling` (ROOT LAW #0 — never
//! rayon), weighted by compressed size so the big packages start first, and the
//! result is held in a `OnceLock` for the process lifetime exactly like `paths`
//! below. The flat-container arms never touch it, so an exact-version pull is
//! as cheap as it ever was.

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

use anyhow::Result;

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

pub mod nuspec;
pub mod registration;

use registration::PackageVersion;

/// The catalog: lowercased package id → its versions, ascending.
type Catalog = BTreeMap<String, Vec<PackageVersion>>;

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

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

/// znippy-backed NuGet (.NET) repository implementing the NuGet V3 protocol.
///
///   * `GET /{repo}/v3/index.json`                                   → service index
///   * `GET /{repo}/v3-flatcontainer/{id}/index.json`               → version list
///   * `GET /{repo}/v3-flatcontainer/{id}/{ver}/{id}.{ver}.nupkg`   → package
///   * `GET /{repo}/v3/registration/{id}/index.json`                → registration
///   * `GET /{repo}/v3/registration/{id}/page/{lo}/{hi}.json`       → one page
///   * `GET /{repo}/v3/registration/{id}/{version}.json`            → one leaf
///   * `GET /{repo}/v3/search?q=&skip=&take=&prerelease=`           → search
///
/// Packages are stored in the archive as `{id}/{ver}/{id}.{ver}.nupkg`
/// (ids lowercased, per the flat-container convention).
pub struct NugetRepoZnippy {
    base: ZnippyBase,
    /// The archive's file table, taken ONCE.
    ///
    /// `ZnippyArchive::list_files()` is `file_index.keys().cloned().collect()`
    /// — one String allocation per file in the archive, every call. The index
    /// endpoint below scans the whole table per request, so a 50 000-file
    /// archive allocated 50 000 Strings to answer one index request, and again
    /// for the next. This backend is READ-ONLY (`put` errors, `is_writable` is
    /// false) and the reader `Arc` is opened at boot and held for the process
    /// lifetime, so the table cannot change underneath: taking it once adds no
    /// staleness the held handle did not already have. Same shape deb, rpm,
    /// python and znippy-rust already use for their synthesized indexes.
    paths: OnceLock<Vec<String>>,
    /// Public base URL advertised in the V3 service index `PackageBaseAddress`
    /// `@id`. NuGet requires an **absolute** URL here and follows it for every
    /// subsequent version/package fetch, so it must be the externally-reachable
    /// holger base (set from `ron_base_url`), not a hardcoded loopback —
    /// otherwise remote clients are sent to `127.0.0.1` and NuGet breaks.
    /// Defaults to the dev loopback when unconfigured.
    base_url: String,
    /// **The parsed `.nuspec` of every package in the archive, taken ONCE.**
    ///
    /// The registration and search resources are answers ABOUT the packages,
    /// not the packages themselves, so building one means opening every
    /// `.nupkg` in the archive and reading the manifest out of it. Doing that
    /// per request would make `GET /v3/search` an O(archive) read — the exact
    /// defect the `paths` `OnceLock` above exists to prevent, one layer up.
    ///
    /// Same argument for holding it: this backend is READ-ONLY and the reader
    /// `Arc` is opened at boot and held for the process lifetime, so the
    /// packages cannot change underneath. Same shape as deb's `Release`
    /// `OnceLock` and conda's repodata cache.
    catalog: OnceLock<Catalog>,
}

const DEFAULT_BASE_URL: &str = "https://127.0.0.1:8443";

impl NugetRepoZnippy {
    fn from_base(base: ZnippyBase) -> Self {
        Self {
            base,
            paths: OnceLock::new(),
            base_url: DEFAULT_BASE_URL.to_string(),
            catalog: OnceLock::new(),
        }
    }

    pub fn new(name: String) -> Self {
        Self::from_base(ZnippyBase::new(name))
    }

    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        Ok(Self::from_base(ZnippyBase::with_archive(
            name,
            archive_path,
        )?))
    }

    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self::from_base(ZnippyBase::with_reader(name, reader))
    }

    /// Override the public base URL advertised in the V3 service index. An empty
    /// string resets to the default loopback. A trailing slash is normalized.
    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(|| self.base.list_files())
    }

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

    fn versions_json(&self, id: &str) -> Option<Vec<u8>> {
        let prefix = format!("{}/", id.to_lowercase());
        let mut versions: Vec<&str> = Vec::new();
        for f in self.paths() {
            if let Some(rest) = f.strip_prefix(&prefix) {
                if let Some(slash) = rest.find('/') {
                    versions.push(&rest[..slash]);
                }
            }
        }
        versions.sort_unstable();
        versions.dedup();
        if versions.is_empty() {
            return None;
        }
        // Built with serde_json (M10): version strings come from archive paths
        // and must not be interpolated raw into the JSON body.
        let doc = serde_json::json!({ "versions": versions });
        Some(serde_json::to_vec(&doc).unwrap_or_else(|_| b"{\"versions\":[]}".to_vec()))
    }

    /// The `{id}/{version}/{file}.nupkg` coordinates the archive holds.
    fn nupkg_coords(&self) -> Vec<NupkgCoord> {
        let mut out = Vec::new();
        for path in self.paths() {
            let mut seg = path.split('/');
            let (Some(id), Some(version), Some(file), None) =
                (seg.next(), seg.next(), seg.next(), seg.next())
            else {
                continue;
            };
            if id.is_empty() || version.is_empty() || !file.ends_with(".nupkg") {
                continue;
            }
            out.push(NupkgCoord {
                path: path.clone(),
                id_lower: id.to_lowercase(),
                version: version.to_string(),
                file: file.to_string(),
            });
        }
        out
    }

    /// Open every `.nupkg` and read its `.nuspec`. Called ONCE; see `catalog`.
    ///
    /// A package whose manifest is missing, over-cap, forged (CRC) or
    /// unparseable is **skipped and logged**, never fatal — one hostile
    /// `.nupkg` must not blind the whole repository, which is the same ruling
    /// `znippy-helm` makes about one bomb chart in an `index.yaml` build.
    fn build_catalog(&self) -> Catalog {
        let Some(reader) = self.base.reader() else {
            return Catalog::new();
        };
        let coords = self.nupkg_coords();
        if coords.is_empty() {
            return Catalog::new();
        }

        // ROOT LAW #0: the fan-out is `gatling`, never rayon. One unit per
        // package, weighted by the archive's own stored size so the biggest
        // `.nupkg`s are dealt first and the tail does not stall a worker. At most
        // CATALOG_WORKERS at once (lane HOL-RSS-3): each holds a zip's directory and
        // its `.nuspec`, never the package.
        let parsed: Vec<Option<(String, PackageVersion)>> =
            znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
                &coords,
                catalog_workers(),
                1,
                |c: &NupkgCoord| reader.file_size(&c.path).unwrap_or(0),
                |_i, c: &NupkgCoord| {
                    let found = match self.read_nuspec(reader.as_ref(), &c.path) {
                        Ok(Some((_name, b))) => b,
                        Ok(None) => {
                            log::warn!("nuget catalog: no root .nuspec in {}", c.path);
                            return None;
                        }
                        Err(e) => {
                            log::warn!("nuget catalog: refused {} : {e}", c.path);
                            return None;
                        }
                    };
                    let text = String::from_utf8_lossy(&found).into_owned();
                    match nuspec::parse_nuspec(&text) {
                        Ok(ns) => Some((
                            c.id_lower.clone(),
                            PackageVersion {
                                nuspec: ns,
                                path_version: c.version.clone(),
                                file: c.file.clone(),
                            },
                        )),
                        Err(e) => {
                            log::warn!("nuget catalog: bad .nuspec in {}: {e}", c.path);
                            None
                        }
                    }
                },
            );

        let mut out = Catalog::new();
        for (id_lower, pv) in parsed.into_iter().flatten() {
            out.entry(id_lower).or_default().push(pv);
        }
        for versions in out.values_mut() {
            registration::sort_versions(versions);
        }
        out
    }

    /// The root `.nuspec` of the `.nupkg` at `rel` (lane HOL-RSS-3): read where it lies
    /// in the archive — the zip's end, its central directory and the one member — never
    /// the package whole. A backend over a plain reader reads the package whole.
    fn read_nuspec(
        &self,
        reader: &dyn ZnippyReader,
        rel: &str,
    ) -> Result<Option<(String, Vec<u8>)>> {
        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));
            return zip_entry::find_member_at(
                &mut src,
                zip_entry::MAX_MEMBER_DECOMPRESSED_BYTES,
                nuspec::is_root_nuspec,
            );
        }
        let bytes = reader.extract_file(rel)?;
        zip_entry::find_member(&bytes, nuspec::is_root_nuspec)
    }

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

    /// The V3 service index. Advertises exactly the resources this backend
    /// actually serves — which is now all three a `dotnet restore` needs.
    fn service_index(&self) -> Vec<u8> {
        let flat = format!("{}/{}/v3-flatcontainer/", self.base_url, self.base.name());
        let reg = format!("{}/{}/v3/registration/", self.base_url, self.base.name());
        let search = format!("{}/{}/v3/search", self.base_url, self.base.name());
        // The `@type` versions are the CLIENT's feature negotiation: a client
        // takes the highest it understands. `/3.6.0` and `/3.5.0` say SemVer
        // 2.0.0 versions are served, which they are — `compare_versions`
        // implements the full prerelease ordering. The older types are listed
        // beside them so an old client is not left with nothing.
        let doc = serde_json::json!({
            "version": "3.0.0",
            "resources": [
                { "@id": flat, "@type": "PackageBaseAddress/3.0.0" },
                { "@id": reg, "@type": "RegistrationsBaseUrl" },
                { "@id": reg, "@type": "RegistrationsBaseUrl/3.0.0-rc" },
                { "@id": reg, "@type": "RegistrationsBaseUrl/3.4.0" },
                { "@id": reg, "@type": "RegistrationsBaseUrl/3.6.0" },
                // ★ MEASURED against the real client, 2026-09-20 (`dotnet package
                // search`, NuGet 6.11.2 in mcr.microsoft.com/dotnet/sdk:8.0): with
                // only the three types below it answered "The source does not have
                // a Search service!". NuGet.Client's `ServiceTypes.SearchQueryService`
                // is `{ "SearchQueryService/Versioned", "SearchQueryService/3.4.0",
                // "SearchQueryService/3.0.0-beta" }` — it never looks for the bare
                // type, `/3.0.0-rc` or `/3.5.0`, which nuget.org lists BESIDE
                // `/3.0.0-beta` and which the spec text alone made look sufficient.
                // `/3.0.0-beta` is the one the client walks to; the rest stay for
                // other readers of the index.
                { "@id": search, "@type": "SearchQueryService" },
                { "@id": search, "@type": "SearchQueryService/3.0.0-beta" },
                { "@id": search, "@type": "SearchQueryService/3.0.0-rc" },
                { "@id": search, "@type": "SearchQueryService/3.5.0" },
            ],
        });
        serde_json::to_vec(&doc).unwrap_or_else(|_| b"{\"version\":\"3.0.0\"}".to_vec())
    }

    /// Answer `…/v3/search?…`.
    fn search(&self, query: &str) -> Vec<u8> {
        let q = query_param(query, "q").unwrap_or_default();
        let skip = query_param(query, "skip")
            .and_then(|v| v.parse().ok())
            .unwrap_or(0usize);
        // NuGet's own default page is 20; the cap keeps a `take=1000000` from
        // building a million-hit document out of one request.
        let take = query_param(query, "take")
            .and_then(|v| v.parse().ok())
            .unwrap_or(20usize)
            .min(MAX_SEARCH_TAKE);
        let prerelease = query_param(query, "prerelease")
            .map(|v| v.eq_ignore_ascii_case("true"))
            .unwrap_or(false);
        registration::search_response(
            &self.base_url,
            self.base.name(),
            self.catalog().iter().map(|(k, v)| (k.as_str(), v)),
            &q,
            skip,
            take,
            prerelease,
        )
    }
}

/// One `.nupkg` as the archive path spells it.
struct NupkgCoord {
    path: String,
    id_lower: String,
    version: String,
    file: String,
}

/// Ceiling on a single search page. See [`NugetRepoZnippy::search`].
const MAX_SEARCH_TAKE: usize = 1000;

/// One query-string parameter, percent- and `+`-decoded.
///
/// Hand-rolled for the same reason the `.nuspec` parser is: three parameters
/// off a query string is not worth a dependency in an airgap bundle.
fn query_param(query: &str, key: &str) -> Option<String> {
    for pair in query.split('&') {
        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
        if k.eq_ignore_ascii_case(key) {
            return Some(percent_decode(v));
        }
    }
    None
}

fn percent_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => {
                out.push(b' ');
                i += 1;
            }
            b'%' if i + 2 < b.len() => {
                match u8::from_str_radix(&s[i + 1..i + 3], 16) {
                    Ok(v) => {
                        out.push(v);
                        i += 3;
                    }
                    // Not an escape after all: a literal `%`.
                    Err(_) => {
                        out.push(b'%');
                        i += 1;
                    }
                }
            }
            c => {
                out.push(c);
                i += 1;
            }
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

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

    /// A package download in pieces (lane HOL-RSS part 2): `{id}/{ver}/{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 (route, _query) = remainder.split_once('?').unwrap_or((remainder, ""));
        let parts: Vec<&str> = route.split('/').filter(|s| !s.is_empty()).collect();
        let ["v3-flatcontainer", id, ver, file] = parts.as_slice() else {
            return Ok(None);
        };
        self.base.stream_get(
            &format!("{}/{}/{}", id.to_lowercase(), ver, file),
            "application/octet-stream",
            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!("nuget repo znippy handle_http2_request.suburl={}", suburl);

        let remainder = self.base.strip_repo(suburl);
        // `handle_http2_request` is handed path AND query (`http.rs` passes
        // `path_and_query`), and `/v3/search` is the one V3 resource whose
        // whole contract lives in the query string.
        let (route, query) = remainder.split_once('?').unwrap_or((remainder, ""));
        let parts: Vec<&str> = route.split('/').filter(|s| !s.is_empty()).collect();

        match parts.as_slice() {
            ["v3", "index.json"] => Ok((
                200,
                vec![("Content-Type".into(), "application/json".into())],
                self.service_index(),
            )),
            // ── The registration resource: what makes a version RANGE and a
            // dependency GRAPH resolvable. See registration.rs. ──
            ["v3", "registration", id, "index.json"] => {
                let id_lower = id.to_lowercase();
                match self.catalog().get(&id_lower) {
                    Some(v) if !v.is_empty() => Ok((
                        200,
                        vec![("Content-Type".into(), "application/json".into())],
                        registration::registration_index(
                            &self.base_url,
                            self.base.name(),
                            &id_lower,
                            v,
                        ),
                    )),
                    _ => Ok((404, Vec::new(), b"Package not found".to_vec())),
                }
            }
            ["v3", "registration", id, "page", lower, upper] => {
                let id_lower = id.to_lowercase();
                let upper = upper.strip_suffix(".json").unwrap_or(upper);
                match self.catalog().get(&id_lower).and_then(|v| {
                    registration::registration_page(
                        &self.base_url,
                        self.base.name(),
                        &id_lower,
                        v,
                        lower,
                        upper,
                    )
                }) {
                    Some(body) => Ok((
                        200,
                        vec![("Content-Type".into(), "application/json".into())],
                        body,
                    )),
                    None => Ok((404, Vec::new(), b"Registration page not found".to_vec())),
                }
            }
            // A leaf. Must come AFTER the `index.json` arm, which it would
            // otherwise swallow.
            ["v3", "registration", id, version] if version.ends_with(".json") => {
                let id_lower = id.to_lowercase();
                let version = version.strip_suffix(".json").unwrap_or(version);
                match self.catalog().get(&id_lower).and_then(|v| {
                    registration::registration_leaf(
                        &self.base_url,
                        self.base.name(),
                        &id_lower,
                        v,
                        version,
                    )
                }) {
                    Some(body) => Ok((
                        200,
                        vec![("Content-Type".into(), "application/json".into())],
                        body,
                    )),
                    None => Ok((404, Vec::new(), b"Version not found".to_vec())),
                }
            }
            ["v3", "search"] => Ok((
                200,
                vec![("Content-Type".into(), "application/json".into())],
                self.search(query),
            )),
            ["v3-flatcontainer", id, "index.json"] => match self.versions_json(id) {
                Some(json) => Ok((
                    200,
                    vec![("Content-Type".into(), "application/json".into())],
                    json,
                )),
                None => Ok((404, Vec::new(), b"Package not found".to_vec())),
            },
            ["v3-flatcontainer", id, ver, file] => {
                let archive_path = format!("{}/{}/{}", id.to_lowercase(), ver, file);
                match self.get_file(&archive_path) {
                    Ok(data) => Ok((
                        200,
                        vec![("Content-Type".into(), "application/octet-stream".into())],
                        data,
                    )),
                    Err(_) => Ok((404, Vec::new(), b"Package not found in archive".to_vec())),
                }
            }
            _ => Ok((404, Vec::new(), b"Not found".to_vec())),
        }
    }

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

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        let lower = id.name.to_lowercase();
        let path = format!("{l}/{v}/{l}.{v}.nupkg", l = lower, v = id.version);
        match self.get_file(&path) {
            Ok(data) => Ok(Some(traits::Bytes::from(data))),
            Err(_) => Ok(None),
        }
    }

    /// Package coordinates from the archive's `{id}/{ver}/{id}.{ver}.nupkg`
    /// 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.
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        self.base
            .list_entries(name_filter, limit, "application/octet-stream", |path| {
                let mut seg = path.split('/');
                let id = seg.next()?;
                let version = seg.next()?;
                let file = seg.next()?;
                if seg.next().is_some() || !file.ends_with(".nupkg") {
                    return None;
                }
                if id.is_empty() || version.is_empty() {
                    return None;
                }
                Some(ArtifactId {
                    namespace: None,
                    name: id.to_string(),
                    version: version.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 = NugetRepoZnippy::new("nuget-test".to_string());
        assert_eq!(repo.name(), "nuget-test");
        assert!(!repo.is_writable());

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

    /// The V3 service index must advertise the configured external base URL in
    /// PackageBaseAddress — never the hardcoded `127.0.0.1:8443` loopback, which
    /// breaks every non-local NuGet client. Also confirms the default and the
    /// trailing-slash normalization.
    #[test]
    fn v3_index_uses_configured_base_url() {
        // Configured external base.
        let repo = NugetRepoZnippy::new("nuget".into())
            .with_base_url("https://holger.example.com/".into()); // trailing slash trimmed
        let (status, _h, body) = repo
            .handle_http2_request("GET", "/nuget/v3/index.json", b"")
            .unwrap();
        assert_eq!(status, 200);
        let body = String::from_utf8(body).unwrap();
        assert!(
            body.contains(r#""@id":"https://holger.example.com/nuget/v3-flatcontainer/""#),
            "index must advertise the configured base: {body}"
        );
        assert!(
            !body.contains("127.0.0.1"),
            "index must not leak the loopback default when configured: {body}"
        );

        // Default (unconfigured) still works for local dev.
        let def = NugetRepoZnippy::new("nuget".into());
        let (_s, _h, dbody) = def
            .handle_http2_request("GET", "/nuget/v3/index.json", b"")
            .unwrap();
        let dbody = String::from_utf8(dbody).unwrap();
        assert!(
            dbody.contains(r#""@id":"https://127.0.0.1:8443/nuget/v3-flatcontainer/""#),
            "default base must remain the dev loopback: {dbody}"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "v3_index_uses_configured_base_url",
            body.contains("holger.example.com") && !body.contains("127.0.0.1"),
            "V3 index advertises the configured external base",
        );
    }

    /// ★ What the REAL client wanted and the spec-reading did not give it.
    /// `dotnet package search` (NuGet 6.11.2) resolves its search endpoint
    /// through `ServiceTypes.SearchQueryService` =
    /// `{ "/Versioned", "/3.4.0", "/3.0.0-beta" }` and nothing else; an index
    /// that lists only the bare type, `/3.0.0-rc` and `/3.5.0` gets "The source
    /// does not have a Search service!". Measured in
    /// `server/lib/tests/real_clients_restore_from_holger.rs` against the
    /// container, 2026-09-20; this is the unit-level pin of that finding.
    #[test]
    fn the_index_advertises_the_search_type_the_real_client_looks_for() {
        let repo = NugetRepoZnippy::new("nuget".into());
        let (status, _h, body) = repo
            .handle_http2_request("GET", "/nuget/v3/index.json", b"")
            .unwrap();
        assert_eq!(status, 200);
        let doc: serde_json::Value = serde_json::from_slice(&body).unwrap();
        let types: Vec<&str> = doc["resources"]
            .as_array()
            .expect("resources")
            .iter()
            .filter_map(|r| r["@type"].as_str())
            .collect();
        // NuGet.Client ServiceTypes.SearchQueryService, in its lookup order.
        let client_looks_for = [
            "SearchQueryService/Versioned",
            "SearchQueryService/3.4.0",
            "SearchQueryService/3.0.0-beta",
        ];
        assert!(
            client_looks_for.iter().any(|t| types.contains(t)),
            "the real client walks {client_looks_for:?} and would find none of {types:?}"
        );
        // And every SearchQueryService entry points at the one search route.
        for r in doc["resources"].as_array().unwrap() {
            if r["@type"]
                .as_str()
                .unwrap_or("")
                .starts_with("SearchQueryService")
            {
                assert_eq!(r["@id"], "https://127.0.0.1:8443/nuget/v3/search");
            }
        }
    }

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

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

    /// Inject a known `.nupkg` into an in-memory reader and assert the real
    /// serve/fetch paths return the exact bytes. Exercises `fetch()` (Some hit /
    /// None miss) and `handle_http2_request()` (200 hit / 404 miss) — neither of
    /// which the `new()`/`format()` tests touch. The archive key follows the
    /// flat-container shape `{id-lowercased}/{ver}/{id}.{ver}.nupkg`.
    #[test]
    fn serves_injected_archive() {
        const KEY: &str = "newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg";
        let nupkg: Vec<u8> = b"PK\x03\x04 fake-nupkg-bytes".to_vec();
        let reader = MemReader::new([(KEY, nupkg.clone())]);
        let repo = NugetRepoZnippy::with_reader("nuget".into(), reader);

        // fetch(): id.name is lowercased into the key; a hit returns Some(bytes).
        let id = ArtifactId {
            namespace: None,
            name: "Newtonsoft.Json".into(),
            version: "13.0.1".into(),
        };
        let fetched = repo.fetch(&id).unwrap();
        assert_eq!(fetched.as_deref(), Some(nupkg.as_slice()));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "fetch_returns_injected_bytes",
            fetched.as_deref() == Some(nupkg.as_slice()),
            &format!(
                "fetched {} bytes for {KEY}",
                fetched.as_ref().map(|b| b.len()).unwrap_or(0)
            ),
        );

        // fetch() miss: unknown version → Ok(None).
        let miss_id = ArtifactId {
            namespace: None,
            name: "Newtonsoft.Json".into(),
            version: "99.0.0".into(),
        };
        let missed = repo.fetch(&miss_id).unwrap();
        assert!(missed.is_none());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "fetch_miss_returns_none",
            missed.is_none(),
            "unknown version yields Ok(None)",
        );

        // handle_http2_request() hit: flat-container package URL → 200 + octet-stream + bytes.
        let url = "/nuget/v3-flatcontainer/Newtonsoft.Json/13.0.1/newtonsoft.json.13.0.1.nupkg";
        let (status, headers, body) = repo.handle_http2_request("GET", url, b"").unwrap();
        let ctype = headers
            .iter()
            .find(|(k, _)| k == "Content-Type")
            .map(|(_, v)| v.as_str());
        assert_eq!(status, 200);
        assert_eq!(ctype, Some("application/octet-stream"));
        assert_eq!(body, nupkg);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "http2_serves_package_200",
            status == 200 && ctype == Some("application/octet-stream") && body == nupkg,
            &format!(
                "status={status} content-type={ctype:?} body_len={}",
                body.len()
            ),
        );

        // handle_http2_request() miss: absent package → 404 with the archive-miss body.
        let miss_url =
            "/nuget/v3-flatcontainer/Newtonsoft.Json/99.0.0/newtonsoft.json.99.0.0.nupkg";
        let (mstatus, _mh, mbody) = repo.handle_http2_request("GET", miss_url, b"").unwrap();
        assert_eq!(mstatus, 404);
        assert_eq!(mbody, b"Package not found in archive".to_vec());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "http2_missing_package_404",
            mstatus == 404 && mbody == b"Package not found in archive".to_vec(),
            &format!("status={mstatus} body_len={}", mbody.len()),
        );
    }

    /// T24: the flat-container version-list route returns the sorted+deduped
    /// versions (lowercasing the id), and an unknown id 404s.
    #[test]
    fn flatcontainer_version_list_route() {
        let reader = MemReader::new([
            (
                "newtonsoft.json/12.0.3/newtonsoft.json.12.0.3.nupkg",
                b"a".to_vec(),
            ),
            (
                "newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg",
                b"b".to_vec(),
            ),
        ]);
        let repo = NugetRepoZnippy::with_reader("nuget".to_string(), reader);

        // Mixed-case id in the URL must still resolve (proving the lowercasing).
        let (status, hdrs, body) = repo
            .handle_http2_request(
                "GET",
                "/nuget/v3-flatcontainer/Newtonsoft.Json/index.json",
                b"",
            )
            .unwrap();
        let ct = hdrs
            .iter()
            .any(|(k, v)| k == "Content-Type" && v == "application/json");
        let doc: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
        let versions: Vec<&str> = doc["versions"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap())
            .collect();
        let ok = status == 200 && ct && versions == ["12.0.3", "13.0.1"];
        assert!(
            ok,
            "version list sorted+deduped: status={status} body={}",
            String::from_utf8_lossy(&body)
        );

        // Unknown id → 404 'Package not found'.
        let (mstatus, _mh, mbody) = repo
            .handle_http2_request("GET", "/nuget/v3-flatcontainer/Ghost/index.json", b"")
            .unwrap();
        let miss_ok = mstatus == 404 && mbody == b"Package not found".to_vec();
        assert!(miss_ok, "unknown id 404: status={mstatus}");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "flatcontainer_version_list",
            ok && miss_ok,
            &format!("versions={versions:?} miss_status={mstatus}"),
        );
    }
    #[test]
    fn list_reports_packages_not_a_false_zero() {
        let reader = MemReader::new([
            (
                "newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg",
                b"nupkg-a".to_vec(),
            ),
            (
                "newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg",
                b"nupkg-bb".to_vec(),
            ),
            ("serilog/3.1.1/serilog.3.1.1.nupkg", b"n".to_vec()),
            // a stray non-package entry must not become a row
            ("serilog/3.1.1/serilog.nuspec", b"<x/>".to_vec()),
        ]);
        let repo = NugetRepoZnippy::with_reader("nuget-test".to_string(), reader);

        let all = repo.list(None, 0).expect("list ok");
        assert_eq!(all.len(), 3, "three nupkgs, the nuspec excluded");

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

        let json = repo.list(Some("newtonsoft"), 0).expect("list ok");
        assert_eq!(json.len(), 2);
        assert_eq!(json[0].id.namespace, None);
        assert!(json.iter().any(|e| e.size_bytes == 7), "real size, not 0");

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

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "list_reports_packages",
            all.len() == 3 && json.len() == 2,
            &format!("all={} filtered={}", all.len(), json.len()),
        );
    }
    /// The archive file table is taken ONCE, not once per request.
    /// Measured before the `paths` OnceLock landed: ten flat-container
    /// `index.json` requests = 10 full table reads. After: 1.
    #[test]
    fn the_archive_file_table_is_read_once_not_per_request() {
        let mem = MemReader::new([
            ("serilog/3.1.1/serilog.3.1.1.nupkg", b"a".to_vec()),
            ("serilog/3.1.2/serilog.3.1.2.nupkg", b"b".to_vec()),
        ]);
        let counting = CountingReader::new(mem);
        let repo = NugetRepoZnippy::with_reader("nuget-test".to_string(), counting.clone());

        assert_eq!(
            counting.table_reads(),
            0,
            "nothing is read before the first request"
        );
        for _ in 0..10 {
            let (status, _, body) = repo
                .handle_http2_request(
                    "GET",
                    "/nuget-test/v3-flatcontainer/Serilog/index.json",
                    b"",
                )
                .expect("index request ok");
            assert_eq!(status, 200);
            assert!(String::from_utf8_lossy(&body).contains("3.1.2"));
        }
        assert_eq!(
            counting.table_reads(),
            1,
            "ten flat-container index requests must cost ONE table read, not ten"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "archive_table_read_once_not_per_request",
            counting.table_reads() == 1,
            &format!(
                "10 requests -> {} table reads (was 10)",
                counting.table_reads()
            ),
        );
    }
    /// The Archive tab was told there was nothing to browse on a repository
    /// that was serving packages: `has_archive`, `archive_files` and
    /// `archive_info` were all 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([
            ("serilog/3.1.1/serilog.3.1.1.nupkg", b"aa".to_vec()),
            ("serilog/3.1.1/serilog.nuspec", b"<x/>".to_vec()),
            (
                "newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg",
                b"b".to_vec(),
            ),
        ]);
        let repo = NugetRepoZnippy::with_reader("nuget-test".to_string(), reader);

        assert!(repo.caps().archive, "a loaded reader IS an archive");
        assert_eq!(repo.archive().expect("ok").paths(None).len(), 3);
        assert_eq!(
            repo.archive().expect("ok").paths(Some("serilog/")).len(),
            2,
            "prefix narrows"
        );
        traits::backend::conformance::check(&repo).unwrap();

        let info = repo.archive().expect("ok").info();
        assert_eq!(info.file_count, 3);
        assert_eq!(
            info.total_uncompressed_bytes,
            2 + 4 + 1,
            "real bytes, not zero"
        );
        assert_eq!(info.archive_path, "nuget-test");

        let empty = NugetRepoZnippy::new("empty".into());
        assert!(!empty.caps().archive);
        traits::backend::conformance::check(&empty).unwrap();
    }

    // ── ?23: the half of the V3 protocol this backend could not serve ────────

    /// Build a real `.nupkg`: a zip with a root `.nuspec` and a payload file,
    /// exactly as `dotnet pack` lays one out.
    fn nupkg(id: &str, version: &str, deps: &[(&str, &str)]) -> Vec<u8> {
        let mut xml = format!(
            "<?xml version=\"1.0\"?>\n<package xmlns=\"http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd\">\
             <metadata><id>{id}</id><version>{version}</version>\
             <authors>Vetra Automation AB</authors>\
             <description>the {id} package</description><tags>airgap holger</tags>"
        );
        if !deps.is_empty() {
            xml.push_str("<dependencies><group targetFramework=\"net8.0\">");
            for (d, v) in deps {
                xml.push_str(&format!("<dependency id=\"{d}\" version=\"{v}\" />"));
            }
            xml.push_str("</group></dependencies>");
        }
        xml.push_str("</metadata></package>");
        let manifest = format!("{id}.nuspec");
        zip_entry::fixture::zip(&[
            (manifest.as_str(), xml.as_bytes()),
            ("lib/net8.0/Thing.dll", b"MZ-not-really".as_slice()),
            ("[Content_Types].xml", b"<Types/>".as_slice()),
        ])
    }

    fn json_of(repo: &NugetRepoZnippy, 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 nuget half.** Before: the service index advertised only
    /// `PackageBaseAddress`, so a client could pull an EXACT version and
    /// nothing else — `dotnet restore` of a project with a version range or a
    /// dependency graph had no resource to ask and failed. After: the index
    /// advertises `RegistrationsBaseUrl` and `SearchQueryService`, and both are
    /// really served, synthesized from the `.nuspec` inside each `.nupkg`.
    ///
    /// This walks the exact sequence `dotnet restore` walks.
    #[test]
    fn a_dependency_graph_and_a_version_range_now_resolve() {
        let reader = MemReader::new([
            (
                "thing/1.0.0/thing.1.0.0.nupkg",
                nupkg("Thing", "1.0.0", &[("Newtonsoft.Json", "13.0.1")]),
            ),
            (
                "thing/1.2.0/thing.1.2.0.nupkg",
                nupkg("Thing", "1.2.0", &[("Newtonsoft.Json", "[13.0.1, 14.0.0)")]),
            ),
            (
                "thing/2.0.0-rc.1/thing.2.0.0-rc.1.nupkg",
                nupkg("Thing", "2.0.0-rc.1", &[]),
            ),
            (
                "newtonsoft.json/13.0.1/newtonsoft.json.13.0.1.nupkg",
                nupkg("Newtonsoft.Json", "13.0.1", &[]),
            ),
        ]);
        let repo = NugetRepoZnippy::with_reader("nuget".into(), reader)
            .with_base_url("https://holger.example.com".into());

        // 1. The service index. The two resources that were MISSING.
        let (status, index) = json_of(&repo, "/nuget/v3/index.json");
        assert_eq!(status, 200);
        let types: Vec<&str> = index["resources"]
            .as_array()
            .expect("resources")
            .iter()
            .map(|r| r["@type"].as_str().unwrap_or(""))
            .collect();
        assert!(types.contains(&"PackageBaseAddress/3.0.0"), "{types:?}");
        assert!(
            types.contains(&"RegistrationsBaseUrl/3.6.0"),
            "the resource whose absence made restore fail: {types:?}"
        );
        assert!(types.contains(&"SearchQueryService/3.5.0"), "{types:?}");
        let reg_base = index["resources"]
            .as_array()
            .expect("resources")
            .iter()
            .find(|r| r["@type"] == "RegistrationsBaseUrl/3.6.0")
            .and_then(|r| r["@id"].as_str())
            .expect("registration base");
        assert_eq!(
            reg_base,
            "https://holger.example.com/nuget/v3/registration/"
        );

        // 2. Follow it. The registration index for Thing.
        let (status, reg) = json_of(&repo, "/nuget/v3/registration/Thing/index.json");
        assert_eq!(
            status, 200,
            "mixed-case id must resolve, like the flat container"
        );
        let page = &reg["items"][0];
        assert_eq!(page["count"], 3);
        // Version-ordered, prerelease last but BELOW its own release line.
        let versions: Vec<&str> = page["items"]
            .as_array()
            .expect("inlined leaves")
            .iter()
            .map(|l| l["catalogEntry"]["version"].as_str().unwrap_or(""))
            .collect();
        assert_eq!(versions, vec!["1.0.0", "1.2.0", "2.0.0-rc.1"]);

        // 3. The range. `13.0.1` in the .nuspec is a MINIMUM, and the document
        //    says so in NuGet's own interval notation — which is the single
        //    fact a range resolution needs and could not get.
        let entry = &page["items"][0]["catalogEntry"];
        let group = &entry["dependencyGroups"][0];
        assert_eq!(group["targetFramework"], "net8.0");
        assert_eq!(group["dependencies"][0]["range"], "[13.0.1, )");
        // 4. The graph. The dependency's own registration is linked, so the
        //    walk continues — and it really resolves in this repository.
        let next = group["dependencies"][0]["registration"]
            .as_str()
            .expect("registration");
        assert_eq!(
            next,
            "https://holger.example.com/nuget/v3/registration/newtonsoft.json/index.json"
        );
        let (status, dep_reg) = json_of(&repo, "/nuget/v3/registration/newtonsoft.json/index.json");
        assert_eq!(
            status, 200,
            "the linked registration is served by this very backend"
        );
        assert_eq!(
            dep_reg["items"][0]["items"][0]["catalogEntry"]["id"],
            "Newtonsoft.Json"
        );

        // 5. The download. packageContent must resolve against the flat
        //    container, or the client gets all the way here and then 404s.
        let content = page["items"][1]["packageContent"]
            .as_str()
            .expect("packageContent");
        assert_eq!(
            content,
            "https://holger.example.com/nuget/v3-flatcontainer/thing/1.2.0/thing.1.2.0.nupkg"
        );
        let suburl = content.trim_start_matches("https://holger.example.com");
        let (dstatus, _h, dbody) = repo
            .handle_http2_request("GET", suburl, b"")
            .expect("download");
        assert_eq!(
            dstatus, 200,
            "the URL the registration advertised really serves bytes"
        );
        assert!(dbody.starts_with(b"PK"), "and they are the .nupkg zip");

        // 6. A leaf, for the clients that follow the leaf @id.
        let (status, leaf) = json_of(&repo, "/nuget/v3/registration/thing/1.2.0.json");
        assert_eq!(status, 200);
        assert_eq!(leaf["catalogEntry"]["version"], "1.2.0");

        // 7. Search — the other resource that was absent.
        let (status, hits) = json_of(&repo, "/nuget/v3/search?q=thing&take=10");
        assert_eq!(status, 200);
        assert_eq!(hits["totalHits"], 1);
        assert_eq!(hits["data"][0]["id"], "Thing");
        assert_eq!(
            hits["data"][0]["version"], "1.2.0",
            "the rc is hidden without prerelease=true"
        );
        let (_s, pre) = json_of(&repo, "/nuget/v3/search?q=thing&prerelease=true");
        assert_eq!(pre["data"][0]["version"], "2.0.0-rc.1");
        // An empty q browses everything.
        let (_s, all) = json_of(&repo, "/nuget/v3/search");
        assert_eq!(all["totalHits"], 2);

        // Unknown package: 404, not an empty registration that a client would
        // read as "exists, has no versions".
        let (status, _v) = json_of(&repo, "/nuget/v3/registration/ghost/index.json");
        assert_eq!(status, 404);
        let (status, _v) = json_of(&repo, "/nuget/v3/registration/thing/9.9.9.json");
        assert_eq!(status, 404);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "registration_and_search_resolve_a_graph",
            versions == vec!["1.0.0", "1.2.0", "2.0.0-rc.1"],
            "service index advertises Registrations+Search; range is [13.0.1, ), \
             dependency registration links and resolves, packageContent downloads",
        );
    }

    /// ★ **The memo, counted.** A registration is an answer ABOUT the
    /// packages, so building one opens every `.nupkg` in the archive. Done per
    /// request that is O(archive) on every `GET /v3/search` — the same defect
    /// the `paths` OnceLock closed one layer up. Measured here: four packages,
    /// twelve registration/search requests. Without the memo that is 48 member
    /// reads; with it, 4.
    #[test]
    fn the_nuspec_catalog_is_built_once_not_per_request() {
        let mem = MemReader::new([
            (
                "thing/1.0.0/thing.1.0.0.nupkg",
                nupkg("Thing", "1.0.0", &[]),
            ),
            (
                "thing/1.2.0/thing.1.2.0.nupkg",
                nupkg("Thing", "1.2.0", &[]),
            ),
            (
                "other/1.0.0/other.1.0.0.nupkg",
                nupkg("Other", "1.0.0", &[]),
            ),
            (
                "other/2.0.0/other.2.0.0.nupkg",
                nupkg("Other", "2.0.0", &[]),
            ),
        ]);
        let counting = CountingReader::new(mem);
        let repo = NugetRepoZnippy::with_reader("nuget".into(), counting.clone());

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

        // The flat-container arms must NOT pay for the catalog at all.
        let (s, _v) = json_of(&repo, "/nuget/v3-flatcontainer/thing/index.json");
        assert_eq!(s, 200);
        assert_eq!(
            counting.member_reads(),
            0,
            "an exact-version client never pays for the registration catalog"
        );

        for _ in 0..6 {
            assert_eq!(
                json_of(&repo, "/nuget/v3/registration/thing/index.json").0,
                200
            );
            assert_eq!(json_of(&repo, "/nuget/v3/search?q=thing").0, 200);
        }
        assert_eq!(
            counting.member_reads(),
            4,
            "twelve registration/search requests over a 4-package archive must cost \
             FOUR member reads (one per .nupkg, once), not 48"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-nuget",
            "nuspec_catalog_built_once",
            counting.member_reads() == 4,
            "12 registration/search requests over 4 packages -> 4 member reads (was 48)",
        );
    }

    /// One hostile `.nupkg` must not blind the repository. A package whose
    /// zip is garbage is skipped and logged; the honest ones beside it still
    /// register — the same ruling `znippy-helm` makes about a bomb chart.
    #[test]
    fn a_nupkg_that_is_not_a_readable_zip_is_skipped_not_fatal() {
        let reader = MemReader::new([
            (
                "thing/1.0.0/thing.1.0.0.nupkg",
                nupkg("Thing", "1.0.0", &[]),
            ),
            (
                "junk/1.0.0/junk.1.0.0.nupkg",
                b"PK\x03\x04 not really a zip".to_vec(),
            ),
            // A zip with no root .nuspec at all.
            (
                "empty/1.0.0/empty.1.0.0.nupkg",
                zip_entry::fixture::zip(&[("lib/net8.0/x.dll", b"MZ".as_slice())]),
            ),
        ]);
        let repo = NugetRepoZnippy::with_reader("nuget".into(), reader);

        assert_eq!(
            json_of(&repo, "/nuget/v3/registration/thing/index.json").0,
            200
        );
        assert_eq!(
            json_of(&repo, "/nuget/v3/registration/junk/index.json").0,
            404
        );
        assert_eq!(
            json_of(&repo, "/nuget/v3/registration/empty/index.json").0,
            404
        );

        let (_s, hits) = json_of(&repo, "/nuget/v3/search");
        assert_eq!(hits["totalHits"], 1, "only the readable package registers");

        // …and the unreadable ones are still DOWNLOADABLE at an exact version:
        // a manifest holger cannot parse does not make the bytes disappear.
        let (s, _h, b) = repo
            .handle_http2_request(
                "GET",
                "/nuget/v3-flatcontainer/junk/1.0.0/junk.1.0.0.nupkg",
                b"",
            )
            .expect("request");
        assert_eq!(s, 200);
        assert_eq!(b, b"PK\x03\x04 not really a zip".to_vec());
    }
}