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/registration.rs

//! **The two V3 resources holger's NuGet backend advertised nothing for** —
//! `RegistrationsBaseUrl` and `SearchQueryService` — synthesized from the
//! `.nuspec` inside each `.nupkg`.
//!
//! ## What was broken, precisely
//!
//! The flat container (`PackageBaseAddress`) answers exactly one question:
//! "give me `Thing` at exactly `1.2.3`". Every other question a real project
//! asks — resolve `Thing >= 1.2.0`, resolve `Thing`'s own dependencies,
//! find `Thing` by name — goes to the registration and search resources. With
//! neither advertised, a `dotnet restore` of any project that does not pin
//! every single package to an exact version fails. That is the whole of the
//! ?23 finding, and this module is the answer to it.
//!
//! ## The documents
//!
//! Per the NuGet V3 API (registration + search):
//!
//! * `…/v3/registration/{id}/index.json` — a `catalog:CatalogRoot` holding
//!   `catalog:CatalogPage`s. Up to [`PAGE_SIZE`] versions the single page is
//!   **inlined** (`items` present), which is the shape the spec recommends for
//!   small registrations and which costs a client one request instead of two.
//!   Past it the pages are linked and served separately, so the index of a
//!   package with thousands of versions stays small.
//! * `…/v3/registration/{id}/page/{lower}/{upper}.json` — one linked page.
//! * `…/v3/registration/{id}/{version}.json` — one leaf, for the clients that
//!   follow a leaf `@id` rather than reading the inlined `catalogEntry`.
//! * `…/v3/search?q=&skip=&take=&prerelease=` — the search response, whose
//!   `data[]` entries carry `versions[]` so a client can see the whole span
//!   without a second round trip.
//!
//! ## Ordering is NOT string ordering
//!
//! `lower`, `upper` and "which version is latest" are all version-ordered, and
//! a lexicographic sort gets that wrong in two ways that matter: `10.0.0`
//! sorts before `9.0.0`, and `1.0.0-beta` sorts *after* `1.0.0` when SemVer
//! says a prerelease precedes its release. [`compare_versions`] implements
//! NuGetVersion ordering — four numeric fields, then SemVer prerelease rules,
//! build metadata ignored.
//!
//! ## Cost
//!
//! Every document here is built from an already-parsed
//! [`PackageVersion`] slice and touches no archive bytes. The expensive half —
//! opening each `.nupkg` for its `.nuspec` — happens ONCE, in `lib.rs`'s
//! memoized catalog.

use std::cmp::Ordering;

use serde_json::{json, Value};

use crate::nuspec::Nuspec;

/// Versions per registration page before the pages stop being inlined.
///
/// Inlining is one request instead of two and is what the spec recommends for
/// a small registration. 64 keeps the inlined index in the tens of kilobytes
/// for a typical package while the overwhelming majority of real packages
/// (which have far fewer than 64 published versions) never pay for a second
/// request at all.
pub const PAGE_SIZE: usize = 64;

/// A fixed commit stamp.
///
/// The registration format carries `commitId`/`commitTimeStamp` so a client can
/// tell one snapshot of nuget.org from the next. holger serves a **read-only
/// znippy archive**: the content cannot change under the process, so there is
/// no sequence of commits to report and inventing a fresh timestamp per request
/// would make every response differ from the last for no reason — defeating
/// client-side caching. A constant is the honest answer, and it is the same
/// answer the archive's own immutability gives.
const COMMIT_TS: &str = "2000-01-01T00:00:00+00:00";

/// One published version, as the catalog knows it.
#[derive(Debug, Clone)]
pub struct PackageVersion {
    /// The parsed `.nuspec`.
    pub nuspec: Nuspec,
    /// The version segment as it appears in the ARCHIVE path.
    ///
    /// Not necessarily the `.nuspec`'s own spelling of the version, and it is
    /// the archive's spelling that `packageContent` must be built from — a
    /// `packageContent` URL that 404s is worse than no registration at all,
    /// because the client gets all the way to the download before failing.
    pub path_version: String,
    /// The `.nupkg` filename inside the archive.
    pub file: String,
}

