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

//! **Every write door goes through the intake chain** (SEC-REVIEW 2026-11-01, P2–P4, P9 and
//! the guard for the class).
//!
//! P19 (lane HOL-FIELD-2) closed the HTTP side doors: on a Holger whose policy requires a
//! scan, a write into a repository outside the chain is refused by name
//! (`holger-intake-15`). The review found the same bytes still walking in by the other
//! doors: the streamed gRPC upload, the gRPC put and read of a scanned `oci` registry, a
//! replication out of the chain, and a promotion into a side door. Each of them is proved
//! here against one booted Holger, over a real tonic client, the way a client would use it.
//!
//! The guard at the end (`every_write_door_is_enumerated_here`) reads the proto files and
//! the job kinds: an RPC or a job that writes artifact bytes and is not named in its table
//! fails the build, so a new door cannot ship without its intake answer.

use std::io::{Read, Write};

use holger_server_lib::grpc::holger_proto::promotion_service_client::PromotionServiceClient;
#[cfg(feature = "mirror")]
use holger_server_lib::grpc::holger_proto::replication_service_client::ReplicationServiceClient;
use holger_server_lib::grpc::holger_proto::repository_service_client::RepositoryServiceClient;
#[cfg(feature = "mirror")]
use holger_server_lib::grpc::holger_proto::ReplicateRequest;
use holger_server_lib::grpc::holger_proto::{
    ArtifactId as ProtoId, FetchArtifactRequest, PromoteArtifactRequest, PutArtifactChunk,
    PutArtifactRequest,
};
use holger_server_lib::{read_ron_config, wire_holger, Holger};
use sha2::Digest;
use traits::test_support::hold_port;

type Chan = tonic::transport::Channel;

fn token(who: &str) -> String {
    format!("{who}-token-of-sufficient-length-for-holger")
}

struct Running {
    _data: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    _holger: Holger,
    rt: tokio::runtime::Runtime,
    http: u16,
    grpc: u16,
}

const REQUIRED: &str = "Some((required: [vulnerability]))";

fn start(scan: &str) -> Running {
    let mut last = String::new();
    for _ in 0..8 {
        match try_start(hold_port(), hold_port(), scan, "", "", "") {
            Ok(r) => return r,
            Err(e) => last = e,
        }
    }
    panic!("server never started; last error: {last}");
}

/// `repos`: more repositories (`{data}` is the data directory); `intake_extra`: more keys
/// inside `promotion.intake: (…)`; `top`: more top-level keys.
fn try_start(
    grpc: u16,
    http: u16,
    scan: &str,
    repos: &str,
    intake_extra: &str,
    top: &str,
) -> Result<Running, String> {
    let data = tempfile::tempdir().unwrap();
    let d = data.path().display().to_string();
    let raw = |name: &str, extra: &str| {
        format!(
            r#"(ron_name: "{name}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")),
         ron_data_dir: Some("{d}/{name}"){extra}),"#
        )
    };
    let oci = format!(
        r#"(ron_name: "oci", ron_repo_type: "oci", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")), ron_data_dir: Some("{d}/oci")),"#
    );
    let ron = format!(
        r#"(
    repositories: [ {incoming} {quarantine} {approved} {production} {bundles} {sealed} {oci} {repos} ],
    exposed_endpoints: [ (ron_name: "door", 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: "{d}") ],
    audit: (dir: "{d}/audit", enabled: true),
    access: (reads: Open, front: Open),
    {top}
    auth: Some((
        methods: [ StaticToken(subject: "ida", token: Some("{ida}")), StaticToken(subject: "adm", token: Some("{adm}")) ],
        roles: {{ "ida": Writer, "adm": Admin }},
        duties: {{ "ida": [Importer], "adm": [RepositoryAdmin] }},
    )),
    promotion: Some((
        ladder_name: "intake",
        stages: [
            (name: "incoming", repo: "incoming"), (name: "quarantine", repo: "quarantine"),
            (name: "approved", repo: "approved"), (name: "production", repo: "production"),
        ],
        intake: Some((
            incoming: "incoming", quarantine: "quarantine", approved: "approved", production: "production",
            rules: (sandbox: off),
            approvals: 1,
            scan: {scan},
            {intake_extra}
        )),
    )),
)"#,
        incoming = raw("incoming", ""),
        quarantine = raw("quarantine", ""),
        approved = raw("approved", ""),
        production = raw("production", ""),
        bundles = raw("bundles", ""),
        sealed = raw(
            "monetize-backup",
            ", ron_require_auth: true, ron_require_role: Some(Admin)"
        ),
        ida = token("ida"),
        adm = token("adm"),
        repos = repos.replace("{data}", &d),
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).map_err(|e| format!("config: {e:#}"))?;
    holger
        .instantiate_backends()
        .map_err(|e| format!("backends: {e:#}"))?;
    wire_holger(&mut holger).map_err(|e| format!("wire: {e:#}"))?;
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .unwrap();
    {
        let _in = rt.enter();
        holger.start().map_err(|e| format!("start: {e:#}"))?;
    }
    let r = Running {
        _data: data,
        _cfg: cfg,
        _holger: holger,
        rt,
        http,
        grpc,
    };
    for _ in 0..200 {
        if std::net::TcpStream::connect(("127.0.0.1", http)).is_ok()
            && request(http, "GET", "/healthz", "ida", &[]).0 == 200
        {
            return Ok(r);
        }
        std::thread::sleep(std::time::Duration::from_millis(20));
    }
    Err("never healthy".into())
}

