diff --git a/Cargo.lock b/Cargo.lock index d42c370..ad47d0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,10 +3300,11 @@ dependencies = [ [[package]] name = "git-remote-gitlawb" -version = "0.3.9" +version = "0.4.0" dependencies = [ "anyhow", "gitlawb-core", + "mockito", "reqwest", "tracing", "tracing-subscriber", @@ -3311,7 +3312,7 @@ dependencies = [ [[package]] name = "gitlawb-attest" -version = "0.3.9" +version = "0.4.0" dependencies = [ "base64", "ed25519-dalek", @@ -3328,7 +3329,7 @@ dependencies = [ [[package]] name = "gitlawb-core" -version = "0.3.9" +version = "0.4.0" dependencies = [ "anyhow", "base64", @@ -3355,7 +3356,7 @@ dependencies = [ [[package]] name = "gitlawb-node" -version = "0.3.9" +version = "0.4.0" dependencies = [ "alloy", "anyhow", @@ -3411,7 +3412,7 @@ dependencies = [ [[package]] name = "gl" -version = "0.3.9" +version = "0.4.0" dependencies = [ "alloy", "anyhow", diff --git a/crates/git-remote-gitlawb/Cargo.toml b/crates/git-remote-gitlawb/Cargo.toml index c772a52..695bd3b 100644 --- a/crates/git-remote-gitlawb/Cargo.toml +++ b/crates/git-remote-gitlawb/Cargo.toml @@ -16,3 +16,6 @@ anyhow = { workspace = true } reqwest = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } + +[dev-dependencies] +mockito = "1" diff --git a/crates/git-remote-gitlawb/src/main.rs b/crates/git-remote-gitlawb/src/main.rs index 63c3b79..e3a9c44 100644 --- a/crates/git-remote-gitlawb/src/main.rs +++ b/crates/git-remote-gitlawb/src/main.rs @@ -14,7 +14,10 @@ //! # Protocol flow (connect capability) //! capabilities → "connect\n\n" //! connect git-upload-pack → GET /info/refs | POST /git-upload-pack -//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack (+ auth header) +//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack +//! When an identity keypair is present, both the advertisement GET and the pack +//! POST are RFC-9421 signed for BOTH services, so the node authorizes the owner on +//! visibility-gated private repos (fetch and push); public repos work anonymously. use anyhow::{bail, Context, Result}; use gitlawb_core::http_sig::sign_request; @@ -63,7 +66,9 @@ fn main() -> Result<()> { let repo_base = format!("{}/{}/{}", node_base, short_owner, repo_name); tracing::debug!("repo_base: {repo_base}"); - // Load keypair for signing push requests (optional — push still works unsigned in v0.1) + // Load keypair for signing requests (optional). When present, the helper signs + // both fetch and push so a private repo's owner is authorized; absent, public + // repos still work and push falls back to unsigned (v0.1 local alpha only). let keypair = load_keypair(); run_helper(&repo_base, keypair.as_ref()) @@ -115,7 +120,7 @@ fn help_text() -> String { \n\ ENVIRONMENT:\n\ \x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\ - \x20 GITLAWB_KEY Identity PEM path for signed pushes (default: ~/.gitlawb/identity.pem)\n\ + \x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\ \x20 GITLAWB_LOG Log filter (default: warn)\n\ \n\ FLAGS:\n\ @@ -201,9 +206,7 @@ fn handle_connect( let refs_url = format!("{}/info/refs?service={}", repo_base, service); tracing::debug!("GET {refs_url}"); - let refs_resp = client - .get(&refs_url) - .header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0") + let refs_resp = build_advertisement_request(&client, &refs_url, keypair) .send() .with_context(|| format!("GET {refs_url}"))?; @@ -266,29 +269,7 @@ fn handle_connect( let post_url = format!("{}/{}", repo_base, service); tracing::debug!("POST {post_url} ({} bytes)", request_body.len()); - // Extract the URL path for signing (e.g., "/z6Mk.../my-repo/git-receive-pack") - let path_for_sig = url_path(&post_url); - - let mut req = client - .post(&post_url) - .header("Content-Type", format!("application/x-{}-request", service)) - .header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0"); - - // Add RFC 9421 HTTP Signature auth on push operations - if service == "git-receive-pack" { - if let Some(kp) = keypair { - let signed = sign_request(kp, "POST", &path_for_sig, &request_body); - req = req - .header("Content-Digest", signed.content_digest) - .header("Signature-Input", signed.signature_input) - .header("Signature", signed.signature); - tracing::debug!("attached RFC 9421 HTTP Signature (DID: {})", kp.did()); - } else { - tracing::warn!( - "no identity keypair found — push will be unsigned (v0.1 local alpha only)" - ); - } - } + let req = build_pack_post_request(&client, &post_url, service, &request_body, keypair); // Attach the body after signing so the pack bytes are moved, not cloned — // packs can be large and the clone doubled peak memory on push. @@ -310,6 +291,68 @@ fn handle_connect( Ok(()) } +// ── Smart-protocol request builders ─────────────────────────────────────────── + +const USER_AGENT: &str = "git/2.0 git-remote-gitlawb/0.1.0"; + +/// Build the Phase-1 ref-advertisement GET, signing it when an identity is +/// present. The node gates the ref advertisement on read visibility for BOTH +/// services, so a private repo's advertisement is denied (404) to an +/// unauthenticated caller; without this signature the repo's own owner can +/// neither fetch (upload-pack) nor push (receive-pack) it. Public repos still +/// work anonymously (no keypair present, or the gate admits anonymous). Sign over +/// the path *and* query (?service=...) because the node verifies the signature +/// over its path_and_query. +fn build_advertisement_request( + client: &reqwest::blocking::Client, + refs_url: &str, + keypair: Option<&Keypair>, +) -> reqwest::blocking::RequestBuilder { + let mut req = client.get(refs_url).header("User-Agent", USER_AGENT); + if let Some(kp) = keypair { + let signed = sign_request(kp, "GET", &url_path(refs_url), b""); + req = req + .header("Content-Digest", signed.content_digest) + .header("Signature-Input", signed.signature_input) + .header("Signature", signed.signature); + tracing::debug!("signed info/refs advertisement (DID: {})", kp.did()); + } + req +} + +/// Build the Phase-2 pack POST, signing it when an identity is present, for BOTH +/// services. The node read-gates the git-upload-pack POST with visibility_check +/// at "/", and separately owner-gates the git-receive-pack POST (signature +/// required, enforced by middleware). The Phase-1 advertisement signature does +/// not carry to this separate request, so a private repo's owner must +/// authenticate here too or their fetch/push is denied. +/// Public-repo fetch still works anonymously when no keypair is present. The body +/// is signed (content-digest) but NOT attached here, so the caller can move the +/// (possibly large) pack bytes into `.body()` rather than clone them. +fn build_pack_post_request( + client: &reqwest::blocking::Client, + post_url: &str, + service: &str, + body: &[u8], + keypair: Option<&Keypair>, +) -> reqwest::blocking::RequestBuilder { + let mut req = client + .post(post_url) + .header("Content-Type", format!("application/x-{}-request", service)) + .header("User-Agent", USER_AGENT); + if let Some(kp) = keypair { + let signed = sign_request(kp, "POST", &url_path(post_url), body); + req = req + .header("Content-Digest", signed.content_digest) + .header("Signature-Input", signed.signature_input) + .header("Signature", signed.signature); + tracing::debug!("signed {service} POST (DID: {})", kp.did()); + } else if service == "git-receive-pack" { + tracing::warn!("no identity keypair found, push will be unsigned (v0.1 local alpha only)"); + } + req +} + // ── URL helpers ─────────────────────────────────────────────────────────────── /// Parse a `gitlawb://did:key:z6Mk.../repo-name` URL. @@ -477,6 +520,224 @@ fn resolve_key_path() -> std::path::PathBuf { mod tests { use super::*; + /// The regression that round-1 missed: the Phase-2 `git-upload-pack` POST was + /// left unsigned, so an owner's fetch of a private repo cleared the (now signed) + /// advertisement and then 404'd on the pack POST. Drive BOTH request builders + /// against a mock node and assert the RFC-9421 headers actually go out on the + /// wire for BOTH services when a keypair is present (the mock only matches when + /// the headers exist, so `.assert()` fails if any are dropped). + #[test] + fn advertisement_and_pack_post_are_signed_for_both_services_with_keypair() { + let kp = Keypair::generate(); + let client = reqwest::blocking::Client::new(); + let body = b"0009done\n".to_vec(); + + for service in ["git-upload-pack", "git-receive-pack"] { + let mut server = mockito::Server::new(); + let repo_base = format!("{}/zOwner/myrepo", server.url()); + + let get_mock = server + .mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string())) + .match_header("signature", mockito::Matcher::Any) + .match_header("signature-input", mockito::Matcher::Any) + .match_header("content-digest", mockito::Matcher::Any) + .with_status(200) + .create(); + let refs_url = format!("{repo_base}/info/refs?service={service}"); + let resp = build_advertisement_request(&client, &refs_url, Some(&kp)) + .send() + .unwrap(); + assert!(resp.status().is_success()); + get_mock.assert(); + + let post_mock = server + .mock("POST", mockito::Matcher::Regex(format!("/{service}$"))) + .match_header("signature", mockito::Matcher::Any) + .match_header("signature-input", mockito::Matcher::Any) + .match_header("content-digest", mockito::Matcher::Any) + .with_status(200) + .create(); + let post_url = format!("{repo_base}/{service}"); + let resp = build_pack_post_request(&client, &post_url, service, &body, Some(&kp)) + .body(body.clone()) + .send() + .unwrap(); + assert!(resp.status().is_success()); + post_mock.assert(); + } + } + + /// Without an identity, the pack POST must go out UNSIGNED so public fetch (and + /// alpha unsigned push) still work. `Matcher::Missing` matches only when the + /// header is absent, so the mock is hit only if NO signature was attached, for + /// BOTH services (the receive-pack iteration also exercises the no-keypair warn + /// branch and confirms it does not block the request). + #[test] + fn pack_post_is_unsigned_without_keypair_for_both_services() { + let client = reqwest::blocking::Client::new(); + let body = b"0009done\n".to_vec(); + + for service in ["git-upload-pack", "git-receive-pack"] { + let mut server = mockito::Server::new(); + let post_url = format!("{}/zOwner/myrepo/{service}", server.url()); + + let post_mock = server + .mock("POST", mockito::Matcher::Regex(format!("/{service}$"))) + .match_header("signature", mockito::Matcher::Missing) + .match_header("signature-input", mockito::Matcher::Missing) + .match_header("content-digest", mockito::Matcher::Missing) + .with_status(200) + .create(); + let resp = build_pack_post_request(&client, &post_url, service, &body, None) + .body(body.clone()) + .send() + .unwrap(); + assert!(resp.status().is_success()); + post_mock.assert(); + } + } + + /// Symmetric negative case for Phase 1: with no identity the advertisement GET + /// carries no signature, so public clones stay anonymous. + #[test] + fn advertisement_get_is_unsigned_without_keypair() { + let client = reqwest::blocking::Client::new(); + let mut server = mockito::Server::new(); + let refs_url = format!( + "{}/zOwner/myrepo/info/refs?service=git-upload-pack", + server.url() + ); + + let get_mock = server + .mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string())) + .match_header("signature", mockito::Matcher::Missing) + .match_header("signature-input", mockito::Matcher::Missing) + .match_header("content-digest", mockito::Matcher::Missing) + .with_status(200) + .create(); + let resp = build_advertisement_request(&client, &refs_url, None) + .send() + .unwrap(); + assert!(resp.status().is_success()); + get_mock.assert(); + } + + /// Cross-crate seam: the signature the CLIENT actually emits (via the real + /// request builders) verifies under the SAME `gitlawb_core::http_sig` + /// primitives the node's `require_signature` uses, over the `@path` the client + /// genuinely transmits (derived from the built reqwest request, exactly as the + /// node derives it from `uri.path_and_query()`). A tampered `@path` must fail: + /// this is the byte-match the whole A1 client fix depends on, now executed end + /// to end (sign here, verify with the node's verifier), not reasoned. + #[test] + fn client_signature_verifies_under_node_verification_for_both_services() { + use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature}; + use gitlawb_core::identity::verify; + use std::collections::HashMap; + + let kp = Keypair::generate(); + let client = reqwest::blocking::Client::new(); + let body = b"0009done\n".to_vec(); + + // Re-implements the node's require_signature verification (auth/mod.rs): + // parse headers, recompute content-digest from the body, rebuild the signing + // string over @method/@path/content-digest, Ed25519-verify. Ok iff the node + // would accept it. + let node_verifies = |method: &str, + path_and_query: &str, + body: &[u8], + sig_input: &str, + sig_header: &str, + content_digest: &str| + -> anyhow::Result<()> { + let sig = HttpSignature::parse(sig_input, sig_header)?; + sig.check_created()?; + assert!( + sig.missing_components().is_empty(), + "signature must cover all required components" + ); + assert_eq!(sig.alg, "ed25519"); + assert_eq!( + content_digest, + compute_content_digest(body), + "content-digest must match the body" + ); + let vk = sig.key_id.to_verifying_key()?; + let mut values = HashMap::new(); + values.insert("@method".to_string(), method.to_uppercase()); + values.insert("@path".to_string(), path_and_query.to_string()); + values.insert("content-digest".to_string(), content_digest.to_string()); + let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input); + let components: Vec<&str> = sig.components.iter().map(String::as_str).collect(); + let signing_string = build_signing_string(&components, sig_params_value, &values)?; + let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?; + verify(&vk, signing_string.as_bytes(), &sig_array)?; + Ok(()) + }; + // @path exactly as the node reconstructs it from the request it receives. + let path_and_query = |req: &reqwest::blocking::Request| match req.url().query() { + Some(q) => format!("{}?{}", req.url().path(), q), + None => req.url().path().to_string(), + }; + let header = |req: &reqwest::blocking::Request, name: &str| { + req.headers() + .get(name) + .unwrap_or_else(|| panic!("missing {name}")) + .to_str() + .unwrap() + .to_string() + }; + + for service in ["git-upload-pack", "git-receive-pack"] { + // Phase-1 advertisement GET (empty body). + let refs_url = format!("http://node.example/zOwner/myrepo/info/refs?service={service}"); + let req = build_advertisement_request(&client, &refs_url, Some(&kp)) + .build() + .unwrap(); + node_verifies( + "GET", + &path_and_query(&req), + b"", + &header(&req, "signature-input"), + &header(&req, "signature"), + &header(&req, "content-digest"), + ) + .expect("client GET signature must verify under the node's verifier"); + + // Phase-2 pack POST (real body). + let post_url = format!("http://node.example/zOwner/myrepo/{service}"); + let req = build_pack_post_request(&client, &post_url, service, &body, Some(&kp)) + .body(body.clone()) + .build() + .unwrap(); + let pq = path_and_query(&req); + node_verifies( + "POST", + &pq, + &body, + &header(&req, "signature-input"), + &header(&req, "signature"), + &header(&req, "content-digest"), + ) + .expect("client POST signature must verify under the node's verifier"); + + // Negative: a tampered @path (the A1 attack surface) must NOT verify. + let tampered = pq.replace(service, "git-evil"); + assert!( + node_verifies( + "POST", + &tampered, + &body, + &header(&req, "signature-input"), + &header(&req, "signature"), + &header(&req, "content-digest"), + ) + .is_err(), + "a tampered @path must fail verification" + ); + } + } + #[test] fn parse_standard_url() { let (did, owner, repo) = parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo").unwrap(); @@ -524,6 +785,21 @@ mod tests { ); } + #[test] + fn url_path_preserves_query_for_signed_advertisement() { + // The Phase-1 advertisement GET is signed over url_path(refs_url), and the + // node verifies the signature over its path_and_query, so the ?service= + // query MUST survive verbatim or the signatures will not byte-match. + assert_eq!( + url_path("http://127.0.0.1:7545/z6Mk/myrepo/info/refs?service=git-upload-pack"), + "/z6Mk/myrepo/info/refs?service=git-upload-pack" + ); + assert_eq!( + url_path("http://127.0.0.1:7545/z6Mk/myrepo/info/refs?service=git-receive-pack"), + "/z6Mk/myrepo/info/refs?service=git-receive-pack" + ); + } + fn args(items: &[&str]) -> Vec { items.iter().map(|s| s.to_string()).collect() } diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index ea4591f..b1b83ec 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -324,6 +324,56 @@ mod authz_guard { }) } + /// Collect `.rs` source files under `dir`. Recursive so the completeness scan + /// covers nested API modules (`api//mod.rs` and deeper), not only the + /// immediate `api/*.rs` children. + fn collect_rs_files(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).expect("read api dir") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + out.extend(collect_rs_files(&path)); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + out + } + + #[test] + fn collect_rs_files_recurses_subdirs() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("a.rs"), "").unwrap(); + std::fs::write(root.join("note.txt"), "").unwrap(); + std::fs::create_dir_all(root.join("sub/deep")).unwrap(); + std::fs::write(root.join("sub/mod.rs"), "").unwrap(); + std::fs::write(root.join("sub/deep/c.rs"), "").unwrap(); + let names: std::collections::HashSet = collect_rs_files(root) + .iter() + .map(|p| { + p.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + assert!(names.contains("a.rs")); + assert!( + names.contains("sub/mod.rs"), + "nested module file must be collected" + ); + assert!( + names.contains("sub/deep/c.rs"), + "deeply nested file must be collected" + ); + assert!( + !names.iter().any(|n| n.ends_with(".txt")), + "non-rs files excluded" + ); + assert_eq!(names.len(), 3); + } + /// Egress gate guard: every repo-scoped handler (`Path<(String, String..)>`) /// must carry an authz marker — a read gate (`authorize_repo_read` / /// `visibility_check`), or a write gate (`require_repo_owner` / `require_owner` @@ -376,29 +426,32 @@ mod authz_guard { "read-guard `sources` lists {f} but the file does not exist" ); } - for entry in std::fs::read_dir(api_dir).expect("read src/api") { - let path = entry.expect("dir entry").path(); + let api_root = std::path::Path::new(api_dir); + for path in collect_rs_files(api_root) { let fname = path.file_name().unwrap().to_string_lossy().into_owned(); - if !fname.ends_with(".rs") || fname == "mod.rs" || listed.contains(fname.as_str()) { + // Skip the guard file itself (the top-level mod.rs) and files already + // covered by the per-handler loop. A nested module file (including a + // nested mod.rs) IS scanned, so a new api// cannot smuggle in + // an ungated repo-scoped handler the scrape never looks at. + if path == api_root.join("mod.rs") || listed.contains(fname.as_str()) { continue; } let src = std::fs::read_to_string(&path).expect("read api file"); let has_repo_handler = handler_names(&src) .iter() .any(|n| is_repo_scoped(&fn_body(&src, n))); + let rel = path.strip_prefix(api_root).unwrap_or(path.as_path()); assert!( !has_repo_handler, - "api/{fname} declares a repo-scoped handler but is not in the egress \ - guard `sources` list — add it so its handlers are gate-checked" + "api/{} declares a repo-scoped handler but is not in the egress \ + guard `sources` list — add it so its handlers are gate-checked", + rel.display() ); } // Repo-scoped reads known to be ungated today, each tracked by an issue. // Remove an entry the moment its gate lands (the staleness assert enforces it). let known_ungated: &[(&str, &str)] = &[ - // info/refs gates only git-upload-pack today; git-receive-pack - // advertisement is ungated until #119 makes the gate unconditional. - ("git_info_refs", "#119"), ("list_certs", "#120"), ("get_cert", "#120"), ("list_issues", "#120"), diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b74f3f6..9e1d94f 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -482,7 +482,7 @@ pub async fn get_tree( // ── Git smart HTTP endpoints ────────────────────────────────────────────── -/// GET /:owner/:repo.git/info/refs?service=git-upload-pack +/// GET /:owner/:repo.git/info/refs?service=git-upload-pack|git-receive-pack pub async fn git_info_refs( State(state): State, Path((owner, repo)): Path<(String, String)>, @@ -508,10 +508,14 @@ pub async fn git_info_refs( .ok_or_else(|| AppError::BadRequest("missing ?service= parameter".into()))?; tracing::debug!(service = %service, repo = %name, "info/refs service"); - // Enforce read (clone/fetch) visibility. The push advertisement - // (service=git-receive-pack) is authorized separately on the - // git-receive-pack POST, so leave it untouched here. - if service == "git-upload-pack" { + // Enforce read visibility on the ref advertisement, for BOTH services. The + // upload-pack (clone/fetch) and receive-pack (push) advertisements expose the + // same ref metadata (branch/tag names and commit tips), so a private repo's + // advertisement must be withheld from a non-reader regardless of which service + // is requested. The push itself stays separately owner-gated on the + // git-receive-pack POST; push access implies read access here, so a + // legitimate pusher (the owner) always clears this gate. + { let rules = state.db.list_visibility_rules(&record.id).await?; let caller = auth.as_ref().map(|e| e.0 .0.as_str()); // Subtree (mode B) rules do not gate the advertisement: refs expose commit @@ -519,7 +523,7 @@ pub async fn git_info_refs( if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny { - tracing::debug!(repo = %name, caller = ?caller, "info/refs read denied by visibility"); + tracing::debug!(repo = %name, caller = ?caller, service = %service, "info/refs read denied by visibility"); return Err(AppError::RepoNotFound(format!("{owner}/{name}"))); } } diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index f7e7f76..128c9c4 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -669,6 +669,436 @@ mod tests { ); } + // ── #119: git-info-refs advertisement gate + client signing ────────────── + + /// A1 read-gate bypass + its client remedy. `git_info_refs` serves BOTH the + /// `git-upload-pack` (clone/fetch) and `git-receive-pack` (push) ref + /// advertisement off one route, but the visibility gate was wrapped in + /// `if service == "git-upload-pack"`, so a private repo's ref advertisement + /// (branch/tag names + commit tips) leaked to any anonymous caller who asked + /// for `?service=git-receive-pack`. The fix gates the advertisement for both + /// services. Because the gate now denies an *unauthenticated* advertisement + /// of a private repo for both services, `git-remote-gitlawb` signs its + /// Phase-1 advertisement GET (over path_and_query) so the owner can still + /// fetch and push; this test exercises that exact request with a REAL + /// RFC-9421 signature through the production `optional_signature` middleware. + /// + /// Denied → 404 (`RepoNotFound`, existence-hiding) at the gate, before disk + /// access. Allowed → the handler clears the gate and falls through to + /// `acquire` + real `git ... --advertise-refs` against a repo absent from the + /// test disk, returning 500; that 500 (anything but 404) is the signal the + /// caller cleared the gate. + #[sqlx::test] + async fn git_info_refs_gates_advertisement_for_both_services(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + // Short owner form in the URL so the signed @path and the node's + // path_and_query() match byte-for-byte; get_repo's owner LIKE + did_matches + // still authorize the full-DID signer as the owner. + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + let mut priv_repo = seed_repo(&owner_did, "ir-priv"); + priv_repo.is_public = false; + state + .db + .create_repo(&priv_repo) + .await + .expect("seed private repo"); + // A public repo to guard against the unconditional gate accidentally + // denying public, anonymous clones. + state + .db + .create_repo(&seed_repo(&owner_did, "ir-pub")) + .await + .expect("seed public repo"); + + // Production-shaped router: the real optional_signature middleware, so a + // signed request is genuinely verified (not the injected-DID shortcut). + let router = || { + Router::new() + .route( + "/{owner}/{repo}/info/refs", + axum::routing::get(crate::api::repos::git_info_refs), + ) + .layer(axum::middleware::from_fn(crate::auth::optional_signature)) + .with_state(state.clone()) + }; + let path = |service: &str| format!("/{short}/ir-priv.git/info/refs?service={service}"); + let anon = |service: &str| { + Request::builder() + .method(Method::GET) + .uri(path(service)) + .body(Body::empty()) + .unwrap() + }; + // The advertisement GET exactly as git-remote-gitlawb now builds it: a + // real signature over the path_and_query, empty body. + let signed = |service: &str| { + let p = path(service); + let s = sign_request(&kp, "GET", &p, b""); + Request::builder() + .method(Method::GET) + .uri(&p) + .header("content-digest", s.content_digest) + .header("signature-input", s.signature_input) + .header("signature", s.signature) + .body(Body::empty()) + .unwrap() + }; + + // Leak fix: anonymous advertisement of a private repo is denied (404) for + // BOTH services. Pre-fix the receive-pack case returned 500 (gate skipped). + for service in ["git-upload-pack", "git-receive-pack"] { + let resp = router().oneshot(anon(service)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "anonymous {service} advertisement of a private repo must be denied" + ); + } + + // No-regression: a PUBLIC repo's advertisement stays anonymous for BOTH + // services. The gate admits the anonymous caller, so the handler clears it + // and 500s on the missing test-disk repo; anything but 404 (a gate denial) + // proves the unconditional gate did not accidentally lock out public reads. + for service in ["git-upload-pack", "git-receive-pack"] { + let req = Request::builder() + .method(Method::GET) + .uri(format!("/{short}/ir-pub.git/info/refs?service={service}")) + .body(Body::empty()) + .unwrap(); + let resp = router().oneshot(req).await.unwrap(); + // 500 (not just non-404): the gate admits the public anonymous caller, + // so the handler reaches acquire + git advertise-refs on the missing + // test-disk repo. Pinning the exact 500 rules out a 401/403 regression + // masquerading as "not gated". + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "anonymous {service} advertisement of a PUBLIC repo must not be gated" + ); + } + + // Client remedy: the owner's SIGNED advertisement GET clears the gate for + // BOTH services (so fetch and push of a private repo keep working). It + // 500s on the missing test-disk repo; anything but 404 means cleared. + for service in ["git-upload-pack", "git-receive-pack"] { + let resp = router().oneshot(signed(service)).await.unwrap(); + // INTERNAL_SERVER_ERROR specifically: the signature VERIFIED (passed + // require_signature, not 401/403) and the owner cleared the read gate + // (not 404), so the handler proceeded to acquire + git on a repo absent + // from the test disk. Asserting the exact 500 (rather than merely + // "not 404") proves the request got PAST auth, not rejected by it. + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "the owner's signed {service} advertisement must verify and clear the gate" + ); + } + } + + /// Push is signature-gated, not merely owner-gated: an UNSIGNED + /// git-receive-pack POST is rejected by `require_signature` (401) before + /// reaching `git_receive_pack`. 401 (not the handler's 404/500) is the + /// discriminator that proves the request never reached the handler. + #[sqlx::test] + async fn unsigned_receive_pack_post_is_rejected(pool: PgPool) { + let state = test_state(pool).await; + let owner_did = Keypair::generate().did().to_string(); + let short = owner_did.split(':').next_back().unwrap().to_string(); + state + .db + .create_repo(&seed_repo(&owner_did, "rp-repo")) + .await + .expect("seed repo"); + + // Production wiring: the receive-pack POST sits behind require_signature + // (server.rs add_auth_layers); apply that same layer here. + let router = Router::new() + .route( + "/{owner}/{repo}/git-receive-pack", + axum::routing::post(crate::api::repos::git_receive_pack), + ) + .layer(axum::middleware::from_fn(crate::auth::require_signature)) + .with_state(state); + + let req = Request::builder() + .method(Method::POST) + .uri(format!("/{short}/rp-repo.git/git-receive-pack")) + .body(Body::from(&b"0000"[..])) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "an unsigned receive-pack POST must be rejected by require_signature, \ + not reach the handler" + ); + } + + /// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after + /// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack + /// POST against a private repo is denied (404), so signing only the Phase-1 + /// advertisement GET is NOT enough; `git-remote-gitlawb` must also sign this + /// POST, or an owner's fetch of their own private repo clears the advertisement + /// and then dies on the pack POST. A real owner signature clears the gate + /// (non-404; the missing test-disk repo then errors downstream). + #[sqlx::test] + async fn git_upload_pack_post_is_read_gated_on_private_repo(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + let mut priv_repo = seed_repo(&owner_did, "up-priv"); + priv_repo.is_public = false; + state + .db + .create_repo(&priv_repo) + .await + .expect("seed private repo"); + + let router = || { + Router::new() + .route( + "/{owner}/{repo}/git-upload-pack", + axum::routing::post(crate::api::repos::git_upload_pack), + ) + .layer(axum::middleware::from_fn(crate::auth::optional_signature)) + .with_state(state.clone()) + }; + // A non-empty body (git-remote-gitlawb skips the POST when the body is empty). + let body = b"0032want 0000000000000000000000000000000000000000\n".to_vec(); + let path = format!("/{short}/up-priv.git/git-upload-pack"); + + // Anonymous Phase-2 fetch of a private repo: denied at the gate (404). This + // is exactly the request git-remote-gitlawb sends today for upload-pack + // (the unsigned POST), which is why fetch breaks for the owner. + let anon = Request::builder() + .method(Method::POST) + .uri(&path) + .header("content-type", "application/x-git-upload-pack-request") + .body(Body::from(body.clone())) + .unwrap(); + let resp = router().oneshot(anon).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "an anonymous upload-pack POST against a private repo must be denied" + ); + + // The same POST signed by the owner clears the read gate (non-404). This is + // the request the client must send once it signs the upload-pack POST. + let signed = sign_request(&kp, "POST", &path, &body); + let signed_req = Request::builder() + .method(Method::POST) + .uri(&path) + .header("content-type", "application/x-git-upload-pack-request") + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::from(body)) + .unwrap(); + let resp = router().oneshot(signed_req).await.unwrap(); + // 500 (not merely non-404): the signature VERIFIED (passed require_signature, + // not 401/403) AND the owner cleared the read gate (not 404), so the handler + // reached git on the missing test-disk repo. Pinning 500 proves the request + // got past auth; a 401 regression would slip through a bare `!= 404`. + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "the owner's signed upload-pack POST must verify and clear the read gate" + ); + } + + /// Served-content seam: with a REAL on-disk bare repo (branch + /// `topsecret-branch`), the advertisement serves the actual ref names to + /// authorized callers and withholds them from denied ones, proving real + /// content egress + withholding, not just the gate decision (the other tests + /// land on a 500 from a missing-disk repo). Asserts the branch name appears for + /// allowed callers and never appears in a denied 404 body. + #[sqlx::test] + async fn advertisement_serves_real_refs_only_to_authorized_callers(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + use std::process::Command; + + // repo_store::for_testing fixes the on-disk layout (/tmp//.git + // and /tmp/gl-seam-src-), so tempfile::TempDir's random paths don't + // fit. Wrap each known path in a Drop guard so the dirs are removed even if + // an assertion below panics. + struct DirGuard(std::path::PathBuf); + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let kp = Keypair::generate(); + let owner_did = kp.did().to_string(); + let short = owner_did.split(':').next_back().unwrap().to_string(); + // repo_store::for_testing uses /tmp; local_path = /tmp//.git + // with slug = owner_did with ':' and '/' replaced by '_'. + let slug = owner_did.replace([':', '/'], "_"); + let state = test_state(pool).await; + + let run = |args: &[&str], cwd: &std::path::Path| { + let out = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + + // Source repo with a recognizable branch + one commit. + let src = std::env::temp_dir().join(format!("gl-seam-src-{short}")); + let _ = std::fs::remove_dir_all(&src); + std::fs::create_dir_all(&src).unwrap(); + let _src_guard = DirGuard(src.clone()); + run(&["init", "-q", "-b", "topsecret-branch"], &src); + run(&["config", "user.email", "t@t"], &src); + run(&["config", "user.name", "t"], &src); + std::fs::write(src.join("f.txt"), b"hi").unwrap(); + run(&["add", "f.txt"], &src); + run(&["commit", "-q", "-m", "seed"], &src); + + // Bare-clone into the exact path repo_store.acquire() will read. + let bare_for = |name: &str| { + let dir = std::path::PathBuf::from("/tmp") + .join(&slug) + .join(format!("{name}.git")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.parent().unwrap()).unwrap(); + let out = Command::new("git") + .args([ + "clone", + "--bare", + "-q", + src.to_str().unwrap(), + dir.to_str().unwrap(), + ]) + .output() + .expect("git clone runs"); + assert!( + out.status.success(), + "bare clone failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + dir + }; + let pub_dir = bare_for("served-pub"); + let _pub_guard = DirGuard(pub_dir.clone()); + let priv_dir = bare_for("served-priv"); + let _priv_guard = DirGuard(priv_dir.clone()); + + state + .db + .create_repo(&seed_repo(&owner_did, "served-pub")) + .await + .expect("seed public repo"); + let mut priv_repo = seed_repo(&owner_did, "served-priv"); + priv_repo.is_public = false; + state + .db + .create_repo(&priv_repo) + .await + .expect("seed private repo"); + + let router = || { + Router::new() + .route( + "/{owner}/{repo}/info/refs", + axum::routing::get(crate::api::repos::git_info_refs), + ) + .layer(axum::middleware::from_fn(crate::auth::optional_signature)) + .with_state(state.clone()) + }; + async fn body_of(resp: axum::response::Response) -> String { + let b = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8_lossy(&b).to_string() + } + + // Public repo, anonymous → 200 and the real ref name is served. + let resp = router() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(format!( + "/{short}/served-pub.git/info/refs?service=git-upload-pack" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!( + body_of(resp).await.contains("topsecret-branch"), + "public advertisement must serve the real ref name" + ); + + // Private repo, anonymous → 404 and the ref name is withheld. + let resp = router() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(format!( + "/{short}/served-priv.git/info/refs?service=git-upload-pack" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert!( + !body_of(resp).await.contains("topsecret-branch"), + "a denied 404 must not leak the real ref name" + ); + + // Private repo, owner's REAL signature → 200 and the real ref is served. + let path = format!("/{short}/served-priv.git/info/refs?service=git-upload-pack"); + let s = sign_request(&kp, "GET", &path, b""); + let resp = router() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(&path) + .header("content-digest", s.content_digest) + .header("signature-input", s.signature_input) + .header("signature", s.signature) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "the owner's signed request reads the private advertisement" + ); + assert!( + body_of(resp).await.contains("topsecret-branch"), + "the verified owner gets the real ref name" + ); + + // Cleanup runs via the DirGuard Drop impls above, on success or panic. + } + // ── #97: repo-listing surfaces are visibility-gated ────────────────────── fn seed_private_repo(owner_did: &str, name: &str) -> RepoRecord {