Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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