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

//! The HTTP/OCI door's request-shape guarantees, over a LIVE gateway.
//!
//! Two things the unit tests around `verify_upload_checksum` / `route_key` cannot
//! prove on their own, because both depend on the *handler* wiring them up in the
//! right order:
//!
//! * **checksum-verify-on-deploy** — an `X-Checksum-Sha256` / `-Sha512` header
//!   that does not match the received bytes must refuse the write with 400 and
//!   leave NOTHING stored. The digests are captured before the body is consumed,
//!   so a regression there fails open (the upload lands unverified) and is
//!   invisible to a pure-function test.
//! * **the reserved `/-/` namespace** — every `/-/…` path keys on the segment
//!   `-`, so a meta door that is not configured answers the same 404 as any
//!   unknown path. Probing must not reveal whether a marketplace is mounted.
//!
//! Offline, no auth configured (the default open-read posture), one temp store.

use std::io::Write;
use traits::test_support::hold_port;

use holger_server_lib::{read_ron_config, wire_holger, Holger};

struct Running {
    _data: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    base: String,
}

/// One writable `raw` repository behind a cleartext HTTP door on `http`.
fn start(grpc: u16, http: u16) -> Running {
    let data = tempfile::tempdir().unwrap();
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
         ron_data_dir: Some("{data}")),
    ],
    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: "{data}") ],
)"#,
        data = data.path().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 {
        _data: data,
        _cfg: cfg,
        base: format!("http://127.0.0.1:{http}"),
    }
}