/// One HTTP/1.1 request as `who`: the status and the body.
fn request(port: u16, method: &str, path: &str, who: &str, body: &[u8]) -> (u16, String) {
    let mut c = std::net::TcpStream::connect(("127.0.0.1", port)).expect("connect");
    let head = format!(
        "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        token(who),
        body.len()
    );
    c.write_all(head.as_bytes()).unwrap();
    c.write_all(body).unwrap();
    let mut out = Vec::new();
    c.read_to_end(&mut out).unwrap();
    let text = String::from_utf8_lossy(&out).to_string();
    let status = text
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(0);
    let body = text
        .split_once("\r\n\r\n")
        .map(|(_, b)| b.to_string())
        .unwrap_or_default();
    (status, body)
}

/// One HTTP/1.1 request as `who`: the status and the header block.
fn request_head(port: u16, method: &str, path: &str, who: &str, body: &[u8]) -> (u16, String) {
    let mut c = std::net::TcpStream::connect(("127.0.0.1", port)).expect("connect");
    let head = format!(
        "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        token(who),
        body.len()
    );
    c.write_all(head.as_bytes()).unwrap();
    c.write_all(body).unwrap();
    let mut out = Vec::new();
    c.read_to_end(&mut out).unwrap();
    let text = String::from_utf8_lossy(&out).to_string();
    let status = text
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(0);
    (
        status,
        text.split_once("\r\n\r\n")
            .map(|(h, _)| h.to_string())
            .unwrap_or(text),
    )
}

fn bearer<T>(who: &str, body: T) -> tonic::Request<T> {
    let mut r = tonic::Request::new(body);
    r.metadata_mut().insert(
        "authorization",
        format!("Bearer {}", token(who)).parse().unwrap(),
    );
    r
}

fn id(namespace: &str, name: &str, version: &str) -> Option<ProtoId> {
    Some(ProtoId {
        namespace: namespace.into(),
        name: name.into(),
        version: version.into(),
    })
}

/// A blob coordinate of the `oci` backend (`_blob` / its sha256 hex).
fn blob_id(bytes: &[u8]) -> Option<ProtoId> {
    id("_blob", &hex::encode(sha2::Sha256::digest(bytes)), "")
}

