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