impl PackageVersion {
    /// The version a client should see: the `.nuspec`'s, which is the
    /// authoritative spelling, falling back to the path's.
    pub fn version(&self) -> &str {
        if self.nuspec.version.is_empty() {
            &self.path_version
        } else {
            &self.nuspec.version
        }
    }

    /// Whether this is a prerelease — i.e. whether it is hidden from a default
    /// `dotnet add package` and from a search without `prerelease=true`.
    pub fn is_prerelease(&self) -> bool {
        is_prerelease(self.version())
    }
}

/// Whether a version string carries a SemVer prerelease label.
pub fn is_prerelease(v: &str) -> bool {
    let core = v.split('+').next().unwrap_or(v);
    core.contains('-')
}

/// Split a version into its numeric fields and its prerelease labels,
/// discarding build metadata.
fn split_version(v: &str) -> ([u64; 4], Vec<String>) {
    let core = v.split('+').next().unwrap_or(v);
    let (nums, pre) = match core.split_once('-') {
        Some((n, p)) => (n, p),
        None => (core, ""),
    };
    let mut fields = [0u64; 4];
    for (i, part) in nums.split('.').take(4).enumerate() {
        fields[i] = part.trim().parse::<u64>().unwrap_or(0);
    }
    let labels = if pre.is_empty() {
        Vec::new()
    } else {
        pre.split('.').map(|s| s.to_string()).collect()
    };
    (fields, labels)
}

/// NuGetVersion ordering.
///
/// Numeric fields first (missing fields are zero, so `1.0` == `1.0.0`), then
/// the SemVer prerelease rules: **absence of a prerelease sorts HIGHER**, a
/// numeric label sorts below an alphanumeric one, numeric labels compare as
/// numbers and alphanumeric ones case-insensitively (NuGet, unlike strict
/// SemVer, is case-insensitive here), and a shorter label list sorts lower when
/// all shared labels are equal. Build metadata is ignored, per SemVer.
pub fn compare_versions(a: &str, b: &str) -> Ordering {
    let (an, ap) = split_version(a);
    let (bn, bp) = split_version(b);
    match an.cmp(&bn) {
        Ordering::Equal => {}
        o => return o,
    }
    match (ap.is_empty(), bp.is_empty()) {
        (true, true) => return Ordering::Equal,
        // A release outranks its own prereleases.
        (true, false) => return Ordering::Greater,
        (false, true) => return Ordering::Less,
        (false, false) => {}
    }
    for (x, y) in ap.iter().zip(bp.iter()) {
        let xn = x.parse::<u64>().ok();
        let yn = y.parse::<u64>().ok();
        let o = match (xn, yn) {
            (Some(x), Some(y)) => x.cmp(&y),
            (Some(_), None) => Ordering::Less,
            (None, Some(_)) => Ordering::Greater,
            (None, None) => x.to_ascii_lowercase().cmp(&y.to_ascii_lowercase()),
        };
        if o != Ordering::Equal {
            return o;
        }
    }
    ap.len().cmp(&bp.len())
}

/// Sort in place, ascending, by [`compare_versions`].
pub fn sort_versions(v: &mut [PackageVersion]) {
    v.sort_by(|a, b| compare_versions(a.version(), b.version()));
}

// ── URLs ─────────────────────────────────────────────────────────────────────

fn reg_base(base: &str, repo: &str) -> String {
    format!("{base}/{repo}/v3/registration")
}

fn index_url(base: &str, repo: &str, id_lower: &str) -> String {
    format!("{}/{id_lower}/index.json", reg_base(base, repo))
}

fn page_url(base: &str, repo: &str, id_lower: &str, lower: &str, upper: &str) -> String {
    format!(
        "{}/{id_lower}/page/{lower}/{upper}.json",
        reg_base(base, repo)
    )
}