async fn repo_client(r: &Running) -> RepositoryServiceClient<Chan> {
    for _ in 0..250 {
        if let Ok(c) =
            RepositoryServiceClient::connect(format!("http://127.0.0.1:{}", r.grpc)).await
        {
            return c;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("gRPC never came up at {}", r.grpc);
}

async fn put(
    c: &mut RepositoryServiceClient<Chan>,
    who: &str,
    repo: &str,
    id: Option<ProtoId>,
    data: &[u8],
) -> Result<String, tonic::Status> {
    let req = PutArtifactRequest {
        repository: repo.into(),
        id,
        data: data.to_vec().into(),
        async_job: false,
    };
    c.put_artifact(bearer(who, req))
        .await
        .map(|r| r.into_inner().message)
}

/// `PutArtifactStream`, in pieces of 16 bytes: the first names the repository and artifact.
async fn put_streamed(
    c: &mut RepositoryServiceClient<Chan>,
    who: &str,
    repo: &str,
    id: Option<ProtoId>,
    data: &[u8],
) -> Result<String, tonic::Status> {
    let pieces: Vec<PutArtifactChunk> = data
        .chunks(16)
        .enumerate()
        .map(|(i, piece)| PutArtifactChunk {
            repository: if i == 0 { repo.into() } else { String::new() },
            id: if i == 0 { id.clone() } else { None },
            data: piece.to_vec().into(),
            async_job: false,
        })
        .collect();
    c.put_artifact_stream(bearer(who, tokio_stream::iter(pieces)))
        .await
        .map(|r| r.into_inner().message)
}

/// What the gRPC read doors serve of `id` in `repo`: `FetchArtifact`'s bytes and the
/// `StreamArtifact` pieces joined (an error or an empty answer is "not served").
async fn served(
    c: &mut RepositoryServiceClient<Chan>,
    who: &str,
    repo: &str,
    id: Option<ProtoId>,
) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
    let req = FetchArtifactRequest {
        repository: repo.into(),
        id,
    };
    let whole = c
        .fetch_artifact(bearer(who, req.clone()))
        .await
        .ok()
        .map(|r| r.into_inner().data.to_vec())
        .filter(|d| !d.is_empty());
    let joined = match c.stream_artifact(bearer(who, req)).await {
        Ok(s) => {
            let mut s = s.into_inner();
            let mut out = Vec::new();
            let mut ok = true;
            loop {
                match s.message().await {
                    Ok(Some(p)) => out.extend_from_slice(&p.data),
                    Ok(None) => break,
                    Err(_) => {
                        ok = false;
                        break;
                    }
                }
            }
            (ok && !out.is_empty()).then_some(out)
        }
        Err(_) => None,
    };
    (whole, joined)
}

fn refused_by_name(r: &Result<String, tonic::Status>, code: &str, what: &str) {
    match r {
        Ok(m) => panic!("{what} is refused, but it was taken: {m}"),
        Err(s) => {
            assert!(
                matches!(
                    s.code(),
                    tonic::Code::PermissionDenied | tonic::Code::FailedPrecondition
                ),
                "{what}: {:?} {}",
                s.code(),
                s.message()
            );
            assert!(s.message().contains(code), "{what}: {}", s.message());
        }
    }
}

// ── P2: the streamed gRPC upload into a side door ─────────────────────────────

/// SEC-REVIEW P2: `PutArtifactStream` took the streamed path past `write_refusal` when no
/// ingest gate was configured (the default), so a Writer streamed bytes into a side door and
/// HTTP served them.
#[test]
fn a_streamed_grpc_upload_into_a_side_door_is_refused() {
    let r = start(REQUIRED);
    r.rt.block_on(async {
        let mut c = repo_client(&r).await;
        let body = b"lodash 4.17.15, never scanned, in several pieces";
        let got = put_streamed(
            &mut c,
            "ida",
            "bundles",
            id("", "x/lodash-4.17.15.tgz", ""),
            body,
        )
        .await;
        refused_by_name(
            &got,
            "holger-intake-15",
            "a streamed upload into the raw bundles repository",
        );
        let got = put(
            &mut c,
            "ida",
            "bundles",
            id("", "x/lodash-4.17.15.tgz", ""),
            body,
        )
        .await;
        refused_by_name(
            &got,
            "holger-intake-15",
            "a whole upload into the raw bundles repository",
        );
    });
    assert_ne!(
        request(r.http, "GET", "/bundles/x/lodash-4.17.15.tgz", "ida", &[]).0,
        200,
        "nothing refused is served"
    );
}

// ── P3: gRPC and the scanned `oci` registry ───────────────────────────────────

/// SEC-REVIEW P3: a scanned registry is no side door (its HTTP pushes walk the chain), so
/// `write_refusal` was None for it and the gRPC put wrote straight into its backend; the
/// gRPC reads served its staged blobs raw. A scanned registry takes pushes only over its
/// OCI door, and its gRPC reads serve nothing staged.
#[test]
fn grpc_never_writes_or_reads_past_a_scanned_registry() {
    let r = start(REQUIRED);
    let blob = b"a layer nobody scanned".to_vec();
    r.rt.block_on(async {
        let mut c = repo_client(&r).await;
        let got = put(&mut c, "ida", "oci", blob_id(&blob), &blob).await;
        refused_by_name(
            &got,
            "holger-intake-11",
            "a gRPC put into the scanned oci registry",
        );
        let got = put_streamed(&mut c, "ida", "oci", blob_id(&blob), &blob).await;
        refused_by_name(
            &got,
            "holger-intake-11",
            "a streamed gRPC put into the scanned oci registry",
        );
        let manifest = br#"{"schemaVersion":2,"layers":[]}"#;
        let got = put(&mut c, "ida", "oci", id("demo", "app", "latest"), manifest).await;
        refused_by_name(
            &got,
            "holger-intake-11",
            "a gRPC manifest put into the scanned oci registry",
        );
    });

    // A blob staged by the OCI door (a push in progress) is never served by gRPC.
    let digest = format!("sha256:{}", hex::encode(sha2::Sha256::digest(&blob)));
    let (s, head) = request_head(
        r.http,
        "POST",
        "/v2/oci/demo/app/blobs/uploads/",
        "ida",
        &[],
    );
    assert_eq!(s, 202, "an upload starts: {head}");
    let loc = head
        .lines()
        .find_map(|l| {
            l.split_once(':')
                .filter(|(k, _)| k.eq_ignore_ascii_case("location"))
                .map(|(_, v)| v.trim().trim_start_matches("http://127.0.0.1").to_string())
        })
        .expect("a Location");
    let sep = if loc.contains('?') { '&' } else { '?' };
    let (s, b) = request(
        r.http,
        "PUT",
        &format!("{loc}{sep}digest={digest}"),
        "ida",
        &blob,
    );
    assert_eq!(s, 201, "the OCI door stages the blob: {b}");
    r.rt.block_on(async {
        let mut c = repo_client(&r).await;
        for who in ["ida", "adm"] {
            let (whole, joined) = served(&mut c, who, "oci", blob_id(&blob)).await;
            assert_eq!(whole, None, "FetchArtifact serves no staged blob to {who}");
            assert_eq!(
                joined, None,
                "StreamArtifact serves no staged blob to {who}"
            );
        }
    });
    // Nor does a path outside `/v2/oci/` reach the raw backend.
    for path in [
        format!("/oci/_blob/{}", &digest[7..]),
        "/oci/demo/app".to_string(),
    ] {
        assert_ne!(request(r.http, "GET", &path, "ida", &[]).0, 200, "{path}");
        assert!(
            request(r.http, "PUT", &path, "ida", b"x").0 >= 400,
            "{path}: a write outside the OCI door is refused"
        );
    }
}

/// SEC-REVIEW P3: the npm door holds nothing of its own; a door with storage had a store a
/// raw write filled around `npm publish`, so it is refused at boot by name.
#[test]
fn an_npm_door_with_storage_is_refused_at_boot() {
    for storage in [
        r#"ron_writable_archive: Some("{data}/npm.znippy")"#,
        r#"ron_data_dir: Some("{data}/npm")"#,
    ] {
        let npm = format!(
            r#"(ron_name: "npm", ron_repo_type: "npm", ron_upstreams: [], ron_in: None,
             ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")), {storage}),"#
        );
        let e = try_start(
            hold_port(),
            hold_port(),
            REQUIRED,
            &npm,
            r#"npm: Some("npm"),"#,
            "",
        )
        .err()
        .unwrap_or_else(|| panic!("an npm door with {storage} is refused at boot"));
        assert!(
            e.contains("holger-npm-5") && e.contains("npm"),
            "{storage}: {e}"
        );
    }
}

// ── P4: replication out of the chain ──────────────────────────────────────────

// The ReplicationService is served with the `mirror` feature.
#[cfg(feature = "mirror")]
async fn replicate(r: &Running, who: &str, from: &str, to: &str) -> Result<String, tonic::Status> {
    let mut c = ReplicationServiceClient::connect(format!("http://127.0.0.1:{}", r.grpc))
        .await
        .expect("gRPC");
    let req = ReplicateRequest {
        from_repo: from.into(),
        to_repo: to.into(),
        execute: true,
        limit: 0,
        async_job: false,
    };
    c.replicate(bearer(who, req)).await.map(|r| {
        let r = r.into_inner();
        format!("copied {} failed {}: {}", r.copied, r.failed, r.message)
    })
}

/// SEC-REVIEW P4: `Replicate` read the source's raw backend and wrote the destination's, so
/// an Admin copied INGESTED (or Admin-only) bytes into a served repository. A replication
/// copies only what a reader may be served (production's AVAILABLE set, through the chain's
/// view) and never into a repository the chain or the scan policy closes.
#[cfg(feature = "mirror")]
#[test]
fn a_replication_never_copies_unapproved_bytes_into_a_served_repository() {
    let r = start(REQUIRED);
    let (s, b) = request(
        r.http,
        "PUT",
        "/incoming/x/evil.txt",
        "ida",
        b"INGESTED, unscanned",
    );
    assert_eq!(s, 201, "{b}");
    let (s, b) = request(
        r.http,
        "PUT",
        "/monetize-backup/k/material",
        "adm",
        b"sealed key",
    );
    assert!((200..300).contains(&s), "{s} {b}");

    r.rt.block_on(async {
        for (from, to, code) in [
            ("incoming", "bundles", "holger-intake-"),
            ("quarantine", "bundles", "holger-intake-"),
            ("monetize-backup", "bundles", "holger-intake-15"),
            ("production", "bundles", "holger-intake-15"),
            ("bundles", "production", "holger-intake-"),
            ("bundles", "incoming", "holger-intake-"),
            ("incoming", "oci", "holger-intake-"),
        ] {
            let got = replicate(&r, "adm", from, to).await;
            refused_by_name(&got, code, &format!("Replicate {from} → {to}"));
        }
    });
    for path in ["/bundles/x/evil.txt", "/bundles/k/material"] {
        assert_ne!(
            request(r.http, "GET", path, "ida", &[]).0,
            200,
            "{path} is never served"
        );
    }
}

/// Without a required scan there are no side doors, but the chain still holds its own:
/// INGESTED bytes never leave incoming by a replication.
#[cfg(feature = "mirror")]
#[test]
fn without_a_required_scan_a_replication_still_never_empties_incoming() {
    let r = start("None");
    let (s, b) = request(
        r.http,
        "PUT",
        "/incoming/x/evil.txt",
        "ida",
        b"INGESTED, unscanned",
    );
    assert_eq!(s, 201, "{b}");
    r.rt.block_on(async {
        let got = replicate(&r, "adm", "incoming", "bundles").await;
        refused_by_name(&got, "holger-intake-", "Replicate incoming → bundles");
    });
    assert_ne!(
        request(r.http, "GET", "/bundles/x/evil.txt", "ida", &[]).0,
        200
    );
}

// ── P5: upstream proxies where a scan is required ─────────────────────────────

fn boot_error(scan: &str, repos: &str) -> String {
    try_start(
        hold_port(),
        hold_port(),
        scan,
        repos,
        "",
        "network: Online,",
    )
    .err()
    .unwrap_or_else(|| panic!("refused at boot: {repos}"))
}

/// SEC-REVIEW P5: a `*-remote` proxy and a repository with `ron_upstreams` serve (and cache)
/// upstream bytes no scan saw, and `ron_upstreams: ["incoming"]` served the chain's raw
/// store. Where a scan is required both are refused at boot, by name; an upstream naming a
/// chain repository is refused on every Holger with a chain.
#[test]
fn an_unscanned_upstream_is_refused_at_boot() {
    let remote = r#"(ron_name: "crates-io", ron_repo_type: "rust-remote", ron_base_url: Some("https://index.crates.io"), ron_upstreams: [], ron_in: None, ron_out: None),"#;
    let e = boot_error(REQUIRED, remote);
    assert!(
        e.contains("holger-intake-17") && e.contains("crates-io"),
        "{e}"
    );

    let cache = r#"(ron_name: "cache", ron_repo_type: "raw", ron_upstreams: ["bundles"], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")), ron_data_dir: Some("{data}/cache")),"#;
    let e = boot_error(REQUIRED, cache);
    assert!(e.contains("holger-intake-17") && e.contains("cache"), "{e}");

    let leak = r#"(ron_name: "leak", ron_repo_type: "raw", ron_upstreams: ["incoming"], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")), ron_data_dir: Some("{data}/leak")),"#;
    for scan in [REQUIRED, "None"] {
        let e = boot_error(scan, leak);
        assert!(
            e.contains("holger-intake-17") && e.contains("leak") && e.contains("incoming"),
            "scan {scan}: {e}"
        );
    }
}

/// Without a required scan a proxy outside the chain still boots: nothing changes there.
#[test]
fn without_a_required_scan_a_proxy_outside_the_chain_boots() {
    let cache = r#"(ron_name: "cache", ron_repo_type: "raw", ron_upstreams: ["bundles"], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")), ron_data_dir: Some("{data}/cache")),"#;
    let mut last = String::new();
    for _ in 0..4 {
        match try_start(hold_port(), hold_port(), "None", cache, "", "") {
            Ok(_) => return,
            Err(e) => last = e,
        }
    }
    panic!("a proxy outside the chain boots where no scan is required: {last}");
}

// ── P8: a group serves its members raw ────────────────────────────────────────

/// SEC-REVIEW P8: a `group` serves its members' raw backends under the group's own gate, so
/// a group naming `incoming` served INGESTED bytes, and one naming an Admin-only repository
/// served its bytes to any reader of the group. Both are refused at boot, by name.
#[test]
fn a_group_never_serves_the_chain_or_an_admin_only_repository_raw() {
    for member in ["incoming", "production", "oci", "monetize-backup"] {
        let group = format!(
            r#"(ron_name: "all", ron_repo_type: "group", ron_members: ["bundles", "{member}"], ron_upstreams: [], ron_in: None,
             ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door"))),"#
        );
        let e = try_start(hold_port(), hold_port(), "None", &group, "", "")
            .err()
            .unwrap_or_else(|| panic!("a group with {member} is refused at boot"));
        assert!(
            e.contains("holger-intake-19") && e.contains("all") && e.contains(member),
            "{member}: {e}"
        );
    }
}

// ── P9: a promotion into a side door ──────────────────────────────────────────

/// SEC-REVIEW P9: `PromoteArtifact` checked only the chain's four repositories as its
/// destination, so a promotion wrote into a side door the HTTP door refuses.
#[test]
fn a_promotion_into_a_side_door_is_refused() {
    let r = start(REQUIRED);
    let (s, b) = request(
        r.http,
        "PUT",
        "/monetize-backup/k/material",
        "adm",
        b"sealed key",
    );
    assert!((200..300).contains(&s), "{s} {b}");
    r.rt.block_on(async {
        let mut c = PromotionServiceClient::connect(format!("http://127.0.0.1:{}", r.grpc))
            .await
            .expect("gRPC");
        let req = PromoteArtifactRequest {
            from_repo: "monetize-backup".into(),
            to_repo: "bundles".into(),
            artifact: id("", "k/material", ""),
            async_job: false,
        };
        let got = c
            .promote_artifact(bearer("adm", req))
            .await
            .map(|r| r.into_inner().message);
        refused_by_name(
            &got,
            "holger-intake-15",
            "PromoteArtifact monetize-backup → bundles",
        );
    });
    assert_ne!(
        request(r.http, "GET", "/bundles/k/material", "ida", &[]).0,
        200
    );
}

// ── P10: one predicate for "a chain view" ─────────────────────────────────────

/// SEC-REVIEW P10: the side doors exempted every archive-less view format, but the wiring
/// makes a chain view only of a repository with no `ron_handler`; a handler-backed deb
/// repository was neither a view nor a side door. One predicate decides both now.
#[test]
fn a_handler_backed_repository_of_a_view_format_is_a_side_door() {
    let data = tempfile::tempdir().unwrap();
    let d = data.path().display();
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "incoming", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: None, ron_data_dir: Some("{d}/i")),
        (ron_name: "quarantine", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: None, ron_data_dir: Some("{d}/q")),
        (ron_name: "approved", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: None, ron_data_dir: Some("{d}/a")),
        (ron_name: "production", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: None, ron_data_dir: Some("{d}/p")),
        (ron_name: "debs", ron_repo_type: "deb", ron_upstreams: [], ron_in: None, ron_out: None, ron_handler: Some("my-deb")),
        (ron_name: "view", ron_repo_type: "deb", ron_upstreams: [], ron_in: None, ron_out: None),
    ],
    exposed_endpoints: [],
    storage_endpoints: [],
    audit: (dir: "{d}/audit", enabled: true),
    promotion: Some((
        ladder_name: "intake",
        stages: [
            (name: "incoming", repo: "incoming"), (name: "quarantine", repo: "quarantine"),
            (name: "approved", repo: "approved"), (name: "production", repo: "production"),
        ],
        intake: Some((
            incoming: "incoming", quarantine: "quarantine", approved: "approved", production: "production",
            rules: (sandbox: off), approvals: 1, scan: {REQUIRED},
        )),
    )),
)"#
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let h = read_ron_config(cfg.path()).expect("parses");
    let doors = h.side_doors().expect("side doors");
    assert!(
        doors.contains_key("debs"),
        "a handler-backed deb repository is a side door: {doors:?}"
    );
    assert!(
        !doors.contains_key("view"),
        "the archive-less deb repository is a chain view: {doors:?}"
    );
}

