server/lib/tests/real_clients_restore_from_holger.rs
//! **The REAL clients, against a real holger** — `dotnet restore` and
//! `composer install`, each in its official container, each pointed at holger
//! and at nothing else.
//!
//! ?23 gave the nuget backend its registration + search resources and the
//! composer backend its p2 synthesis from bare dists. Both were verified against
//! the NuGet V3 and Composer v2 SPECS only, because the build box carries
//! neither `dotnet` nor `composer`. A spec-reading is a claim about what a
//! client will accept; this file is the measurement. Each test:
//!
//! 1. packs two packages into a real znippy archive — one that DEPENDS on the
//! other, so the client has to resolve a graph, not fetch one file,
//! 2. boots a real holger (gRPC + HTTP) on free ports with that archive behind
//! the backend under test and `ron_base_url` pointing at the HTTP port,
//! 3. runs the official client image under `podman run --network host`, with a
//! client config that names holger as the ONLY source (`<clear/>` /
//! `packagist.org: false`),
//! 4. asserts the exit code, that BOTH packages were restored, and that the
//! bytes the client wrote to its mounted output dir are byte-for-byte what
//! holger served (and what was packed).
//!
//! # Dark guards are refused, not skipped
//!
//! A box without `podman` does not make these tests pass; it makes them FAIL
//! BY NAME (`podman not on PATH`). Same for an image that cannot be pulled. A
//! test that silently skipped would be exactly the shape ?23 already was — a
//! spec read that looked like a proof.
//!
//! # `#[ignore]` by default, run by the arm
//!
//! They pull two container images and take ~10-60 s each, so they are
//! `#[ignore]`d for a plain `cargo test` and REACHED by the two arms in
//! `.nornir/testmatrix-arms.json` (`ignored: true`), which `cargo xtask gate
//! --arms` runs and counts. The `ArmRun::green()` rule refuses an arm that ran
//! zero tests, so the arm cannot quietly stop reaching them either.
//!
//! The test driver calls a container runtime through `std::process::Command`.
//! That is a test invoking `podman`, not a shell script and not a build step;
//! the server under test shells out to nothing.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use traits::test_support::hold_port;
use holger_server_lib::{read_ron_config, wire_holger, Holger};
const DOTNET_IMAGE: &str = "mcr.microsoft.com/dotnet/sdk:8.0";
const COMPOSER_IMAGE: &str = "docker.io/library/composer:2";
// ───────────────────────────── the guards ─────────────────────────────
/// `podman` on `PATH`, or a failure that says so. Never a skip.
fn podman() -> PathBuf {
let path = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path) {
let candidate = dir.join("podman");
if candidate.is_file() {
return candidate;
}
}
panic!(
"podman not on PATH — these tests drive the REAL dotnet/composer clients in \
containers and cannot prove anything without a container runtime. Install \
podman (rootless is enough); do not make this test skip."
);
}
/// The image is present locally, or is pulled once. A pull that fails is a
/// failure that names the image, not a skip.
fn ensure_image(podman: &Path, image: &str) {
let exists = Command::new(podman)
.args(["image", "exists", image])
.status()
.unwrap_or_else(|e| panic!("podman image exists {image}: cannot run podman: {e}"));
if exists.success() {
return;
}
let pull = Command::new(podman)
.args(["pull", image])
.output()
.unwrap_or_else(|e| panic!("podman pull {image}: cannot run podman: {e}"));
assert!(
pull.status.success(),
"image not pullable: {image}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&pull.stdout),
String::from_utf8_lossy(&pull.stderr)
);
}
// ───────────────────────────── the server ─────────────────────────────
/// Pack `(relative_path, bytes)` into a real znippy archive at `<dir>/<stem>.znippy`.
fn pack_archive(dir: &Path, stem: &str, entries: &[(&str, Vec<u8>)]) -> PathBuf {
let base = dir.join(stem);
let compressor = znippy_compress::compress_stream(&base, true).unwrap();
let sender = compressor.sender().clone();
for (path, data) in entries {
sender
.send(znippy_compress::ArchiveEntry {
relative_path: (*path).into(),
data: data.clone(),
pkg_type: None,
repo: None,
})
.unwrap();
}
drop(sender);
compressor.finish().unwrap();
base.with_extension("znippy")
}
struct Running {
_cfg: tempfile::NamedTempFile,
/// `http://127.0.0.1:<http>` — what the client is pointed at AND what the
/// backend advertises as its base URL, so every absolute link in the
/// synthesized metadata points back at this server.
base: String,
}
/// Boot a holger serving ONE archive-backed repo of `repo_type` under `name`.
fn start(name: &str, repo_type: &str, archive: &Path, storage_dir: &Path) -> Running {
let (grpc, http) = (hold_port(), hold_port());
let base = format!("http://127.0.0.1:{http}");
let ron = format!(
r#"(
repositories: [
(
ron_name: "{name}",
ron_repo_type: "{repo_type}",
ron_upstreams: [],
ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
ron_archive_path: Some("{archive}"),
ron_base_url: Some("{base}"),
),
],
exposed_endpoints: [
(ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
],
storage_endpoints: [
(ron_name: "storage", ron_storage_type: "znippy", ron_path: "{storage}"),
],
)"#,
archive = archive.display(),
storage = storage_dir.display(),
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
holger.start().unwrap();
Running { _cfg: cfg, base }
}
async fn wait_healthy(c: &reqwest::Client, base: &str) {
for _ in 0..200 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status() == 200 {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("holger never became healthy at {base}");
}
/// What holger actually put on the wire for `url` — the reference the
/// client's output is compared against.
async fn served(c: &reqwest::Client, url: &str) -> Vec<u8> {
let r = c
.get(url)
.send()
.await
.unwrap_or_else(|e| panic!("GET {url}: {e}"));
assert_eq!(r.status(), 200, "GET {url}");
r.bytes().await.unwrap().to_vec()
}
/// Run one container to completion, `--network host`, and hand back what it
/// said. The mounts are `(host_dir, container_path)` pairs.
fn run_container(
podman: PathBuf,
image: &str,
mounts: Vec<(PathBuf, &str)>,
env: Vec<(&str, String)>,
workdir: &str,
args: Vec<String>,
) -> std::process::Output {
let mut cmd = Command::new(&podman);
cmd.args(["run", "--rm", "--network", "host", "-w", workdir]);
for (host, inner) in mounts {
cmd.arg("-v").arg(format!("{}:{inner}", host.display()));
}
for (k, v) in env {
cmd.arg("-e").arg(format!("{k}={v}"));
}
cmd.arg(image).args(&args);
cmd.output()
.unwrap_or_else(|e| panic!("podman run {image}: {e}"))
}
fn show(out: &std::process::Output) -> String {
format!(
"exit={:?}\n── stdout ──\n{}\n── stderr ──\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
// ───────────────────────────── the fixtures ─────────────────────────────
/// A real `.nupkg`: a zip with a root `.nuspec` and a payload, as `dotnet pack`
/// lays one out. `deps` are `(id, range)` in a `net8.0` group.
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/_._", b"".as_slice()),
("[Content_Types].xml", b"<Types/>".as_slice()),
])
}
/// A real Composer dist: a zip whose root holds the package's `composer.json`.
fn dist(name: &str, requires: &str) -> Vec<u8> {
let manifest = format!(
r#"{{"name":"{name}","description":"a package","type":"library",
"license":["MIT"],"require":{{{requires}}},
"autoload":{{"psr-4":{{"Acme\\":"src/"}}}}}}"#
);
zip_entry::fixture::zip(&[
("composer.json", manifest.as_bytes()),
(
"src/Thing.php",
b"<?php namespace Acme; class Thing {}".as_slice(),
),
])
}
// ───────────────────────────── dotnet ─────────────────────────────
/// ★ `dotnet restore`, from holger only, of a project whose one package
/// reference pulls in a second package through its `.nuspec` dependency group.
///
/// Holger is the ONLY source: `nuget.config` starts with `<clear/>`, so the
/// SDK image's built-in nuget.org is gone and every byte the client restores
/// had to come from the HTTP port this test opened.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "drives the real dotnet SDK container; run by the arm in .nornir/testmatrix-arms.json"]
async fn dotnet_restore_resolves_a_dependency_graph_from_holger_alone() {
let podman = podman();
ensure_image(&podman, DOTNET_IMAGE);
let tmp = tempfile::tempdir().unwrap();
let widget = nupkg("Acme.Widget", "1.2.0", &[("Acme.Core", "[2.0.0, 3.0.0)")]);
let core = nupkg("Acme.Core", "2.1.0", &[]);
// The flat-container layout the backend reads: `{id-lower}/{version}/{file}`.
let archive = pack_archive(
tmp.path(),
"nuget",
&[
("acme.widget/1.2.0/acme.widget.1.2.0.nupkg", widget.clone()),
("acme.core/2.1.0/acme.core.2.1.0.nupkg", core.clone()),
],
);
let storage = tmp.path().join("storage");
std::fs::create_dir_all(&storage).unwrap();
let r = start("nuget", "nuget", &archive, &storage);
let c = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&c, &r.base).await;
// What holger serves for the two leaves — the reference for the client's output.
let served_widget = served(
&c,
&format!(
"{}/nuget/v3-flatcontainer/acme.widget/1.2.0/acme.widget.1.2.0.nupkg",
r.base
),
)
.await;
let served_core = served(
&c,
&format!(
"{}/nuget/v3-flatcontainer/acme.core/2.1.0/acme.core.2.1.0.nupkg",
r.base
),
)
.await;
assert_eq!(
served_widget, widget,
"holger must serve the packed widget bytes"
);
assert_eq!(served_core, core, "holger must serve the packed core bytes");
// The project: one PackageReference, no nuget.org anywhere.
let proj = tmp.path().join("proj");
let pkgs = tmp.path().join("pkgs");
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&pkgs).unwrap();
std::fs::write(
proj.join("proof.csproj"),
"<Project Sdk=\"Microsoft.NET.Sdk\">\n\
<PropertyGroup>\n\
<OutputType>Exe</OutputType>\n\
<TargetFramework>net8.0</TargetFramework>\n\
</PropertyGroup>\n\
<ItemGroup>\n\
<PackageReference Include=\"Acme.Widget\" Version=\"1.2.0\" />\n\
</ItemGroup>\n\
</Project>\n",
)
.unwrap();
std::fs::write(
proj.join("Program.cs"),
"System.Console.WriteLine(\"holger\");\n",
)
.unwrap();
std::fs::write(
proj.join("nuget.config"),
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<configuration>\n\
<packageSources>\n\
<clear />\n\
<add key=\"holger\" value=\"{}/nuget/v3/index.json\" allowInsecureConnections=\"true\" />\n\
</packageSources>\n\
</configuration>\n",
r.base
),
)
.unwrap();
let env = || {
vec![
("NUGET_PACKAGES", "/pkgs".to_string()),
("DOTNET_CLI_TELEMETRY_OPTOUT", "1".to_string()),
("DOTNET_NOLOGO", "1".to_string()),
("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1".to_string()),
("DOTNET_CLI_HOME", "/tmp".to_string()),
]
};
let mounts = || vec![(proj.clone(), "/src"), (pkgs.clone(), "/pkgs")];
let (podman_b, mounts_b, env_b) = (podman.clone(), mounts(), env());
let out = tokio::task::spawn_blocking(move || {
run_container(
podman_b,
DOTNET_IMAGE,
mounts_b,
env_b,
"/src",
["dotnet", "restore", "--verbosity", "normal", "proof.csproj"]
.iter()
.map(|s| s.to_string())
.collect(),
)
})
.await
.unwrap();
assert!(
out.status.success(),
"dotnet restore failed against holger\n{}",
show(&out)
);
// Both packages restored — the dependency edge was followed — and the
// bytes on the mounted output dir are exactly what holger served.
let restored_widget = std::fs::read(pkgs.join("acme.widget/1.2.0/acme.widget.1.2.0.nupkg"))
.unwrap_or_else(|e| panic!("Acme.Widget was not restored: {e}\n{}", show(&out)));
let restored_core = std::fs::read(pkgs.join("acme.core/2.1.0/acme.core.2.1.0.nupkg"))
.unwrap_or_else(|e| {
panic!(
"Acme.Core (the dependency) was not restored: {e}\n{}",
show(&out)
)
});
assert_eq!(
restored_widget, served_widget,
"restored Acme.Widget bytes != served"
);
assert_eq!(
restored_core, served_core,
"restored Acme.Core bytes != served"
);
// The lock the client wrote names both, with the versions holger holds.
let assets = std::fs::read_to_string(proj.join("obj/project.assets.json")).unwrap();
assert!(
assets.contains("\"Acme.Widget/1.2.0\""),
"assets lacks Acme.Widget/1.2.0:\n{assets}"
);
assert!(
assets.contains("\"Acme.Core/2.1.0\""),
"assets lacks Acme.Core/2.1.0:\n{assets}"
);
// And the SEARCH resource, through the same client: `dotnet package search`
// asks `SearchQueryService` — the other half ?23 added.
let (podman_b, mounts_b, env_b) = (podman.clone(), mounts(), env());
let search = tokio::task::spawn_blocking(move || {
run_container(
podman_b,
DOTNET_IMAGE,
mounts_b,
env_b,
"/src",
[
"dotnet", "package", "search", "acme", "--source", "holger", "--format", "json",
]
.iter()
.map(|s| s.to_string())
.collect(),
)
})
.await
.unwrap();
assert!(
search.status.success(),
"dotnet package search failed against holger\n{}",
show(&search)
);
let text = String::from_utf8_lossy(&search.stdout);
assert!(
text.contains("Acme.Widget"),
"search did not list Acme.Widget:\n{}",
show(&search)
);
assert!(
text.contains("Acme.Core"),
"search did not list Acme.Core:\n{}",
show(&search)
);
println!(
"dotnet restore: exit={:?} restored 2 packages, {}+{} bytes matched; package search listed both",
out.status.code(),
restored_widget.len(),
restored_core.len()
);
}
// ───────────────────────────── composer ─────────────────────────────
/// ★ `composer install`, from holger only, of a root `composer.json` requiring
/// a package that requires another — resolved entirely from the p2 documents
/// holger synthesizes out of the `composer.json` inside each bare dist zip.
///
/// `packagist.org: false` and one `composer` repository: every version the
/// solver saw and every zip it downloaded came from this test's HTTP port.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "drives the real composer container; run by the arm in .nornir/testmatrix-arms.json"]
async fn composer_install_resolves_a_dependency_graph_from_holger_alone() {
let podman = podman();
ensure_image(&podman, COMPOSER_IMAGE);
let tmp = tempfile::tempdir().unwrap();
let widget = dist("acme/widget", r#""php":">=8.1","acme/other":"^2.0""#);
let other = dist("acme/other", r#""php":">=8.1""#);
let archive = pack_archive(
tmp.path(),
"composer",
&[
("dist/acme/widget/1.2.0.zip", widget.clone()),
("dist/acme/other/2.3.1.zip", other.clone()),
],
);
let storage = tmp.path().join("storage");
std::fs::create_dir_all(&storage).unwrap();
let r = start("composer", "composer", &archive, &storage);
let c = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&c, &r.base).await;
let served_widget = served(
&c,
&format!("{}/composer/dist/acme/widget/1.2.0.zip", r.base),
)
.await;
let served_other = served(
&c,
&format!("{}/composer/dist/acme/other/2.3.1.zip", r.base),
)
.await;
assert_eq!(
served_widget, widget,
"holger must serve the packed widget dist"
);
assert_eq!(
served_other, other,
"holger must serve the packed other dist"
);
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(
app.join("composer.json"),
format!(
r#"{{
"name": "holger/real-client-proof",
"description": "composer install with holger as the only repository",
"license": "MIT",
"require": {{ "acme/widget": "^1.0" }},
"repositories": [
{{ "type": "composer", "url": "{}/composer" }},
{{ "packagist.org": false }}
],
"config": {{ "secure-http": false }}
}}
"#,
r.base
),
)
.unwrap();
let env = vec![
("COMPOSER_CACHE_DIR", "/app/.cache".to_string()),
("COMPOSER_ALLOW_SUPERUSER", "1".to_string()),
("COMPOSER_NO_INTERACTION", "1".to_string()),
("COMPOSER_HOME", "/tmp/composer-home".to_string()),
];
let mounts = vec![(app.clone(), "/app")];
let podman_b = podman.clone();
let out = tokio::task::spawn_blocking(move || {
run_container(
podman_b,
COMPOSER_IMAGE,
mounts,
env,
"/app",
[
"install",
"--no-dev",
"--no-plugins",
"--no-scripts",
"--prefer-dist",
"-vv",
]
.iter()
.map(|s| s.to_string())
.collect(),
)
})
.await
.unwrap();
assert!(
out.status.success(),
"composer install failed against holger\n{}",
show(&out)
);
// Both packages installed — the require edge was followed.
for (pkg, expect_json) in [
("acme/widget", "\"acme/widget\""),
("acme/other", "\"acme/other\""),
] {
let manifest = app.join("vendor").join(pkg).join("composer.json");
let text = std::fs::read_to_string(&manifest).unwrap_or_else(|e| {
panic!(
"{pkg} was not installed ({}): {e}\n{}",
manifest.display(),
show(&out)
)
});
assert!(
text.contains(expect_json),
"{pkg}: unexpected vendor manifest:\n{text}"
);
}
let lock = std::fs::read_to_string(app.join("composer.lock")).unwrap();
assert!(
lock.contains("\"name\": \"acme/widget\""),
"lock lacks acme/widget:\n{lock}"
);
assert!(
lock.contains("\"name\": \"acme/other\""),
"lock lacks acme/other:\n{lock}"
);
assert!(
lock.contains("\"version\": \"1.2.0\""),
"lock lacks widget 1.2.0:\n{lock}"
);
assert!(
lock.contains("\"version\": \"2.3.1\""),
"lock lacks other 2.3.1:\n{lock}"
);
assert!(
lock.contains(&format!("{}/composer/dist/acme/widget/1.2.0.zip", r.base)),
"lock's dist url must point back at holger:\n{lock}"
);
// The zips composer downloaded (its file cache keeps them verbatim) are
// byte-for-byte what holger served.
let cached = |pkg: &str| -> Vec<u8> {
let dir = app.join(".cache/files").join(pkg);
let mut zips: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| {
panic!(
"no cache dir for {pkg} at {}: {e}\n{}",
dir.display(),
show(&out)
)
})
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "zip").unwrap_or(false))
.collect();
assert_eq!(
zips.len(),
1,
"expected exactly one cached zip for {pkg}: {zips:?}"
);
std::fs::read(zips.remove(0)).unwrap()
};
let cached_widget = cached("acme/widget");
let cached_other = cached("acme/other");
assert_eq!(
cached_widget, served_widget,
"downloaded acme/widget bytes != served"
);
assert_eq!(
cached_other, served_other,
"downloaded acme/other bytes != served"
);
println!(
"composer install: exit={:?} installed 2 packages, {}+{} bytes matched",
out.status.code(),
cached_widget.len(),
cached_other.len()
);
}
/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
let _ = rustls::crypto::ring::default_provider().install_default();
b.tls_certs_only(
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|c| reqwest::Certificate::from_der(c).ok()),
)
}