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 = ®["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());
}
}