// ── the guard: every door is named here ───────────────────────────────────────

/// What one gRPC method does to artifact bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Door {
    /// Reads, lists, reports: no artifact bytes land anywhere.
    Reads,
    /// Writes state that is not an artifact a reader is served (keys, tokens, properties,
    /// votes, an SBOM document, entitlements, twin and grow control).
    NoArtifactBytes,
    /// Lands artifact bytes only through the intake chain's own door (an ingest into
    /// incoming, a signed bundle through the import door, a twin's recharge of the same chain).
    ThroughTheChain,
    /// Lands artifact bytes raw; the chain's refusal is asked first, and the named test of
    /// this file proves it.
    Refused(&'static str),
}

/// **Every gRPC method, by what it does to artifact bytes.** A method missing here fails
/// [`every_write_door_is_enumerated_here`]: a new door does not ship until it says how its
/// bytes meet the intake chain (and a raw one names the test that proves its refusal).
const DOORS: &[(&str, Door)] = &[
    ("AccountService.MyRepositories", Door::Reads),
    ("AccountService.CreateRepository", Door::NoArtifactBytes),
    ("AccountService.Grant", Door::NoArtifactBytes),
    ("BundleService.Export", Door::Reads),
    ("BundleService.AllowFirstImport", Door::NoArtifactBytes),
    ("FirstKeyService.Invite", Door::NoArtifactBytes),
    ("DownstreamService.Invite", Door::NoArtifactBytes),
    ("EstateService.Manifest", Door::Reads),
    ("EstateService.Fetch", Door::Reads),
    ("GrowService.Start", Door::NoArtifactBytes),
    ("GrowService.Status", Door::Reads),
    ("GrowService.Resume", Door::NoArtifactBytes),
    ("GrowService.Flush", Door::NoArtifactBytes),
    ("GrowService.TwinReport", Door::NoArtifactBytes),
    ("GrowService.Twins", Door::Reads),
    ("GrowService.DownstreamReport", Door::NoArtifactBytes),
    ("GrowService.Downstreams", Door::Reads),
    ("EntitlementService.Get", Door::Reads),
    ("EntitlementService.Set", Door::NoArtifactBytes),
    ("EntitlementService.List", Door::Reads),
    ("EntitlementService.Purge", Door::NoArtifactBytes),
    ("RoomService.Room", Door::Reads),
    ("RoomService.PrivateEstate", Door::NoArtifactBytes),
    ("TokenService.Mint", Door::NoArtifactBytes),
    ("TokenService.List", Door::Reads),
    ("TokenService.Revoke", Door::NoArtifactBytes),
    ("RepositoryService.FetchArtifact", Door::Reads),
    ("RepositoryService.ListArtifacts", Door::Reads),
    (
        "RepositoryService.PutArtifact",
        Door::Refused("grpc_never_writes_or_reads_past_a_scanned_registry"),
    ),
    ("RepositoryService.StreamArtifact", Door::Reads),
    (
        "RepositoryService.PutArtifactStream",
        Door::Refused("a_streamed_grpc_upload_into_a_side_door_is_refused"),
    ),
    ("ArchiveService.ListArchiveFiles", Door::Reads),
    ("ArchiveService.ArchiveInfo", Door::Reads),
    ("AdminService.Health", Door::Reads),
    ("AdminService.ListRepositories", Door::Reads),
    ("AdminService.ServerProfile", Door::Reads),
    ("AdminService.WhoAmI", Door::Reads),
    ("AdminService.Capacity", Door::Reads),
    ("AdminService.BecomeTwin", Door::NoArtifactBytes),
    ("AdminService.TwinCsr", Door::NoArtifactBytes),
    ("AdminService.IssueTwinCertificate", Door::NoArtifactBytes),
    ("AdminService.InstallTwinCertificate", Door::NoArtifactBytes),
    ("AdminService.Recharge", Door::ThroughTheChain),
    ("AdminService.RechargeStatus", Door::Reads),
    ("AdminService.TwinStatus", Door::Reads),
    ("AdminService.FencePrimary", Door::NoArtifactBytes),
    ("AdminService.AdminSnapshot", Door::Reads),
    ("AdminService.Topology", Door::Reads),
    ("AdminService.FlowSvg", Door::Reads),
    ("AdminService.SecuritySummary", Door::Reads),
    ("AdminService.ImportIntelBundle", Door::NoArtifactBytes),
    ("AdminService.ImportBundle", Door::ThroughTheChain),
    (
        "PromotionService.PromoteArtifact",
        Door::Refused("a_promotion_into_a_side_door_is_refused"),
    ),
    ("PromotionService.AdvanceArtifact", Door::ThroughTheChain),
    ("PromotionService.GetArtifactState", Door::Reads),
    ("PromotionService.ListIntakeArtifacts", Door::Reads),
    ("PromotionService.GetArtifactEvidence", Door::Reads),
    ("PromotionService.ListDependencySets", Door::Reads),
    ("PromotionService.GetDependencySet", Door::Reads),
    (
        "PromotionService.ApproveDependencySet",
        Door::ThroughTheChain,
    ),
    (
        "PromotionService.RefuseDependencySet",
        Door::ThroughTheChain,
    ),
    ("AuditService.GetArtifactTrail", Door::Reads),
    ("AuditService.VerifyAuditChain", Door::Reads),
    ("AuditService.QueryDownloads", Door::Reads),
    ("AuditService.QueryAudit", Door::Reads),
    ("AuditService.AggregateAudit", Door::Reads),
    ("AuditService.ExportAudit", Door::Reads),
    ("SearchService.Search", Door::Reads),
    ("SbomService.AttachSbom", Door::NoArtifactBytes),
    ("SbomService.FetchSbom", Door::Reads),
    ("PropertyService.SetProperty", Door::NoArtifactBytes),
    ("PropertyService.GetProperties", Door::Reads),
    (
        "ReplicationService.Replicate",
        Door::Refused("a_replication_never_copies_unapproved_bytes_into_a_served_repository"),
    ),
    ("MigrationService.Discover", Door::Reads),
    (
        "MigrationService.Start",
        Door::Refused("a_migration_never_lands_bytes_past_the_chain"),
    ),
    ("MigrationService.Watch", Door::Reads),
    ("MigrationService.Cancel", Door::NoArtifactBytes),
    ("UsageService.ReadUsage", Door::Reads),
    ("JobService.GetJob", Door::Reads),
    ("JobService.ListJobs", Door::Reads),
    ("AdmissionService.AdmitPlan", Door::NoArtifactBytes),
    ("AdmissionService.ListUsage", Door::Reads),
];