fn leaf_url(base: &str, repo: &str, id_lower: &str, version: &str) -> String {
    format!(
        "{}/{id_lower}/{}.json",
        reg_base(base, repo),
        version.to_lowercase()
    )
}

/// The flat-container URL for the actual bytes. Built from the ARCHIVE's own
/// path segments, so it always resolves against the same backend's
/// `v3-flatcontainer` arm.
fn content_url(base: &str, repo: &str, id_lower: &str, pv: &PackageVersion) -> String {
    format!(
        "{base}/{repo}/v3-flatcontainer/{id_lower}/{}/{}",
        pv.path_version, pv.file
    )
}

// ── Documents ────────────────────────────────────────────────────────────────

fn dependency_groups_json(base: &str, repo: &str, n: &Nuspec) -> Value {
    Value::Array(
        n.dependency_groups
            .iter()
            .map(|g| {
                let deps: Vec<Value> = g
                    .dependencies
                    .iter()
                    .map(|d| {
                        json!({
                            "@type": "PackageDependency",
                            "id": d.id,
                            "range": d.range,
                            // The registration of the DEPENDENCY, so a client
                            // resolving a graph walks straight on without
                            // going back to the service index.
                            "registration": index_url(base, repo, &d.id.to_lowercase()),
                        })
                    })
                    .collect();
                let mut g_json = json!({
                    "@type": "PackageDependencyGroup",
                    "dependencies": deps,
                });
                if let Some(tfm) = &g.target_framework {
                    g_json["targetFramework"] = json!(tfm);
                }
                g_json
            })
            .collect(),
    )
}

/// One `catalogEntry` — the `PackageDetails` a client reads a version's facts
/// out of.
fn catalog_entry(base: &str, repo: &str, id_lower: &str, pv: &PackageVersion) -> Value {
    let n = &pv.nuspec;
    let version = pv.version();
    let mut e = json!({
        "@id": leaf_url(base, repo, id_lower, version),
        "@type": "PackageDetails",
        "id": n.id,
        "version": version,
        "dependencyGroups": dependency_groups_json(base, repo, n),
        // `listed: false` HIDES a version from restore. Everything in the
        // archive is there to be served, so everything is listed.
        "listed": true,
        "packageContent": content_url(base, repo, id_lower, pv),
        // See COMMIT_TS: a read-only archive has no publication history, and a
        // fabricated date would be a fact holger does not have. The V3 sentinel
        // for "unknown" is the year-1900 stamp, which clients already handle.
        "published": "1900-01-01T00:00:00+00:00",
        "requireLicenseAcceptance": n.require_license_acceptance,
    });
    if let Some(v) = &n.authors {
        e["authors"] = json!(v);
    }
    if let Some(v) = &n.description {
        e["description"] = json!(v);
    }
    if let Some(v) = &n.summary {
        e["summary"] = json!(v);
    }
    if let Some(v) = &n.title {
        e["title"] = json!(v);
    }
    if let Some(v) = &n.tags {
        e["tags"] = json!(v
            .split(&[' ', ',', ';'][..])
            .filter(|s| !s.is_empty())
            .collect::<Vec<_>>());
    }
    if let Some(v) = &n.project_url {
        e["projectUrl"] = json!(v);
    }
    if let Some(v) = &n.license_url {
        e["licenseUrl"] = json!(v);
    }
    if let Some(v) = &n.license_expression {
        e["licenseExpression"] = json!(v);
    }
    e
}

fn leaf(base: &str, repo: &str, id_lower: &str, pv: &PackageVersion) -> Value {
    json!({
        "@id": leaf_url(base, repo, id_lower, pv.version()),
        "@type": ["Package", "catalog:Permalink"],
        "commitId": COMMIT_TS,
        "commitTimeStamp": COMMIT_TS,
        "catalogEntry": catalog_entry(base, repo, id_lower, pv),
        "packageContent": content_url(base, repo, id_lower, pv),
        "registration": index_url(base, repo, id_lower),
    })
}