async fn wait_healthy(c: &reqwest::Client, base: &str) {
    for _ in 0..100 {
        if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
            if r.status() == 200 {
                return;
            }
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("gateway never became healthy at {base}");
}

fn sha256_hex(b: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    hex::encode(Sha256::digest(b))
}

fn sha512_hex(b: &[u8]) -> String {
    use sha2::{Digest, Sha512};
    hex::encode(Sha512::digest(b))
}

/// **A digest the client advertised must match the bytes that arrived, or the
/// write is refused and nothing is stored.**
///
/// RED-when-broken: if the headers were read after the body is consumed (or the
/// gate moved below the backend dispatch), a corrupted upload would land and
/// only the response would be wrong — an integrity guarantee that silently
/// stopped guaranteeing.
#[tokio::test(flavor = "multi_thread")]
async fn a_mismatched_checksum_refuses_the_write_and_stores_nothing() {
    let r = start(hold_port(), hold_port());
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &r.base).await;

    const GOOD: &[u8] = b"the artifact bytes, exactly as advertised";

    // The truth: a correct digest is accepted and the bytes come back.
    let put = c
        .put(format!("{}/raw/good.bin", r.base))
        .header("X-Checksum-Sha256", sha256_hex(GOOD))
        .body(GOOD)
        .send()
        .await
        .unwrap();
    assert!(
        put.status().is_success(),
        "a verified upload must be stored, got {}",
        put.status()
    );
    let got = c
        .get(format!("{}/raw/good.bin", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(got.status(), 200);
    assert_eq!(
        got.bytes().await.unwrap().as_ref(),
        GOOD,
        "byte-identical round trip"
    );

    // Uppercase hex is the same digest (the compare is case-insensitive), and so
    // is a differently-cased header name.
    let put = c
        .put(format!("{}/raw/upper.bin", r.base))
        .header("x-checksum-SHA256", sha256_hex(GOOD).to_uppercase())
        .body(GOOD)
        .send()
        .await
        .unwrap();
    assert!(
        put.status().is_success(),
        "uppercase hex is the same digest, got {}",
        put.status()
    );

    // The lie: a digest for OTHER bytes refuses the write.
    let put = c
        .put(format!("{}/raw/corrupt.bin", r.base))
        .header(
            "X-Checksum-Sha256",
            sha256_hex(b"what the client MEANT to send"),
        )
        .body(GOOD)
        .send()
        .await
        .unwrap();
    assert_eq!(put.status(), 400, "a mismatched sha256 must be refused");
    assert!(
        put.text()
            .await
            .unwrap()
            .contains("sha256 checksum mismatch"),
        "the refusal says which digest failed"
    );
    // …and nothing was stored under that path.
    let got = c
        .get(format!("{}/raw/corrupt.bin", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(
        got.status(),
        404,
        "a refused upload must leave nothing behind"
    );

    // sha512 is verified on the same terms, and a correct sha256 does not excuse
    // a wrong sha512.
    let put = c
        .put(format!("{}/raw/corrupt512.bin", r.base))
        .header("X-Checksum-Sha256", sha256_hex(GOOD))
        .header("X-Checksum-Sha512", sha512_hex(b"different bytes entirely"))
        .body(GOOD)
        .send()
        .await
        .unwrap();
    assert_eq!(put.status(), 400, "a mismatched sha512 must be refused too");
    assert!(put
        .text()
        .await
        .unwrap()
        .contains("sha512 checksum mismatch"));
    let got = c
        .get(format!("{}/raw/corrupt512.bin", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(got.status(), 404);

    // Verification stays OPT-IN: no header, no gate.
    let put = c
        .put(format!("{}/raw/unverified.bin", r.base))
        .body(GOOD)
        .send()
        .await
        .unwrap();
    assert!(
        put.status().is_success(),
        "an unadvertised upload is still accepted"
    );
}

/// The unauthenticated door surface: the probes answer, `/metrics` scrapes, and
/// **every `/-/…` path that is not a mounted meta door answers a plain 404** —
/// including `/-/marketplace/*` on a server with no `marketplace` block, so
/// probing cannot tell a server that sells subscriptions from one that does not.
///
/// RED-when-broken: a `/-/…` path that fell through to routing would be looked
/// up as a repository called `-`; a marketplace path that answered anything but
/// 404 would advertise the fulfilment surface to the open internet.
#[tokio::test(flavor = "multi_thread")]
async fn the_probes_answer_and_the_reserved_namespace_reveals_nothing() {
    let r = start(hold_port(), hold_port());
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &r.base).await;

    for probe in ["/healthz", "/readyz"] {
        let resp = c.get(format!("{}{probe}", r.base)).send().await.unwrap();
        assert_eq!(resp.status(), 200, "{probe}");
        assert_eq!(resp.text().await.unwrap(), "ok", "{probe}");
    }

    // The OCI version probe, which clients hit before they authenticate.
    for probe in ["/v2", "/v2/"] {
        let resp = c.get(format!("{}{probe}", r.base)).send().await.unwrap();
        assert_eq!(resp.status(), 200, "{probe}");
        assert_eq!(
            resp.headers()
                .get("docker-distribution-api-version")
                .and_then(|v| v.to_str().ok()),
            Some("registry/2.0"),
            "{probe}"
        );
    }

    // Prometheus scrape (the default profile serves it on any interface).
    let metrics = c.get(format!("{}/metrics", r.base)).send().await.unwrap();
    assert_eq!(metrics.status(), 200);
    assert!(
        metrics
            .headers()
            .get("content-type")
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .starts_with("text/plain; version=0.0.4"),
        "the Prometheus text exposition content type"
    );

    // No `marketplace` block ⇒ every fulfilment path is a plain 404, the same
    // answer as any unknown reserved path. Nothing distinguishes them.
    let landing = c
        .get(format!("{}/-/marketplace/landing?token=probe", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(
        landing.status(),
        404,
        "an unmounted marketplace reveals nothing"
    );
    let callback = c
        .get(format!("{}/-/marketplace/callback?code=x&state=y", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(callback.status(), 404);
    let webhook = c
        .post(format!("{}/-/marketplace/webhook", r.base))
        .body(r#"{"id":"op-1","action":"Unsubscribe"}"#)
        .send()
        .await
        .unwrap();
    assert_eq!(
        webhook.status(),
        404,
        "the lifecycle door is not mounted either"
    );

    // An unknown reserved path is a 404 too — it is never looked up as a
    // repository, and never served by one.
    for path in ["/-/", "/-/nope", "/-/marketplace", "/-/search/extra"] {
        let resp = c.get(format!("{}{path}", r.base)).send().await.unwrap();
        assert_eq!(resp.status(), 404, "{path}");
    }

    // The meta doors that ARE mounted still answer on the open-read default.
    let search = c
        .get(format!("{}/-/search?name=nothing-is-stored-here", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(
        search.status(),
        200,
        "cross-repo search is an open read by default"
    );
    assert!(
        search.text().await.unwrap().starts_with('{'),
        "the search door answers JSON"
    );

    // An unknown repository is a 404 that names no path and no backend.
    let miss = c
        .get(format!("{}/no-such-repo/x", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(miss.status(), 404);
    assert_eq!(miss.text().await.unwrap(), "Unknown repository");
}

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