/// `Service.Method` of every `rpc` in the proto files.
fn proto_methods() -> std::collections::BTreeSet<String> {
    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../proto");
    let mut out = std::collections::BTreeSet::new();
    for entry in std::fs::read_dir(&dir).expect("the proto directory") {
        let path = entry.unwrap().path();
        if path.extension().and_then(|e| e.to_str()) != Some("proto") {
            continue;
        }
        let mut service = String::new();
        for line in std::fs::read_to_string(&path).unwrap().lines() {
            let t = line.trim_start();
            if let Some(rest) = t.strip_prefix("service ") {
                service = rest.split_whitespace().next().unwrap_or("").to_string();
            } else if let Some(rest) = t.strip_prefix("rpc ") {
                let name = rest.split(['(', ' ']).next().unwrap_or("");
                out.insert(format!("{service}.{name}"));
            }
        }
    }
    out
}

/// **The guard for the class** (SEC-REVIEW 2026-11-01): every gRPC method is classified in
/// [`DOORS`], every raw-byte door names the test of this file that proves the chain refuses
/// it, and every job kind answers [`holger_server_lib::jobs::Job::writes`] (an exhaustive
/// match, so a new kind does not compile until it does). The HTTP door is one handler; its
/// write verbs into every kind of repository are walked by
/// [`every_http_write_verb_meets_the_chain`].
#[test]
fn every_write_door_is_enumerated_here() {
    let methods = proto_methods();
    assert!(methods.len() > 50, "the protos were read: {methods:?}");
    let named: std::collections::BTreeMap<&str, Door> = DOORS.iter().copied().collect();
    let missing: Vec<&String> = methods
        .iter()
        .filter(|m| !named.contains_key(m.as_str()))
        .collect();
    assert!(
        missing.is_empty(),
        "a new gRPC door: say in DOORS how its bytes meet the intake chain: {missing:?}"
    );
    let stale: Vec<&&str> = named.keys().filter(|k| !methods.contains(**k)).collect();
    assert!(
        stale.is_empty(),
        "DOORS names methods no proto declares: {stale:?}"
    );
    let me = include_str!("every_write_door_goes_through_intake.rs");
    for (method, door) in DOORS {
        if let Door::Refused(test) = door {
            assert!(
                me.contains(&format!("fn {test}()")),
                "{method}: its proof `{test}` is no test of this file"
            );
        }
    }
}