/// One page object. `inline` decides whether its `items` are carried here (the
/// small-registration shape) or left to a separate document.
fn page(base: &str, repo: &str, id_lower: &str, chunk: &[PackageVersion], inline: bool) -> Value {
    let lower = chunk.first().map(|p| p.version()).unwrap_or("0.0.0");
    let upper = chunk.last().map(|p| p.version()).unwrap_or("0.0.0");
    let mut p = json!({
        "@id": page_url(base, repo, id_lower, lower, upper),
        "@type": "catalog:CatalogPage",
        "commitId": COMMIT_TS,
        "commitTimeStamp": COMMIT_TS,
        "count": chunk.len(),
        "lower": lower,
        "parent": index_url(base, repo, id_lower),
        "upper": upper,
    });
    if inline {
        p["items"] = Value::Array(
            chunk
                .iter()
                .map(|pv| leaf(base, repo, id_lower, pv))
                .collect(),
        );
    }
    p
}

/// `…/v3/registration/{id}/index.json`. `versions` must be sorted ascending.
pub fn registration_index(
    base: &str,
    repo: &str,
    id_lower: &str,
    versions: &[PackageVersion],
) -> Vec<u8> {
    let inline = versions.len() <= PAGE_SIZE;
    let pages: Vec<Value> = versions
        .chunks(PAGE_SIZE.max(1))
        .map(|c| page(base, repo, id_lower, c, inline))
        .collect();
    let doc = json!({
        "@id": index_url(base, repo, id_lower),
        "@type": ["catalog:CatalogRoot", "PackageRegistration", "catalog:Permalink"],
        "commitId": COMMIT_TS,
        "commitTimeStamp": COMMIT_TS,
        "count": pages.len(),
        "items": pages,
    });
    serde_json::to_vec(&doc).unwrap_or_else(|_| b"{}".to_vec())
}

/// One linked page document, selected by its `lower`/`upper` bounds. `None`
/// when no page has those bounds — which is a 404, not an empty page.
pub fn registration_page(
    base: &str,
    repo: &str,
    id_lower: &str,
    versions: &[PackageVersion],
    lower: &str,
    upper: &str,
) -> Option<Vec<u8>> {
    let chunk = versions.chunks(PAGE_SIZE.max(1)).find(|c| {
        c.first().map(|p| p.version()) == Some(lower)
            && c.last().map(|p| p.version()) == Some(upper)
    })?;
    let mut p = page(base, repo, id_lower, chunk, true);
    // A standalone page document is its own root; the inlined form is not.
    p["@type"] = json!("catalog:CatalogPage");
    Some(serde_json::to_vec(&p).unwrap_or_else(|_| b"{}".to_vec()))
}

/// One leaf document, `…/{id}/{version}.json`.
pub fn registration_leaf(
    base: &str,
    repo: &str,
    id_lower: &str,
    versions: &[PackageVersion],
    version: &str,
) -> Option<Vec<u8>> {
    let pv = versions
        .iter()
        .find(|p| p.version().eq_ignore_ascii_case(version))?;
    Some(serde_json::to_vec(&leaf(base, repo, id_lower, pv)).unwrap_or_else(|_| b"{}".to_vec()))
}

