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

//! What the cross-repo `GET /-/search` door is allowed to walk, and what it is
//! allowed to be asked to do — over a LIVE gateway with two repositories.
//!
//! Both guarantees depend on the HANDLER, not on the search engine, so neither
//! can be proved by a pure-function test:
//!
//! * **read authorization is per REPOSITORY.** The door authenticates once at the
//!   GLOBAL floor and then searches every repository it has. A per-repo `Denied`
//!   carve-out — the whole reason the `Denied` role exists — has to hold here too,
//!   or the private repository is readable through search results instead of
//!   through its own path.
//! * **the checksum axis is not free.** It is the one axis that fetches and
//!   sha256s every candidate artifact, and it used to be reachable by an
//!   unauthenticated GET.

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

/// Two writable `raw` repositories — `open-repo` and `secret-repo` — behind one
/// cleartext HTTP door, with whatever `auth` block the test needs. Retries on a
/// lost port race so a flake cannot be mistaken for a security regression.
fn start(auth_ron: &str) -> Running {
    for _ in 0..8 {
        let (grpc, http) = (hold_port(), hold_port());
        if let Some(r) = try_start(auth_ron, grpc, http) {
            return r;
        }
    }
    panic!("could not bind a free loopback port pair");
}

fn try_start(auth_ron: &str, grpc: u16, http: u16) -> Option<Running> {
    let data = tempfile::tempdir().unwrap();
    let root = data.path().display().to_string();
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "open-repo", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
         ron_data_dir: Some("{root}/open")),
        (ron_name: "secret-repo", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
         ron_data_dir: Some("{root}/secret")),
    ],
    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: "{root}") ],
    {auth}
)"#,
        auth = auth_ron,
    );
    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().ok()?;
    Some(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}");
}

/// **A per-repo `Denied` covers the cross-repo search.**
///
/// The door read-gated once at the GLOBAL floor (`repo = ""`) and then handed the
/// engine `all_repos()`. An identity carved out of one repository with `Denied`
/// therefore read it anyway — its artifact names, namespaces, versions, sizes and
/// content types — through the search door instead of through the repository's own
/// path. `Denied` is the role that exists to make one repository private; a door
/// that walks past it makes it decorative.
///
/// RED-when-broken: with the per-repository filter removed, `ci`'s search returns
/// the `secret-repo` hit as well and this fails on the first assertion.
#[tokio::test(flavor = "multi_thread")]
async fn a_denied_repository_never_enters_the_cross_repo_search() {
    // `admin` writes both repositories; `ci` reads everywhere EXCEPT secret-repo.
    let auth = r#"auth: Some((
        methods: [
            StaticToken(subject: "admin", token: Some("admintoken")),
            StaticToken(subject: "ci", token: Some("citoken")),
        ],
        require_auth_for_reads: true,
        roles: { "admin": Admin, "ci": Reader },
        repo_roles: { "secret-repo": { "ci": Denied } },
    )),"#;
    let r = start(auth);
    let cl = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&cl, &r.base).await;

    // One findable blob in each repository, under the same searchable name.
    for repo in ["open-repo", "secret-repo"] {
        let put = cl
            .put(format!("{}/{repo}/needle-1.0.bin", r.base))
            .header("authorization", "Bearer admintoken")
            .body(b"the bytes".to_vec())
            .send()
            .await
            .unwrap();
        assert_eq!(put.status(), 201, "admin must be able to seed {repo}");
    }

    // The control: admin, who is denied nothing, finds BOTH — so the artifacts
    // really are indexed and this test is about the identity, not about seeding.
    let all = cl
        .get(format!("{}/-/search?name=needle", r.base))
        .header("authorization", "Bearer admintoken")
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    assert!(all.contains("open-repo"), "admin must see open-repo: {all}");
    assert!(
        all.contains("secret-repo"),
        "admin must see secret-repo: {all}"
    );

    // The guarantee: ci passes the global floor, so the door answers 200 — with
    // the denied repository filtered out of it.
    let resp = cl
        .get(format!("{}/-/search?name=needle", r.base))
        .header("authorization", "Bearer citoken")
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 200, "ci passes the global read floor");
    let seen = resp.text().await.unwrap();
    assert!(
        !seen.contains("secret-repo"),
        "a Denied repository must not appear in search results: {seen}"
    );
    // …and the carve-out is a carve-out, not a wall.
    assert!(
        seen.contains("open-repo"),
        "the repositories ci MAY read must still be searched: {seen}"
    );

    // Naming the denied repository explicitly does not reopen it: the filter is
    // applied to the repository list, not to the caller's request.
    let named = cl
        .get(format!("{}/-/search?name=needle&repo=secret-repo", r.base))
        .header("authorization", "Bearer citoken")
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    assert!(
        !named.contains("secret-repo"),
        "asking for the denied repository by name must not serve it: {named}"
    );
}

/// **The checksum axis requires an authenticated identity.**
///
/// `checksum=` is the only search axis that FETCHES AND HASHES every candidate —
/// `search.rs` pulls each artifact's bytes and sha256s them. One unauthenticated
/// `GET /-/search?checksum=<64 hex>` therefore asked the server to read and digest
/// every artifact in every repository, with no credential, no rate limit and
/// nobody to attribute the work to.
///
/// RED-when-broken: without the gate the anonymous checksum query answers 200 and
/// the store gets hashed on demand.
#[tokio::test(flavor = "multi_thread")]
async fn the_checksum_search_axis_is_closed_to_anonymous_callers() {
    // Open reads (the default airgap-mirror posture) with a token that CAN
    // authenticate — so the refusal below is about the credential, not about the
    // server having no way to check one.
    let auth = r#"access: (reads: Open, front: Open), auth: Some(( methods: [ StaticToken(subject: "ci", token: Some("citoken")) ] )),"#;
    let r = start(auth);
    let cl = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&cl, &r.base).await;

    let digest = "a".repeat(64);

    // Every ordinary axis stays open — the door is not closed, the expensive axis
    // is.
    let cheap = cl
        .get(format!("{}/-/search?name=needle", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(cheap.status(), 200, "the ordinary axes stay open");

    for q in [format!("checksum={digest}"), format!("sha256={digest}")] {
        let s = cl
            .get(format!("{}/-/search?{q}", r.base))
            .send()
            .await
            .unwrap()
            .status();
        assert_eq!(
            s, 403,
            "an anonymous checksum search must be refused ({q}), got {s}"
        );
    }

    // A request that DOES carry a credential keeps the axis, even with the read
    // gate off: the refusal is "who is asking", not "nobody may ask".
    let authed = cl
        .get(format!("{}/-/search?checksum={digest}", r.base))
        .header("authorization", "Bearer citoken")
        .send()
        .await
        .unwrap();
    assert_eq!(
        authed.status(),
        200,
        "an authenticated caller keeps the checksum axis"
    );
}

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