/// The HTTP half of the guard: every write verb into every kind of repository either is
/// refused or lands in the chain's incoming (never served), except the Admin-only
/// repository, which serves nobody but an Admin.
#[test]
fn every_http_write_verb_meets_the_chain() {
    let r = start(REQUIRED);
    for repo in ["bundles", "quarantine", "approved", "production", "oci"] {
        for verb in ["PUT", "POST", "PATCH", "DELETE"] {
            let path = format!("/{repo}/guard/{verb}.txt");
            let (s, b) = request(r.http, verb, &path, "ida", b"guard");
            assert!(s >= 400, "{verb} {path} is refused: {s} {b}");
            assert_ne!(request(r.http, "GET", &path, "ida", &[]).0, 200, "{path}");
        }
    }
    let (s, b) = request(r.http, "PUT", "/incoming/guard/in.txt", "ida", b"guard");
    assert_eq!(s, 201, "incoming INGESTS: {b}");
    for repo in ["incoming", "production"] {
        let path = format!("/{repo}/guard/in.txt");
        assert_ne!(request(r.http, "GET", &path, "ida", &[]).0, 200, "{path}");
    }
}

/// `MigrationService.Start` landed a Nexus or Artifactory repository's assets raw into this
/// Holger's repositories. With an intake chain it is refused by name: the chain's import
/// door (a signed bundle) and incoming are the ways in.
#[test]
fn a_migration_never_lands_bytes_past_the_chain() {
    let src = include_str!("../src/grpc/migration.rs");
    assert!(
        src.contains("INTAKE_RAW_IMPORT_REFUSED"),
        "MigrationService.Start asks the intake chain before it plans a byte"
    );
}