/// One search hit: the package, at its newest matching version, with the whole
/// version span carried inline.
fn search_hit(
    base: &str,
    repo: &str,
    id_lower: &str,
    versions: &[PackageVersion],
    prerelease: bool,
) -> Option<Value> {
    let visible: Vec<&PackageVersion> = versions
        .iter()
        .filter(|p| prerelease || !p.is_prerelease())
        .collect();
    let newest = visible.last()?;
    let n = &newest.nuspec;
    let mut hit = json!({
        "@id": index_url(base, repo, id_lower),
        "@type": "Package",
        "registration": index_url(base, repo, id_lower),
        "id": n.id,
        "version": newest.version(),
        "versions": visible.iter().map(|p| json!({
            "@id": leaf_url(base, repo, id_lower, p.version()),
            "version": p.version(),
            // holger counts no downloads and will not invent a number.
            "downloads": 0,
        })).collect::<Vec<_>>(),
        "totalDownloads": 0,
        "verified": false,
    });
    if let Some(v) = &n.title {
        hit["title"] = json!(v);
    }
    if let Some(v) = &n.description {
        hit["description"] = json!(v);
    }
    if let Some(v) = &n.summary {
        hit["summary"] = json!(v);
    }
    if let Some(v) = &n.authors {
        hit["authors"] = json!(v
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .collect::<Vec<_>>());
    }
    if let Some(v) = &n.tags {
        hit["tags"] = json!(v
            .split(&[' ', ',', ';'][..])
            .filter(|s| !s.is_empty())
            .collect::<Vec<_>>());
    }
    if let Some(v) = &n.project_url {
        hit["projectUrl"] = json!(v);
    }
    if let Some(v) = &n.license_url {
        hit["licenseUrl"] = json!(v);
    }
    Some(hit)
}

/// The `SearchQueryService` response.
///
/// `q` matches case-insensitively against the package id, its title and its
/// tags — the three fields `dotnet package search` actually shows. An empty
/// `q` lists everything, which is what a client's "browse" does.
pub fn search_response<'a>(
    base: &str,
    repo: &str,
    catalog: impl Iterator<Item = (&'a str, &'a Vec<PackageVersion>)>,
    q: &str,
    skip: usize,
    take: usize,
    prerelease: bool,
) -> Vec<u8> {
    let needle = q.trim().to_lowercase();
    let mut hits: Vec<(String, Value)> = Vec::new();
    for (id_lower, versions) in catalog {
        if versions.is_empty() {
            continue;
        }
        if !needle.is_empty() {
            let n = &versions[versions.len() - 1].nuspec;
            let hay = format!(
                "{} {} {}",
                id_lower,
                n.title.clone().unwrap_or_default().to_lowercase(),
                n.tags.clone().unwrap_or_default().to_lowercase()
            );
            if !hay.contains(&needle) {
                continue;
            }
        }
        if let Some(h) = search_hit(base, repo, id_lower, versions, prerelease) {
            hits.push((id_lower.to_string(), h));
        }
    }
    // Stable, id-ordered: holger has no relevance signal (no downloads, no
    // ranking), and a stable order is worth more to a client than a fake one.
    hits.sort_by(|a, b| a.0.cmp(&b.0));
    let total = hits.len();
    let data: Vec<Value> = hits
        .into_iter()
        .skip(skip)
        .take(take)
        .map(|(_, v)| v)
        .collect();
    let doc = json!({
        "@context": { "@vocab": "http://schema.nuget.org/schema#", "@base": reg_base(base, repo) },
        "totalHits": total,
        "data": data,
    });
    serde_json::to_vec(&doc).unwrap_or_else(|_| b"{\"totalHits\":0,\"data\":[]}".to_vec())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::nuspec::{Dependency, DependencyGroup};

    fn pv(id: &str, version: &str, deps: &[(&str, &str)]) -> PackageVersion {
        PackageVersion {
            nuspec: Nuspec {
                id: id.to_string(),
                version: version.to_string(),
                title: Some(format!("{id} title")),
                authors: Some("Vetra Automation AB".into()),
                description: Some("a package".into()),
                summary: None,
                tags: Some("airgap holger".into()),
                project_url: None,
                license_url: None,
                license_expression: Some("MIT".into()),
                require_license_acceptance: false,
                dependency_groups: if deps.is_empty() {
                    Vec::new()
                } else {
                    vec![DependencyGroup {
                        target_framework: Some("net8.0".into()),
                        dependencies: deps
                            .iter()
                            .map(|(i, r)| Dependency {
                                id: (*i).to_string(),
                                range: (*r).to_string(),
                            })
                            .collect(),
                    }]
                },
            },
            path_version: version.to_string(),
            file: format!("{}.{version}.nupkg", id.to_lowercase()),
        }
    }

    /// ★ The ordering a lexicographic sort gets wrong in both directions.
    #[test]
    fn versions_order_as_nuget_orders_them_not_as_strings() {
        let mut v = vec![
            "1.0.0".to_string(),
            "10.0.0".into(),
            "9.0.0".into(),
            "1.0.0-beta".into(),
            "1.0.0-alpha".into(),
            "1.0.0-alpha.2".into(),
            "1.0.0-alpha.10".into(),
            "2.0".into(),
        ];
        v.sort_by(|a, b| compare_versions(a, b));
        assert_eq!(
            v,
            vec![
                "1.0.0-alpha",
                "1.0.0-alpha.2",
                // .10 after .2: NUMERIC label comparison, not string.
                "1.0.0-alpha.10",
                "1.0.0-beta",
                // The release outranks all of its prereleases.
                "1.0.0",
                "2.0",
                // 9 before 10: numeric field comparison, not string.
                "9.0.0",
                "10.0.0",
            ]
        );
        // Missing fields are zero, so these are the same version.
        assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Equal);
        assert_eq!(
            compare_versions("1.0.0+build1", "1.0.0+build2"),
            Ordering::Equal
        );
        // NuGet is case-insensitive in prerelease labels; SemVer is not.
        assert_eq!(
            compare_versions("1.0.0-Alpha", "1.0.0-alpha"),
            Ordering::Equal
        );
    }

    /// ★ **The document `dotnet restore` needs and could not get.** A range
    /// (`[1.0.0, )`) can only be resolved by reading a registration index, and
    /// the dependency's own registration URL must be in it or the walk stops.
    #[test]
    fn the_registration_index_carries_ranges_and_the_next_registration() {
        let versions = vec![
            pv("Thing", "1.0.0", &[("Newtonsoft.Json", "[13.0.1, )")]),
            pv("Thing", "1.1.0", &[]),
        ];
        let body = registration_index("https://h.example", "nuget", "thing", &versions);
        let doc: Value = serde_json::from_slice(&body).expect("valid JSON");

        assert_eq!(
            doc["@id"],
            "https://h.example/nuget/v3/registration/thing/index.json"
        );
        assert_eq!(doc["count"], 1, "two versions fit one page");
        let pagev = &doc["items"][0];
        assert_eq!(pagev["lower"], "1.0.0");
        assert_eq!(pagev["upper"], "1.1.0");
        assert_eq!(pagev["count"], 2);
        let leaf0 = &pagev["items"][0];
        assert_eq!(leaf0["catalogEntry"]["version"], "1.0.0");
        assert_eq!(leaf0["catalogEntry"]["listed"], true);
        // packageContent must point at THIS backend's flat container, or the
        // client resolves the graph and then 404s on the download.
        assert_eq!(
            leaf0["packageContent"],
            "https://h.example/nuget/v3-flatcontainer/thing/1.0.0/thing.1.0.0.nupkg"
        );
        let group = &leaf0["catalogEntry"]["dependencyGroups"][0];
        assert_eq!(group["targetFramework"], "net8.0");
        assert_eq!(group["dependencies"][0]["id"], "Newtonsoft.Json");
        assert_eq!(group["dependencies"][0]["range"], "[13.0.1, )");
        assert_eq!(
            group["dependencies"][0]["registration"],
            "https://h.example/nuget/v3/registration/newtonsoft.json/index.json",
            "the graph walk continues without going back to the service index"
        );
    }

    /// Past PAGE_SIZE the index stops inlining and the pages become documents
    /// of their own — so a package with thousands of versions does not make
    /// every index request a multi-megabyte one.
    #[test]
    fn a_large_registration_links_its_pages_instead_of_inlining_them() {
        let versions: Vec<PackageVersion> = (0..PAGE_SIZE * 2 + 5)
            .map(|i| pv("Thing", &format!("1.0.{i}"), &[]))
            .collect();
        let body = registration_index("https://h.example", "nuget", "thing", &versions);
        let doc: Value = serde_json::from_slice(&body).expect("valid JSON");
        assert_eq!(doc["count"], 3, "three pages");
        assert!(
            doc["items"][0]["items"].is_null(),
            "pages are LINKED, not inlined"
        );

        // …and each linked page is fetchable at the bounds the index names.
        let lower = doc["items"][1]["lower"]
            .as_str()
            .expect("lower")
            .to_string();
        let upper = doc["items"][1]["upper"]
            .as_str()
            .expect("upper")
            .to_string();
        let page = registration_page(
            "https://h.example",
            "nuget",
            "thing",
            &versions,
            &lower,
            &upper,
        )
        .expect("the page the index pointed at exists");
        let p: Value = serde_json::from_slice(&page).expect("valid JSON");
        assert_eq!(p["count"], PAGE_SIZE);
        assert_eq!(p["items"][0]["catalogEntry"]["version"], lower);

        // A page nobody advertised is a 404, not an empty page.
        assert!(registration_page(
            "https://h.example",
            "nuget",
            "thing",
            &versions,
            "9.9.9",
            "9.9.9"
        )
        .is_none());
    }

    #[test]
    fn a_leaf_is_served_by_version_case_insensitively() {
        let versions = vec![pv("Thing", "1.0.0-Beta", &[])];
        let body = registration_leaf(
            "https://h.example",
            "nuget",
            "thing",
            &versions,
            "1.0.0-beta",
        )
        .expect("found");
        let doc: Value = serde_json::from_slice(&body).expect("valid JSON");
        assert_eq!(doc["catalogEntry"]["version"], "1.0.0-Beta");
        assert!(
            registration_leaf("https://h.example", "nuget", "thing", &versions, "2.0.0").is_none()
        );
    }

    /// ★ Search: the resource that lets a human find a package at all, and the
    /// `prerelease` flag that decides whether a beta is a legal answer.
    #[test]
    fn search_filters_by_name_and_hides_prereleases_unless_asked() {
        let thing = vec![pv("Thing", "1.0.0", &[]), pv("Thing", "2.0.0-rc.1", &[])];
        let other = vec![pv("Other.Pkg", "1.0.0", &[])];
        let catalog: Vec<(&str, &Vec<PackageVersion>)> =
            vec![("thing", &thing), ("other.pkg", &other)];

        let all = search_response(
            "https://h.example",
            "nuget",
            catalog.iter().copied(),
            "",
            0,
            20,
            false,
        );
        let d: Value = serde_json::from_slice(&all).expect("valid JSON");
        assert_eq!(d["totalHits"], 2);
        // id-ordered, and the prerelease is NOT the reported version.
        assert_eq!(d["data"][0]["id"], "Other.Pkg");
        assert_eq!(d["data"][1]["id"], "Thing");
        assert_eq!(
            d["data"][1]["version"], "1.0.0",
            "the rc is hidden by default"
        );
        assert_eq!(d["data"][1]["versions"].as_array().expect("array").len(), 1);

        let pre = search_response(
            "https://h.example",
            "nuget",
            catalog.iter().copied(),
            "thing",
            0,
            20,
            true,
        );
        let d: Value = serde_json::from_slice(&pre).expect("valid JSON");
        assert_eq!(d["totalHits"], 1, "q filters");
        assert_eq!(
            d["data"][0]["version"], "2.0.0-rc.1",
            "prerelease=true reveals the rc"
        );

        // skip/take are the paging a client walks a big result with.
        let paged = search_response(
            "https://h.example",
            "nuget",
            catalog.iter().copied(),
            "",
            1,
            1,
            false,
        );
        let d: Value = serde_json::from_slice(&paged).expect("valid JSON");
        assert_eq!(d["totalHits"], 2, "totalHits is the UNPAGED count");
        assert_eq!(d["data"].as_array().expect("array").len(), 1);
        assert_eq!(d["data"][0]["id"], "Thing");
    }
}