diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 875e6261..8f596677 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -13,6 +13,18 @@ use crate::state::AppState; /// shared collector's clamp and the per-handler request caps so they can't drift. const MAX_VISIBLE_REF_UPDATES: i64 = 200; +/// Documented maximum page size of the repo-scoped push-event poll surface, and +/// the value an over-large `limit` is clamped to. Named separately from the +/// ref-update ceiling above because it bounds a different feed with a different +/// gate; they happen to agree on a number today. +const MAX_PUSH_EVENT_PAGE: i64 = 200; + +/// Smallest page the poll surface will serve. Not zero, and that is the point: a +/// zero-row page reads to a cursor-persisting poller exactly like "you have +/// reached the end", so combined with a cursor it could not carry forward it +/// silently sent the poller back to the beginning of history. +const MIN_PUSH_EVENT_PAGE: i64 = 1; + /// Collect up to `limit` ref-update rows visible to `caller`, newest first, /// paging past rows the feed gate drops. Filtering after a plain SQL `LIMIT` /// under-serves an anonymous caller whenever the newest rows name private local @@ -327,6 +339,106 @@ pub async fn list_repo_events( )) } +/// A caller-supplied `cursor` value, or a 400. +/// +/// The only legal cursor is one this surface issued: a non-negative sequence +/// number. Anything else is refused rather than reinterpreted, because both +/// silent readings are wrong in a way the poller cannot see. Reading it as "no +/// cursor" replays the repo's whole history to a client that believed it was up +/// to date; reading it as "the end" hides every event after it. +fn parse_push_cursor(raw: &str) -> Result { + match raw.parse::() { + Ok(v) if v >= 0 => Ok(v), + _ => Err(crate::error::AppError::BadRequest( + "cursor must be a non-negative integer issued by this endpoint as \ + `next_cursor`" + .into(), + )), + } +} + +/// GET /api/v1/repos/{owner}/{repo}/push-events?cursor=&limit= +/// +/// The catch-up half of push notification. A subscriber whose webhook delivery +/// failed polls this with the cursor it last saw and gets every push since, +/// oldest first, so a missed delivery costs a poll rather than needing retry +/// machinery on the send side. +/// +/// The cursor is the `next_cursor` the previous page returned, a +/// database-assigned sequence number, and rows are read strictly after it. It is +/// one value rather than a timestamp pair because the timestamp is stamped by +/// the application before the insert and cannot order these rows (see +/// [`crate::db::Db::list_repo_push_events_keyset`]). Omitting it starts at the +/// beginning of the repo's history. +/// +/// `next_cursor` is always present, never null, and never lower than the cursor +/// the request carried: an empty page hands back the position the caller already +/// had. A poller that persists it therefore stays where it is when there is +/// nothing new, instead of restarting from the beginning. +/// +/// `limit` is clamped to [`MIN_PUSH_EVENT_PAGE`]..=[`MAX_PUSH_EVENT_PAGE`]. A +/// value that does not parse falls back to the default page size. +/// +/// Rows come from `repo_push_events`, which only this node's own pushes write. +/// The gossip-sourced `received_ref_updates` rows are a different data class on +/// a different surface, and nothing here reads or writes them. +pub async fn list_repo_push_events( + State(state): State, + Path((owner, repo_name)): Path<(String, String)>, + Query(params): Query>, + auth: Option>, +) -> Result> { + let limit = params + .get("limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50) + .clamp(MIN_PUSH_EVENT_PAGE, MAX_PUSH_EVENT_PAGE); + + // Repo-root read gate on the requested path, before any event row is + // touched: a caller who cannot read the repo gets the repo's own not-found, + // byte-identical to a repo that does not exist here, so the surface is not an + // oracle for which private repos are being pushed to. Rows are keyed by the + // unique repo record id, so unlike the gossip feed there is no lossy wire + // slug to re-gate per row. + let caller = auth.as_ref().map(|e| e.0 .0.as_str()); + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &repo_name, caller, "/").await?; + + // Cursor validation runs AFTER the gate on purpose: a caller who may not read + // this repo gets the not-found for every request shape, so a 400 can never + // become the tell that distinguishes a private repo from a missing one. + let cursor = match params.get("cursor") { + Some(raw) => Some(parse_push_cursor(raw)?), + None => None, + }; + + let rows = state + .db + .list_repo_push_events_keyset(&record.id, cursor, limit) + .await?; + + let events: Vec = rows + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "ref_name": e.ref_name, + "after_sha": e.after_sha, + "created_at": e.created_at, + }) + }) + .collect(); + // Never null and never backwards: an empty page returns the cursor the caller + // arrived with (or the start of history, if it arrived with none). + let next = rows.last().map_or(cursor.unwrap_or(0), |e| e.seq); + let count = events.len(); + Ok(Json(serde_json::json!({ + "events": events, + "count": count, + "next_cursor": next, + }))) +} + #[cfg(test)] mod ref_updates_feed_tests { use crate::db::{ReceivedRefUpdate, RefCertificate, RepoRecord}; @@ -1493,3 +1605,750 @@ mod ref_updates_feed_tests { ); } } + +#[cfg(test)] +mod push_events_tests { + use crate::db::{RepoPushEvent, RepoRecord}; + use crate::test_support::{signed_request_as, test_state}; + use axum::body::Body; + use axum::http::{Method, Request, StatusCode}; + use axum::Router; + use chrono::Utc; + use sqlx::PgPool; + use tower::ServiceExt; + + use super::MAX_PUSH_EVENT_PAGE; + + const OWNER: &str = "did:key:z6MkOwner"; + const SHA_A: &str = "1111111111111111111111111111111111111111"; + const SHA_B: &str = "2222222222222222222222222222222222222222"; + + fn repo(id: &str, name: &str, is_public: bool) -> RepoRecord { + let now = Utc::now(); + RepoRecord { + id: id.into(), + name: name.into(), + owner_did: OWNER.into(), + description: None, + is_public, + default_branch: "main".into(), + created_at: now, + updated_at: now, + disk_path: format!("/tmp/{id}"), + forked_from: None, + machine_id: None, + } + } + + fn poll_router(state: crate::state::AppState) -> Router { + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/push-events", + axum::routing::get(super::list_repo_push_events), + ) + .with_state(state) + } + + fn global_feed_router(state: crate::state::AppState) -> Router { + Router::new() + .route( + "/api/v1/events/ref-updates", + axum::routing::get(super::list_ref_updates), + ) + .with_state(state) + } + + fn poll_uri(name: &str, query: &str) -> String { + let base = format!("/api/v1/repos/{OWNER}/{name}/push-events"); + if query.is_empty() { + base + } else { + format!("{base}?{query}") + } + } + + async fn poll( + state: &crate::state::AppState, + caller: Option<&str>, + uri: &str, + ) -> axum::response::Response { + let req = match caller { + Some(did) => signed_request_as(did, Method::GET, uri, Body::empty()), + None => Request::builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .expect("request builder"), + }; + poll_router(state.clone()).oneshot(req).await.unwrap() + } + + async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body bytes"); + serde_json::from_slice(&bytes).expect("json body") + } + + /// Status plus the full response body, for the byte-identical deny comparison. + async fn status_and_bytes(resp: axum::response::Response) -> (StatusCode, Vec) { + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, bytes) + } + + fn event_rows(v: &serde_json::Value) -> Vec<(String, String, String)> { + v["events"] + .as_array() + .expect("events array") + .iter() + .map(|e| { + ( + e["id"].as_str().expect("id").to_string(), + e["ref_name"].as_str().expect("ref_name").to_string(), + e["after_sha"].as_str().expect("after_sha").to_string(), + ) + }) + .collect() + } + + /// Walk the whole poll surface one row at a time, following the cursor the + /// surface itself hands back, and return the event ids in the order served. + /// `max_steps` bounds the walk, so a cursor that fails to advance fails the + /// test instead of hanging it. + async fn walk_cursor( + state: &crate::state::AppState, + name: &str, + max_steps: usize, + ) -> Vec { + let mut ids: Vec = Vec::new(); + let mut query = "limit=1".to_string(); + for _ in 0..max_steps { + let body = body_json(poll(state, Some(OWNER), &poll_uri(name, &query)).await).await; + let rows = event_rows(&body); + if rows.is_empty() { + return ids; + } + ids.extend(rows.into_iter().map(|r| r.0)); + query = format!( + "limit=1&cursor={}", + body["next_cursor"].as_i64().expect("next_cursor"), + ); + } + panic!("the cursor walk did not terminate within {max_steps} steps: {ids:?}"); + } + + /// Seed one push-event row directly, for the read-side scenarios that are + /// about paging and gating rather than about the producer. + async fn seed_event( + state: &crate::state::AppState, + id: &str, + repo_id: &str, + ref_name: &str, + sha: &str, + at: &str, + ) { + state + .db + .insert_repo_push_event(&RepoPushEvent { + id: id.into(), + // Ignored on insert; the database assigns the ordering key. + seq: 0, + repo_id: repo_id.into(), + ref_name: ref_name.into(), + after_sha: sha.into(), + created_at: at.into(), + }) + .await + .unwrap(); + } + + /// A TCP port with nothing listening on it: bind, read the port, drop the + /// listener. Used to make "the webhook target is unreachable" a property the + /// test observes rather than one it assumes. + fn dead_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + let port = l.local_addr().expect("local addr").port(); + drop(l); + port + } + + /// Covers AE6. The repo's webhook points at a port nothing is listening on, + /// so the push notification cannot be delivered; the test proves that by + /// firing a request at the same target with the same client and observing the + /// transport error, rather than assuming it. The push still records its event, + /// and a poll with a cursor from before the push returns the pushed ref and + /// SHA. That is the whole point of the unit: delivery reliability becomes a + /// read-side property, with no retry machinery anywhere. + #[sqlx::test] + async fn ae6_poll_catches_up_when_the_webhook_target_is_unreachable(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-ae6", "widget", true)) + .await + .unwrap(); + + let url = format!("http://127.0.0.1:{}/hook", dead_port()); + state + .db + .create_webhook(&crate::db::Webhook { + id: "hook-ae6".into(), + repo_id: "r-ae6".into(), + url: url.clone(), + secret: None, + events: vec!["push".into()], + created_by_did: OWNER.into(), + created_at: Utc::now().to_rfc3339(), + active: true, + }) + .await + .unwrap(); + + // The cursor a subscriber last polled at. The repo has never been pushed + // to, so that cursor is the start of history. + let before = 0; + + // The push happens. The webhook fires into the void. + crate::api::repos::record_push_events( + &state.db, + "r-ae6", + &[crate::api::repos::RefUpdate { + old_sha: "0".repeat(40), + new_sha: SHA_A.into(), + ref_name: "refs/heads/main".into(), + }], + ) + .await; + crate::webhooks::fire_event( + state.db.clone(), + state.http_client.clone(), + "r-ae6", + "push", + serde_json::json!({ "ref": "refs/heads/main", "after": SHA_A }), + ); + + // The target really is unreachable: same client, same URL, transport error. + let delivery = state.http_client.post(&url).body("{}").send().await; + assert!( + delivery.is_err(), + "the webhook target must be unreachable for this scenario to prove \ + anything; got {delivery:?}" + ); + + let body = body_json( + poll( + &state, + Some(OWNER), + &poll_uri("widget", &format!("cursor={before}")), + ) + .await, + ) + .await; + let rows = event_rows(&body); + assert_eq!( + rows.len(), + 1, + "the missed push must be discoverable by polling, got {body}" + ); + assert_eq!(rows[0].1, "refs/heads/main"); + assert_eq!(rows[0].2, SHA_A); + assert_eq!(body["count"].as_u64(), Some(1)); + } + + /// Two ref updates in one push share a timestamp by construction, which is + /// the case a timestamp-based cursor cannot page: it either repeats a row or + /// drops one at the boundary. The collision is produced deterministically by + /// pushing two refs at once (the producer stamps one timestamp for the whole + /// push), not raced for. With a page size of one, the walk must return both + /// rows exactly once and then terminate. + /// + /// The cursor is fed back verbatim into the query string, so this also pins + /// that the emitted cursor survives that round trip. + #[sqlx::test] + async fn colliding_timestamps_page_once_each(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-tie", "widget", true)) + .await + .unwrap(); + + crate::api::repos::record_push_events( + &state.db, + "r-tie", + &[ + crate::api::repos::RefUpdate { + old_sha: "0".repeat(40), + new_sha: SHA_A.into(), + ref_name: "refs/heads/main".into(), + }, + crate::api::repos::RefUpdate { + old_sha: "0".repeat(40), + new_sha: SHA_B.into(), + ref_name: "refs/heads/other".into(), + }, + ], + ) + .await; + + let all = body_json(poll(&state, Some(OWNER), &poll_uri("widget", "")).await).await; + let stamps: Vec<&str> = all["events"] + .as_array() + .unwrap() + .iter() + .map(|e| e["created_at"].as_str().unwrap()) + .collect(); + assert_eq!(stamps.len(), 2); + assert_eq!( + stamps[0], stamps[1], + "the scenario needs a real timestamp collision to test, got {stamps:?}" + ); + + let first = + body_json(poll(&state, Some(OWNER), &poll_uri("widget", "limit=1")).await).await; + let page1 = event_rows(&first); + assert_eq!( + page1.len(), + 1, + "page size of one must return one row, got {first}" + ); + + let next_cursor = first["next_cursor"].as_i64().expect("next_cursor"); + + let cursor = format!("cursor={next_cursor}&limit=1"); + let second = body_json(poll(&state, Some(OWNER), &poll_uri("widget", &cursor)).await).await; + let page2 = event_rows(&second); + assert_eq!( + page2.len(), + 1, + "the second row must survive the page boundary, got {second}" + ); + + assert_ne!( + page1[0].0, page2[0].0, + "the cursor must advance past the first row, not repeat it" + ); + let mut refs = vec![page1[0].1.clone(), page2[0].1.clone()]; + refs.sort(); + assert_eq!( + refs, + vec![ + "refs/heads/main".to_string(), + "refs/heads/other".to_string() + ], + "both colliding-timestamp rows must be returned, once each" + ); + + let cursor2 = format!( + "cursor={}&limit=1", + second["next_cursor"].as_i64().expect("next_cursor"), + ); + let third = body_json(poll(&state, Some(OWNER), &poll_uri("widget", &cursor2)).await).await; + assert!( + event_rows(&third).is_empty(), + "the walk must terminate rather than repeat a row, got {third}" + ); + } + + /// The cursor must order on insertion, not on the wall clock. `created_at` is + /// stamped by the application before the insert, so a row stamped later can + /// commit earlier, and an NTP step backwards makes that ordinary rather than + /// a race. A poller that has already advanced past the later stamp then never + /// sees the earlier-stamped row at all. + /// + /// The disagreement is constructed rather than raced for: the first row + /// written carries the LATER timestamp. A walk must still return both rows + /// exactly once, in the order they were inserted. + #[sqlx::test] + async fn the_cursor_walk_follows_insertion_order_not_the_wall_clock(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-clock", "widget", true)) + .await + .unwrap(); + + seed_event( + &state, + "evt-first", + "r-clock", + "refs/heads/one", + SHA_A, + "2026-08-07T13:00:00.000000Z", + ) + .await; + seed_event( + &state, + "evt-second", + "r-clock", + "refs/heads/two", + SHA_B, + "2026-08-07T12:00:00.000000Z", + ) + .await; + + let walked = walk_cursor(&state, "widget", 6).await; + assert_eq!( + walked, + vec!["evt-first".to_string(), "evt-second".to_string()], + "the walk must return every row exactly once in insertion order; \ + ordering on the application-stamped clock reverses these two and \ + strands the second behind a cursor that has already passed it" + ); + } + + /// Seed two events and hand back the repo name plus the cursor that sits + /// between them, for the degenerate-input cases below. + async fn two_event_repo(state: &crate::state::AppState) -> i64 { + state + .db + .create_repo(&repo("r-bad", "widget", true)) + .await + .unwrap(); + seed_event( + state, + "evt-1", + "r-bad", + "refs/heads/one", + SHA_A, + "2026-08-07T12:00:00.000000Z", + ) + .await; + seed_event( + state, + "evt-2", + "r-bad", + "refs/heads/two", + SHA_B, + "2026-08-07T12:00:01.000000Z", + ) + .await; + + let first = body_json(poll(state, Some(OWNER), &poll_uri("widget", "limit=1")).await).await; + first["next_cursor"].as_i64().expect("next_cursor") + } + + /// A cursor the surface could not have issued is a client bug, and the only + /// safe answer is to say so. Silently treating it as "no cursor" replays the + /// repo's whole history to a poller that believed it was up to date, and + /// silently treating it as "the end" hides every event after it. + #[sqlx::test] + async fn a_malformed_cursor_is_rejected_with_a_400(pool: PgPool) { + let state = test_state(pool).await; + let mid = two_event_repo(&state).await; + + for bad in [ + "notanumber", + "", + "-1", + "1.5", + "99999999999999999999999999", + // Percent-encoded leading space: a valid URI that decodes to " 1". + "%201", + ] { + let resp = poll( + &state, + Some(OWNER), + &poll_uri("widget", &format!("cursor={bad}")), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "cursor={bad:?} must be refused, not reinterpreted" + ); + let body = body_json(resp).await; + assert_eq!( + body["error"], "bad_request", + "the refusal needs a stable code a client can branch on, got {body}" + ); + } + + // The control: a cursor the surface actually issued still works, so the + // rejection above is validation and not a blanket refusal. + let ok = poll( + &state, + Some(OWNER), + &poll_uri("widget", &format!("cursor={mid}")), + ) + .await; + assert_eq!(ok.status(), StatusCode::OK); + assert_eq!(event_rows(&body_json(ok).await).len(), 1); + } + + /// `limit=0` used to return an empty page carrying no cursor, which reads to + /// a cursor-persisting poller exactly like "you are at the end" while also + /// wiping the position it had, so the next poll started from the beginning + /// of history. A limit below one is clamped up instead: the page makes + /// progress, and the cursor it returns moves forward. + #[sqlx::test] + async fn a_zero_limit_makes_progress_instead_of_rewinding(pool: PgPool) { + let state = test_state(pool).await; + let mid = two_event_repo(&state).await; + + let body = body_json( + poll( + &state, + Some(OWNER), + &poll_uri("widget", &format!("cursor={mid}&limit=0")), + ) + .await, + ) + .await; + let next = body["next_cursor"].as_i64().expect("next_cursor"); + assert!( + next >= mid, + "a cursor must never move backwards; asked from {mid}, got {next} in {body}" + ); + assert_eq!( + event_rows(&body).len(), + 1, + "a limit below one is clamped to one row, not to an empty page, got {body}" + ); + } + + /// The upper clamp, from the other side: a caller asking for more than the + /// surface serves gets the documented maximum rather than an unbounded scan. + #[sqlx::test] + async fn an_oversized_limit_is_clamped_to_the_documented_maximum(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-big", "widget", true)) + .await + .unwrap(); + for i in 0..(MAX_PUSH_EVENT_PAGE + 5) { + seed_event( + &state, + &format!("evt-{i}"), + "r-big", + "refs/heads/main", + SHA_A, + "2026-08-07T12:00:00.000000Z", + ) + .await; + } + + let body = + body_json(poll(&state, Some(OWNER), &poll_uri("widget", "limit=100000")).await).await; + assert_eq!( + event_rows(&body).len() as i64, + MAX_PUSH_EVENT_PAGE, + "an oversized limit must be clamped, got {}", + event_rows(&body).len() + ); + } + + /// A caller already up to date polls with the cursor it got last time. That is + /// the steady state of this surface, and it is a 200 with an empty page, not + /// an error and not a repeat of the last row. + #[sqlx::test] + async fn cursor_past_the_last_event_returns_an_empty_page(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-tail", "widget", true)) + .await + .unwrap(); + seed_event( + &state, + "evt-1", + "r-tail", + "refs/heads/main", + SHA_A, + "2026-08-07T12:00:00.000000Z", + ) + .await; + + let resp = poll(&state, Some(OWNER), &poll_uri("widget", "cursor=999999")).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "an exhausted cursor is not an error" + ); + let body = body_json(resp).await; + assert!( + event_rows(&body).is_empty(), + "expected an empty page, got {body}" + ); + assert_eq!(body["count"].as_u64(), Some(0)); + assert_eq!( + body["next_cursor"].as_i64(), + Some(999_999), + "an empty page must hand back the cursor it was given; a null (or a \ + lower value) tells a poller that persists it to start over from the \ + beginning of history, got {body}" + ); + + // And the same on a repo with no events at all, where there is no row to + // derive a cursor from: the start of history is not a rewind. + state + .db + .create_repo(&repo("r-empty", "quiet", true)) + .await + .unwrap(); + let empty = body_json(poll(&state, Some(OWNER), &poll_uri("quiet", "")).await).await; + assert_eq!( + empty["next_cursor"].as_i64(), + Some(0), + "a first poll of a repo with no events starts at zero, got {empty}" + ); + } + + /// An anonymous poll of a PRIVATE repo answers byte for byte what the same + /// caller gets for a repo that does not exist. Events are seeded first, so the + /// deny cannot pass vacuously on an empty projection. + #[sqlx::test] + async fn anon_poll_on_a_private_repo_is_indistinguishable_from_missing(pool: PgPool) { + let state = test_state(pool).await; + let target = poll_uri("secret", ""); + + let missing = status_and_bytes(poll(&state, None, &target).await).await; + + state + .db + .create_repo(&repo("r-priv", "secret", false)) + .await + .unwrap(); + seed_event( + &state, + "evt-priv", + "r-priv", + "refs/heads/main", + SHA_A, + "2026-08-07T12:00:00.000000Z", + ) + .await; + + let denied = status_and_bytes(poll(&state, None, &target).await).await; + + assert_eq!(missing.0, StatusCode::NOT_FOUND); + assert_eq!( + denied, missing, + "a private-repo deny must be byte-identical to the missing-repo response" + ); + assert!( + !String::from_utf8_lossy(&denied.1).contains(SHA_A), + "the deny must carry no trace of the seeded event" + ); + } + + /// The other half of the gate: a public repo's push events are served to an + /// anonymous caller, so the deny above is a gate and not a blanket refusal. + #[sqlx::test] + async fn public_repo_push_events_served_to_anon(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-pub", "openrepo", true)) + .await + .unwrap(); + seed_event( + &state, + "evt-pub", + "r-pub", + "refs/heads/main", + SHA_A, + "2026-08-07T12:00:00.000000Z", + ) + .await; + + let body = body_json(poll(&state, None, &poll_uri("openrepo", "")).await).await; + assert_eq!( + event_rows(&body).len(), + 1, + "a public repo's events are anonymous-readable" + ); + } + + /// Containment. A push to a PRIVATE repo must not surface on the + /// unauthenticated global feed at `/api/v1/events/ref-updates`, which reads + /// `received_ref_updates`. If the producer ever wrote there instead of into + /// the repo-scoped table, this unit would be introducing an anonymous leak of + /// private-repo push metadata. + #[sqlx::test] + async fn a_private_push_never_reaches_the_anonymous_global_feed(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-contain", "secret", false)) + .await + .unwrap(); + + crate::api::repos::record_push_events( + &state.db, + "r-contain", + &[crate::api::repos::RefUpdate { + old_sha: "0".repeat(40), + new_sha: SHA_A.into(), + ref_name: "refs/heads/main".into(), + }], + ) + .await; + + let anon = Request::builder() + .method(Method::GET) + .uri("/api/v1/events/ref-updates") + .body(Body::empty()) + .expect("request builder"); + let resp = global_feed_router(state.clone()) + .oneshot(anon) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let raw = String::from_utf8( + axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert!( + !raw.contains(SHA_A) && !raw.contains("refs/heads/main"), + "a local push must not appear on the anonymous global feed, got {raw}" + ); + + // And the row really was written somewhere: the owner's poll finds it, so + // the assertion above is not passing because nothing was recorded at all. + let body = body_json(poll(&state, Some(OWNER), &poll_uri("secret", "")).await).await; + assert_eq!( + event_rows(&body).len(), + 1, + "the push event must exist on the repo-scoped surface, got {body}" + ); + } + + /// A branch deletion carries an all-zero new SHA. Recording it would hand a + /// poller a target that resolves to no commit, so the producer skips it, the + /// same way the stored-head update does. + #[sqlx::test] + async fn a_branch_deletion_records_no_push_event(pool: PgPool) { + let state = test_state(pool).await; + state + .db + .create_repo(&repo("r-del", "widget", true)) + .await + .unwrap(); + + crate::api::repos::record_push_events( + &state.db, + "r-del", + &[crate::api::repos::RefUpdate { + old_sha: SHA_A.into(), + new_sha: "0".repeat(40), + ref_name: "refs/heads/gone".into(), + }], + ) + .await; + + let body = body_json(poll(&state, Some(OWNER), &poll_uri("widget", "")).await).await; + assert!( + event_rows(&body).is_empty(), + "a deletion must not record a push event, got {body}" + ); + } +} diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 71bfa43c..d516ec06 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -22,6 +22,7 @@ pub mod replicas; pub mod repos; pub mod resolve; pub mod stars; +pub mod status; pub mod tasks; pub mod visibility; pub mod webhooks; @@ -67,17 +68,14 @@ pub(crate) async fn authorize_repo_read( /// representation only within `did:key`; never let a bare id match across methods — /// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a /// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal. +/// +/// The collapse itself lives in exactly one place, [`crate::db::normalize_owner_key`], +/// which is also the function the stored `owner_did` / `authorizing_did` columns +/// are normalized through and which `OWNER_KEY_CASE_SQL` mirrors byte for byte. +/// Two identities match when they normalize to the same key; a second copy of the +/// rule here is how the Rust gate and the SQL filters would drift apart. pub(crate) fn did_matches(a: &str, b: &str) -> bool { - if a == b { - return true; - } - fn key_id(d: &str) -> &str { - d.strip_prefix("did:key:").unwrap_or(d) - } - let (ka, kb) = (key_id(a), key_id(b)); - // After stripping `did:key:`, a value still containing ':' is a non-key full - // DID — do not let it match a bare `did:key` id. - !ka.contains(':') && !kb.contains(':') && ka == kb + crate::db::normalize_owner_key(a) == crate::db::normalize_owner_key(b) } /// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check @@ -175,6 +173,7 @@ mod authz_guard { let events = include_str!("events.rs"); let tasks = include_str!("tasks.rs"); let stars = include_str!("stars.rs"); + let status = include_str!("status/mod.rs"); let protect = include_str!("protect.rs"); let visibility = include_str!("visibility.rs"); let profiles = include_str!("profiles.rs"); @@ -195,6 +194,13 @@ mod authz_guard { // (existence hiding) and require_repo_owner guards the owner half. (webhooks, "list_webhooks", "authorize_repo_read("), (webhooks, "list_webhooks", "require_repo_owner("), + // Same two-half shape as list_webhooks: authorize_repo_read runs + // first (a quarantined or unreadable repo gets the missing-repo + // not-found, never a 403 that would confirm existence), then + // require_repo_owner 403s a non-owner of a readable repo. Both halves + // are pinned, because dropping either changes what a stranger learns. + (status, "create_status", "authorize_repo_read("), + (status, "create_status", "require_repo_owner("), (labels, "add_label", "require_repo_owner("), (labels, "remove_label", "require_repo_owner("), // Bucket A' — owner OR author (did_matches against the author) @@ -217,6 +223,19 @@ mod authz_guard { (protect, "list_protected_branches", "authorize_repo_read("), (labels, "list_labels", "authorize_repo_read("), (events, "list_repo_events", "authorize_repo_read("), + // The catch-up poll surface gates before any push-event row is read, + // so a stranger cannot use it to learn that a private repo exists or + // is being pushed to. + (events, "list_repo_push_events", "authorize_repo_read("), + // The status read gates before any claim data is loaded, so its deny + // is the repo's own not-found rather than an existence oracle over + // which commits were reported on. + (status, "commit_status", "authorize_repo_read("), + // The rollup gates on the same helper before the pull request row is + // loaded, so a caller who cannot read the repo cannot learn which + // pull request numbers exist on it — and the fallback's branch + // resolve and head persist sit behind that same gate. + (status, "pull_request_status", "authorize_repo_read("), // Bucket C — signer-self: the acting DID is matched/bound to auth.0 (tasks, "create_task", "did_matches("), (tasks, "claim_task", "did_matches("), @@ -490,6 +509,7 @@ mod authz_guard { (include_str!("replicas.rs"), "replicas.rs"), (include_str!("repos.rs"), "repos.rs"), (include_str!("stars.rs"), "stars.rs"), + (include_str!("status/mod.rs"), "status/mod.rs"), (include_str!("visibility.rs"), "visibility.rs"), (include_str!("webhooks.rs"), "webhooks.rs"), ]; diff --git a/crates/gitlawb-node/src/api/pulls.rs b/crates/gitlawb-node/src/api/pulls.rs index 26be6109..853cdb84 100644 --- a/crates/gitlawb-node/src/api/pulls.rs +++ b/crates/gitlawb-node/src/api/pulls.rs @@ -65,6 +65,7 @@ pub async fn create_pr( status: "open".to_string(), merged_by_did: None, merged_at: None, + head_commit: None, created_at: now.clone(), updated_at: now, }; @@ -216,6 +217,19 @@ pub async fn merge_pr( .map_err(|e| AppError::Git(e.to_string()))?; let disk_path = guard.path().to_path_buf(); let merger_did = auth.0; + + // The source head this merge is about to consume, read under the write lock. + // The PR row above was loaded before the lock, so a push landing in between + // already moved the branch and the row's own `head_commit` is stale; the + // value frozen on the merged PR has to be what was merged. `None` when the + // ref cannot be resolved, which leaves whatever the push path last stored. + let merged_source_head = store::list_refs(&disk_path).ok().and_then(|refs| { + let want = format!("refs/heads/{}", pr.source_branch); + refs.into_iter() + .find(|(name, _)| *name == want) + .map(|(_, sha)| sha) + }); + let merge_result = store::merge_branch( &disk_path, &pr.target_branch, @@ -229,7 +243,10 @@ pub async fn merge_pr( let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?; - state.db.merge_pr(&pr.id, &merger_did).await?; + state + .db + .merge_pr(&pr.id, &merger_did, merged_source_head.as_deref()) + .await?; let _ = state.db.touch_repo(&record.id).await; webhooks::fire_event( @@ -424,3 +441,159 @@ pub async fn list_comments( let comments = state.db.list_pr_comments(&pr.id).await?; Ok(Json(serde_json::json!({ "comments": comments }))) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Method, StatusCode}; + use axum::routing::post; + use axum::Router; + use std::process::Command; + use tower::ServiceExt; + + const OWNER_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH"; + const STALE_SHA: &str = "9999999999999999999999999999999999999999"; + + fn git(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@example.com") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@example.com") + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + /// A bare repo with `main` and a `feature` branch one commit ahead. + /// Returns the bare path and the real `feature` tip. + fn bare_repo_with_feature( + root: &std::path::Path, + owner_slug: &str, + ) -> (std::path::PathBuf, String) { + let work = root.join("work"); + std::fs::create_dir_all(&work).unwrap(); + git(&work, &["init", "-q", "-b", "main", "."]); + std::fs::write(work.join("a.txt"), "one").unwrap(); + git(&work, &["add", "."]); + git(&work, &["commit", "-qm", "one"]); + git(&work, &["checkout", "-qb", "feature"]); + std::fs::write(work.join("b.txt"), "two").unwrap(); + git(&work, &["add", "."]); + git(&work, &["commit", "-qm", "two"]); + git(&work, &["checkout", "-q", "main"]); + + let bare_dir = root.join(owner_slug); + std::fs::create_dir_all(&bare_dir).unwrap(); + let bare = bare_dir.join("demo.git"); + git( + root, + &[ + "clone", + "-q", + "--bare", + work.to_str().unwrap(), + bare.to_str().unwrap(), + ], + ); + + let tip = Command::new("git") + .args(["rev-parse", "refs/heads/feature"]) + .current_dir(&bare) + .output() + .unwrap(); + let tip = String::from_utf8_lossy(&tip.stdout).trim().to_string(); + assert_eq!(tip.len(), 40, "feature tip must resolve"); + (bare, tip) + } + + /// Merging freezes the stored head at the source commit the merge actually + /// consumed. The PR row is loaded before the write lock is taken, so a push + /// that lands in between leaves the row's own `head_commit` stale; seeding a + /// value that matches nothing on disk stands in for that race, and the + /// merged PR must still come out pointing at the real `feature` tip. + #[sqlx::test] + async fn merge_stamps_the_source_head_it_merged_over_a_racing_value(pool: sqlx::PgPool) { + let tmp = tempfile::tempdir().unwrap(); + let owner_slug = OWNER_DID.replace([':', '/'], "_"); + let (bare, feature_tip) = bare_repo_with_feature(tmp.path(), &owner_slug); + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.repo_store = + crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool); + + let now = Utc::now(); + let repo = crate::db::RepoRecord { + id: uuid::Uuid::new_v4().to_string(), + name: "demo".into(), + owner_did: OWNER_DID.into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: now, + updated_at: now, + disk_path: bare.to_string_lossy().into_owned(), + forked_from: None, + machine_id: None, + }; + state.db.create_repo(&repo).await.unwrap(); + + let pr = PullRequest { + id: Uuid::new_v4().to_string(), + repo_id: repo.id.clone(), + number: 1, + title: "add b".into(), + body: None, + author_did: OWNER_DID.into(), + source_branch: "feature".into(), + target_branch: "main".into(), + status: "open".into(), + merged_by_did: None, + merged_at: None, + head_commit: None, + created_at: now.to_rfc3339(), + updated_at: now.to_rfc3339(), + }; + state.db.create_pr(&pr).await.unwrap(); + // The stale value the racing push scenario leaves on the row. + state + .db + .set_open_pr_heads(&repo.id, "feature", STALE_SHA) + .await + .unwrap(); + + let db = state.db.clone(); + let app = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", + post(merge_pr), + ) + .with_state(state); + + let response = app + .oneshot(crate::test_support::signed_request_as( + OWNER_DID, + Method::POST, + "/api/v1/repos/z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH/demo/pulls/1/merge", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let merged = db.get_pr(&repo.id, 1).await.unwrap().unwrap(); + assert_eq!(merged.status, "merged"); + assert_eq!( + merged.head_commit, + Some(feature_tip), + "the merge must stamp the source head it consumed, not the racing value" + ); + } +} diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b38b177b..c8769a07 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -877,6 +877,10 @@ pub async fn git_receive_pack( ref_count = ref_updates.len(), "parsed ref updates from pack" ); + // Before the per-ref protection queries below, and well before the pack + // reaches git: everything downstream of receive-pack can only report on refs + // the client has already been told were taken. + bound_declared_refs(&ref_updates)?; // ── Owner-only push enforcement (opt-in: GITLAWB_ENFORCE_OWNER_PUSH) ── // Runs before branch protection on purpose: when enabled, a non-owner is @@ -1010,6 +1014,18 @@ pub async fn git_receive_pack( } } + // Keep the stored head of every open PR fed by this push in step with the + // branch, and let closed and merged PRs keep the head they froze at. + if !ref_updates.is_empty() { + update_open_pr_heads(&state.db, &record.id, &ref_updates).await; + } + + // Record the push for the catch-up poll surface, before the webhooks below + // fire. A subscriber whose delivery fails finds the work by polling instead. + if !ref_updates.is_empty() { + record_push_events(&state.db, &record.id, &ref_updates).await; + } + // Fire push webhooks — one per ref update if !ref_updates.is_empty() { let base_url = state @@ -1647,10 +1663,215 @@ pub async fn get_icaptcha_proof( // ── Pkt-line parsing ────────────────────────────────────────────────────── -struct RefUpdate { - old_sha: String, - new_sha: String, - ref_name: String, +pub(crate) struct RefUpdate { + pub(crate) old_sha: String, + pub(crate) new_sha: String, + pub(crate) ref_name: String, +} + +/// Record one catch-up poll event per ref update of a push. +/// +/// This is the producer behind the repo-scoped push-event poll surface: a +/// subscriber whose webhook delivery failed can still find the work by polling +/// the repo's events since its last cursor, which makes delivery reliability a +/// read-side property instead of requiring retry machinery on the send side. +/// +/// The rows go into `repo_push_events`, never `received_ref_updates`: the +/// unauthenticated global feed reads the latter, so a local push written there +/// would publish a private repo's push metadata to anonymous callers. +/// +/// Every row of one push shares a single timestamp, which is why the read side +/// pages on the database-assigned `seq` and not on the timestamp: it is unique +/// per row, so there is no tiebreak to get wrong, and it is assigned at insert +/// rather than stamped here. `created_at` is display metadata on this surface. +/// A failure is logged and skipped: the push itself already succeeded and the +/// objects are on disk, so refusing the response over a missed poll row would be +/// the worse trade. +/// How many ref updates of one push go into a single database statement. +/// +/// A CHUNK SIZE, not a cap. `parse_ref_updates` puts no bound on how many refs a +/// receive-pack request may declare, and both writers below run inline on the +/// user's `git push`, so an unbounded single statement is a real hazard: each +/// pair or row costs bind parameters against a protocol ceiling of 65535, and +/// one statement's size would be set by whoever pushed. +/// +/// It used to truncate at this number, and that was wrong in a way the warning +/// it logged could not fix. Both writers run AFTER receive-pack has accepted +/// every ref, so a dropped ref is one git already told the client it took: the +/// pull request on that branch keeps a stale `head_commit` with no push event to +/// correct it, permanently. Chunking keeps the per-statement bound the cap was +/// really for while every accepted ref still lands. +pub(crate) const PUSH_WRITE_CHUNK: usize = 256; + +/// Ceiling on how many ref updates one receive-pack request may declare. +/// +/// Chunking makes each statement bounded, but it does not bound the request: +/// the per-ref work upstream of it is a branch-protection query per ref and a +/// signed certificate per ref, and `parse_ref_updates` accepts as many lines as +/// the body carries (the git routes disable axum's body limit). So the total +/// still needs a ceiling, and the only place a ceiling means anything is BEFORE +/// receive-pack runs: past that point git has told the client it took the refs, +/// and a limit that fires afterwards can only report, not refuse. +/// +/// Far above any real push. The largest repositories in public use are in the +/// low tens of thousands of refs, and a repository-wide force-push of every +/// branch at once is rarer still; a client over this is either broken or +/// probing. +pub(crate) const MAX_REFS_PER_PUSH: usize = 10_000; + +/// Refuse a push that declares more refs than [`MAX_REFS_PER_PUSH`]. +/// +/// A 400, not a 429: the request itself is the problem and waiting will not make +/// it acceptable. The message names the limit so a client can split the push. +fn bound_declared_refs(ref_updates: &[RefUpdate]) -> Result<()> { + if ref_updates.len() > MAX_REFS_PER_PUSH { + return Err(AppError::BadRequest(format!( + "push declares {} ref updates, which is more than the {MAX_REFS_PER_PUSH} this node \ + accepts in one request; push fewer refs at a time", + ref_updates.len() + ))); + } + Ok(()) +} + +pub(crate) async fn record_push_events( + db: &crate::db::Db, + repo_id: &str, + ref_updates: &[RefUpdate], +) { + // Canonical UTC rfc3339 with a fixed sub-second width and a `Z` offset, not + // the default `+00:00`. This is display metadata now, not the ordering key: + // the cursor pages on the database-assigned `seq`, so neither the fixed + // width nor the offset is load-bearing for paging any more. Kept canonical + // anyway so the value a poller reads back sorts and compares the way a + // reader expects, and so a literal `+` never reaches a query string, where + // it would decode to a space. + let created_at = + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, /* use_z */ true); + let events: Vec = ref_updates + .iter() + // A deletion carries an all-zero new SHA. Recording it would hand a + // poller a target that resolves to no commit. + .filter(|update| update.new_sha != ZERO_SHA) + .map(|update| crate::db::RepoPushEvent { + id: uuid::Uuid::new_v4().to_string(), + // Ignored on insert; the database assigns the ordering key. + seq: 0, + repo_id: repo_id.to_string(), + ref_name: update.ref_name.clone(), + after_sha: update.new_sha.clone(), + created_at: created_at.clone(), + }) + .collect(); + + // One statement per chunk, never one per ref: a per-ref loop would put a + // database round trip for every ref onto the push response, and a single + // unbounded statement would let the pusher choose its parameter count. Every + // event is written either way, because receive-pack has already accepted + // them. + for chunk in events.chunks(PUSH_WRITE_CHUNK) { + let mut outcome = db.insert_repo_push_events(chunk).await; + if let Err(first) = &outcome { + // One retry, because the write now waits on a per-repo lock and the + // failures that wait produces are transient by construction: a lock + // wait that expired, or a pool acquire that timed out behind the + // queue. The retry is bounded because this runs inline on the push. + tracing::warn!( + err = %first, + repo_id = %repo_id, + refs = chunk.len(), + "recording push events failed; retrying once" + ); + outcome = db.insert_repo_push_events(chunk).await; + } + if let Err(e) = outcome { + // Error, not a warning, and it names the rows. receive-pack has + // already told the client it took these refs, and no later poll asks + // for an event that was never written, so this is permanent loss on + // a surface whose whole purpose is that nothing is missed. The refs + // and SHAs are what make it recoverable by hand. + let lost = chunk + .iter() + .map(|e| format!("{}@{}", e.ref_name, e.after_sha)) + .collect::>() + .join(" "); + tracing::error!( + err = %e, + repo_id = %repo_id, + refs = chunk.len(), + lost = %lost, + "push events lost; the catch-up poll surface will never deliver them" + ); + } + } +} + +/// The bare branch name behind a full ref, or `None` if the ref is not a +/// branch. +/// +/// The push path only ever sees full refs (`refs/heads/feature`) while a pull +/// request stores the bare name (`feature`), so the two have to be reconciled +/// somewhere; comparing them directly matches nothing and turns the head update +/// into a silent no-op. Stripping only `refs/heads/` (rather than taking the +/// last path segment) is what keeps a tag push off the branch update: a +/// `refs/tags/feature` push must not move the head of a PR whose source branch +/// is `feature`, and it also preserves slashes in names like `release/1.x`. +fn branch_from_ref(ref_name: &str) -> Option<&str> { + ref_name + .strip_prefix("refs/heads/") + .filter(|branch| !branch.is_empty()) +} + +/// Point every open pull request fed by this push at the branch's new tip. +/// +/// One UPDATE per chunk of branches, keyed on (repo, source branch, open): no +/// per-PR probing, no repo listing, no filesystem access, and the cost grows +/// with neither the number of open pull requests nor, per statement, the number +/// of refs pushed. A failure is logged and skipped: the push itself already +/// succeeded and the objects are on disk, so refusing the response over a stale +/// rollup target would be the worse trade. +async fn update_open_pr_heads(db: &crate::db::Db, repo_id: &str, ref_updates: &[RefUpdate]) { + let mut heads: Vec<(String, String)> = Vec::new(); + // Position of each branch already in `heads`, so the dedupe below is a + // lookup rather than a scan. A linear scan per ref was fine while the input + // was capped at one chunk; over an uncapped push it is quadratic in the + // number of refs the pusher chose to send. + let mut at: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for update in ref_updates { + // A deletion carries an all-zero new SHA. Storing it would hand the + // rollup a target that resolves to no commit, so leave the last real + // head standing. + if update.new_sha == ZERO_SHA { + continue; + } + let Some(branch) = branch_from_ref(&update.ref_name) else { + continue; + }; + // Last write wins, as the per-ref loop this replaced did. A single + // request naming one branch twice is not something git produces, but a + // `VALUES` join over duplicate branches would pick a row arbitrarily, + // and an arbitrary head is worse than a deterministic one. + match at.get(branch) { + Some(&i) => heads[i].1 = update.new_sha.clone(), + None => { + at.insert(branch, heads.len()); + heads.push((branch.to_string(), update.new_sha.clone())); + } + } + } + + // Chunked for the same reason the push events are: bounded per statement, + // and every branch git accepted still gets its head moved. + for chunk in heads.chunks(PUSH_WRITE_CHUNK) { + if let Err(e) = db.set_open_pr_heads_batch(repo_id, chunk).await { + tracing::warn!( + err = %e, + repo_id = %repo_id, + branches = chunk.len(), + "failed to update stored pull request heads for a pushed branch" + ); + } + } } /// Parse git receive-pack pkt-line ref updates from the request body. @@ -2552,6 +2773,633 @@ mod tests { ); } + // ── Stored pull-request head maintenance on push ────────────────────── + + const PR_SHA_OLD: &str = "1111111111111111111111111111111111111111"; + const PR_SHA_NEW: &str = "2222222222222222222222222222222222222222"; + + async fn pr_head_db(pool: sqlx::PgPool) -> crate::db::Db { + let db = crate::db::Db::for_testing(pool); + db.run_migrations().await.unwrap(); + db + } + + /// An open PR on `feature` in `repo_id`, with no stored head yet. + async fn seed_open_pr(db: &crate::db::Db, repo_id: &str, number: i64, source_branch: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.create_pr(&crate::db::PullRequest { + id: uuid::Uuid::new_v4().to_string(), + repo_id: repo_id.to_string(), + number, + title: "t".into(), + body: None, + author_did: "did:key:zAuthor".into(), + source_branch: source_branch.into(), + target_branch: "main".into(), + status: "open".into(), + merged_by_did: None, + merged_at: None, + head_commit: None, + created_at: now.clone(), + updated_at: now, + }) + .await + .unwrap(); + } + + fn ref_update(ref_name: &str, old_sha: &str, new_sha: &str) -> RefUpdate { + RefUpdate { + old_sha: old_sha.into(), + new_sha: new_sha.into(), + ref_name: ref_name.into(), + } + } + + async fn head_of(db: &crate::db::Db, repo_id: &str, number: i64) -> Option { + db.get_pr(repo_id, number) + .await + .unwrap() + .unwrap() + .head_commit + } + + /// The push path sees `refs/heads/feature`; `source_branch` stores + /// `feature`. Pin the mapping directly, because a WHERE clause comparing + /// the full ref against the bare name matches nothing and would make the + /// whole update a silent no-op that still reads green everywhere else. + #[test] + fn branch_from_ref_strips_only_branch_refs() { + assert_eq!(branch_from_ref("refs/heads/feature"), Some("feature")); + assert_eq!( + branch_from_ref("refs/heads/release/1.x"), + Some("release/1.x") + ); + // A tag push must not move a PR whose source branch shares the name. + assert_eq!(branch_from_ref("refs/tags/feature"), None); + assert_eq!(branch_from_ref("refs/notes/commits"), None); + assert_eq!(branch_from_ref("feature"), None); + assert_eq!(branch_from_ref("refs/heads/"), None); + } + + /// Scenario 1: a push to a PR's source branch moves that PR's stored head. + #[sqlx::test] + async fn push_to_source_branch_moves_the_open_pr_head(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/heads/feature", PR_SHA_OLD, PR_SHA_NEW)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_NEW.to_string()) + ); + } + + /// Scenario 2: a push to a different branch of the same repo changes + /// nothing. + #[sqlx::test] + async fn push_to_unrelated_branch_leaves_the_pr_head_unchanged(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + db.set_open_pr_heads("repo-1", "feature", PR_SHA_OLD) + .await + .unwrap(); + + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/heads/main", PR_SHA_OLD, PR_SHA_NEW)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_OLD.to_string()), + "a push to main must not move a PR whose source is `feature`" + ); + } + + /// Scenario 3: after the PR is closed, further pushes to the same branch + /// leave the frozen head alone. + #[sqlx::test] + async fn push_after_close_does_not_move_the_frozen_head(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + db.set_open_pr_heads("repo-1", "feature", PR_SHA_OLD) + .await + .unwrap(); + let pr = db.get_pr("repo-1", 1).await.unwrap().unwrap(); + db.close_pr(&pr.id).await.unwrap(); + + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/heads/feature", PR_SHA_OLD, PR_SHA_NEW)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_OLD.to_string()), + "a closed PR keeps the head it froze at" + ); + } + + /// Scenario 5: a force-push is a non-fast-forward ref update, so the stored + /// head must follow it. The rollup target is the branch head, not the + /// newest commit, and the discarded SHA must stop being the target. + #[sqlx::test] + async fn force_push_moves_the_pr_head_to_the_new_sha(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + db.set_open_pr_heads("repo-1", "feature", PR_SHA_OLD) + .await + .unwrap(); + + // Non-fast-forward: old_sha is the discarded tip, new_sha is unrelated. + let rewritten = "3333333333333333333333333333333333333333"; + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/heads/feature", PR_SHA_OLD, rewritten)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(rewritten.to_string()), + "the rewritten tip becomes the rollup target" + ); + } + + /// A same-named branch in two repos is an ordinary shape, and a missing + /// repo predicate would be invisible to every single-repo assertion. + #[sqlx::test] + async fn push_does_not_move_a_same_named_branch_in_another_repo(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + seed_open_pr(&db, "repo-2", 1, "feature").await; + + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/heads/feature", PR_SHA_OLD, PR_SHA_NEW)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_NEW.to_string()) + ); + assert_eq!( + head_of(&db, "repo-2", 1).await, + None, + "the other repo's PR on the same branch name must be untouched" + ); + } + + /// A tag push carries a ref name whose last segment can equal a branch + /// name. It is not a branch update and must not move a head. + #[sqlx::test] + async fn tag_push_does_not_move_a_same_named_branch_pr(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + db.set_open_pr_heads("repo-1", "feature", PR_SHA_OLD) + .await + .unwrap(); + + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/tags/feature", PR_SHA_OLD, PR_SHA_NEW)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_OLD.to_string()), + "a tag named like the source branch must not move the head" + ); + } + + /// Deleting the source branch is a ref update whose new SHA is all zeros. + /// Writing that as the head would hand the rollup a target no commit + /// resolves to, so the last real SHA stands. + #[sqlx::test] + async fn branch_deletion_leaves_the_last_real_sha_in_place(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + db.set_open_pr_heads("repo-1", "feature", PR_SHA_OLD) + .await + .unwrap(); + + update_open_pr_heads( + &db, + "repo-1", + &[ref_update("refs/heads/feature", PR_SHA_OLD, ZERO_SHA)], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_OLD.to_string()), + "a deletion must not store an all-zero head" + ); + } + + /// The scenario tests above drive `update_open_pr_heads` directly, because a + /// full receive-pack POST needs a real pack. That proves the mechanism and + /// says nothing about the sink: delete the call from the push handler and + /// every one of them still passes while no push ever moves a head again. + /// Pin the wiring by source, the way the authz guards in `api/mod.rs` do. + /// The slice stops at the handler's own closing brace (the first `}` in + /// column 0) so a call in a later function cannot satisfy this, and + /// full-line comments are stripped so the doc comment on the call cannot + /// stand in for the call. Note the boundary is found by brace rather than by + /// a declaration prefix: `handler_names` in `api/mod.rs` scrapes every + /// source file for the public-async declaration prefix, so spelling that + /// prefix here as data (in a literal or even in this comment) registers an + /// empty handler name and panics that guard. + #[test] + fn the_push_handler_actually_calls_the_head_update() { + let src = include_str!("repos.rs"); + let body = crate::test_support::scrape_source_region( + src, + Some("fn git_receive_pack("), + Some("\n}"), + ) + .expect("git_receive_pack not found (renamed or removed?)"); + + assert!( + body.contains("update_open_pr_heads(&state.db, &record.id, &ref_updates)"), + "the receive-pack handler must feed the parsed ref updates to the \ + stored-head update; without that call every head-maintenance test \ + here passes against a helper nothing invokes" + ); + } + + /// A multi-ref push is one request. Every branch in it that has an open PR + /// must be updated, not just the first — the same collapse-to-first bug the + /// ref-certificate loop above already had to fix. + #[sqlx::test] + async fn a_multi_ref_push_updates_every_matching_pr(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-1", 1, "feature").await; + seed_open_pr(&db, "repo-1", 2, "second").await; + + let other = "4444444444444444444444444444444444444444"; + update_open_pr_heads( + &db, + "repo-1", + &[ + ref_update("refs/heads/feature", PR_SHA_OLD, PR_SHA_NEW), + ref_update("refs/heads/second", PR_SHA_OLD, other), + ], + ) + .await; + + assert_eq!( + head_of(&db, "repo-1", 1).await, + Some(PR_SHA_NEW.to_string()) + ); + assert_eq!(head_of(&db, "repo-1", 2).await, Some(other.to_string())); + } + + // ── Push-path fan-out: bounded work, not one round trip per ref ─────── + + /// Statements executed on the push write path since the last call. The + /// counter is thread-local, so it measures this test alone with no + /// cross-test serialization to remember to take. + fn statements_since_last_check() -> usize { + crate::db::take_push_write_statements() + } + + /// A `tracing` sink for the current thread, so "the drop is logged" is a + /// property the test observes rather than one it takes on trust. The + /// subscriber is installed for the lifetime of the returned value and + /// captures whatever this thread emits while it lives. + struct LogCapture { + buf: std::sync::Arc>>, + _guard: tracing::subscriber::DefaultGuard, + } + + #[derive(Clone)] + struct LogSink(std::sync::Arc>>); + + impl std::io::Write for LogSink { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.0.lock().expect("log buffer").extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogSink { + type Writer = LogSink; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + impl LogCapture { + fn contents(&self) -> String { + String::from_utf8_lossy(&self.buf.lock().expect("log buffer")).into_owned() + } + } + + fn capture_logs() -> LogCapture { + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let subscriber = tracing_subscriber::fmt() + .with_writer(LogSink(buf.clone())) + .with_ansi(false) + .with_max_level(tracing::Level::WARN) + .finish(); + LogCapture { + buf, + _guard: tracing::subscriber::set_default(subscriber), + } + } + + /// `n` distinct branch updates. The SHAs start at 1, not 0: forty hex zeroes + /// is the deletion sentinel, which both writers skip on purpose, and a + /// generator that emitted one would silently make every count here one short. + fn many_ref_updates(n: usize) -> Vec { + (0..n) + .map(|i| ref_update(&format!("refs/heads/b{i}"), PR_SHA_OLD, &sha_for(i))) + .collect() + } + + fn sha_for(i: usize) -> String { + format!("{:040x}", i + 1) + } + + /// The cost of a push must not grow with the number of refs in it. Both + /// writers on the receive-pack path used to issue one sequential round trip + /// per ref, so a push of many refs multiplied database latency onto the + /// user's `git push` response. + /// + /// This asserts on the WORK, not on the rows: a per-ref loop and a batched + /// statement leave byte-identical rows behind, so a row-count assertion + /// passes against either and proves nothing about the thing that was wrong. + #[sqlx::test] + async fn a_multi_ref_push_costs_one_statement_per_writer(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + seed_open_pr(&db, "repo-fan", 1, "b0").await; + seed_open_pr(&db, "repo-fan", 2, "b3").await; + let updates = many_ref_updates(8); + + statements_since_last_check(); + record_push_events(&db, "repo-fan", &updates).await; + assert_eq!( + statements_since_last_check(), + 1, + "recording an 8-ref push must be one multi-row insert, not one round \ + trip per ref" + ); + + update_open_pr_heads(&db, "repo-fan", &updates).await; + assert_eq!( + statements_since_last_check(), + 1, + "moving the stored heads for an 8-ref push must be one statement" + ); + + // And the batching did not cost correctness: every row still landed, and + // both open pull requests still followed their own branch. + let rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-fan'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(rows, 8, "every ref must still be recorded"); + assert_eq!(head_of(&db, "repo-fan", 1).await, Some(sha_for(0))); + assert_eq!(head_of(&db, "repo-fan", 2).await, Some(sha_for(3))); + } + + /// The total-ref bound refuses, and it refuses BEFORE git is handed the + /// pack: at the bound the push is admitted, one over it is a 400 naming the + /// limit. + #[test] + fn a_push_declaring_more_refs_than_the_bound_is_refused() { + let at = many_ref_updates(MAX_REFS_PER_PUSH); + assert!( + bound_declared_refs(&at).is_ok(), + "a push exactly at the bound must still be admitted" + ); + + let over = many_ref_updates(MAX_REFS_PER_PUSH + 1); + let err = bound_declared_refs(&over).expect_err("one ref past the bound must be refused"); + assert!( + matches!(err, AppError::BadRequest(_)), + "an oversized push is the client's request to fix, got: {err}" + ); + assert!( + err.to_string().contains(&MAX_REFS_PER_PUSH.to_string()), + "the refusal must name the limit the client has to get under, got: {err}" + ); + } + + /// The bound is only worth anything ahead of the accept. It runs before the + /// per-ref branch-protection queries and before the repository is acquired + /// and receive-pack is run, so an oversized push costs one parse and is + /// refused, rather than being applied and then reported on. + #[test] + fn the_ref_bound_runs_before_git_accepts_the_push() { + let src = include_str!("repos.rs"); + let body = crate::test_support::scrape_source_region( + src, + Some("fn git_receive_pack("), + Some("\n}"), + ) + .expect("git_receive_pack not found (renamed or removed?)"); + + let bound = body + .find("bound_declared_refs(&ref_updates)?") + .expect("the receive-pack handler must bound the declared ref count"); + let protection = body + .find("is_branch_protected(") + .expect("branch protection loop not found (renamed or removed?)"); + let accept = body + .find("smart_http::receive_pack(") + .expect("receive_pack call not found (renamed or removed?)"); + + assert!( + bound < protection && bound < accept, + "the ref bound must precede the per-ref protection queries and the \ + receive-pack call; a bound applied after git has accepted the refs \ + is a report, not a refusal" + ); + } + + /// A push bigger than one chunk still records EVERY ref, and a pull request + /// whose source branch sits past the chunk boundary still gets its head + /// moved. + /// + /// This is the case truncation lost. receive-pack has already accepted every + /// ref by the time these writers run, so a ref dropped here is a ref git told + /// the client it took: the pull request keeps a stale `head_commit` forever + /// and no push event is ever produced for it. A `tracing::warn!` is not a + /// remedy for that, which is why the bound is now a chunk size and not a cap. + #[sqlx::test] + async fn a_push_past_the_chunk_boundary_records_every_ref_and_moves_its_pr_head( + pool: sqlx::PgPool, + ) { + let db = pr_head_db(pool).await; + let over = PUSH_WRITE_CHUNK + 7; + // One pull request inside the first chunk and one past the boundary. The + // second is the one a truncating fan-out abandoned. + let past = PUSH_WRITE_CHUNK + 3; + seed_open_pr(&db, "repo-chunk", 1, "b0").await; + seed_open_pr(&db, "repo-chunk", 2, &format!("b{past}")).await; + let updates = many_ref_updates(over); + + let logs = capture_logs(); + record_push_events(&db, "repo-chunk", &updates).await; + update_open_pr_heads(&db, "repo-chunk", &updates).await; + let logged = logs.contents(); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-chunk'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!( + rows as usize, over, + "every accepted ref must be recorded; git already took all {over}" + ); + assert_eq!( + head_of(&db, "repo-chunk", 1).await, + Some(sha_for(0)), + "the pull request inside the first chunk follows its branch" + ); + assert_eq!( + head_of(&db, "repo-chunk", 2).await, + Some(sha_for(past)), + "the pull request whose branch sits past the chunk boundary must \ + follow its branch too, or its rollup points at a commit the push \ + already replaced" + ); + assert!( + !logged.contains("truncated"), + "nothing is dropped, so nothing is truncated, got: {logged}" + ); + } + + /// Every ref is written, and the cost stays bounded per statement: the work + /// is chunked, so the count is one statement per chunk and never one per ref. + /// + /// Asserted on the WORK, not the rows: a per-ref loop leaves byte-identical + /// rows behind, so only the counter can tell the two apart. The two halves + /// are what make the property real together: the test above says nothing is + /// lost, this one says nothing is unbounded. + #[sqlx::test] + async fn a_push_past_the_chunk_boundary_costs_one_statement_per_chunk(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + let over = PUSH_WRITE_CHUNK + 7; + let chunks = over.div_ceil(PUSH_WRITE_CHUNK); + assert_eq!(chunks, 2, "the fixture must actually cross the boundary"); + let updates = many_ref_updates(over); + + statements_since_last_check(); + record_push_events(&db, "repo-chunk-cost", &updates).await; + assert_eq!( + statements_since_last_check(), + chunks, + "recording a {over}-ref push must be {chunks} multi-row inserts, not \ + one round trip per ref" + ); + + update_open_pr_heads(&db, "repo-chunk-cost", &updates).await; + assert_eq!( + statements_since_last_check(), + chunks, + "moving the stored heads for a {over}-ref push must be {chunks} \ + statements" + ); + } + + /// A chunk the database refuses is retried once and then escalated, never + /// dropped behind a warning. + /// + /// receive-pack has already told the client it took these refs, so a write + /// that fails here is a permanent hole in the catch-up surface: no later + /// poll asks for those events again. The write now waits on a per-repo lock, + /// which makes a bounded failure (lock timeout, pool exhaustion under the + /// queue it creates) a real outcome rather than a theoretical one, so the + /// caller has to say what was lost loudly enough for an operator to find it. + #[sqlx::test] + async fn a_chunk_that_cannot_be_written_is_retried_and_then_escalated(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + // The table is gone, so every attempt fails the same way a real outage + // would, in both attempts rather than only the first. + sqlx::query("DROP TABLE repo_push_events") + .execute(db.pool()) + .await + .unwrap(); + + let updates = many_ref_updates(2); + let logs = capture_logs(); + statements_since_last_check(); + record_push_events(&db, "repo-lost", &updates).await; + let statements = statements_since_last_check(); + let logged = logs.contents(); + + assert_eq!( + statements, 2, + "a chunk that failed must be attempted a second time before it is \ + given up on; got {statements} attempts" + ); + assert!( + logged.contains("ERROR"), + "events that are gone for good must escalate above the warning level \ + a poller can never see; got: {logged}" + ); + assert!( + logged.contains("repo-lost") + && logged.contains("refs/heads/b0") + && logged.contains(&sha_for(0)), + "the escalation must name the repo, the refs and the SHAs that were \ + lost, or nothing can be replayed by hand; got: {logged}" + ); + } + + /// The boundary itself: a push of exactly one chunk is one statement per + /// writer, with no empty second chunk behind it. + #[sqlx::test] + async fn a_push_of_exactly_one_chunk_is_one_statement_per_writer(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + let updates = many_ref_updates(PUSH_WRITE_CHUNK); + + statements_since_last_check(); + record_push_events(&db, "repo-atchunk", &updates).await; + assert_eq!( + statements_since_last_check(), + 1, + "a push of exactly one chunk must not issue a second statement" + ); + update_open_pr_heads(&db, "repo-atchunk", &updates).await; + assert_eq!(statements_since_last_check(), 1); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-atchunk'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!( + rows as usize, PUSH_WRITE_CHUNK, + "a push of exactly one chunk keeps every ref" + ); + } + /// The receive-pack *advertisement* (`GET info/refs?service=git-receive-pack`) /// must be throttled by the per-IP push limiter BEFORE it does the fresh /// Tigris acquire — otherwise the flood brake on the POST is bypassable via @@ -2644,3 +3492,62 @@ mod tests { ); } } + +#[cfg(test)] +mod push_event_wiring_tests { + /// The catch-up scenarios in `api/events.rs` drive `record_push_events` + /// directly, because a full receive-pack POST needs a real pack. That proves + /// the mechanism and says nothing about the sink: delete the call from the + /// push handler and every one of them still passes while no push is ever + /// discoverable by polling again. Pin the wiring by source, the way the + /// stored-head guard above and the authz guards in `api/mod.rs` do. The slice + /// stops at the handler's own closing brace (the first `}` in column 0) so a + /// call in a later function cannot satisfy this, and full-line comments are + /// stripped so the doc comment on the call cannot stand in for the call. + #[test] + fn the_push_handler_actually_records_push_events() { + let src = include_str!("repos.rs"); + let body = crate::test_support::scrape_source_region( + src, + Some("fn git_receive_pack("), + Some("\n}"), + ) + .expect("git_receive_pack not found (renamed or removed?)"); + + assert!( + body.contains("record_push_events(&state.db, &record.id, &ref_updates)"), + "the receive-pack handler must feed the parsed ref updates to the \ + push-event recorder; without that call the catch-up poll surface is \ + fed by nothing and every scenario test for it passes against a \ + helper the push path never invokes" + ); + } + + /// The containment constraint as a source property, independent of any one + /// scenario: the producer must never write a local push into the table the + /// unauthenticated global feed reads. + #[test] + fn the_push_event_recorder_never_targets_the_gossip_table() { + let src = include_str!("repos.rs"); + let body = crate::test_support::scrape_source_region( + src, + Some("async fn record_push_events("), + Some("\n}"), + ) + .expect("record_push_events not found (renamed or removed?)"); + + // This is a must-not assertion, so it passes on an empty region. Pin what + // the scan covered first: without this, a helper that returned nothing + // would make the guard below green while proving nothing. + assert!( + body.contains("insert_repo_push_events("), + "the scan must cover the recorder's own body, which writes through \ + insert_repo_push_events; got: {body}" + ); + assert!( + !body.contains("insert_ref_update"), + "a local push must not be written into received_ref_updates, which the \ + anonymous /api/v1/events/ref-updates feed also reads" + ); + } +} diff --git a/crates/gitlawb-node/src/api/status/mod.rs b/crates/gitlawb-node/src/api/status/mod.rs new file mode 100644 index 00000000..42ec0866 --- /dev/null +++ b/crates/gitlawb-node/src/api/status/mod.rs @@ -0,0 +1,591 @@ +//! Commit status claims: the write path and the combined read. +//! +//! POST /api/v1/repos/:owner/:repo/statuses/:sha — append one claim (owner only) +//! GET /api/v1/repos/:owner/:repo/commits/:sha/status — the combined projection +//! +//! Claims are append-only: a producer reporting twice for the same context +//! leaves both rows and the visible status is a projection over the history. +//! Reporting twice means two REQUESTS, though. An exact repeat of one already +//! accepted is answered with 200 and the row it wrote, not a second row: the +//! projection elects the highest `seq`, so a replayed claim would not duplicate +//! a verdict but overturn the one that superseded it. + +use axum::extract::{Extension, Path, State}; +use axum::http::StatusCode; +use axum::Json; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::AuthenticatedDid; +use crate::db::StatusClaim; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +/// Claims for one (repo, commit, producer, context). Generous for a real +/// producer; a producer past it is misbehaving. +const MAX_CLAIMS_PER_TUPLE: i64 = 100; +/// Distinct contexts for one (repo, commit). The context string is caller-chosen +/// and free-form, so the tuple cap alone bounds nothing. +const MAX_CONTEXTS_PER_COMMIT: i64 = 50; +/// Claim rows for one repo within the trailing window the db layer counts over. +/// The commit SHA is caller-chosen and never existence-checked, so a writer at +/// the two caps above can still fan out over fresh 40-hex strings without this +/// one. It bounds the RATE of that fan-out rather than a lifetime total, because +/// nothing prunes the table: a lifetime bound would close the surface on a repo +/// permanently while answering with a status every client retries. +const MAX_CLAIMS_PER_REPO_PER_WINDOW: i64 = 10_000; + +#[derive(Deserialize)] +pub struct CreateStatusRequest { + pub state: String, + pub context: String, + pub target_url: Option, + pub description: Option, +} + +/// The wire state set (KTD-1): GitHub's four commit-status states, so absence +/// stays distinguishable without a fifth value no client understands. +const CLAIM_STATES: [&str; 4] = ["error", "failure", "pending", "success"]; + +/// Bounds on the signature material this write path persists verbatim. Every one +/// of the four is caller-influenced: the headers come off the request, the +/// signing string grows with the component list the caller's Signature-Input +/// chose, and the body is the caller's. Without a bound the claim log's row size +/// is set by whoever writes to it, so these are explicit and generous rather than +/// implicit and absent. A conforming `sign_request` produces roughly 100, 200 and +/// 400 bytes for the first three. +const MAX_SIGNATURE_CHARS: usize = 512; +const MAX_SIGNATURE_INPUT_CHARS: usize = 1024; +const MAX_SIGNING_STRING_CHARS: usize = 4096; +/// The body is a `CreateStatusRequest`, whose own fields are already capped at +/// roughly 3.3 KB in total by the three limits below; this leaves room for JSON +/// framing and nothing more. +const MAX_REQUEST_BODY_BYTES: usize = 8192; + +const MAX_CONTEXT_CHARS: usize = 255; +const MAX_TARGET_URL_CHARS: usize = 2048; +const MAX_DESCRIPTION_CHARS: usize = 1024; + +/// The 201 body for an accepted claim: what the client needs to identify the row +/// it just wrote, and nothing else. +/// +/// Deliberately not the stored [`StatusClaim`]. That struct carries the signature +/// material, and serializing it echoed the signature, the signing string and the +/// whole request body back on every write — the body as a JSON array of integers. +/// Write-time provenance belongs in the row, not on the wire, which is the same +/// line [`StatusEntry`] draws on the read side. +#[derive(Serialize)] +pub struct CreatedStatus { + pub id: String, + /// The database-assigned ordering key, so the client can name its own row. + pub seq: i64, + pub repo_id: String, + pub commit_sha: String, + pub state: String, + pub context: String, + pub target_url: Option, + pub description: Option, + pub producer_did: String, + pub created_at: String, +} + +impl CreatedStatus { + fn from_claim(claim: StatusClaim, seq: i64) -> Self { + Self { + id: claim.id, + seq, + repo_id: claim.repo_id, + commit_sha: claim.commit_sha, + state: claim.state, + context: claim.context, + target_url: claim.target_url, + description: claim.description, + producer_did: claim.producer_did, + created_at: claim.created_at, + } + } +} + +/// POST /api/v1/repos/:owner/:repo/statuses/:sha +pub async fn create_status( + State(state): State, + Extension(auth): Extension, + Path((owner, name, sha)): Path<(String, String, String)>, + material: Option>, + Json(req): Json, +) -> Result<(StatusCode, Json)> { + // Read-visibility first, then owner, and the order is the security property. + // authorize_repo_read denies a quarantined repo before the visibility gate and + // answers with the repo's own not-found, byte-identical to a missing repo, so + // a caller who cannot read the repo cannot learn it exists. Loading the repo + // and comparing the owner would answer 403 there, turning this endpoint into + // an existence oracle. require_repo_owner then 403s a non-owner of a repo the + // caller can read, where existence is not secret. + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &name, Some(&auth.0), "/").await?; + crate::api::require_repo_owner(&record, &auth.0)?; + + let commit_sha = normalize_sha(&sha)?; + let claim_state = validate_state(&req.state)?; + let context = validate_context(&req.context)?; + let target_url = validate_target_url(req.target_url.as_deref())?; + let description = validate_description(req.description.as_deref())?; + + // KTD-5: the signature material is captured here because the request carries + // the only copy, and `signing_string` is the exact byte sequence the + // middleware verified. Its absence means this handler was reached without + // `require_signature` — a server misconfiguration (a route group that lost + // its auth layer, a handler mounted somewhere new), not anything the client + // did, so it is a 500 and no row is written. Storing an empty payload instead + // would leave every claim from that moment on unverifiable with nothing going + // red, which is exactly the absence-renders-as-success shape. + let Some(Extension(material)) = material else { + return Err(AppError::Internal(anyhow::anyhow!( + "status claim write reached without verified signature material — \ + the route is not behind require_signature" + ))); + }; + // The body is carried only behind the `PersistsSignedBody` marker, which + // this route's group applies outside the auth layers. Its absence means the + // marker did not reach the middleware, because the layer was dropped or + // reordered. That is the same class of misconfiguration as the branch above + // and gets the same answer. Storing an empty body instead would write claims + // that carry a signature over a digest of bytes nobody kept, which is + // exactly the case `stored_claim_re_verifies_and_a_tampered_row_does_not` + // exists to make impossible. + let Some(body) = material.body.clone() else { + return Err(AppError::Internal(anyhow::anyhow!( + "status claim write reached with signature material carrying no request body: \ + the route lost its persist-body marker or applied it inside the auth layers" + ))); + }; + // Bounded before anything is written. The material is verified, which says + // the caller holds the key, not that what they signed is a reasonable size. + bound("signature", material.signature.len(), MAX_SIGNATURE_CHARS)?; + bound( + "signature_input", + material.signature_input.len(), + MAX_SIGNATURE_INPUT_CHARS, + )?; + bound( + "signing_string", + material.signing_string.len(), + MAX_SIGNING_STRING_CHARS, + )?; + bound("request body", body.len(), MAX_REQUEST_BODY_BYTES)?; + + let digest = request_digest(&material, &body); + let (signature, signature_input, signing_string, request_body) = ( + material.signature, + material.signature_input, + material.signing_string, + body.to_vec(), + ); + + let claim = StatusClaim { + id: Uuid::new_v4().to_string(), + // Ignored on insert: the database assigns the ordering key (KTD-3). + seq: 0, + repo_id: record.id.clone(), + commit_sha, + state: claim_state, + context, + target_url, + description, + // Both are the owner identity today (KTD-5); they split when delegated + // capabilities land. + producer_did: auth.0.clone(), + authorizing_did: auth.0, + signature, + signature_input, + signing_string, + request_body, + request_digest: digest, + created_at: Utc::now().to_rfc3339(), + }; + + let caps = crate::db::ClaimCaps { + per_tuple: MAX_CLAIMS_PER_TUPLE, + contexts_per_commit: MAX_CONTEXTS_PER_COMMIT, + per_repo_window: MAX_CLAIMS_PER_REPO_PER_WINDOW, + }; + match state.db.insert_status_claim_capped(&claim, &caps).await? { + crate::db::ClaimInsert::Inserted(seq) => Ok(( + StatusCode::CREATED, + Json(CreatedStatus::from_claim(claim, seq)), + )), + // Already recorded, so 200 and the original row rather than 201 and a + // second one. The claim the caller gets back is the stored one, id and + // seq included, which is what makes a retry safe to treat as success. + crate::db::ClaimInsert::AlreadyRecorded(existing) => { + let seq = existing.seq; + Ok(( + StatusCode::OK, + Json(CreatedStatus::from_claim(*existing, seq)), + )) + } + crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!( + "claim limit reached for {which}" + ))), + } +} + +/// The identity of one signed write, as a hex sha-256. +/// +/// All four inputs together, because a replay is the same bytes arriving twice: +/// the signature, the input that names what it covers, the canonical string it +/// was verified over, and the body that string covers through a content-digest. +/// The signature alone would nearly do (it covers the other three transitively), +/// but hashing what is actually stored means the digest describes the row rather +/// than a claim about it. +/// +/// Each field is length-prefixed so no two different requests can concatenate to +/// the same bytes: without it, a signature ending in some prefix of the next +/// field would collide with the shorter signature that absorbed it. +/// +/// This is not a nonce, and deliberately so. A nonce table needs pruning and a +/// pruning window is a second replay window; the claim row IS the record of what +/// was accepted, so uniqueness on it is self-maintaining. +fn request_digest(material: &crate::auth::SignatureMaterial, body: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + for part in [ + material.signature.as_bytes(), + material.signature_input.as_bytes(), + material.signing_string.as_bytes(), + body, + ] { + h.update((part.len() as u64).to_be_bytes()); + h.update(part); + } + format!("{:x}", h.finalize()) +} + +/// One reported context in the combined response. Signature material is +/// deliberately absent: it is write-time provenance, not read-surface data. +#[derive(Serialize)] +pub struct StatusEntry { + pub state: String, + pub context: String, + pub target_url: Option, + pub description: Option, + pub producer_did: String, + pub created_at: String, +} + +/// The combined commit status. `state` never leaves the four-value set (KTD-1), +/// so absence is carried by `total_count` 0 with the pending state rather than a +/// fifth value, and `reported_only` (R19) says out loud that the state covers the +/// contexts that reported, not every check a caller expected. +#[derive(Serialize)] +pub struct CombinedStatus { + pub state: String, + pub sha: String, + pub total_count: usize, + pub statuses: Vec, + pub reported_only: bool, +} + +/// GET /api/v1/repos/:owner/:repo/commits/:sha/status +/// +/// The auth extension is optional and MUST be last in the extractor list: the +/// route group carries `optional_signature`, so an unsigned caller reaches here +/// with no `AuthenticatedDid` at all and a public repo still answers. +pub async fn commit_status( + State(state): State, + Path((owner, name, sha)): Path<(String, String, String)>, + auth: Option>, +) -> Result> { + let caller = auth.as_ref().map(|Extension(a)| a.0.as_str()); + // The gate runs first and on the requested path, before any claim data is + // touched. Its deny is the repo's own not-found, byte-identical to a missing + // repo, so the status surface cannot answer "does this repo exist". + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?; + + let commit_sha = normalize_sha(&sha)?; + let statuses = project_claims(&state, &record, &commit_sha).await?; + + Ok(Json(CombinedStatus { + state: combined_state(&statuses).to_string(), + sha: commit_sha, + total_count: statuses.len(), + statuses, + reported_only: true, + })) +} + +/// The projection, in one place. Both read surfaces call it, so R3's "the rollup +/// is derived by the same projection as the commit read" holds by construction +/// rather than by two implementations happening to agree. +/// +/// KTD-5: current authorization, not write-time authorization. An ownership +/// transfer drops the prior owner's claims from the projection while the +/// append-only history keeps them. +async fn project_claims( + state: &AppState, + record: &crate::db::RepoRecord, + commit_sha: &str, +) -> Result> { + let claims = state + .db + .latest_status_claims(&record.id, commit_sha, &record.owner_did) + .await?; + Ok(claims + .into_iter() + .map(|c| StatusEntry { + state: c.state, + context: c.context, + target_url: c.target_url, + description: c.description, + producer_did: c.producer_did, + created_at: c.created_at, + }) + .collect()) +} + +/// The pull request head rollup (R11). `state` stays inside the four wire values +/// whatever happened to the head (KTD-1): an unresolvable head is carried by +/// `head_resolved`, alongside the pull request's own state, so a client can tell +/// "the head could not be resolved" from "the head resolved and nothing reported" +/// without a fifth state value no client understands. +#[derive(Serialize)] +pub struct PullRequestStatus { + pub number: i64, + /// The pull request's own state: open, closed, or merged. + pub pull_request_state: String, + pub head_resolved: bool, + /// The target commit, absent exactly when `head_resolved` is false. + pub sha: Option, + pub state: String, + pub total_count: usize, + pub statuses: Vec, + pub reported_only: bool, +} + +/// GET /api/v1/repos/:owner/:repo/pulls/:number/status +/// +/// Same optional-auth shape as the commit read, and the auth extension MUST stay +/// last in the extractor list. +pub async fn pull_request_status( + State(state): State, + Path((owner, name, number)): Path<(String, String, i64)>, + auth: Option>, +) -> Result> { + let caller = auth.as_ref().map(|Extension(a)| a.0.as_str()); + // The gate runs first, on the requested path, before the pull request row is + // loaded. Its deny is the repo's own not-found, byte-identical to a missing + // repo. Once it passes, a missing pull request number is the plain not-found: + // existence is no longer secret. + let (record, _rules) = + crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?; + + let pr = state + .db + .get_pr(&record.id, number) + .await? + .ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?; + + let head = rollup_head(&state, &record, &pr).await?; + + let statuses = match &head { + Some(sha) => project_claims(&state, &record, sha).await?, + None => Vec::new(), + }; + + Ok(Json(PullRequestStatus { + number: pr.number, + pull_request_state: pr.status, + head_resolved: head.is_some(), + sha: head, + state: combined_state(&statuses).to_string(), + total_count: statuses.len(), + statuses, + reported_only: true, + })) +} + +/// Branch-head lookups performed by the rollup fallback, counted so a test can +/// assert WORK DONE rather than only the answer returned. A fallback that +/// re-resolved on every read and then discarded the answer would be invisible in +/// the response and visible here. +#[cfg(test)] +pub(crate) static BRANCH_RESOLVES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +/// The rollup's target commit (KTD-4). +/// +/// The stored head wins whenever it is set. It is frozen at close or merge, so a +/// closed or merged pull request keeps the commit the decision was made against, +/// and only an OPEN pull request with no stored head falls back to the branch. +/// +/// The fallback is one database read: the latest push this node recorded for the +/// pull request's source branch, from `repo_push_events`. It deliberately does +/// NOT go through `git::store` ref listing, which needs an acquired repository +/// path — on a cold node that downloads the whole repository from object storage, +/// and this read group carries no rate limiter, so an anonymous caller could +/// drive repeated downloads with a URL. +/// +/// `repo_push_events` is the source rather than `branch_cids` because +/// `record_push_events` writes it for every ref update unconditionally, whereas +/// the sole writer of `branch_cids` sits behind a pin CID. A node with no object +/// pinning configured never writes that table, so a resolve keyed on it could +/// never answer there and every open pull request without a stored head reported +/// `head_resolved: false` forever. The residual limit is that only pushes taken +/// after this shipped have rows, so a branch last pushed before then still does +/// not resolve. +/// +/// The resolved head is persisted, which makes an unauthenticated GET write. That +/// is only acceptable because the write is self-limiting: it fires exactly when +/// `head_commit` is absent and it fills it, and the fill is conditioned on that +/// same absence in SQL, so the PERSIST happens at most once per pull request. +/// The resolve attempt itself is not once-only — a pull request whose branch does +/// not resolve stores nothing, so there is nothing to short-circuit on and the +/// lookup runs again on every read until it succeeds. +async fn rollup_head( + state: &AppState, + record: &crate::db::RepoRecord, + pr: &crate::db::PullRequest, +) -> Result> { + if let Some(stored) = pr.head_commit.clone() { + return Ok(Some(stored)); + } + if pr.status != "open" { + return Ok(None); + } + + // The push path records the full ref while a pull request stores the bare + // branch name, the same mismatch `crate::api::repos::branch_from_ref` + // reconciles on the write side; this is that mapping run backwards. The empty + // branch is excluded for the same reason it is there: `refs/heads/` is not a + // branch and nothing can have written it. + if pr.source_branch.is_empty() { + return Ok(None); + } + let ref_name = format!("refs/heads/{}", pr.source_branch); + #[cfg(test)] + BRANCH_RESOLVES.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let resolved = state + .db + .latest_push_sha_for_ref(&record.id, &ref_name) + .await? + // A recorded head that is not a commit SHA is no target at all, and + // storing it would leave the rollup pointing at nothing forever. + .and_then(|sha| normalize_sha(&sha).ok()); + + let Some(sha) = resolved else { + return Ok(None); + }; + // Best effort, and deliberately not `?`. The head is already resolved and the + // response does not depend on this write landing, so a transient database + // failure here must not turn a served read into a 500; the next read resolves + // again and re-attempts the fill. Same catch-and-log the sibling writes on the + // push path use (`update_open_pr_heads`, `record_push_events`). + if let Err(e) = state.db.set_pr_head_if_absent(&pr.id, &sha).await { + tracing::warn!( + err = %e, + pr_id = %pr.id, + "failed to persist a resolved pull request head; serving the resolved head anyway" + ); + } + Ok(Some(sha)) +} + +/// KTD-1's fold: any error or failure yields failure, else any pending yields +/// pending, else success. An empty set is pending, never success — total absence +/// of a verdict is its own state and must not read as a pass (R10). +fn combined_state(statuses: &[StatusEntry]) -> &'static str { + if statuses.is_empty() { + return "pending"; + } + if statuses + .iter() + .any(|s| s.state == "error" || s.state == "failure") + { + "failure" + } else if statuses.iter().any(|s| s.state == "pending") { + "pending" + } else { + "success" + } +} + +/// 40 hex characters, lowercased. The SHA is never existence-checked (KTD-7): +/// a claim describes a commit object, and the object may not have arrived yet. +fn normalize_sha(sha: &str) -> Result { + if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(sha.to_ascii_lowercase()) + } else { + Err(AppError::BadRequest( + "commit sha must be exactly 40 hexadecimal characters".into(), + )) + } +} + +/// One size bound on persisted signature material, measured in bytes. +fn bound(what: &str, len: usize, max: usize) -> Result<()> { + if len > max { + return Err(AppError::BadRequest(format!( + "{what} must be at most {max} bytes" + ))); + } + Ok(()) +} + +fn validate_state(state: &str) -> Result { + if CLAIM_STATES.contains(&state) { + Ok(state.to_string()) + } else { + Err(AppError::BadRequest(format!( + "state must be one of {}", + CLAIM_STATES.join(", ") + ))) + } +} + +/// The context is the projection key, so it is trimmed, bounded, and required to +/// be free of control characters. +fn validate_context(context: &str) -> Result { + let trimmed = context.trim(); + if trimmed.is_empty() || trimmed.chars().count() > MAX_CONTEXT_CHARS { + return Err(AppError::BadRequest(format!( + "context must be 1 to {MAX_CONTEXT_CHARS} characters" + ))); + } + if trimmed.chars().any(char::is_control) { + return Err(AppError::BadRequest( + "context must not contain control characters".into(), + )); + } + Ok(trimmed.to_string()) +} + +fn validate_target_url(url: Option<&str>) -> Result> { + let Some(url) = url else { return Ok(None) }; + if url.chars().count() > MAX_TARGET_URL_CHARS { + return Err(AppError::BadRequest(format!( + "target_url must be at most {MAX_TARGET_URL_CHARS} characters" + ))); + } + let lower = url.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + Ok(Some(url.to_string())) + } else { + Err(AppError::BadRequest( + "target_url must be an http or https URL".into(), + )) + } +} + +fn validate_description(description: Option<&str>) -> Result> { + match description { + Some(d) if d.chars().count() > MAX_DESCRIPTION_CHARS => Err(AppError::BadRequest(format!( + "description must be at most {MAX_DESCRIPTION_CHARS} characters" + ))), + other => Ok(other.map(str::to_string)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/gitlawb-node/src/api/status/tests.rs b/crates/gitlawb-node/src/api/status/tests.rs new file mode 100644 index 00000000..f04d45a3 --- /dev/null +++ b/crates/gitlawb-node/src/api/status/tests.rs @@ -0,0 +1,2777 @@ +use axum::body::Body; +use axum::http::{Method, StatusCode}; +use axum::routing::post; +use axum::Router; +use sqlx::PgPool; +use tower::ServiceExt; + +use crate::db::RepoRecord; +use crate::test_support::{signed_request_as, test_state}; + +const OWNER: &str = "did:key:zSTATUSOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const STRANGER: &str = "did:key:zSTATUSSTRANGERBBBBBBBBBBBBBBBBBBBBBBBBB"; +const SHA_A: &str = "1111111111111111111111111111111111111111"; +const SHA_B: &str = "2222222222222222222222222222222222222222"; +const SHA_C: &str = "3333333333333333333333333333333333333333"; + +fn seed_repo(owner_did: &str, name: &str, is_public: bool) -> RepoRecord { + let now = chrono::Utc::now(); + RepoRecord { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + owner_did: owner_did.to_string(), + description: None, + is_public, + default_branch: "main".to_string(), + created_at: now, + updated_at: now, + disk_path: format!("/tmp/{name}"), + forked_from: None, + machine_id: None, + } +} + +fn router(state: crate::state::AppState) -> Router { + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/statuses/{sha}", + post(super::create_status), + ) + .with_state(state) +} + +fn body_of(state: &str, context: &str) -> Body { + Body::from(format!(r#"{{"state":"{state}","context":"{context}"}}"#)) +} + +fn uri(owner: &str, repo: &str, sha: &str) -> String { + format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}") +} + +/// `signed_request_as` plus the verified RFC 9421 material the signature +/// middleware injects in production, so a handler mounted bare still runs the +/// real path instead of a missing-material branch. +fn signed_with_material(did: &str, uri: &str, body: Body) -> axum::http::Request { + let mut req = signed_request_as(did, Method::POST, uri, body); + req.extensions_mut().insert(sample_material()); + req +} + +/// Stand-in material, well inside every bound, and DISTINCT on every call. +/// +/// Distinct because that is what production looks like: a real signature covers +/// a `created` parameter and the body's own digest, so two genuine requests +/// never carry the same bytes. A constant stand-in would make the second write +/// of any test an exact replay of the first, and the write path answers a replay +/// with the row it already has. The replay tests below get their identical bytes +/// the honest way, by signing once and putting the same headers on the wire +/// twice through the production router. +fn sample_material() -> crate::auth::SignatureMaterial { + static NTH: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let nth = NTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + crate::auth::SignatureMaterial { + signature: format!("sig1=:dGVzdA=={nth}:"), + signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" + .to_string(), + signing_string: "\"@method\": POST".to_string(), + body: Some(axum::body::Bytes::from_static(b"{}")), + } +} + +async fn post_as( + state: &crate::state::AppState, + did: &str, + uri: &str, + body: Body, +) -> axum::response::Response { + router(state.clone()) + .oneshot(signed_with_material(did, uri, body)) + .await + .unwrap() +} + +/// Status plus the full response body, for the byte-identical deny comparison. +async fn status_and_bytes(resp: axum::response::Response) -> (StatusCode, Vec) { + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, bytes) +} + +/// The owner's claim is recorded with both DIDs and a server timestamp. The +/// repo is seeded with the BARE owner key while the caller presents the full +/// `did:key:` form, so the owner gate has to normalize (did_matches), not +/// compare raw strings. +#[sqlx::test] +async fn owner_writes_a_claim_recording_both_dids(pool: PgPool) { + let state = test_state(pool).await; + let bare = OWNER.strip_prefix("did:key:").unwrap(); + let repo = seed_repo(bare, "status-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "status-repo", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let claims = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].state, "success"); + assert_eq!(claims[0].context, "ci/build"); + assert_eq!(claims[0].producer_did, OWNER); + assert_eq!(claims[0].authorizing_did, OWNER); + assert!( + chrono::DateTime::parse_from_rfc3339(&claims[0].created_at).is_ok(), + "created_at must be a server-assigned rfc3339 timestamp, got {:?}", + claims[0].created_at + ); + assert!(claims[0].seq > 0, "seq is assigned by the database"); + // The verified RFC 9421 material is persisted with the row: a claim that + // cannot be re-verified after the request is gone is not history. + assert!(claims[0].signature.starts_with("sig1=:dGVzdA==")); + assert!(claims[0].signature_input.starts_with("sig1=(")); + assert_eq!(claims[0].signing_string, "\"@method\": POST"); + assert_eq!(claims[0].request_body, b"{}"); +} + +/// Covers AE1. Two claims for one context both survive: the history is +/// append-only and the later claim never overwrites the earlier row. +#[sqlx::test] +async fn ae1_pending_then_success_keeps_both_rows(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "history-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + for st in ["pending", "success"] { + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "history-repo", SHA_A), + body_of(st, "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "{st} claim"); + } + + let claims = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + let states: Vec<&str> = claims.iter().map(|c| c.state.as_str()).collect(); + assert_eq!( + states, + vec!["pending", "success"], + "both claims must remain, ordered by seq" + ); +} + +/// Covers AE5. A signed non-owner writing to a repo it CAN read is refused +/// with exactly 403 (existence is not secret on a public repo) and writes +/// nothing. +#[sqlx::test] +async fn ae5_non_owner_on_public_repo_is_forbidden(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "public-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let resp = post_as( + &state, + STRANGER, + &uri(OWNER, "public-repo", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .is_empty(), + "a refused write must leave no row" + ); +} + +/// A non-owner writing to a private repo gets the response a MISSING repo +/// returns, byte for byte. The same URI is driven twice — once before the +/// repo exists, once after it is seeded private — so the two bodies are +/// comparable and the deny cannot pass vacuously on an absent row. +#[sqlx::test] +async fn non_owner_on_private_repo_is_indistinguishable_from_missing(pool: PgPool) { + let state = test_state(pool).await; + let target = uri(OWNER, "hidden-repo", SHA_A); + + let missing = post_as(&state, STRANGER, &target, body_of("success", "ci/build")).await; + let missing = status_and_bytes(missing).await; + + let repo = seed_repo(OWNER, "hidden-repo", false); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let denied = post_as(&state, STRANGER, &target, body_of("success", "ci/build")).await; + let denied = status_and_bytes(denied).await; + + assert_eq!(missing.0, StatusCode::NOT_FOUND); + assert_eq!( + denied, missing, + "a private-repo deny must be byte-identical to the missing-repo response" + ); + assert!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .is_empty(), + "a refused write must leave no row" + ); +} + +/// A quarantined repo denies before the visibility gate, so even a PUBLIC +/// quarantined repo answers with the missing-repo response. A plain repo load +/// plus owner comparison would answer 403 here, which is what makes this the +/// case that separates the two implementations. +#[sqlx::test] +async fn non_owner_on_quarantined_repo_is_indistinguishable_from_missing(pool: PgPool) { + let state = test_state(pool).await; + let target = uri(OWNER, "quarantined-repo", SHA_A); + + let missing = post_as(&state, STRANGER, &target, body_of("success", "ci/build")).await; + let missing = status_and_bytes(missing).await; + + // Public on purpose: quarantine must deny independently of visibility. + let repo = seed_repo(OWNER, "quarantined-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + state.db.set_repo_quarantine(&repo_id, true).await.unwrap(); + + let denied = post_as(&state, STRANGER, &target, body_of("success", "ci/build")).await; + let denied = status_and_bytes(denied).await; + + assert_eq!(missing.0, StatusCode::NOT_FOUND); + assert_eq!( + denied, missing, + "a quarantined-repo deny must be byte-identical to the missing-repo response" + ); + assert!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .is_empty(), + "a refused write must leave no row" + ); +} + +/// Every malformed field is exactly 400 and writes nothing. The two SHA cases +/// ride in the path, the rest in the body. +#[sqlx::test] +async fn malformed_claims_are_rejected_with_400(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "valid-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let long_description = "d".repeat(1025); + let cases: Vec<(&str, String, String)> = vec![ + ( + "state outside the four-value set", + uri(OWNER, "valid-repo", SHA_A), + r#"{"state":"passed","context":"ci"}"#.into(), + ), + ( + "sha of 39 characters", + uri(OWNER, "valid-repo", &"1".repeat(39)), + r#"{"state":"success","context":"ci"}"#.into(), + ), + ( + "sha with a non-hex character", + uri(OWNER, "valid-repo", &format!("{}z", "1".repeat(39))), + r#"{"state":"success","context":"ci"}"#.into(), + ), + ( + "empty context", + uri(OWNER, "valid-repo", SHA_A), + r#"{"state":"success","context":" "}"#.into(), + ), + ( + "control characters in context", + uri(OWNER, "valid-repo", SHA_A), + r#"{"state":"success","context":"ci\u0007build"}"#.into(), + ), + ( + "oversized description", + uri(OWNER, "valid-repo", SHA_A), + format!(r#"{{"state":"success","context":"ci","description":"{long_description}"}}"#), + ), + ( + "javascript: target url", + uri(OWNER, "valid-repo", SHA_A), + r#"{"state":"success","context":"ci","target_url":"javascript:alert(1)"}"#.into(), + ), + ]; + + for (label, target, body) in cases { + let resp = post_as(&state, OWNER, &target, Body::from(body)).await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "{label} must be rejected with 400" + ); + } + + for sha in [SHA_A, &"1".repeat(39), &format!("{}z", "1".repeat(39))] { + assert!( + state + .db + .list_status_claims(&repo_id, sha) + .await + .unwrap() + .is_empty(), + "a rejected claim must leave no row" + ); + } +} + +/// Seed one (repo, commit, producer, context) tuple to its cap; the next claim +/// on that tuple is exactly 429, while a fresh context on the same commit +/// still writes — proving the refusal came from the tuple cap and not from a +/// broader bound. +#[sqlx::test] +async fn per_tuple_cap_refuses_the_next_claim(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "tuple-cap-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + seed_claims( + &pool, + &repo_id, + SHA_A, + OWNER, + "ci/build", + super::MAX_CLAIMS_PER_TUPLE, + ) + .await; + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "tuple-cap-repo", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "tuple-cap-repo", SHA_A), + body_of("success", "ci/lint"), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "a different context on the same commit is under its own tuple cap" + ); +} + +/// Seed the per-(repo, commit) context limit under distinct contexts; a claim +/// carrying a fresh context is exactly 429 even though its own tuple is empty. +#[sqlx::test] +async fn context_fanout_cap_refuses_a_fresh_context(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "context-cap-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + for i in 0..super::MAX_CONTEXTS_PER_COMMIT { + seed_claims(&pool, &repo_id, SHA_A, OWNER, &format!("ci/{i}"), 1).await; + } + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "context-cap-repo", SHA_A), + body_of("success", "ci/fresh"), + ) + .await; + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "context-cap-repo", SHA_A), + body_of("success", "ci/0"), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "a context already present does not widen the fanout, so it still writes" + ); +} + +/// Seed the per-repo limit across distinct well-formed SHAs, all inside the +/// window; a claim against a fresh SHA is exactly 429, which is the bound the +/// caller-chosen (never existence-checked) SHA would otherwise escape. +#[sqlx::test] +async fn repo_row_cap_refuses_a_fresh_sha(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "repo-cap-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + seed_claims_across_shas( + &pool, + &repo_id, + OWNER, + super::MAX_CLAIMS_PER_REPO_PER_WINDOW, + &chrono::Utc::now().to_rfc3339(), + ) + .await; + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "repo-cap-repo", SHA_B), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); +} + +/// The per-repo bound is a rate, not a lifetime quota, and 429 says so +/// honestly: a client that waits and retries eventually gets through. The same +/// rows dated before the window admit a fresh claim, so a repo that once burst +/// to the limit is not permanently unable to accept a status while every CI +/// client retries a refusal that could never succeed. +#[sqlx::test] +async fn repo_cap_is_a_window_so_the_refusal_is_actually_retryable(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "repo-window-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + // Enough rows to blow a lifetime bound, every one of them older than the + // window: this is the repo that reached the cap yesterday. + let long_ago = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339(); + seed_claims_across_shas( + &pool, + &repo_id, + OWNER, + super::MAX_CLAIMS_PER_REPO_PER_WINDOW, + &long_ago, + ) + .await; + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "repo-window-repo", SHA_B), + body_of("success", "ci/build"), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "claims outside the window must not count against it; a repo cannot be \ + permanently barred from accepting a status" + ); +} + +/// A handler reached without the verified signature material writes NOTHING +/// and answers 500. The material is a server-side invariant (`require_signature` +/// always injects it), so its absence is a misconfiguration, not a client +/// error — and a claim stored without it is unverifiable history, which the +/// substrate cannot adopt. Failing open here would be silent: the row looks +/// fine and nothing goes red. +#[sqlx::test] +async fn missing_signature_material_fails_closed(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "material-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + // Deliberately NOT signed_with_material: only the identity is injected. + let resp = router(state.clone()) + .oneshot(signed_request_as( + OWNER, + Method::POST, + &uri(OWNER, "material-repo", SHA_A), + body_of("success", "ci/build"), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .is_empty(), + "an unverifiable claim must never be stored" + ); +} + +/// Every piece of signature material is bounded before it is persisted, and +/// an oversized one is exactly 400 with no row written. +/// +/// The signing string is the one that actually grows: it carries a line per +/// covered component, and the component list comes from the caller's own +/// Signature-Input. The other three are bounded for the same reason, since +/// all four are caller-supplied bytes that the write path stores verbatim. +#[sqlx::test] +async fn oversized_signature_material_is_rejected_with_400(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "material-cap-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let cases: Vec<(&str, crate::auth::SignatureMaterial)> = vec![ + ( + "signature", + crate::auth::SignatureMaterial { + signature: "s".repeat(super::MAX_SIGNATURE_CHARS + 1), + ..sample_material() + }, + ), + ( + "signature-input", + crate::auth::SignatureMaterial { + signature_input: "i".repeat(super::MAX_SIGNATURE_INPUT_CHARS + 1), + ..sample_material() + }, + ), + ( + "signing string", + crate::auth::SignatureMaterial { + signing_string: "c".repeat(super::MAX_SIGNING_STRING_CHARS + 1), + ..sample_material() + }, + ), + ( + "request body", + crate::auth::SignatureMaterial { + body: Some(vec![b'b'; super::MAX_REQUEST_BODY_BYTES + 1].into()), + ..sample_material() + }, + ), + ]; + + for (label, material) in cases { + let mut req = signed_request_as( + OWNER, + Method::POST, + &uri(OWNER, "material-cap-repo", SHA_A), + body_of("success", "ci/build"), + ); + req.extensions_mut().insert(material); + let resp = router(state.clone()).oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "an oversized {label} must be rejected with 400" + ); + } + + assert!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .is_empty(), + "a refused write must leave no row" + ); + + // The control: the same request with material inside every bound writes. + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "material-cap-repo", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "material inside the bounds must still write, or the caps prove nothing" + ); +} + +/// Material that reached this handler without the body is a refusal, not an +/// empty column. +/// +/// The body is only captured on routes that mark themselves as persisting it, +/// so an absent body here means the status route lost that marker or the +/// marker layer was reordered behind the signature middleware. Writing the row +/// anyway would record a claim nobody can re-verify, with nothing going red: +/// the same absence-renders-as-success shape the missing-material branch +/// already refuses. +#[sqlx::test] +async fn material_without_the_body_is_refused_rather_than_stored_empty(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "no-body-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let mut req = signed_request_as( + OWNER, + Method::POST, + &uri(OWNER, "no-body-repo", SHA_A), + body_of("success", "ci/build"), + ); + req.extensions_mut().insert(crate::auth::SignatureMaterial { + body: None, + ..sample_material() + }); + let resp = router(state.clone()).oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "a status write whose material carries no body must be refused" + ); + assert!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .is_empty(), + "a refused write must leave no row" + ); + + // The control: the same request with the body present writes, so the + // refusal above is about the missing body and not the route. + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "no-body-repo", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "material carrying the body must still write" + ); +} + +/// The 201 body carries exactly the client-facing fields and no signature +/// material. Asserted as the whole key set, not field by field, so a field +/// added to the response type later has to be added here deliberately +/// instead of leaking on the next serialize. +#[sqlx::test] +async fn create_response_carries_no_signature_material(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "response-repo", true); + state.db.create_repo(&repo).await.unwrap(); + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "response-repo", SHA_A), + Body::from( + r#"{"state":"success","context":"ci/build","target_url":"https://ci.example/1","description":"ok"}"#, + ), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let body = body_json(resp).await; + let mut keys: Vec<&str> = body + .as_object() + .expect("the 201 body must be a json object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "commit_sha", + "context", + "created_at", + "description", + "id", + "producer_did", + "repo_id", + "seq", + "state", + "target_url", + ], + "the write response must carry exactly the client-facing fields" + ); + + assert_eq!(body["state"], "success"); + assert_eq!(body["context"], "ci/build"); + assert_eq!(body["commit_sha"], SHA_A); + assert_eq!(body["producer_did"], OWNER); + assert!( + body["seq"].as_i64().unwrap() > 0, + "the response must report the seq the database assigned" + ); +} + +// ── Signed writes through the production router ─────────────────────── +// +// Everything above injects a hand-built `SignatureMaterial` onto a bare +// router, which cannot see a middleware that populates the material wrongly. +// These drive a genuinely signed request through `build_router`. + +/// Sign `body` for `path` with `kp` and POST it through the production +/// router, headers and all. Returns the headers that went on the wire +/// alongside the response, so a test can compare them against what the node +/// persisted. +async fn post_really_signed( + state: &crate::state::AppState, + kp: &gitlawb_core::identity::Keypair, + path: &str, + body: &[u8], +) -> ( + axum::response::Response, + gitlawb_core::http_sig::SignedHeaders, +) { + let signed = gitlawb_core::http_sig::sign_request(kp, "POST", path, body); + let resp = post_signed_headers(state, path, body, &signed).await; + (resp, signed) +} + +/// POST `body` to `path` under signature headers that were produced earlier. +/// +/// Splitting this out of [`post_really_signed`] is what makes a REPLAY +/// expressible: sign once, then put the identical bytes on the wire a second +/// time. Signing twice would not be a replay, because a fresh signature covers +/// a fresh `created` parameter. +async fn post_signed_headers( + state: &crate::state::AppState, + path: &str, + body: &[u8], + signed: &gitlawb_core::http_sig::SignedHeaders, +) -> axum::response::Response { + let req = axum::http::Request::builder() + .method(Method::POST) + .uri(path) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .header("content-digest", signed.content_digest.clone()) + .header("signature-input", signed.signature_input.clone()) + .header("signature", signed.signature.clone()) + .body(Body::from(body.to_vec())) + .unwrap(); + crate::server::build_router(state.clone()) + .oneshot(req) + .await + .unwrap() +} + +/// Re-verify a stored claim from the row alone, the way a third party +/// adopting this history would have to: +/// +/// 1. the row's own fields are the ones the stored body carries, +/// 2. the digest of the stored body is the one the signing string covers, +/// 3. the stored signature verifies over that signing string under the +/// producer's key. +/// +/// Returns the first broken link rather than panicking, so the negative case +/// can drive the same procedure and observe it refuse. +fn re_verify(claim: &crate::db::StatusClaim) -> std::result::Result<(), String> { + let body: serde_json::Value = serde_json::from_slice(&claim.request_body) + .map_err(|e| format!("stored body is not json: {e}"))?; + if body["state"] != claim.state.as_str() { + return Err(format!( + "row state {:?} is not the state the signed body carries ({:?})", + claim.state, body["state"] + )); + } + if body["context"] != claim.context.as_str() { + return Err(format!( + "row context {:?} is not the context the signed body carries ({:?})", + claim.context, body["context"] + )); + } + + let digest = gitlawb_core::http_sig::compute_content_digest(&claim.request_body); + let covered = format!("\"content-digest\": {digest}"); + if !claim.signing_string.contains(&covered) { + return Err(format!( + "the signing string does not cover the stored body's digest ({covered})" + )); + } + + let did: gitlawb_core::did::Did = claim + .producer_did + .parse() + .map_err(|e| format!("producer did does not parse: {e}"))?; + let key = did + .to_verifying_key() + .map_err(|e| format!("producer did does not resolve to a key: {e}"))?; + let parsed = + gitlawb_core::http_sig::HttpSignature::parse(&claim.signature_input, &claim.signature) + .map_err(|e| format!("stored signature headers do not parse: {e}"))?; + let bytes: [u8; 64] = parsed + .signature_bytes + .as_slice() + .try_into() + .map_err(|_| "stored signature is not 64 bytes".to_string())?; + gitlawb_core::identity::verify(&key, claim.signing_string.as_bytes(), &bytes) + .map_err(|e| format!("signature does not verify over the stored signing string: {e}")) +} + +/// The stored row is re-verifiable end to end, and the chain closes: mutating +/// the row's `state` after the fact breaks it. +/// +/// This is the property the whole write path exists for. Storing only the +/// signing string would pass the signature check and still leave step 1 and 2 +/// unanswerable, because the signing string covers the body only through a +/// digest of bytes nobody kept. +#[sqlx::test] +async fn stored_claim_re_verifies_and_a_tampered_row_does_not(pool: PgPool) { + let state = test_state(pool.clone()).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let did = kp.did().to_string(); + let repo = seed_repo(&did, "verify-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let path = uri(&did, "verify-repo", SHA_A); + let body = br#"{"state":"success","context":"ci/build"}"#; + let (resp, _) = post_really_signed(&state, &kp, &path, body).await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let claims = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + assert_eq!(claims.len(), 1); + re_verify(&claims[0]).expect("a stored claim must be re-verifiable from the row alone"); + + // The negative: change the claim's verdict in place. Nothing about the + // signature material changes, so this passes unless the stored body is + // what ties the row to the signature. + sqlx::query("UPDATE status_claims SET state='failure' WHERE id=$1") + .bind(&claims[0].id) + .execute(&pool) + .await + .unwrap(); + + let tampered = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + assert_eq!(tampered[0].state, "failure"); + let err = re_verify(&tampered[0]) + .expect_err("a row whose state no longer matches the signed body must not re-verify"); + assert!( + err.contains("row state"), + "the mutated verdict must be what refuses, got: {err}" + ); +} + +/// What `require_signature` puts in the extension is what the client sent, +/// field for field. +/// +/// Every other 201-path test injects the material by hand onto a bare router, +/// so a middleware that swapped, truncated or corrupted these fields would +/// leave all of them green. This drives the real router and compares the +/// persisted row against the headers that went on the wire. +#[sqlx::test] +async fn middleware_persists_the_material_the_client_actually_sent(pool: PgPool) { + use gitlawb_core::http_sig::{build_signing_string, COVERED_COMPONENTS}; + + let state = test_state(pool).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let did = kp.did().to_string(); + let repo = seed_repo(&did, "material-wire-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let path = uri(&did, "material-wire-repo", SHA_A); + let body = br#"{"state":"pending","context":"ci/wire","description":"in flight"}"#; + let (resp, signed) = post_really_signed(&state, &kp, &path, body).await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let claims = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + assert_eq!(claims.len(), 1); + let claim = &claims[0]; + + assert_eq!( + claim.signature, signed.signature, + "the stored Signature is the header the client sent" + ); + assert_eq!( + claim.signature_input, signed.signature_input, + "the stored Signature-Input is the header the client sent" + ); + assert_eq!( + claim.request_body, + body.to_vec(), + "the stored body is the request body, whole and unaltered" + ); + + // The signing string the node verified must be the one the client signed, + // rebuilt here independently rather than read back from the row. + let mut values = std::collections::HashMap::new(); + values.insert("@method".to_string(), "POST".to_string()); + values.insert("@path".to_string(), path.clone()); + values.insert("content-digest".to_string(), signed.content_digest.clone()); + let expected = build_signing_string( + COVERED_COMPONENTS, + signed.signature_input.strip_prefix("sig1=").unwrap(), + &values, + ) + .unwrap(); + assert_eq!( + claim.signing_string, expected, + "the stored signing string is the canonical string the client signed" + ); + + assert_eq!(claim.producer_did, did); + assert_eq!(claim.state, "pending"); + assert_eq!(claim.context, "ci/wire"); +} + +/// The status write route shows the persist marker to the signature +/// middleware, so the body is captured on the one route that stores it. +/// +/// The marker is an extension layer on the status write group and the +/// middleware reads it when it decides whether to carry the body. That only +/// works if the marker layer is the OUTER of the two, and axum's layer order +/// is a property of how `build_router` is written, not something the type +/// system checks. Reordering the group, or dropping the layer, leaves a +/// handler that still passes every test driving a hand-built material onto a +/// bare router and stores nothing in production. This drives the real router. +#[sqlx::test] +async fn the_status_route_shows_the_persist_marker_to_the_signature_middleware(pool: PgPool) { + let state = test_state(pool).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let did = kp.did().to_string(); + let repo = seed_repo(&did, "marker-order-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let path = uri(&did, "marker-order-repo", SHA_A); + let body = br#"{"state":"success","context":"ci/marker"}"#; + let (resp, _) = post_really_signed(&state, &kp, &path, body).await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "the status route must reach the signature middleware with the persist \ + marker already applied; a marker layer inside the auth layers is never seen" + ); + + let claims = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!( + claims[0].request_body, + body.to_vec(), + "the persist marker must make the middleware carry the body through to the row" + ); +} + +/// A captured signed write, put on the wire a second time, returns the claim +/// it already recorded instead of appending a new one. +/// +/// `require_signature` bounds only the clock skew on `created`, so the same +/// bytes are accepted again for as long as that window lasts. The row count is +/// asserted directly: a response that merely LOOKS right while a second row +/// landed is the failure this is written against. +#[sqlx::test] +async fn a_replayed_signed_write_returns_the_original_claim_and_writes_no_second_row(pool: PgPool) { + let state = test_state(pool).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let did = kp.did().to_string(); + let repo = seed_repo(&did, "replay-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let path = uri(&did, "replay-repo", SHA_A); + let body = br#"{"state":"success","context":"ci/build"}"#; + + let (first, signed) = post_really_signed(&state, &kp, &path, body).await; + assert_eq!(first.status(), StatusCode::CREATED); + let first_body = body_json(first).await; + + let replay = post_signed_headers(&state, &path, body, &signed).await; + assert_eq!( + replay.status(), + StatusCode::OK, + "an exact replay is already recorded, not newly created" + ); + let replay_body = body_json(replay).await; + assert_eq!( + replay_body, first_body, + "the replay must answer with the claim the first request wrote, id and \ + seq included" + ); + + let rows: i64 = sqlx::query_scalar("SELECT count(*) FROM status_claims WHERE repo_id = $1") + .bind(&repo_id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!( + rows, 1, + "a replayed request must leave exactly one row; got {rows}" + ); +} + +/// The consequence the replay actually buys an attacker: resurrecting a +/// superseded verdict. +/// +/// The projection takes the latest claim per (producer, context) by `seq`, and +/// `seq` is assigned at insert. So a replay does not merely duplicate a row. +/// It earns a FRESH sequence number, which puts the stale `success` ahead of +/// the `failure` that superseded it and flips the commit's answer back. +#[sqlx::test] +async fn a_replayed_success_cannot_overturn_the_later_failure(pool: PgPool) { + let state = test_state(pool).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let did = kp.did().to_string(); + let repo = seed_repo(&did, "replay-order-repo", true); + state.db.create_repo(&repo).await.unwrap(); + + let path = uri(&did, "replay-order-repo", SHA_A); + let success = br#"{"state":"success","context":"ci/build"}"#; + let failure = br#"{"state":"failure","context":"ci/build"}"#; + + let (resp, captured) = post_really_signed(&state, &kp, &path, success).await; + assert_eq!(resp.status(), StatusCode::CREATED); + let (resp, _) = post_really_signed(&state, &kp, &path, failure).await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let read = status_uri(&did, "replay-order-repo", SHA_A); + let before = body_json(get_status(&state, Some(&did), &read).await).await; + assert_eq!(before["state"], "failure", "the later claim is the verdict"); + + // The capture, replayed inside the skew window. Accepted either way: which + // status it carries is the previous test's business, and this one is about + // what the commit reads afterwards. + let replay = post_signed_headers(&state, &path, success, &captured).await; + assert!(replay.status().is_success()); + + let after = body_json(get_status(&state, Some(&did), &read).await).await; + assert_eq!( + after["state"], "failure", + "a replayed success must not outrank the failure that superseded it" + ); + assert_eq!(after["total_count"], 1); + assert_eq!(after["statuses"][0]["state"], "failure"); +} + +/// The idempotency key is the request, not the tuple it writes about. Two +/// genuinely different signed requests for one producer and context both +/// record, and the append-only history keeps both. +#[sqlx::test] +async fn two_distinct_signed_writes_for_one_context_both_record(pool: PgPool) { + let state = test_state(pool).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let did = kp.did().to_string(); + let repo = seed_repo(&did, "distinct-repo", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + + let path = uri(&did, "distinct-repo", SHA_A); + for body in [ + &br#"{"state":"pending","context":"ci/build"}"#[..], + &br#"{"state":"success","context":"ci/build"}"#[..], + ] { + let (resp, _) = post_really_signed(&state, &kp, &path, body).await; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "a distinct request is a new claim, not a replay" + ); + } + + let claims = state.db.list_status_claims(&repo_id, SHA_A).await.unwrap(); + let states: Vec<&str> = claims.iter().map(|c| c.state.as_str()).collect(); + assert_eq!( + states, + vec!["pending", "success"], + "both distinct claims must remain, ordered by seq" + ); +} + +/// The route is registered on the production router AND its group reached the +/// merge chain: an unsigned request is refused by the signature layer with 401, +/// which a path axum never learned about would answer 404 instead. +#[sqlx::test] +async fn route_is_registered_behind_the_signature_layer(pool: PgPool) { + let state = test_state(pool).await; + let router = crate::server::build_router(state); + let req = axum::http::Request::builder() + .method(Method::POST) + .uri(uri(OWNER, "any-repo", SHA_A)) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(body_of("success", "ci/build")) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "the status write route must exist and sit behind require_signature" + ); +} + +// ── Read path (U4) ──────────────────────────────────────────────────── + +const NEW_OWNER: &str = "did:key:zSTATUSNEWOWNERCCCCCCCCCCCCCCCCCCCCCC"; + +fn read_router(state: crate::state::AppState) -> Router { + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/commits/{sha}/status", + axum::routing::get(super::commit_status), + ) + .with_state(state) +} + +fn status_uri(owner: &str, repo: &str, sha: &str) -> String { + format!("/api/v1/repos/{owner}/{repo}/commits/{sha}/status") +} + +/// GET the read surface as `did`, or anonymously when it is `None` (no +/// `AuthenticatedDid` extension at all, which is what an unsigned caller +/// looks like once `optional_signature` has passed it through). +async fn get_status( + state: &crate::state::AppState, + did: Option<&str>, + uri: &str, +) -> axum::response::Response { + let req = match did { + Some(d) => signed_request_as(d, Method::GET, uri, Body::empty()), + None => axum::http::Request::builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .unwrap(), + }; + read_router(state.clone()).oneshot(req).await.unwrap() +} + +async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).expect("response body must be json") +} + +/// One claim row with every field chosen by the caller, including the +/// authorizing DID and the display timestamp, so the projection's ordering +/// key and its current-authorization filter can both be driven directly. +#[allow(clippy::too_many_arguments)] +async fn seed_claim( + pool: &PgPool, + id: &str, + repo_id: &str, + sha: &str, + producer: &str, + authorizing: &str, + context: &str, + claim_state: &str, + created_at: &str, +) { + sqlx::query( + "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, + signature, signature_input, signing_string, request_body, request_digest, + created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'','','',''::bytea,gen_random_uuid()::text,$8)", + ) + .bind(id) + .bind(repo_id) + .bind(sha) + .bind(claim_state) + .bind(context) + .bind(producer) + .bind(authorizing) + .bind(created_at) + .execute(pool) + .await + .expect("seed claim"); +} + +/// Covers AE1. A context reported pending then success reads as success with +/// exactly one entry: the projection takes the LATEST claim per (producer, +/// context), not every row in the history. +#[sqlx::test] +async fn ae1_latest_claim_per_context_reads_as_success(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-ae1", true); + state.db.create_repo(&repo).await.unwrap(); + + for st in ["pending", "success"] { + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-ae1", SHA_A), + body_of(st, "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "{st} claim"); + } + + let resp = get_status(&state, Some(OWNER), &status_uri(OWNER, "read-ae1", SHA_A)).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["state"], "success"); + assert_eq!(body["total_count"], 1); + assert_eq!(body["statuses"].as_array().unwrap().len(), 1); + assert_eq!(body["statuses"][0]["state"], "success"); + assert_eq!(body["statuses"][0]["context"], "ci/build"); + assert_eq!(body["statuses"][0]["producer_did"], OWNER); + assert_eq!( + body["reported_only"], true, + "R19: the response marks that the state covers reported contexts only" + ); +} + +/// The latest claim for a context is the highest server-assigned `seq` +/// (KTD-3), never the newest timestamp and never the largest row id. The two +/// rows are seeded so every other candidate key picks the LOSING row: the +/// earlier claim carries a far-future `created_at` and a lexically larger +/// uuid than the later one. +#[sqlx::test] +async fn latest_claim_is_highest_seq_not_newest_timestamp_or_id(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "read-order", true); + state.db.create_repo(&repo).await.unwrap(); + + seed_claim( + &pool, + "zzzzzzzz-0000-0000-0000-000000000001", + &repo.id, + SHA_A, + OWNER, + OWNER, + "ci/build", + "pending", + "2099-01-01T00:00:00Z", + ) + .await; + seed_claim( + &pool, + "aaaaaaaa-0000-0000-0000-000000000002", + &repo.id, + SHA_A, + OWNER, + OWNER, + "ci/build", + "success", + "2000-01-01T00:00:00Z", + ) + .await; + + let resp = get_status(&state, Some(OWNER), &status_uri(OWNER, "read-order", SHA_A)).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!( + body["state"], "success", + "the highest-seq claim wins; ordering on created_at or on the row id \ + would elect the stale pending claim" + ); + assert_eq!(body["total_count"], 1); +} + +/// Covers AE2. Two producers, two contexts, one success and one failure: the +/// combined state is failure and BOTH entries are present. +#[sqlx::test] +async fn ae2_two_producers_one_failure_reads_as_failure(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "read-ae2", true); + state.db.create_repo(&repo).await.unwrap(); + + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-ae2", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + // A second producer, authorized by the same owner key. + seed_claim( + &pool, + "11111111-0000-0000-0000-000000000001", + &repo.id, + SHA_A, + STRANGER, + OWNER, + "ci/test", + "failure", + "2026-01-01T00:00:00Z", + ) + .await; + + let resp = get_status(&state, Some(OWNER), &status_uri(OWNER, "read-ae2", SHA_A)).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["state"], "failure"); + assert_eq!(body["total_count"], 2); + let contexts: Vec<&str> = body["statuses"] + .as_array() + .unwrap() + .iter() + .map(|s| s["context"].as_str().unwrap()) + .collect(); + assert!(contexts.contains(&"ci/build"), "contexts: {contexts:?}"); + assert!(contexts.contains(&"ci/test"), "contexts: {contexts:?}"); +} + +/// An error-state claim folds to the combined failure state (the error arm of +/// KTD-1, distinct from the failure arm the AE2 case covers). +#[sqlx::test] +async fn error_claim_folds_to_combined_failure(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-error", true); + state.db.create_repo(&repo).await.unwrap(); + + for (st, ctx) in [("success", "ci/build"), ("error", "ci/test")] { + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-error", SHA_A), + body_of(st, ctx), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "{st} claim"); + } + + let body = + body_json(get_status(&state, Some(OWNER), &status_uri(OWNER, "read-error", SHA_A)).await) + .await; + assert_eq!( + body["state"], "failure", + "an error claim must fold to failure, never to success or pending" + ); +} + +/// A pending claim alongside a success yields the combined pending state (the +/// middle arm of KTD-1). +#[sqlx::test] +async fn pending_alongside_success_reads_as_pending(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-pending", true); + state.db.create_repo(&repo).await.unwrap(); + + for (st, ctx) in [("success", "ci/build"), ("pending", "ci/test")] { + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-pending", SHA_A), + body_of(st, ctx), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "{st} claim"); + } + + let body = body_json( + get_status( + &state, + Some(OWNER), + &status_uri(OWNER, "read-pending", SHA_A), + ) + .await, + ) + .await; + assert_eq!(body["state"], "pending"); + assert_eq!(body["total_count"], 2); +} + +/// Covers AE3. A commit nobody reported on is exactly 200 with the pending +/// zero-count body. The WHOLE body is asserted, not just the status: a client +/// that renders this must not be able to arrive at green through a missing +/// field, an empty object, or a success state with an empty array. +#[sqlx::test] +async fn ae3_commit_with_no_claims_is_pending_zero(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-ae3", true); + state.db.create_repo(&repo).await.unwrap(); + + let resp = get_status(&state, Some(OWNER), &status_uri(OWNER, "read-ae3", SHA_A)).await; + let (status, bytes) = status_and_bytes(resp).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + String::from_utf8(bytes).unwrap(), + format!( + r#"{{"state":"pending","sha":"{SHA_A}","total_count":0,"statuses":[],"reported_only":true}}"# + ), + "absence must serialize as the explicit pending zero-count body" + ); +} + +/// Covers AE4. An anonymous read of a PRIVATE repo that HAS a claim answers +/// byte for byte what the same caller gets for a repo that does not exist. +/// The claim is seeded first, so the deny cannot pass vacuously on an empty +/// projection. +#[sqlx::test] +async fn ae4_anon_private_repo_read_is_indistinguishable_from_missing(pool: PgPool) { + let state = test_state(pool.clone()).await; + let target = status_uri(OWNER, "read-ae4", SHA_A); + + let missing = status_and_bytes(get_status(&state, None, &target).await).await; + + let repo = seed_repo(OWNER, "read-ae4", false); + state.db.create_repo(&repo).await.unwrap(); + seed_claim( + &pool, + "22222222-0000-0000-0000-000000000001", + &repo.id, + SHA_A, + OWNER, + OWNER, + "ci/build", + "success", + "2026-01-01T00:00:00Z", + ) + .await; + + let denied = status_and_bytes(get_status(&state, None, &target).await).await; + + assert_eq!(missing.0, StatusCode::NOT_FOUND); + assert_eq!( + denied, missing, + "a private-repo deny must be byte-identical to the missing-repo response" + ); + assert!( + !String::from_utf8_lossy(&denied.1).contains("ci/build"), + "the deny must carry no trace of the claim" + ); +} + +/// The other half of the visibility pair: a PUBLIC repo's status is served to +/// an anonymous caller, so the gate above is a gate and not a blanket refusal. +#[sqlx::test] +async fn public_repo_status_served_to_anon(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-public", true); + state.db.create_repo(&repo).await.unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-public", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let resp = get_status(&state, None, &status_uri(OWNER, "read-public", SHA_A)).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["state"], "success"); + assert_eq!(body["statuses"][0]["context"], "ci/build"); +} + +/// KTD-2 regression: the projection is computed per read, so tightening +/// visibility AFTER a claim was written retroactively hides it. A write-time +/// gated derived index kept serving a repo made private afterwards +/// (docs/solutions/security-issues/write-time-visibility-gate-leaves-derived-index-stale.md); +/// this is that shape on the status surface. +#[sqlx::test] +async fn tightening_visibility_hides_existing_claims_from_anon(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-tighten", true); + state.db.create_repo(&repo).await.unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-tighten", SHA_A), + body_of("success", "ci/secret-build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let target = status_uri(OWNER, "read-tighten", SHA_A); + let served = get_status(&state, None, &target).await; + assert_eq!( + served.status(), + StatusCode::OK, + "the claim is anonymously readable while the repo is public" + ); + + // Tighten: a root rule with an empty reader list denies everyone but the + // owner, even though the repo row is still is_public. + state + .db + .set_visibility_rule(&repo.id, "/", crate::db::VisibilityMode::B, &[], OWNER) + .await + .unwrap(); + + let (status, bytes) = status_and_bytes(get_status(&state, None, &target).await).await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "a claim written while public must stop being served once visibility tightens" + ); + assert!( + !String::from_utf8_lossy(&bytes).contains("ci/secret-build"), + "the deny must carry no trace of the claim" + ); +} + +/// KTD-5: the projection filters on CURRENT authorization. After the repo +/// changes hands, the previous owner's claims drop out of the read, while the +/// append-only history keeps every row. +#[sqlx::test] +async fn claims_authorized_by_a_former_owner_leave_the_projection(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "read-transfer", true); + let repo_id = repo.id.clone(); + state.db.create_repo(&repo).await.unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-transfer", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + sqlx::query("UPDATE repos SET owner_did = $1 WHERE id = $2") + .bind(NEW_OWNER) + .bind(&repo_id) + .execute(&pool) + .await + .expect("transfer the repo"); + + let resp = get_status( + &state, + Some(NEW_OWNER), + &status_uri(NEW_OWNER, "read-transfer", SHA_A), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!( + body["state"], "pending", + "a claim the current owner never authorized must not decide the state" + ); + assert_eq!(body["total_count"], 0); + assert!(body["statuses"].as_array().unwrap().is_empty()); + + assert_eq!( + state + .db + .list_status_claims(&repo_id, SHA_A) + .await + .unwrap() + .len(), + 1, + "the history row survives the transfer; only the projection drops it" + ); +} + +/// The current-authorization filter accepts the owner DID in either +/// representation, matching `did_matches`: a repo whose stored owner is the +/// BARE key still projects claims authorized under the full `did:key:` form. +#[sqlx::test] +async fn projection_matches_the_owner_in_either_did_form(pool: PgPool) { + let state = test_state(pool).await; + let bare = OWNER.strip_prefix("did:key:").unwrap(); + let repo = seed_repo(bare, "read-didform", true); + state.db.create_repo(&repo).await.unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-didform", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let body = body_json( + get_status( + &state, + Some(OWNER), + &status_uri(OWNER, "read-didform", SHA_A), + ) + .await, + ) + .await; + assert_eq!(body["state"], "success"); + assert_eq!(body["total_count"], 1); +} + +/// One identity spelled two ways is one producer. The owner reports the same +/// context first as `did:key:X` and then as the bare `X` — both pass the owner +/// gate, which normalizes — so the stored producer DID has to be normalized +/// too. Without that the projection's dedupe compares raw strings, leaves two +/// entries for one context, and the superseded claim keeps voting in the +/// combined state. +#[sqlx::test] +async fn one_identity_in_two_did_forms_projects_as_one_entry(pool: PgPool) { + let state = test_state(pool).await; + let bare = OWNER.strip_prefix("did:key:").unwrap(); + let repo = seed_repo(OWNER, "read-diddedupe", true); + state.db.create_repo(&repo).await.unwrap(); + + for (did, claim_state) in [(OWNER, "failure"), (bare, "success")] { + let resp = post_as( + &state, + did, + &uri(OWNER, "read-diddedupe", SHA_A), + body_of(claim_state, "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "claim as {did}"); + } + + let body = body_json( + get_status( + &state, + Some(OWNER), + &status_uri(OWNER, "read-diddedupe", SHA_A), + ) + .await, + ) + .await; + assert_eq!( + body["total_count"], 1, + "the two spellings are one producer reporting one context" + ); + assert_eq!(body["statuses"].as_array().unwrap().len(), 1); + assert_eq!( + body["state"], "success", + "the newer claim supersedes the older one; a stale failure must not \ + keep voting because it was written under the other spelling" + ); +} + +/// The lookup-failure path. With the claims table gone, the read is exactly +/// 500 carrying the stable `db_error` code — never a 200, and never an empty +/// statuses array. The message is the sqlx text and is not asserted; a +/// connectivity failure would take the separate 503 arm instead. +#[sqlx::test] +async fn claim_lookup_failure_is_500_never_empty_success(pool: PgPool) { + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "read-dberr", true); + state.db.create_repo(&repo).await.unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "read-dberr", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + sqlx::query("DROP TABLE status_claims") + .execute(&pool) + .await + .expect("drop the claims table"); + + let resp = get_status(&state, Some(OWNER), &status_uri(OWNER, "read-dberr", SHA_A)).await; + let (status, bytes) = status_and_bytes(resp).await; + assert_eq!( + status, + StatusCode::INTERNAL_SERVER_ERROR, + "a failed projection query must not render as a served status" + ); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"], "db_error"); + let text = String::from_utf8_lossy(&bytes); + assert!( + !text.contains("statuses"), + "an error must not carry an empty statuses array: {text}" + ); +} + +/// The read route is registered on the production router AND its group kept +/// the `optional_signature` layer. A path axum never learned about answers +/// 404 for the anonymous case; a group missing the layer would ignore the +/// signature headers in the second case and serve 200 instead of 401. +#[sqlx::test] +async fn read_route_is_registered_with_optional_signature(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "read-wired", true); + state.db.create_repo(&repo).await.unwrap(); + let target = status_uri(OWNER, "read-wired", SHA_A); + + let resp = crate::server::build_router(state.clone()) + .oneshot( + axum::http::Request::builder() + .method(Method::GET) + .uri(&target) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "the read route must exist on the production router and serve a public repo to anon" + ); + + let resp = crate::server::build_router(state) + .oneshot( + axum::http::Request::builder() + .method(Method::GET) + .uri(&target) + .header("signature", "sig1=:bm90YXNpZw==:") + .header("signature-input", "sig1=(\"@method\");alg=\"ed25519\"") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let (status, bytes) = status_and_bytes(resp).await; + // A presented signature is verified, and this one is unusable (no keyid, + // required components missing), so the signature layer refuses it. Only + // the layer can produce this: the same request against a group without it + // is treated as anonymous and served 200 by the handler above. + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "a presented signature must be verified, which only happens if the \ + read group still carries optional_signature" + ); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + body["error"], "invalid_signature", + "the refusal must come from the signature layer, not the handler" + ); +} + +/// The `did:key` collapse is a security predicate, and it has exactly one +/// definition: `db::normalize_owner_key`, which the owner gate (`did_matches`) +/// and the projection's stored identity both go through. A second copy in this +/// module is how the two drift apart, one of them lagging a hardening, so the +/// production half is scanned for one. +#[test] +fn status_module_holds_no_second_copy_of_the_did_collapse() { + let src = include_str!("mod.rs"); + let body_of_module = + crate::test_support::scrape_source_region(src, None, Some("\n#[cfg(test)]\nmod tests;")) + .expect("module has a tests module"); + assert!( + body_of_module.contains("project_claims"), + "the scan must cover the whole production half of the module" + ); + assert!( + !body_of_module.contains("did:key:"), + "this module must not reimplement the did:key collapse — the \ + projection's identity comparison goes through db::normalize_owner_key" + ); +} + +/// The must-not case the collapse exists to preserve: a bare base58 id must +/// never match across DID methods. A claim authorized by `did:gitlawb:X` is +/// not authorized by the owner `did:key:X`, so it stays out of the projection. +#[sqlx::test] +async fn a_cross_method_authorizing_did_never_projects(pool: PgPool) { + let state = test_state(pool.clone()).await; + let key_id = OWNER.strip_prefix("did:key:").unwrap(); + let repo = seed_repo(OWNER, "read-crossmethod", true); + state.db.create_repo(&repo).await.unwrap(); + + seed_claim( + &pool, + "cccccccc-0000-0000-0000-000000000001", + &repo.id, + SHA_A, + &format!("did:gitlawb:{key_id}"), + &format!("did:gitlawb:{key_id}"), + "ci/build", + "success", + "2026-01-01T00:00:00Z", + ) + .await; + + let body = body_json( + get_status( + &state, + Some(OWNER), + &status_uri(OWNER, "read-crossmethod", SHA_A), + ) + .await, + ) + .await; + assert_eq!( + body["total_count"], 0, + "did:gitlawb:X and did:key:X share the base58 space and are different \ + identities; the projection must not treat one as the other" + ); + assert_eq!(body["state"], "pending"); +} + +/// `n` claims on one (repo, commit, producer, context) tuple, inserted in one +/// statement so the cap tests stay fast. +async fn seed_claims( + pool: &PgPool, + repo_id: &str, + sha: &str, + producer: &str, + context: &str, + n: i64, +) { + sqlx::query( + "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, + signature, signature_input, signing_string, request_body, request_digest, + created_at) + SELECT md5(random()::text || g::text), $1, $2, 'success', $3, $4, $4, + '', '', '', ''::bytea, gen_random_uuid()::text, + '2026-01-01T00:00:00Z' + FROM generate_series(1, $5) g", + ) + .bind(repo_id) + .bind(sha) + .bind(context) + .bind(producer) + .bind(n) + .execute(pool) + .await + .expect("seed claims"); +} + +/// `n` claims on one repo spread across `n` distinct 40-hex SHAs, one claim +/// each, so no tuple or context cap is reached before the per-repo one. The +/// timestamp is explicit because the per-repo bound is a rolling window: it +/// decides whether these rows are inside it. +async fn seed_claims_across_shas( + pool: &PgPool, + repo_id: &str, + producer: &str, + n: i64, + created_at: &str, +) { + sqlx::query( + "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, + signature, signature_input, signing_string, request_body, request_digest, + created_at) + SELECT md5(random()::text || g::text), $1, + substr(md5(g::text) || md5((g + 1)::text), 1, 40), + 'success', 'ci/build', $2, $2, + '', '', '', ''::bytea, gen_random_uuid()::text, $4 + FROM generate_series(1, $3) g", + ) + .bind(repo_id) + .bind(producer) + .bind(n) + .bind(created_at) + .execute(pool) + .await + .expect("seed claims across shas"); +} + +// ── Pull request rollup (U5) ────────────────────────────────────────── + +fn rollup_router(state: crate::state::AppState) -> Router { + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/status", + axum::routing::get(super::pull_request_status), + ) + .with_state(state) +} + +fn rollup_uri(owner: &str, repo: &str, number: i64) -> String { + format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/status") +} + +async fn get_rollup( + state: &crate::state::AppState, + did: Option<&str>, + uri: &str, +) -> axum::response::Response { + let req = match did { + Some(d) => signed_request_as(d, Method::GET, uri, Body::empty()), + None => axum::http::Request::builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .unwrap(), + }; + rollup_router(state.clone()).oneshot(req).await.unwrap() +} + +fn seed_pr(repo_id: &str, number: i64, source_branch: &str) -> crate::db::PullRequest { + let now = chrono::Utc::now().to_rfc3339(); + crate::db::PullRequest { + id: uuid::Uuid::new_v4().to_string(), + repo_id: repo_id.to_string(), + number, + title: format!("PR {number}"), + body: None, + author_did: OWNER.to_string(), + source_branch: source_branch.to_string(), + target_branch: "main".to_string(), + status: "open".to_string(), + merged_by_did: None, + merged_at: None, + head_commit: None, + created_at: now.clone(), + updated_at: now, + } +} + +/// Point a branch at a SHA the way the receive-pack path does: one +/// `repo_push_events` row, which is what the rollup's fallback resolves +/// through. Call order is what decides: the fallback reads the highest `seq`, +/// which the database assigns at insert, so a second call to the same branch +/// reads as a later push. The distinct timestamps are only there to keep two +/// fixture rows distinguishable in a failure message; changing them changes +/// nothing about which one wins. +async fn seed_branch_head( + state: &crate::state::AppState, + repo: &RepoRecord, + branch: &str, + sha: &str, +) { + static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let created_at = format!("2026-01-01T00:00:00.{n:06}Z"); + seed_push_event(state, repo, branch, sha, &created_at).await; +} + +/// One `repo_push_events` row, exactly the shape `record_push_events` writes +/// on the receive-pack path: a FULL ref name and the post-push SHA. The +/// timestamp is a caller-chosen display value, NOT the ordering key: the +/// fallback picks the row with the highest database-assigned `seq`, so the row +/// inserted last wins even if it carries the earlier stamp, and there is no +/// uuid tiebreak. `latest_push_sha_for_ref_follows_insertion_not_the_stamp` +/// pins that. A fixture reordered on the assumption the stamp decides will get +/// a different answer than it expects. +async fn seed_push_event( + state: &crate::state::AppState, + repo: &RepoRecord, + branch: &str, + sha: &str, + created_at: &str, +) { + state + .db + .insert_repo_push_event(&crate::db::RepoPushEvent { + id: uuid::Uuid::new_v4().to_string(), + // Ignored on insert; the database assigns the ordering key. + seq: 0, + repo_id: repo.id.clone(), + ref_name: format!("refs/heads/{branch}"), + after_sha: sha.to_string(), + created_at: created_at.to_string(), + }) + .await + .expect("seed push event"); +} + +const PUSH_T1: &str = "2026-01-01T00:00:00.000000Z"; +const PUSH_T2: &str = "2026-01-02T00:00:00.000000Z"; + +/// Serializes the tests that read [`super::BRANCH_RESOLVES`] and zeroes it, so +/// the counter measures one test's requests rather than whatever else the +/// harness is running in the same process. Held for the test's lifetime. +/// +/// Every test that TRIGGERS a resolve takes it too, not only the ones that +/// read the count. The counter is process-global while the databases are +/// per-test, so an unguarded resolver running concurrently inflates a +/// guarded test's count and the failure looks like a bug in the fallback. +/// Async-aware on purpose: the guard is held across the test's awaits, which a +/// blocking `std` mutex must not be. +async fn resolve_count_guard() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let guard = LOCK.lock().await; + super::BRANCH_RESOLVES.store(0, std::sync::atomic::Ordering::SeqCst); + guard +} + +fn resolve_count() -> usize { + super::BRANCH_RESOLVES.load(std::sync::atomic::Ordering::SeqCst) +} + +/// The four wire states (KTD-1). The rollup never adds a fifth value, whatever +/// happened to the head. +fn assert_wire_state(body: &serde_json::Value) { + let state = body["state"].as_str().expect("state must be a string"); + assert!( + ["error", "failure", "pending", "success"].contains(&state), + "rollup state {state:?} is outside the four-value wire set" + ); +} + +/// R3/R11: the rollup is the SAME projection as the commit read. Asserted by +/// comparing the two responses on the head SHA, so the two surfaces cannot +/// drift into two answers. +#[sqlx::test] +async fn rollup_matches_the_commit_read_for_the_stored_head(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-same", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_A) + .await + .unwrap(); + + for (st, ctx) in [("success", "ci/build"), ("pending", "ci/test")] { + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "rollup-same", SHA_A), + body_of(st, ctx), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + } + + let commit = + body_json(get_status(&state, None, &status_uri(OWNER, "rollup-same", SHA_A)).await).await; + let rollup = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-same", 1)).await).await; + + assert_eq!(rollup["sha"], SHA_A); + assert_eq!(rollup["head_resolved"], true); + assert_eq!(rollup["pull_request_state"], "open"); + assert_eq!(rollup["reported_only"], true); + assert_eq!(rollup["state"], commit["state"]); + assert_eq!(rollup["total_count"], commit["total_count"]); + assert_eq!( + rollup["statuses"], commit["statuses"], + "the rollup must serve the commit read's projection unchanged" + ); +} + +/// Covers AE2 (rollup half). Two contexts on the head, one failing: the +/// rollup must not read as success. +#[sqlx::test] +async fn ae2_rollup_with_a_failing_context_does_not_read_as_success(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-ae2", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_A) + .await + .unwrap(); + + for (st, ctx) in [("success", "ci/build"), ("failure", "ci/test")] { + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "rollup-ae2", SHA_A), + body_of(st, ctx), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + } + + let body = body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-ae2", 1)).await).await; + assert_eq!(body["state"], "failure"); + assert_ne!(body["state"], "success"); + assert_eq!(body["total_count"], 2); +} + +/// R12's first arm: a resolved head with nothing reported is pending-zero with +/// `head_resolved` TRUE. Pairs with the unresolvable case below, which differs +/// on that boolean alone. +#[sqlx::test] +async fn resolved_head_with_no_claims_is_pending_zero_and_head_resolved(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-silent", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_A) + .await + .unwrap(); + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-silent", 1)).await).await; + assert_wire_state(&body); + assert_eq!(body["state"], "pending"); + assert_eq!(body["total_count"], 0); + assert_eq!(body["statuses"].as_array().unwrap().len(), 0); + assert_eq!(body["head_resolved"], true); + assert_eq!(body["sha"], SHA_A); +} + +/// R17: an open pull request whose source branch is gone has no resolvable +/// head. The answer is pending with zero contexts and `head_resolved` FALSE, +/// which is the ONLY field separating it from the reported-nothing case above. +#[sqlx::test] +async fn unresolvable_head_differs_from_silent_head_on_head_resolved_alone(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-gone", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "deleted-branch"); + state.db.create_pr(&pr).await.unwrap(); + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-gone", 1)).await).await; + assert_wire_state(&body); + assert_eq!(body["state"], "pending"); + assert_eq!(body["total_count"], 0); + assert_eq!(body["statuses"].as_array().unwrap().len(), 0); + assert_eq!( + body["head_resolved"], false, + "an unresolvable head must report head_resolved false, which is the \ + only field separating it from a resolved head nobody reported on" + ); + assert!( + body["sha"].is_null(), + "an unresolved head must carry no target sha, got {:?}", + body["sha"] + ); + assert_eq!(body["pull_request_state"], "open"); + // Nothing was resolvable, so nothing was persisted either. + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + None + ); +} + +/// R17's closed arm: a closed pull request with no stored head answers +/// unresolved and does NOT fall back to the branch, even when the branch still +/// has a head. Resolving there would hand a reader a commit the pull request +/// was never decided against. +#[sqlx::test] +async fn closed_pr_with_no_stored_head_does_not_resolve_the_branch(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-closed", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state.db.close_pr(&pr.id).await.unwrap(); + // The branch is alive and would resolve if the fallback ran. + seed_branch_head(&state, &repo, "feature", SHA_B).await; + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-closed", 1)).await).await; + assert_wire_state(&body); + assert_eq!(body["pull_request_state"], "closed"); + assert_eq!( + body["head_resolved"], false, + "a closed pull request with no stored head is unresolved, not back-filled" + ); + assert!(body["sha"].is_null(), "no branch resolve on a closed PR"); + assert_eq!(body["total_count"], 0); + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + None, + "a closed PR's head must not be back-filled from the live branch" + ); + assert_eq!( + resolve_count(), + 0, + "a closed pull request must not trigger a branch resolve at all" + ); +} + +/// R17's merged arm: the stored head is frozen at merge and its claims are +/// still served, with the pull request state named. +#[sqlx::test] +async fn merged_pr_serves_its_frozen_head(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-merged", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "rollup-merged", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + state.db.merge_pr(&pr.id, OWNER, Some(SHA_A)).await.unwrap(); + // The branch moved on after the merge; the frozen head must win. + seed_branch_head(&state, &repo, "feature", SHA_B).await; + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-merged", 1)).await).await; + assert_eq!(body["pull_request_state"], "merged"); + assert_eq!(body["head_resolved"], true); + assert_eq!(body["sha"], SHA_A); + assert_eq!(body["state"], "success"); + assert_eq!(body["total_count"], 1); +} + +/// The force-push flow: a re-pointed head is a fresh target, so the rollup +/// goes back to pending-zero until something reports on the new SHA. A rollup +/// keyed to the OLD sha would keep showing a green that describes code nobody +/// is looking at any more. +#[sqlx::test] +async fn moving_the_stored_head_repoints_the_rollup(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-force", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_A) + .await + .unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "rollup-force", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let before = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-force", 1)).await).await; + assert_eq!(before["state"], "success"); + + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_B) + .await + .unwrap(); + + let after = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-force", 1)).await).await; + assert_eq!(after["sha"], SHA_B); + assert_eq!(after["state"], "pending"); + assert_eq!(after["total_count"], 0); +} + +/// The open-PR fallback: no stored head, a live source branch, so the branch +/// head is resolved from the database, served, AND persisted as the stored +/// head for the next read. +#[sqlx::test] +async fn open_pr_without_a_stored_head_resolves_and_persists_the_branch_head(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-fallback", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + seed_branch_head(&state, &repo, "feature", SHA_A).await; + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-fallback", 1)).await).await; + assert_eq!(body["head_resolved"], true); + assert_eq!(body["sha"], SHA_A); + assert_eq!(body["state"], "pending"); + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + Some(SHA_A.to_string()), + "the resolved head must be persisted as the stored head" + ); +} + +/// The fallback must resolve on a node with no object-storage pinning at all. +/// +/// `branch_cids` has exactly one production writer, and it only fires for a +/// ref whose objects came back with a pin CID, so on a node with no Pinata JWT +/// the table stays empty forever. `repo_push_events` is written unconditionally +/// for every ref update on the receive-pack path, which is why it is the +/// fallback's source. Nothing here seeds `branch_cids`: if the resolve still +/// went through it, this open pull request would answer `head_resolved: false` +/// permanently. +#[sqlx::test] +async fn fallback_resolves_from_push_events_with_no_pin_recorded(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "rollup-nopin", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + seed_push_event(&state, &repo, "feature", SHA_A, PUSH_T1).await; + + let pinned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM branch_cids") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(pinned, 0, "the unpinned node premise must hold"); + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-nopin", 1)).await).await; + assert_eq!( + body["head_resolved"], true, + "a pushed branch must resolve on a node that pins nothing" + ); + assert_eq!(body["sha"], SHA_A); + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + Some(SHA_A.to_string()), + "the resolved head must still be persisted" + ); +} + +/// The fallback takes the LATEST push for the branch, not any push, and it +/// takes it for the right ref: a tag sharing the branch's name and a push to a +/// different branch are both seeded ahead of the real one, so a query missing +/// either predicate returns the wrong SHA rather than passing vacuously. +#[sqlx::test] +async fn fallback_takes_the_latest_push_for_that_exact_branch(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-latest", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + + seed_push_event(&state, &repo, "feature", SHA_A, PUSH_T1).await; + seed_push_event(&state, &repo, "other", SHA_C, PUSH_T2).await; + // A tag named like the branch, written the way the push path would. + state + .db + .insert_repo_push_event(&crate::db::RepoPushEvent { + id: uuid::Uuid::new_v4().to_string(), + // Ignored on insert; the database assigns the ordering key. + seq: 0, + repo_id: repo.id.clone(), + ref_name: "refs/tags/feature".to_string(), + after_sha: SHA_C.to_string(), + created_at: PUSH_T2.to_string(), + }) + .await + .unwrap(); + seed_push_event(&state, &repo, "feature", SHA_B, PUSH_T2).await; + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-latest", 1)).await).await; + assert_eq!( + body["sha"], SHA_B, + "the newest push to refs/heads/feature is the head" + ); +} + +/// A push recorded for a DIFFERENT repository must never resolve this one's +/// branch. Same branch name, same timestamp, no row for this repo. +#[sqlx::test] +async fn fallback_does_not_cross_repositories(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let mine = seed_repo(OWNER, "rollup-mine", true); + let theirs = seed_repo(OWNER, "rollup-theirs", true); + state.db.create_repo(&mine).await.unwrap(); + state.db.create_repo(&theirs).await.unwrap(); + let pr = seed_pr(&mine.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + seed_push_event(&state, &theirs, "feature", SHA_A, PUSH_T1).await; + + let body = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-mine", 1)).await).await; + assert_eq!( + body["head_resolved"], false, + "another repository's push must not resolve this pull request's head" + ); +} + +/// The comment on `rollup_head` claims the PERSIST is once-per-pull-request, +/// not the resolve. This is what keeps that claim honest: an open pull request +/// whose branch never resolves runs the lookup again on every read, forever, +/// because there is nothing to store and therefore nothing to short-circuit on. +#[sqlx::test] +async fn an_unresolvable_head_re_runs_the_resolve_on_every_read(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-retry", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "never-pushed"); + state.db.create_pr(&pr).await.unwrap(); + + let first = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-retry", 1)).await).await; + assert_eq!(first["head_resolved"], false); + assert_eq!(resolve_count(), 1, "the first read attempts the resolve"); + + let second = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-retry", 1)).await).await; + assert_eq!(second["head_resolved"], false); + assert_eq!( + resolve_count(), + 2, + "with nothing stored there is nothing to short-circuit on, so the \ + second read attempts the resolve again" + ); +} + +/// The fallback writes on an UNAUTHENTICATED read, so it has to be +/// self-limiting: it fires only while `head_commit` is absent, and it sets it. +/// +/// Proven two ways, because the output alone would not show it. The response +/// says the second read returned the STORED sha after the branch moved +/// underneath it, which it could not do if it had re-resolved; and the resolve +/// counter says the branch lookup RAN once across two reads, which is the +/// work-done bound rather than the results-emitted one — a fallback that +/// re-read the branch on every call and then discarded the answer would leave +/// the response identical and the cost doubled. +#[sqlx::test] +async fn head_fallback_resolves_once_and_later_reads_use_the_stored_head(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-once", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + seed_branch_head(&state, &repo, "feature", SHA_A).await; + + let first = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-once", 1)).await).await; + assert_eq!(first["sha"], SHA_A, "the first read resolves the branch"); + assert_eq!( + resolve_count(), + 1, + "the first read must perform exactly one branch-head lookup" + ); + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + Some(SHA_A.to_string()), + "the first read persists what it resolved" + ); + + // Move the branch WITHOUT going through the push path, so nothing updates + // the stored head. A second read that resolved again would return SHA_B. + seed_branch_head(&state, &repo, "feature", SHA_B).await; + + let second = + body_json(get_rollup(&state, None, &rollup_uri(OWNER, "rollup-once", 1)).await).await; + assert_eq!( + second["sha"], SHA_A, + "the second read must be served from the stored head, not a fresh branch resolve" + ); + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + Some(SHA_A.to_string()), + "the stored head must not be rewritten by a later read" + ); + assert_eq!( + resolve_count(), + 1, + "the branch resolve must not run again once a head is stored" + ); +} + +/// The write-back is a cache fill, so its failure must not destroy an answer +/// the read already has. The head is resolved BEFORE the persist is attempted, +/// and the persist is the only thing that fails here. +/// +/// The failure is induced with a CHECK constraint that rejects any non-null +/// `head_commit`: the UPDATE errors while every read in the request path +/// (`repos`, `pull_requests`, `repo_push_events`) still works, which is what +/// isolates the write. Dropping a table would take the read down with it and +/// the test would pass for the wrong reason. +#[sqlx::test] +async fn a_failed_head_write_back_still_serves_the_resolved_head(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool.clone()).await; + let repo = seed_repo(OWNER, "rollup-wbfail", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + seed_push_event(&state, &repo, "feature", SHA_A, PUSH_T1).await; + + sqlx::query( + "ALTER TABLE pull_requests + ADD CONSTRAINT no_head_commit_writes CHECK (head_commit IS NULL)", + ) + .execute(&pool) + .await + .expect("install the write-blocking constraint"); + // The premise: this exact call is the one the rollup makes, and it errors. + state + .db + .set_pr_head_if_absent(&pr.id, SHA_A) + .await + .expect_err("the persist must fail for this test to mean anything"); + + let resp = get_rollup(&state, None, &rollup_uri(OWNER, "rollup-wbfail", 1)).await; + let (status, bytes) = status_and_bytes(resp).await; + assert_eq!( + status, + StatusCode::OK, + "a failed best-effort cache fill must not turn a resolved read into an \ + error: {}", + String::from_utf8_lossy(&bytes) + ); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["head_resolved"], true); + assert_eq!(body["sha"], SHA_A); + assert_eq!(body["state"], "pending"); + assert_eq!( + resolve_count(), + 1, + "the read resolved the branch itself rather than reading a stored head" + ); + assert_eq!( + state + .db + .get_pr(&repo.id, 1) + .await + .unwrap() + .unwrap() + .head_commit, + None, + "nothing was persisted, which is the point: the answer was served anyway" + ); +} + +/// The rollup's deny is the repo's own not-found, byte for byte what a caller +/// gets for a repo that does not exist. The pull request and its claims are +/// seeded first, so the deny cannot pass vacuously. +#[sqlx::test] +async fn anon_rollup_on_private_repo_is_indistinguishable_from_missing(pool: PgPool) { + let state = test_state(pool.clone()).await; + let target = rollup_uri(OWNER, "rollup-private", 1); + + let missing = status_and_bytes(get_rollup(&state, None, &target).await).await; + + let repo = seed_repo(OWNER, "rollup-private", false); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_A) + .await + .unwrap(); + seed_claim( + &pool, + "33333333-0000-0000-0000-000000000001", + &repo.id, + SHA_A, + OWNER, + OWNER, + "ci/build", + "success", + "2026-01-01T00:00:00Z", + ) + .await; + + let denied = status_and_bytes(get_rollup(&state, None, &target).await).await; + + assert_eq!(missing.0, StatusCode::NOT_FOUND); + assert_eq!( + denied, missing, + "a private-repo deny must be byte-identical to the missing-repo response" + ); + assert!( + !String::from_utf8_lossy(&denied.1).contains("ci/build"), + "the deny must carry no trace of the claim" + ); +} + +/// A pull request number that does not exist on a repo the caller CAN read is +/// the plain not-found: existence is not secret once the read gate passed. +#[sqlx::test] +async fn unknown_pr_number_on_a_visible_repo_is_404(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-missing-pr", true); + state.db.create_repo(&repo).await.unwrap(); + + let resp = get_rollup(&state, None, &rollup_uri(OWNER, "rollup-missing-pr", 99)).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +/// R18's endpoint boundary: the rollup lives ONLY on the per-pull-request +/// endpoint. The list response must gain no rollup field and do no status +/// work, or one list call becomes N projections. +/// +/// Asserted on the ABSENCE of the named rollup fields, never on the full field +/// set: `head_commit` already surfaces on this response through the pull +/// request's own Serialize, and `status` is the pull request's own open/closed +/// state, so neither is evidence either way. +#[sqlx::test] +async fn pr_list_response_carries_no_rollup_fields(pool: PgPool) { + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-boundary", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + state + .db + .set_open_pr_heads(&repo.id, "feature", SHA_A) + .await + .unwrap(); + let resp = post_as( + &state, + OWNER, + &uri(OWNER, "rollup-boundary", SHA_A), + body_of("success", "ci/build"), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let router = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/pulls", + axum::routing::get(crate::api::pulls::list_prs), + ) + .with_state(state.clone()); + let resp = router + .oneshot( + axum::http::Request::builder() + .method(Method::GET) + .uri(format!("/api/v1/repos/{OWNER}/rollup-boundary/pulls")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + let entry = &body["pulls"][0]; + assert_eq!( + entry["number"], 1, + "the list must still serve the pull request" + ); + for field in [ + "combined_state", + "head_resolved", + "reported_only", + "rollup", + "statuses", + "total_count", + ] { + assert!( + entry.get(field).is_none(), + "the pull request list response must not carry `{field}`" + ); + assert!( + body.get(field).is_none(), + "the pull request list envelope must not carry `{field}`" + ); + } +} + +/// R18's cost bound, by source read: no path in this module acquires the +/// repository. `repo_store.acquire` downloads the whole repository from object +/// storage on a cold node, and this read group carries no rate limiter, so an +/// anonymous caller could drive repeated downloads. The ref helper that needs +/// an acquired path is named here too, since reaching for it is how the +/// acquire gets reintroduced. +#[test] +fn status_module_never_acquires_the_repo_or_lists_refs_from_disk() { + let src = include_str!("mod.rs"); + // The module's production half is now the whole of `status/mod.rs`, which + // ends at the declaration of the tests file. Anchoring on that declaration + // rather than on any `#[cfg(test)]` attribute still matters: the + // production half carries test-only instrumentation of its own, and + // stopping at the first attribute would leave most of the module unscanned. + let body_of_module = + crate::test_support::scrape_source_region(src, None, Some("\n#[cfg(test)]\nmod tests;")) + .expect("module has a tests module"); + assert!( + body_of_module.contains("pull_request_status"), + "the scan must cover the whole production half of the module" + ); + for banned in ["repo_store.acquire", "store::list_refs"] { + assert!( + !body_of_module.contains(banned), + "the status module must not call `{banned}` — the rollup's branch \ + resolve is a database read, not a repository acquire" + ); + } + assert!( + body_of_module.contains("latest_push_sha_for_ref("), + "the fallback's branch lookup must be the database-backed \ + latest_push_sha_for_ref over repo_push_events" + ); + assert!( + !body_of_module.contains("list_branch_cids("), + "the fallback must not read branch_cids — that table has one writer \ + and it only fires when the pushed objects came back with a pin CID, \ + so on a node with no pinning configured it is never written" + ); +} + +/// The rollup route exists on the production router and its group kept +/// `optional_signature`: a group that is never merged is not a route, and a +/// group without the layer reads every caller as anonymous. +#[sqlx::test] +async fn rollup_route_is_registered_with_optional_signature(pool: PgPool) { + let _counting = resolve_count_guard().await; + let state = test_state(pool).await; + let repo = seed_repo(OWNER, "rollup-wired", true); + state.db.create_repo(&repo).await.unwrap(); + let pr = seed_pr(&repo.id, 1, "feature"); + state.db.create_pr(&pr).await.unwrap(); + let target = rollup_uri(OWNER, "rollup-wired", 1); + + let resp = crate::server::build_router(state.clone()) + .oneshot( + axum::http::Request::builder() + .method(Method::GET) + .uri(&target) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::OK, + "the rollup route must exist on the production router and serve a public repo to anon" + ); + + let resp = crate::server::build_router(state) + .oneshot( + axum::http::Request::builder() + .method(Method::GET) + .uri(&target) + .header("signature", "sig1=:bm90YXNpZw==:") + .header("signature-input", "sig1=(\"@method\");alg=\"ed25519\"") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let (status, bytes) = status_and_bytes(resp).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "a presented signature must be verified, which only happens if the \ + rollup's group still carries optional_signature" + ); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"], "invalid_signature"); +} diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 720fb3ae..03eca1a4 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -17,6 +17,53 @@ use crate::state::AppState; #[derive(Clone, Debug)] pub struct AuthenticatedDid(pub String); +/// The RFC 9421 material this request was actually verified against, injected +/// alongside [`AuthenticatedDid`] by `require_signature`. +/// +/// A handler that records a signed claim as history has to store it: the header +/// values and the canonical signing string are gone once the request ends, and a +/// stored claim nobody can re-verify is not history. `signing_string` is the exact +/// byte sequence the Ed25519 verification succeeded over, not a rebuild — a +/// handler reconstructing it from headers would be verifying a different string +/// than the middleware did. +#[derive(Clone, Debug)] +pub struct SignatureMaterial { + /// The `Signature` header value. + pub signature: String, + /// The `Signature-Input` header value. + pub signature_input: String, + /// The canonical bytes the signature covered. + pub signing_string: String, + /// The buffered request body, on the routes that persist it. The signing + /// string covers it only through the content-digest, so without these bytes + /// a stored claim can be shown to carry *a* valid signature over *some* + /// digest and nothing more. Carried here because the body is consumed + /// downstream by the extractor and cannot be recovered afterwards. + /// + /// `Option`, and populated only behind [`PersistsSignedBody`], because a + /// route that never stores the body has no use for a second handle to it. + /// The unlike-sized field is this one: the three above are header-derived + /// and bounded by the server's header limit, while a receive-pack POST + /// carries up to GITLAWB_MAX_PACK_BYTES (2 GB by default). `Bytes`, not + /// `Vec`, so the handle the marked routes do take is a refcount bump + /// over the buffer the middleware already holds rather than a copy. + pub body: Option, +} + +/// Marks a route group whose handler persists the signed request body. +/// +/// Applied as an extension layer OUTSIDE the auth layers, so it is on the +/// request before [`require_signature`] decides whether to carry the body. A +/// marker applied inside them is never seen, and the failure is silent: the +/// handler still compiles, tests that inject material by hand still pass, and +/// production stores nothing. The status write handler refuses an absent body +/// for that reason. +/// +/// A marker rather than a path check because `require_signature` must not learn +/// which URL persists claims; that knowledge belongs at the route table. +#[derive(Clone, Copy, Debug)] +pub struct PersistsSignedBody; + /// Whether `caller` is authorized to push to `record`. /// /// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical @@ -238,10 +285,28 @@ pub async fn require_signature(request: Request, next: Next) -> Response { tracing::info!(did = %sig.key_id, "✓ authenticated request"); + // The body travels only where it is stored. On every other signed route the + // middleware's handle would keep the buffer alive for the rest of the layer + // chain, until the handler's own extractor consumes the request. + let body = parts + .extensions + .get::() + .map(|_| body_bytes.clone()); + + let material = SignatureMaterial { + signature: sig_header, + signature_input: sig_input, + signing_string, + body, + }; + let mut request = Request::from_parts(parts, Body::from(body_bytes)); request .extensions_mut() .insert(AuthenticatedDid(sig.key_id.to_string())); + // Carry the verified material forward for handlers that persist a signed + // claim; none of it can be recovered once the request is gone. + request.extensions_mut().insert(material); next.run(request).await } @@ -523,6 +588,100 @@ mod tests { } } + /// Reports what the signature middleware put in `SignatureMaterial.body`, + /// so a test can tell "captured" from "deliberately not captured" apart + /// from "the middleware never ran". + async fn report_body(material: Option>) -> String { + match material { + Some(axum::Extension(m)) => match m.body { + Some(bytes) => format!("captured:{}", bytes.len()), + None => "absent".to_string(), + }, + None => "no-material".to_string(), + } + } + + fn signed_post(path: &str, body: &[u8]) -> Request { + let kp = Keypair::generate(); + let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", path, body); + Request::builder() + .method("POST") + .uri(path) + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(axum::body::Body::from(body.to_vec())) + .unwrap() + } + + /// The body handle travels only on a route that asked for it. + /// + /// Both arms drive a genuinely signed request through `require_signature`; + /// the only difference is the [`PersistsSignedBody`] marker layer. A + /// middleware that captured unconditionally would make both arms read + /// `captured`, and one that never read the marker would make both read + /// `absent`, so the pair pins the decision rather than the mechanism. + #[tokio::test] + async fn the_body_is_carried_only_when_the_route_marks_itself_as_persisting_it() { + let body = br#"{"state":"success"}"#; + + // The marker layer is added AFTER the middleware layer, which is what + // makes it the outer of the two and therefore the one that runs first. + // This is the same relative position `build_router` uses on the status + // write group, where the marker sits outside `add_auth_layers`. + let marked = Router::new() + .route("/", axum::routing::post(report_body)) + .layer(middleware::from_fn(require_signature)) + .layer(axum::Extension(PersistsSignedBody)); + let resp = marked.oneshot(signed_post("/", body)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap(); + assert_eq!( + String::from_utf8(seen.to_vec()).unwrap(), + format!("captured:{}", body.len()), + "a route carrying the persist marker must receive the buffered body" + ); + + let unmarked = Router::new() + .route("/", axum::routing::post(report_body)) + .layer(middleware::from_fn(require_signature)); + let resp = unmarked.oneshot(signed_post("/", body)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap(); + assert_eq!( + String::from_utf8(seen.to_vec()).unwrap(), + "absent", + "a signed route that never persists the body must not be handed a handle to it" + ); + } + + /// The marker is read from the request the middleware was given, and the + /// request it hands downstream still carries it. Both halves matter: the + /// first is the capture decision, the second is that rebuilding the request + /// from its parts does not drop extensions a later layer may want. + #[tokio::test] + async fn the_marker_survives_the_request_rebuild() { + async fn report_marker(marker: Option>) -> String { + match marker { + Some(_) => "marked".to_string(), + None => "unmarked".to_string(), + } + } + + let app = Router::new() + .route("/", axum::routing::post(report_marker)) + .layer(middleware::from_fn(require_signature)) + .layer(axum::Extension(PersistsSignedBody)); + let resp = app.oneshot(signed_post("/", b"{}")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap(); + assert_eq!( + String::from_utf8(seen.to_vec()).unwrap(), + "marked", + "the marker must reach the handler, so the middleware saw it too" + ); + } + #[tokio::test] async fn require_ucan_chain_no_header_passes_through() { let state = make_test_state(Keypair::generate().did()); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index c6ff644b..0045ebe3 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -81,10 +81,82 @@ pub struct PullRequest { pub status: String, // "open" | "merged" | "closed" pub merged_by_did: Option, pub merged_at: Option, + /// Head commit of the source branch, tracked while the pull request is open + /// and frozen at close or merge. Null until something pushes to that branch. + pub head_commit: Option, pub created_at: String, pub updated_at: String, } +/// One append-only commit-status claim. A producer reporting twice for the same +/// context leaves both rows; the visible status is a projection over the history, +/// ordered on the database-assigned `seq`. +/// No `Serialize`: the signature material below must not reach a response body, +/// and the write path's 201 uses a dedicated type +/// ([`crate::api::status::CreatedStatus`]) that carries none of it. Dropping the +/// derive is what stops the leak from reappearing by accident. +#[derive(Debug, Clone, Deserialize)] +pub struct StatusClaim { + pub id: String, + /// Database-assigned ordering key. Ignored on insert (see + /// [`Db::insert_status_claim`]); nothing the producer supplies orders the + /// projection. + // Read back by the row mapper and asserted in tests, but no production + // reader takes it off the record: the projection orders on `seq` inside SQL, + // and the write response reports the value the insert returned directly. + #[allow(dead_code)] + pub seq: i64, + pub repo_id: String, + /// Lowercase 40-character hex commit SHA. Never existence-checked. + pub commit_sha: String, + pub state: String, + pub context: String, + pub target_url: Option, + pub description: Option, + pub producer_did: String, + pub authorizing_did: String, + /// The producer's RFC 9421 `Signature` header value, and below it the + /// `Signature-Input`, the canonical string the signature was verified over, + /// and the request body itself. Captured at write time because none of it + /// can be recovered once the request is gone, which is what keeps a claim + /// verifiable as history. + /// + /// Both of the last two are needed, and neither substitutes for the other. + /// The signing string covers the body only through a content-digest, so with + /// the signing string alone a reader can confirm that somebody signed a + /// request carrying some digest but cannot show that THIS row is what they + /// signed. `request_body` is what closes that gap: recompute its digest, + /// find it in `signing_string`, verify `signature` over that string. + pub signature: String, + pub signature_input: String, + pub signing_string: String, + pub request_body: Vec, + /// Stable digest over the four fields above: the request's identity. + /// + /// Unique across the table, so an exact replay of an accepted request cannot + /// append a second row. See [`crate::api::status::request_digest`] for what + /// goes into it and [`Db::insert_status_claim_capped`] for what happens when + /// it collides. + pub request_digest: String, + /// Server-assigned rfc3339 timestamp. Display data only; ordering is `seq`. + pub created_at: String, +} + +/// One row of the commit-status projection: what the read surfaces render and +/// nothing else. Deliberately NOT a [`StatusClaim`]. The claim carries the +/// signature material, which no read renders and which runs to kilobytes per row +/// (`signing_string` plus `request_body`), so reading the projection as claims +/// pulled that weight off disk on every public status request. +#[derive(Debug, Clone)] +pub struct StatusProjection { + pub state: String, + pub context: String, + pub target_url: Option, + pub description: Option, + pub producer_did: String, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrReview { pub id: String, @@ -125,6 +197,32 @@ pub struct Webhook { pub active: bool, } +/// One local push event: a single ref update observed on this node's +/// receive-pack path, recorded so a subscriber that missed the push webhook can +/// still discover the work by polling. +/// +/// Stored in `repo_push_events`, deliberately NOT in `received_ref_updates`. +/// That table is also read by the unauthenticated global feed at +/// `GET /api/v1/events/ref-updates`, so writing a local push there would publish +/// a private repo's push metadata to anonymous callers. This one is read only by +/// the repo-scoped poll surface, behind that repo's read gate. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoPushEvent { + pub id: String, + /// Database-assigned ordering key, and the poll cursor itself. Ignored on + /// insert (see [`Db::insert_repo_push_events`]): nothing the writer supplies + /// can order these rows, because `created_at` is stamped before the insert + /// and a row stamped later can commit earlier. + pub seq: i64, + pub repo_id: String, + pub ref_name: String, + /// The SHA the ref points at after the push. A deletion is never recorded, + /// so this is always a real commit at the time it was written. + pub after_sha: String, + /// Display data only. Never an ordering key; see `seq`. + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefCertificate { pub id: String, @@ -901,8 +999,138 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE sync_queue ADD COLUMN IF NOT EXISTS attempted_at TEXT", ], }, + // Reservation: v24, not main's current_max + 1 (which is 18). Same reason as + // v17 above — the runner keys the applied set on the version integer alone, + // so a version another in-flight branch also claims is skipped in full on + // whichever side merges second, silently and with schema_migrations still + // reading healthy. Two open branches claim 18 through 23 under their own + // names: origin/pr-173 and origin/fix/issue-135-ipfs-cid-tree-gate. 24 + // clears both. Gaps are harmless; the runner never requires contiguity. + // + // The whole commit-status surface lands in this one entry rather than three, + // because a merged entry is never edited and a second version would collide + // with the same branches this one already had to clear. + Migration { + version: 24, + name: "status_claims_pr_head_and_push_events", + stmts: &[ + // Append-only claim history. `seq` is a bigserial because the + // ordering key must be assigned by the database: the producer + // supplies nothing that orders the projection, and created_at is + // display data that can collide within a single second. + r#"CREATE TABLE IF NOT EXISTS status_claims ( + id TEXT NOT NULL PRIMARY KEY, + seq BIGSERIAL NOT NULL, + repo_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + state TEXT NOT NULL, + context TEXT NOT NULL, + target_url TEXT, + description TEXT, + producer_did TEXT NOT NULL, + authorizing_did TEXT NOT NULL, + signature TEXT NOT NULL, + signature_input TEXT NOT NULL, + -- The canonical RFC 9421 string the signature was verified over, + -- and the request body it covers through a content-digest. Two + -- columns because one cannot stand in for the other: the signing + -- string proves a signature exists, the body proves this row is + -- what it signed. + signing_string TEXT NOT NULL, + request_body BYTEA NOT NULL, + -- Stable digest over the material that identifies one request: + -- the signature, its input, the signing string and the body. Two + -- rows carrying the same value are the same request written + -- twice, which is what the unique index below refuses. + request_digest TEXT NOT NULL, + created_at TEXT NOT NULL + )"#, + // Replay containment, and the reason it is an index and not a check + // in Rust: `require_signature` bounds the clock skew on `created` and + // nothing else, so a captured request stays acceptable for the whole + // window. A read-then-insert would race, and losing that race is not + // a duplicate row, because the projection takes the highest `seq` per + // (producer, context), so a replayed row earns a FRESH sequence + // number and puts a superseded verdict back in front of the one that + // replaced it. The database is what has to hold this. + "CREATE UNIQUE INDEX IF NOT EXISTS uq_status_claims_request_digest ON status_claims(request_digest)", + // Read path: every claim for one commit. + "CREATE INDEX IF NOT EXISTS idx_status_claims_repo_commit ON status_claims(repo_id, commit_sha)", + // Cap count and latest-per-tuple lookup, which orders on seq DESC. + "CREATE INDEX IF NOT EXISTS idx_status_claims_tuple ON status_claims(repo_id, commit_sha, producer_did, context, seq DESC)", + // The per-repo write bound, which counts the rows a repo wrote inside + // a trailing window rather than every row it ever wrote. + "CREATE INDEX IF NOT EXISTS idx_status_claims_repo_created ON status_claims(repo_id, created_at)", + // Stored head of a pull request's source branch. Nullable: a PR + // opened before anything pushed has no head yet. + "ALTER TABLE pull_requests ADD COLUMN IF NOT EXISTS head_commit TEXT", + // Local push events, one row per ref update observed on the + // receive-pack path. Deliberately NOT received_ref_updates, which + // the unauthenticated global feed at /api/v1/events/ref-updates also + // reads: writing local pushes there would publish private-repo + // pushes on an anonymous surface. Also not `push_events`, which v1 + // already defines for agent trust scoring on a different shape + // (agent_did, commit_hash, object_count, pushed_at); reusing that + // name would have been a silent no-op under IF NOT EXISTS. + // `seq` is a bigserial for the same reason `status_claims.seq` is: the + // poll cursor's ordering key must be assigned by the database. The + // application stamps `created_at` BEFORE the insert, so a row stamped + // later can commit earlier and a poller that has advanced past the + // later stamp never sees it, and an NTP step backwards makes that + // ordinary rather than a race. `created_at` stays as display data. + r#"CREATE TABLE IF NOT EXISTS repo_push_events ( + id TEXT NOT NULL PRIMARY KEY, + seq BIGSERIAL NOT NULL, + repo_id TEXT NOT NULL, + ref_name TEXT NOT NULL, + after_sha TEXT NOT NULL, + created_at TEXT NOT NULL + )"#, + // Keyset paging cursor: `seq` ascending within a repo. One column, so + // no tiebreak is needed: the sequence is unique by construction, and + // every row of one multi-ref push gets its own value even though they + // share a timestamp. + "CREATE INDEX IF NOT EXISTS idx_repo_push_events_cursor ON repo_push_events(repo_id, seq)", + // The pull request rollup's branch resolve: newest row for one ref, + // ordered on the same database-assigned key as the cursor. + "CREATE INDEX IF NOT EXISTS idx_repo_push_events_ref ON repo_push_events(repo_id, ref_name, seq DESC)", + ], + }, ]; +// ── Push-path statement accounting ──────────────────────────────────────────── + +// Count of database statements the receive-pack write path has executed, so a +// test can assert on the WORK a push does and not only on the rows it leaves. +// Those two come apart exactly where it matters: a loop issuing one round trip +// per ref and a single multi-row statement produce byte-identical rows, and only +// the first multiplies latency on the user's `git push`. +// +// Thread-local, not a process-global atomic: `#[sqlx::test]` gives each test its +// own database but the whole suite shares one process, and other modules' tests +// write push events too. A shared counter would need every one of them to take a +// mutex, and the tests that forgot would show up as an inflated count in an +// unrelated test. A thread-local is isolated by construction. +#[cfg(test)] +thread_local! { + pub(crate) static PUSH_WRITE_STATEMENTS: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +/// Record one statement against the push-path counter. Compiles away entirely +/// outside tests. +#[inline] +fn count_push_write_statement() { + #[cfg(test)] + PUSH_WRITE_STATEMENTS.with(|c| c.set(c.get() + 1)); +} + +/// Zero the counter and return what it held, so a test can measure one call. +#[cfg(test)] +pub(crate) fn take_push_write_statements() -> usize { + PUSH_WRITE_STATEMENTS.with(|c| c.replace(0)) +} + // ── Repos ───────────────────────────────────────────────────────────────────── pub(crate) fn normalize_owner_key(did: &str) -> &str { @@ -912,6 +1140,34 @@ pub(crate) fn normalize_owner_key(did: &str) -> &str { } } +/// The canonical spelling of an identity for a column that stores one identity +/// per row, as `status_claims.producer_did` and `authorizing_did` do: the full +/// `did:key:` form for a `did:key` in either spelling, the value unchanged +/// for every other method. +/// +/// Written as a function of [`normalize_owner_key`] rather than beside it, so the +/// two cannot disagree about which identities are the same one: `canonical_did` +/// is injective over the normalized key, which makes +/// `canonical_did(a) == canonical_did(b)` exactly `did_matches(a, b)`. Pinned by +/// `canonical_did_agrees_with_did_matches`. +/// +/// It expands where `normalize_owner_key` collapses because the two answer +/// different questions. The owner key is a lookup key; a stored `producer_did` is +/// evidence, and a claim has to stay verifiable from the row alone — a bare +/// base58 id does not parse as a DID, so the key behind the signature could not +/// be recovered from it. +pub(crate) fn canonical_did(did: &str) -> std::borrow::Cow<'_, str> { + let key = normalize_owner_key(did); + // A residual carrying ':' is a full DID of some other method (or a value that + // is not a did:key at all); prefixing it would merge two identities that + // `did_matches` keeps apart. + if key.contains(':') { + std::borrow::Cow::Borrowed(key) + } else { + std::borrow::Cow::Owned(format!("did:key:{key}")) + } +} + /// SQL CASE expression byte-identical to `normalize_owner_key`. All queries that /// filter or group by owner key use this const so the Rust and SQL sides cannot /// drift apart. If you change `normalize_owner_key`, update this const too. @@ -923,7 +1179,49 @@ const PROFILE_DID_CASE_SQL: &str = "CASE WHEN did LIKE 'did:key:%' AND position( #[cfg(test)] mod normalize_owner_key_tests { - use super::normalize_owner_key; + use super::{canonical_did, normalize_owner_key}; + + /// `canonical_did` is what the claim columns store, and the projection + /// compares stored values for equality. That comparison is the authorization + /// filter, so it must decide exactly what `did_matches` decides: equal + /// canonical spellings for identities that match, distinct ones for + /// identities that do not. Both directions, including the cross-method cases + /// a bare base58 id must never reach across. + #[test] + fn canonical_did_agrees_with_did_matches() { + let values = [ + "did:key:zABC", + "zABC", + "did:key:zXYZ", + "zXYZ", + "did:gitlawb:zABC", + "did:web:example.com", + "example.com", + "did:key:did:gitlawb:zABC", + "z:A", + "did:key:z:A", + "did:key:", + "", + ]; + for a in values { + for b in values { + assert_eq!( + canonical_did(a) == canonical_did(b), + crate::api::did_matches(a, b), + "canonical_did disagrees with did_matches on ({a:?}, {b:?})" + ); + } + } + } + + /// The stored spelling has to parse as a DID: a claim is evidence, and + /// recovering the key behind its signature starts from `producer_did`. + #[test] + fn canonical_did_keeps_a_did_key_parseable() { + assert_eq!(canonical_did("zABC"), "did:key:zABC"); + assert_eq!(canonical_did("did:key:zABC"), "did:key:zABC"); + assert_eq!(canonical_did("did:gitlawb:zABC"), "did:gitlawb:zABC"); + } // Boundary set matching the SQL CASE: did:key short/full, empty residual, // did:key:z:extra, non-key, bare, empty, uppercase. @@ -1773,7 +2071,7 @@ impl Db { pub async fn list_prs(&self, repo_id: &str) -> Result> { let rows = sqlx::query( "SELECT id,repo_id,number,title,body,author_did,source_branch,target_branch, - status,merged_by_did,merged_at,created_at,updated_at + status,merged_by_did,merged_at,head_commit,created_at,updated_at FROM pull_requests WHERE repo_id=$1 ORDER BY number DESC", ) .bind(repo_id) @@ -1785,7 +2083,7 @@ impl Db { pub async fn get_pr(&self, repo_id: &str, number: i64) -> Result> { let row = sqlx::query( "SELECT id,repo_id,number,title,body,author_did,source_branch,target_branch, - status,merged_by_did,merged_at,created_at,updated_at + status,merged_by_did,merged_at,head_commit,created_at,updated_at FROM pull_requests WHERE repo_id=$1 AND number=$2", ) .bind(repo_id) @@ -1795,16 +2093,133 @@ impl Db { Ok(row.map(row_to_pr)) } - pub async fn merge_pr(&self, pr_id: &str, merged_by_did: &str) -> Result<()> { + /// Point every OPEN pull request in `repo_id` whose source branch is + /// `branch` at `new_sha`, in one statement. Returns the rows updated. + /// + /// The `status='open'` predicate is the freeze: once a pull request is + /// closed or merged, later pushes to the same branch no longer move its + /// stored head, so the recorded target stays the commit the decision was + /// actually made against. `branch` is a bare name — callers on the push + /// path see full refs and must strip `refs/heads/` first. + /// + /// Test-only since the push path started batching: production has exactly one + /// caller and it always has a whole push's worth of branches to write, so the + /// single-branch form exists to keep the many tests that seed one head + /// readable. Compiling it only under `cfg(test)` is what keeps that fact from + /// decaying into an unused production API. + #[cfg(test)] + pub async fn set_open_pr_heads( + &self, + repo_id: &str, + branch: &str, + new_sha: &str, + ) -> Result { + self.set_open_pr_heads_batch(repo_id, &[(branch.to_string(), new_sha.to_string())]) + .await + } + + /// The same update for several branches of one repo, in a single statement. + /// + /// This is the shape the receive-pack path needs: one push carries many ref + /// updates, and issuing one round trip per ref put that latency directly on + /// the user's `git push` response. The per-branch new head rides along as a + /// `VALUES` list joined against the table, so the cost is one statement + /// regardless of how many branches moved. The casts on the first row are what + /// give Postgres the column types it cannot infer from parameters alone. + /// + /// Callers must chunk the list (see `api::repos::PUSH_WRITE_CHUNK`): every + /// pair costs two bind parameters, against a protocol ceiling of 65535. The + /// chunking bounds one STATEMENT, not the push: a caller that dropped the + /// tail instead would leave those pull requests pointing at a commit the + /// push already replaced. + /// + /// Same predicates as the single-branch form, and for the same reason: + /// `status='open'` is the freeze that keeps a decided pull request's head + /// from moving under a later push. + pub async fn set_open_pr_heads_batch( + &self, + repo_id: &str, + updates: &[(String, String)], + ) -> Result { + if updates.is_empty() { + return Ok(0); + } + // $1 is the repo; each pair takes the next two positions. + let mut values = String::new(); + for i in 0..updates.len() { + if i > 0 { + values.push(','); + } + let base = 2 + i * 2; + if i == 0 { + values.push_str(&format!("(${}::text, ${}::text)", base, base + 1)); + } else { + values.push_str(&format!("(${}, ${})", base, base + 1)); + } + } + let sql = format!( + "UPDATE pull_requests SET head_commit = v.sha + FROM (VALUES {values}) AS v(branch, sha) + WHERE pull_requests.repo_id = $1 + AND pull_requests.source_branch = v.branch + AND pull_requests.status = 'open'" + ); + + let mut q = sqlx::query(&sql).bind(repo_id); + for (branch, sha) in updates { + q = q.bind(branch).bind(sha); + } + count_push_write_statement(); + let result = q.execute(&self.pool).await?; + Ok(result.rows_affected()) + } + + /// Record `sha` as the head of one OPEN pull request that has none yet. + /// Returns the rows updated, so a caller can tell a fill from a no-op. + /// + /// Both predicates are the point. `head_commit IS NULL` makes the write + /// self-limiting: the rollup's read-side fallback is reachable by an + /// unauthenticated GET, and this is what stops it from firing more than once + /// per pull request, or from rolling a head a concurrent push already + /// recorded back to a staler branch tip. `status='open'` is the same freeze + /// [`Db::set_open_pr_heads`] applies: a closed or merged pull request's head, + /// including its absence, is a decided fact and not back-fillable. + pub async fn set_pr_head_if_absent(&self, pr_id: &str, sha: &str) -> Result { + let result = sqlx::query( + "UPDATE pull_requests SET head_commit=$1 + WHERE id=$2 AND head_commit IS NULL AND status='open'", + ) + .bind(sha) + .bind(pr_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// Mark a pull request merged, stamping the source head it merged. + /// + /// `head_commit` is what the merge actually consumed, read under the repo + /// write lock: the caller loaded the row before taking that lock, so a push + /// landing in between moved the branch and the row's own value is stale. + /// `None` (an unresolvable source head) leaves the stored value alone + /// rather than nulling a head the push path had already recorded. + pub async fn merge_pr( + &self, + pr_id: &str, + merged_by_did: &str, + head_commit: Option<&str>, + ) -> Result<()> { let now = Utc::now().to_rfc3339(); sqlx::query( "UPDATE pull_requests - SET status='merged', merged_by_did=$1, merged_at=$2, updated_at=$2 + SET status='merged', merged_by_did=$1, merged_at=$2, updated_at=$2, + head_commit=COALESCE($4, head_commit) WHERE id=$3", ) .bind(merged_by_did) .bind(&now) .bind(pr_id) + .bind(head_commit) .execute(&self.pool) .await?; Ok(()) @@ -1968,6 +2383,316 @@ impl Db { } } +// ── Status Claims ───────────────────────────────────────────────────────────── + +const INSERT_STATUS_CLAIM_SQL: &str = "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, target_url, description, + producer_did, authorizing_did, signature, signature_input, + signing_string, request_body, request_digest, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING seq"; + +/// The same insert, made a no-op when the request has already been recorded. +/// The column list must stay identical to [`INSERT_STATUS_CLAIM_SQL`]: both are +/// bound by [`status_claim_insert`], so a divergence is a parameter-count error +/// on the first write either way runs. +/// +/// `DO NOTHING` rather than an error, so `RETURNING` yields no row at all. That +/// empty result is how the caller learns the write was a replay; see +/// [`Db::insert_status_claim_capped`]. +const INSERT_STATUS_CLAIM_IF_NEW_SQL: &str = "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, target_url, description, + producer_did, authorizing_did, signature, signature_input, + signing_string, request_body, request_digest, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (request_digest) DO NOTHING + RETURNING seq"; + +/// The claim a given request already wrote, if any. Rides the unique index the +/// replay containment is built on, so it is one probe rather than a scan. +/// +/// Takes the transaction rather than the pool: the caller runs it inside the +/// capped insert, both before the caps and again after a conflict, and reading +/// outside that transaction would answer from a different snapshot. +async fn claim_by_digest( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + digest: &str, +) -> Result> { + let row = sqlx::query( + "SELECT id,seq,repo_id,commit_sha,state,context,target_url,description, + producer_did,authorizing_did,signature,signature_input, + signing_string,request_body,request_digest,created_at + FROM status_claims WHERE request_digest = $1", + ) + .bind(digest) + .fetch_optional(&mut **tx) + .await?; + Ok(row.map(row_to_status_claim)) +} + +/// The bind chain for [`INSERT_STATUS_CLAIM_SQL`], shared by the plain and the +/// capped insert so the two can never bind different columns. +/// +/// Both DIDs are normalized here, at the one place claims are written, rather +/// than at each call site. The owner gate accepts `did:key:X` and the bare `X` as +/// the same identity, so a producer who alternates spellings would otherwise +/// store two values for one identity: the projection dedupes on the raw +/// `producer_did`, and the per-tuple cap counts on it, so both would be split in +/// half and the superseded claim would keep voting in the combined state. +fn status_claim_insert<'a>( + sql: &'a str, + claim: &'a StatusClaim, +) -> sqlx::query::Query<'a, sqlx::Postgres, sqlx::postgres::PgArguments> { + sqlx::query(sql) + .bind(&claim.id) + .bind(&claim.repo_id) + .bind(&claim.commit_sha) + .bind(&claim.state) + .bind(&claim.context) + .bind(&claim.target_url) + .bind(&claim.description) + .bind(canonical_did(&claim.producer_did).into_owned()) + .bind(canonical_did(&claim.authorizing_did).into_owned()) + .bind(&claim.signature) + .bind(&claim.signature_input) + .bind(&claim.signing_string) + .bind(&claim.request_body) + .bind(&claim.request_digest) + .bind(&claim.created_at) +} + +/// The projection behind the commit-status read, with its own column list: only +/// the fields a read surface renders. `seq` is selected in the inner query for +/// the ordering and dropped by the outer one. The signature material is +/// deliberately absent — see [`StatusProjection`]. +const LATEST_STATUS_CLAIMS_SQL: &str = + "SELECT state,context,target_url,description,producer_did,created_at FROM ( + SELECT DISTINCT ON (producer_did, context) + seq,state,context,target_url,description,producer_did,created_at + FROM status_claims + WHERE repo_id=$1 AND commit_sha=$2 AND authorizing_did = $3 + ORDER BY producer_did, context, seq DESC + ) latest ORDER BY seq ASC"; + +/// The trailing window the per-repo bound counts over. Named here, next to the +/// query that uses it, and quoted in the refusal message the handler surfaces. +const CLAIM_REPO_WINDOW_HOURS: i64 = 24; + +/// The three write-path bounds. All three are checked in the insert transaction: +/// one alone bounds nothing, because the context string and the commit SHA are +/// both caller-chosen and the SHA is never existence-checked. +pub struct ClaimCaps { + /// Claims for one (repo, commit, producer, context). + pub per_tuple: i64, + /// Distinct contexts for one (repo, commit). + pub contexts_per_commit: i64, + /// Claim rows for one repo within the trailing + /// [`CLAIM_REPO_WINDOW_HOURS`]-hour window. + /// + /// A window and not an all-time total. Nothing prunes this table, so a + /// lifetime bound would be a dead end: the repo's claim count never falls, + /// the refusal never becomes satisfiable, and the surface is closed for + /// good — while the 429 tells every CI client to retry forever. As a rate it + /// still bounds the fan-out this cap exists for (the commit SHA is + /// caller-chosen and never existence-checked, so the other two bounds do not + /// contain it), and the refusal is honest: waiting out the window works. + pub per_repo_window: i64, +} + +/// Outcome of a capped append. `CapExceeded` names the bound that refused, for +/// the caller's 429 message; it is not a database error. +/// +/// `AlreadyRecorded` carries the row the identical request wrote the first time, +/// so the caller can answer with the claim it already has instead of an error. A +/// refusal would be wrong twice over: it tells a CI client whose response was +/// lost to the network that its report failed when it succeeded, and it makes +/// the honest retry indistinguishable from the attack. +pub enum ClaimInsert { + Inserted(i64), + AlreadyRecorded(Box), + CapExceeded(&'static str), +} + +impl Db { + /// Append one claim. `claim.seq` is ignored: the column is a `bigserial` and + /// the database assigns the value, which is returned here so a caller can + /// report the row it just wrote without a second read. + // The write handler uses the capped form below; this stays the uncapped + // primitive the db tests drive directly. + #[allow(dead_code)] + pub async fn insert_status_claim(&self, claim: &StatusClaim) -> Result { + let row = status_claim_insert(INSERT_STATUS_CLAIM_SQL, claim) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("seq")) + } + + /// Append one claim only if it is new and all three caps still admit it. + /// + /// A request already recorded returns its original row and writes nothing, + /// BEFORE any cap is consulted. That order matters: a client retrying a + /// request whose response was lost must not be told 429 by a tuple cap its + /// own earlier write filled. + /// + /// Writers against one repo are serialized on a transaction-scoped advisory + /// lock taken before the first count. A transaction alone would not hold the + /// bounds: Postgres runs READ COMMITTED, so concurrent writers each count the + /// rows committed when they started, every one of them reads a value under the + /// cap, and every one of them inserts — the table ends up over the bound by + /// the concurrency, which is exactly the burst the caps exist to stop. + /// + /// The lock is keyed on the repo id, which is the coarsest of the three + /// bounds, so one key covers all of them. It is released by the commit or the + /// rollback, including the early returns below. `hashtext` collisions put two + /// unrelated repos behind one key, which costs a little serialization on the + /// write path and nothing in correctness. + pub async fn insert_status_claim_capped( + &self, + claim: &StatusClaim, + caps: &ClaimCaps, + ) -> Result { + let mut tx = self.pool.begin().await?; + + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)") + .bind(&claim.repo_id) + .execute(&mut *tx) + .await?; + + // Replay first, and cheapest: one unique-index probe. Under the repo + // lock this settles every same-repo racer, and the index below settles + // the rest. + if let Some(existing) = claim_by_digest(&mut tx, &claim.request_digest).await? { + return Ok(ClaimInsert::AlreadyRecorded(Box::new(existing))); + } + + // The tuple bound and the context-fanout bound read the same + // (repo, commit) rows, so they are one aggregate over one scan rather + // than two round trips: the tuple count is the same scan narrowed by a + // FILTER. + let row = sqlx::query( + "SELECT count(*) FILTER (WHERE producer_did=$3 AND context=$4) AS tuple_count, + count(DISTINCT context) AS contexts, + COALESCE(bool_or(context = $4), false) AS present + FROM status_claims WHERE repo_id=$1 AND commit_sha=$2", + ) + .bind(&claim.repo_id) + .bind(&claim.commit_sha) + // Canonicalized to match what the insert stores (see + // status_claim_insert): counting on the raw DID would give one identity a + // fresh cap per spelling. + .bind(canonical_did(&claim.producer_did).into_owned()) + .bind(&claim.context) + .fetch_one(&mut *tx) + .await?; + let tuple_count: i64 = row.get("tuple_count"); + let contexts: i64 = row.get("contexts"); + let present: bool = row.get("present"); + + if tuple_count >= caps.per_tuple { + return Ok(ClaimInsert::CapExceeded( + "claims for this commit, producer and context", + )); + } + + // A context already present does not widen the fanout, so only a NEW + // context is measured against the limit. + if !present && contexts >= caps.contexts_per_commit { + return Ok(ClaimInsert::CapExceeded( + "distinct contexts for this commit", + )); + } + + // Trailing window, not an all-time count: see ClaimCaps::per_repo_window. + // `created_at` is TEXT, and the server writes every row as one rfc3339 + // UTC format, so the comparison is chronological as well as lexical. + let window_start = + (Utc::now() - chrono::Duration::hours(CLAIM_REPO_WINDOW_HOURS)).to_rfc3339(); + let repo_rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM status_claims WHERE repo_id=$1 AND created_at >= $2", + ) + .bind(&claim.repo_id) + .bind(&window_start) + .fetch_one(&mut *tx) + .await?; + if repo_rows >= caps.per_repo_window { + return Ok(ClaimInsert::CapExceeded( + "claims for this repository in the last 24 hours", + )); + } + + // The probe above is the fast path, not the guarantee: two nodes sharing + // this database, or two requests the repo lock does not cover, can both + // pass it. The unique index is what actually holds, and `DO NOTHING` + // turns losing that race into an empty result rather than an error. + let inserted = status_claim_insert(INSERT_STATUS_CLAIM_IF_NEW_SQL, claim) + .fetch_optional(&mut *tx) + .await?; + let Some(inserted) = inserted else { + let existing = claim_by_digest(&mut tx, &claim.request_digest) + .await? + .context("status claim insert conflicted on request_digest but the conflicting row could not be read back")?; + return Ok(ClaimInsert::AlreadyRecorded(Box::new(existing))); + }; + let seq = inserted.get::("seq"); + tx.commit().await?; + Ok(ClaimInsert::Inserted(seq)) + } + + /// Every claim recorded for one commit, oldest first. Ordering is on `seq`, + /// never on the timestamp, which is display data and can collide. + #[allow(dead_code)] + pub async fn list_status_claims( + &self, + repo_id: &str, + commit_sha: &str, + ) -> Result> { + let rows = sqlx::query( + "SELECT id,seq,repo_id,commit_sha,state,context,target_url,description, + producer_did,authorizing_did,signature,signature_input, + signing_string,request_body,request_digest,created_at + FROM status_claims WHERE repo_id=$1 AND commit_sha=$2 ORDER BY seq ASC", + ) + .bind(repo_id) + .bind(commit_sha) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(row_to_status_claim).collect()) + } + + /// The projection behind the commit-status read (KTD-2): the latest claim per + /// (producer, context) for one commit, restricted to claims authorized by + /// `authorizing_did`. + /// + /// Computed on every read and never materialized, so a repo whose visibility + /// or ownership changes after a claim was written is answered from current + /// state with no reconciliation job. "Latest" is the highest `seq` (KTD-3): + /// the timestamp is producer-visible display data and the id is a random + /// uuid, so neither orders the history. + /// + /// The authorization filter is one equality evaluated inside the query rather + /// than a per-claim check in Rust, which is the constraint the + /// delegated-capability follow-on inherits: it may widen the filter, never + /// move the test into the read loop. It is a single comparison and not a set + /// expansion because the column is already canonicalized on write through + /// [`canonical_did`], which is defined over the same [`normalize_owner_key`] + /// that `did_matches` collapses with, so the `did:key` rule has one definition + /// instead of a copy per query. + pub async fn latest_status_claims( + &self, + repo_id: &str, + commit_sha: &str, + authorizing_did: &str, + ) -> Result> { + let rows = sqlx::query(LATEST_STATUS_CLAIMS_SQL) + .bind(repo_id) + .bind(commit_sha) + .bind(canonical_did(authorizing_did).into_owned()) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(row_to_status_projection).collect()) + } +} + // ── Webhooks ────────────────────────────────────────────────────────────────── impl Db { @@ -2399,6 +3124,266 @@ impl Db { } } +// ── Repo Push Events ────────────────────────────────────────────────────────── + +/// How long a push-event write waits for its repo's lock before its statement +/// is cancelled, in milliseconds. +/// +/// Well under the pool's `acquire_timeout` (5 seconds by default), and +/// deliberately so: a writer that queued on the lock is holding a pooled +/// connection while it waits, so a wait longer than the acquire timeout would +/// convert one slow repo into pool exhaustion for every unrelated request. +/// Three attempts at this bound stay inside that budget. +const PUSH_LOCK_TIMEOUT_MS: u32 = 1_000; + +/// How many times a push-event write may be re-attempted after its lock wait +/// expired. Bounded, because the caller runs inline on the user's `git push`. +const PUSH_LOCK_ATTEMPTS: u32 = 3; + +/// Postgres `lock_not_available`, which is what `SET LOCAL lock_timeout` +/// cancels a statement with. The retryable failure, as distinct from every +/// other database error, which is returned on the first attempt. +const LOCK_NOT_AVAILABLE: &str = "55P03"; + +fn is_lock_timeout(err: &sqlx::Error) -> bool { + err.as_database_error() + .and_then(|e| e.code()) + .is_some_and(|code| code == LOCK_NOT_AVAILABLE) +} + +impl Db { + /// Record one local push event. Idempotent on the row id. + /// + /// Test-only, for the same reason as [`Db::set_open_pr_heads`]: the push path + /// writes a whole push in one statement, and this single-row form survives + /// only to keep row-seeding tests readable. + #[cfg(test)] + pub async fn insert_repo_push_event(&self, event: &RepoPushEvent) -> Result<()> { + self.insert_repo_push_events(std::slice::from_ref(event)) + .await + } + + /// Record a whole push's events in one statement, under the repo's write + /// lock. Idempotent on the row ids. + /// + /// One multi-row insert rather than one round trip per ref: the receive-pack + /// path runs this inline on the user's `git push`, so a sequential loop makes + /// the push response pay a database round trip for every ref it carries. + /// + /// `seq` is not bound. It is a bigserial the database assigns in insert + /// order, which is exactly why the poll cursor can trust it; a value supplied + /// here would defeat that. + /// + /// WRITERS AGAINST ONE REPO ARE SERIALIZED, on the same transaction-scoped + /// advisory lock [`Db::insert_status_claim_capped`] takes, so the two agree + /// on what a repo lock means. Insert order alone is not enough for the + /// cursor: `nextval` allocates the sequence at INSERT and the row becomes + /// visible at COMMIT, and it does not roll back, so two overlapping writers + /// can commit out of allocation order. When they do, the poller reads the + /// higher sequence, advances its cursor past the lower one, and that row, + /// once it commits, is never delivered by any later poll. Holding the lock + /// across the commit is what makes allocation order equal visibility order + /// within a repo. Across repos nothing is serialized, and nothing needs to + /// be: the cursor is per repo. + /// + /// The wait is bounded rather than indefinite, because this runs on the + /// user's push: `SET LOCAL lock_timeout` cancels the wait and the write is + /// re-attempted [`PUSH_LOCK_ATTEMPTS`] times before the error is returned. + /// The caller must not swallow it (see `api::repos::record_push_events`); a + /// dropped chunk is the same permanent loss the lock exists to prevent. + /// + /// Callers must chunk the slice (see `api::repos::PUSH_WRITE_CHUNK`): every + /// row costs five bind parameters, against a protocol ceiling of 65535. The + /// chunking bounds one STATEMENT, not the push: every ref receive-pack + /// accepted is written, across as many chunks as that takes. The lock is + /// therefore taken once per chunk, which still holds the property: each + /// chunk commits before any other writer can allocate. + pub async fn insert_repo_push_events(&self, events: &[RepoPushEvent]) -> Result<()> { + if events.is_empty() { + return Ok(()); + } + + // Every repo the batch touches, in one fixed order. In practice a push + // is one repo and this is one key; sorting is what keeps two batches + // that did span repos from taking the same keys in opposite orders and + // deadlocking. + let mut repo_ids: Vec<&str> = events.iter().map(|e| e.repo_id.as_str()).collect(); + repo_ids.sort_unstable(); + repo_ids.dedup(); + + let mut values = String::new(); + for i in 0..events.len() { + if i > 0 { + values.push(','); + } + let b = i * 5; + values.push_str(&format!( + "(${},${},${},${},${})", + b + 1, + b + 2, + b + 3, + b + 4, + b + 5 + )); + } + let sql = format!( + "INSERT INTO repo_push_events (id, repo_id, ref_name, after_sha, created_at) + VALUES {values} + ON CONFLICT(id) DO NOTHING" + ); + + let mut attempt = 1; + loop { + match self.push_events_attempt(&sql, &repo_ids, events).await { + Ok(()) => return Ok(()), + Err(e) if attempt < PUSH_LOCK_ATTEMPTS && is_lock_timeout(&e) => { + tracing::debug!( + attempt, + repos = repo_ids.len(), + "push-event write timed out waiting for the repo lock; retrying" + ); + attempt += 1; + } + Err(e) => return Err(e.into()), + } + } + } + + /// One attempt at the write above: take the repo locks, insert, commit. + /// + /// The lock releases with the transaction, so the commit here is what makes + /// the next writer's allocation safe. + async fn push_events_attempt( + &self, + sql: &str, + repo_ids: &[&str], + events: &[RepoPushEvent], + ) -> std::result::Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + // First statement of the transaction, so it bounds the lock waits below + // and nothing else: `SET LOCAL` reverts at commit or rollback. + sqlx::query(&format!( + "SET LOCAL lock_timeout = '{PUSH_LOCK_TIMEOUT_MS}ms'" + )) + .execute(&mut *tx) + .await?; + + for repo_id in repo_ids { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)") + .bind(*repo_id) + .execute(&mut *tx) + .await?; + } + + let mut q = sqlx::query(sql); + for e in events { + q = q + .bind(&e.id) + .bind(&e.repo_id) + .bind(&e.ref_name) + .bind(&e.after_sha) + .bind(&e.created_at); + } + count_push_write_statement(); + q.execute(&mut *tx).await?; + + tx.commit().await + } + + /// The SHA of the most recent push this node recorded for one ref of a repo, + /// or `None` if it has never seen a push for it. + /// + /// This is the pull request rollup's branch resolve. It reads + /// `repo_push_events` rather than `branch_cids` because that table is written + /// unconditionally for every ref update on the receive-pack path, while + /// `branch_cids` only gets a row when the pushed objects came back carrying a + /// pin CID — on a node with no pinning configured it is never written at all, + /// which left the resolve permanently unable to answer. + /// + /// Both the ref filter and the ordering are the database's work: the caller + /// gets one row, not the repo's whole push history to scan. "Most recent" is + /// the highest `seq`, not the latest `created_at`: the timestamp is stamped + /// before the insert, so the last row written is not necessarily the one + /// carrying the largest stamp, and every row of one multi-ref push shares a + /// stamp anyway. `(repo_id, ref_name, seq DESC)` is the index this rides. + pub async fn latest_push_sha_for_ref( + &self, + repo_id: &str, + ref_name: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT after_sha FROM repo_push_events + WHERE repo_id = $1 AND ref_name = $2 + ORDER BY seq DESC LIMIT 1", + ) + .bind(repo_id) + .bind(ref_name) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| r.get("after_sha"))) + } + + /// One page of a repo's push events, OLDEST first, walked by a `seq` keyset + /// cursor rather than an offset. + /// + /// `after` is the cursor a poller last saw; the page reads rows strictly + /// after it via `seq > after`, which matches the `ORDER BY` exactly. One + /// column is enough because `seq` is a database-assigned bigserial: it is + /// unique, so there is no tiebreak to get wrong, and it is assigned at insert + /// time rather than by the application. It does not follow from that alone + /// that the sequence agrees with the order the rows became visible: the + /// value is allocated at INSERT and published at COMMIT, so two overlapping + /// writers can commit out of allocation order and strand the lower row + /// behind a cursor that has already passed it. What makes the agreement + /// hold is the write side, where [`Db::insert_repo_push_events`] serializes + /// a repo's writers on an advisory lock held across the commit. + /// `created_at` cannot do this job either: it is + /// stamped before the insert, every row of one multi-ref push carries the + /// same value, and a clock that steps backwards makes a later row sort + /// earlier, stranding it behind a cursor that has already passed. + /// + /// Oldest-first is what makes the cursor stable under concurrent writes: a + /// push landing mid-walk sorts after the window being paged, so it cannot + /// shift it, and the next poll picks it up. + pub async fn list_repo_push_events_keyset( + &self, + repo_id: &str, + after: Option, + limit: i64, + ) -> Result> { + const COLS: &str = "id, seq, repo_id, ref_name, after_sha, created_at"; + + // Positional params in bind order: repo_id, after?, limit. + let (cursor_clause, limit_param) = match after { + Some(_) => (" AND seq > $2", 3), + None => ("", 2), + }; + let sql = format!( + "SELECT {COLS} FROM repo_push_events WHERE repo_id = $1{cursor_clause} \ + ORDER BY seq ASC LIMIT ${limit_param}" + ); + + let mut q = sqlx::query(&sql).bind(repo_id.to_string()); + if let Some(seq) = after { + q = q.bind(seq); + } + let rows = q.bind(limit).fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(row_to_repo_push_event).collect()) + } +} + +fn row_to_repo_push_event(r: sqlx::postgres::PgRow) -> RepoPushEvent { + RepoPushEvent { + id: r.get("id"), + seq: r.get("seq"), + repo_id: r.get("repo_id"), + ref_name: r.get("ref_name"), + after_sha: r.get("after_sha"), + created_at: r.get("created_at"), + } +} + // ── Received Ref Updates ────────────────────────────────────────────────────── impl Db { @@ -2867,11 +3852,44 @@ fn row_to_pr(r: sqlx::postgres::PgRow) -> PullRequest { status: r.get("status"), merged_by_did: r.get("merged_by_did"), merged_at: r.get("merged_at"), + head_commit: r.get("head_commit"), created_at: r.get("created_at"), updated_at: r.get("updated_at"), } } +fn row_to_status_claim(r: sqlx::postgres::PgRow) -> StatusClaim { + StatusClaim { + id: r.get("id"), + seq: r.get("seq"), + repo_id: r.get("repo_id"), + commit_sha: r.get("commit_sha"), + state: r.get("state"), + context: r.get("context"), + target_url: r.get("target_url"), + description: r.get("description"), + producer_did: r.get("producer_did"), + authorizing_did: r.get("authorizing_did"), + signature: r.get("signature"), + signature_input: r.get("signature_input"), + signing_string: r.get("signing_string"), + request_body: r.get("request_body"), + request_digest: r.get("request_digest"), + created_at: r.get("created_at"), + } +} + +fn row_to_status_projection(r: sqlx::postgres::PgRow) -> StatusProjection { + StatusProjection { + state: r.get("state"), + context: r.get("context"), + target_url: r.get("target_url"), + description: r.get("description"), + producer_did: r.get("producer_did"), + created_at: r.get("created_at"), + } +} + fn row_to_webhook(r: sqlx::postgres::PgRow) -> Webhook { let events_str: String = r.get("events"); let events: Vec = @@ -3840,6 +4858,1007 @@ mod migration_tests { db.migrate().await.unwrap(); } + // ── Status claims, stored PR head, push events (v24) ───────────────────── + + /// The version this branch's migration claims. Kept as a named constant so + /// the upgrade test's rollback target is derived from `MIGRATIONS` rather + /// than hard-coded twice. + const V24: i64 = 24; + + const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + /// Seed a stored head without going through the code under test, so a + /// freeze assertion cannot pass just because the writer never ran. + async fn set_head_directly(db: &super::Db, pr_id: &str, sha: &str) { + sqlx::query("UPDATE pull_requests SET head_commit=$1 WHERE id=$2") + .bind(sha) + .bind(pr_id) + .execute(&db.pool) + .await + .unwrap(); + } + + fn sample_claim(repo_id: &str, sha: &str, context: &str) -> super::StatusClaim { + super::StatusClaim { + id: uuid::Uuid::new_v4().to_string(), + // Ignored on insert: the database assigns the ordering key. + seq: 0, + repo_id: repo_id.to_string(), + commit_sha: sha.to_string(), + state: "success".to_string(), + context: context.to_string(), + target_url: Some("https://ci.example.com/runs/1".to_string()), + description: Some("all checks passed".to_string()), + producer_did: "did:key:zProducer".to_string(), + authorizing_did: "did:key:zOwner".to_string(), + signature: "sig1=:YWJj:".to_string(), + signature_input: "sig1=(\"@method\" \"@target-uri\");created=1754524800".to_string(), + signing_string: "\"@method\": POST\n\"@path\": /api/v1/repos/o/r/statuses/aaa" + .to_string(), + request_body: b"{\"state\":\"success\"}".to_vec(), + // Fresh per call, the way two real requests are: a genuine signature + // covers a `created` parameter and the body's digest, so no two + // distinct requests carry the same material. A constant here would + // make the second sample_claim of any test a replay of the first, + // which is a different property than the one under test. + request_digest: uuid::Uuid::new_v4().to_string(), + created_at: "2026-08-07T00:00:00+00:00".to_string(), + } + } + + async fn table_exists(db: &super::Db, table: &str) -> bool { + sqlx::query_scalar::<_, bool>("SELECT to_regclass($1) IS NOT NULL") + .bind(format!("public.{table}")) + .fetch_one(&db.pool) + .await + .unwrap() + } + + async fn column_exists(db: &super::Db, table: &str, column: &str) -> bool { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2)", + ) + .bind(table) + .bind(column) + .fetch_one(&db.pool) + .await + .unwrap() + } + + async fn insert_push_event(db: &super::Db, repo_id: &str, ref_name: &str, sha: &str, at: &str) { + sqlx::query( + "INSERT INTO repo_push_events (id, repo_id, ref_name, after_sha, created_at) + VALUES ($1,$2,$3,$4,$5)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(repo_id) + .bind(ref_name) + .bind(sha) + .bind(at) + .execute(&db.pool) + .await + .unwrap(); + } + + fn sample_pr(repo_id: &str, number: i64) -> super::PullRequest { + super::PullRequest { + id: uuid::Uuid::new_v4().to_string(), + repo_id: repo_id.to_string(), + number, + title: "add a thing".to_string(), + body: None, + author_did: "did:key:zAuthor".to_string(), + source_branch: "feature".to_string(), + target_branch: "main".to_string(), + status: "open".to_string(), + merged_by_did: None, + merged_at: None, + head_commit: None, + created_at: "2026-08-07T00:00:00+00:00".to_string(), + updated_at: "2026-08-07T00:00:00+00:00".to_string(), + } + } + + /// Fresh-database path: the whole chain on an empty database creates all + /// three v24 objects, and a claim round-trips field for field, including + /// the nullable columns in both directions. + #[sqlx::test] + async fn migration_v24_fresh_chain_round_trips_a_claim(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + assert!(table_exists(&db, "status_claims").await); + assert!(table_exists(&db, "repo_push_events").await); + assert!(column_exists(&db, "pull_requests", "head_commit").await); + + let full = sample_claim("repo-1", SHA_A, "ci/build"); + let seq = db.insert_status_claim(&full).await.unwrap(); + assert!(seq > 0, "the database must assign a positive sequence"); + + // Same tuple, nullable fields absent. + let mut sparse = sample_claim("repo-1", SHA_A, "ci/build"); + sparse.state = "pending".to_string(); + sparse.target_url = None; + sparse.description = None; + db.insert_status_claim(&sparse).await.unwrap(); + + let claims = db.list_status_claims("repo-1", SHA_A).await.unwrap(); + assert_eq!(claims.len(), 2, "both claims are kept, nothing overwritten"); + + let got = &claims[0]; + assert_eq!(got.id, full.id); + assert_eq!(got.seq, seq); + assert_eq!(got.repo_id, full.repo_id); + assert_eq!(got.commit_sha, full.commit_sha); + assert_eq!(got.state, full.state); + assert_eq!(got.context, full.context); + assert_eq!(got.target_url, full.target_url); + assert_eq!(got.description, full.description); + assert_eq!(got.producer_did, full.producer_did); + assert_eq!(got.authorizing_did, full.authorizing_did); + assert_eq!(got.signature, full.signature); + assert_eq!(got.signature_input, full.signature_input); + assert_eq!(got.signing_string, full.signing_string); + assert_eq!(got.request_body, full.request_body); + assert_eq!(got.created_at, full.created_at); + + let sparse_row = &claims[1]; + assert_eq!(sparse_row.id, sparse.id); + assert_eq!(sparse_row.state, "pending"); + assert_eq!(sparse_row.target_url, None, "nullable column reads as None"); + assert_eq!( + sparse_row.description, None, + "nullable column reads as None" + ); + + // A different commit does not see this commit's claims. + assert!(db + .list_status_claims("repo-1", &"b".repeat(40)) + .await + .unwrap() + .is_empty()); + + // The push-events table takes a row per observed ref update. + insert_push_event( + &db, + "repo-1", + "refs/heads/main", + SHA_A, + "2026-08-07T00:00:00+00:00", + ) + .await; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM repo_push_events WHERE repo_id = $1") + .bind("repo-1") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + /// The rollup's branch resolve asks for the newest push on one ref, and + /// "newest" has to mean the last row written, not the largest `created_at`. + /// The stamp is applied before the insert, so a push that stamped later can + /// commit earlier, the same defect the poll cursor had, in the reader next + /// door. Constructed rather than raced for: the row written first carries the + /// later timestamp. + #[sqlx::test] + async fn latest_push_sha_for_ref_follows_insertion_not_the_stamp(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.run_migrations().await.unwrap(); + + insert_push_event( + &db, + "repo-1", + "refs/heads/main", + SHA_A, + "2026-08-07T13:00:00+00:00", + ) + .await; + insert_push_event( + &db, + "repo-1", + "refs/heads/main", + SHA_B, + "2026-08-07T12:00:00+00:00", + ) + .await; + + assert_eq!( + db.latest_push_sha_for_ref("repo-1", "refs/heads/main") + .await + .unwrap() + .as_deref(), + Some(SHA_B), + "the resolve must return the last push written, not the one carrying \ + the largest application-stamped timestamp" + ); + + // The ref filter is still doing its job, so the assertion above is not + // just reading whatever row happens to be newest in the whole repo. + insert_push_event( + &db, + "repo-1", + "refs/heads/other", + SHA_A, + "2026-08-07T14:00:00+00:00", + ) + .await; + assert_eq!( + db.latest_push_sha_for_ref("repo-1", "refs/heads/main") + .await + .unwrap() + .as_deref(), + Some(SHA_B), + "a push to another ref must not move this ref's resolve" + ); + } + + /// One push event on a caller-owned transaction, returning the sequence the + /// database allocated. The allocation is what these tests are about, so the + /// row goes in through raw SQL rather than the public writer: the writer now + /// owns its own transaction and the reproduction below needs to own the + /// commit boundaries itself. + async fn raw_push_insert( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + repo_id: &str, + ref_name: &str, + sha: &str, + ) -> i64 { + sqlx::query_scalar( + "INSERT INTO repo_push_events (id, repo_id, ref_name, after_sha, created_at) + VALUES ($1,$2,$3,$4,$5) RETURNING seq", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(repo_id) + .bind(ref_name) + .bind(sha) + .bind("2026-08-07T00:00:00+00:00") + .fetch_one(&mut **tx) + .await + .unwrap() + } + + fn push_event(repo_id: &str, ref_name: &str, sha: &str) -> super::RepoPushEvent { + super::RepoPushEvent { + id: uuid::Uuid::new_v4().to_string(), + seq: 0, + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + after_sha: sha.to_string(), + created_at: "2026-08-07T00:00:00+00:00".to_string(), + } + } + + /// The defect the repo lock exists to stop, pinned at the layer it lives in. + /// + /// `seq` is a bigserial: `nextval` allocates at INSERT and the row becomes + /// visible at COMMIT, and the two orders are independent. A writer that + /// allocates first and commits second leaves its row behind a cursor that + /// has already passed, and no later poll asks for it again, so the event is + /// lost silently and permanently. + /// + /// Driven with two test-owned transactions rather than two racing calls to + /// the public writer, because the test has to decide when each one commits. + /// A barrier around the public writer cannot reproduce this: allocate and + /// commit were a single round trip, so there was no client-side window to + /// interleave. This test therefore holds against the shape the write had + /// before the lock as well as after it; what it documents is the mechanism, + /// and the guard that the writer actually takes the lock is the test below. + #[sqlx::test] + async fn out_of_order_commits_strand_a_row_behind_the_cursor(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + let mut first = pool.begin().await.unwrap(); + let seq_first = raw_push_insert(&mut first, "repo-1", "refs/heads/main", SHA_A).await; + let mut second = pool.begin().await.unwrap(); + let seq_second = raw_push_insert(&mut second, "repo-1", "refs/heads/other", SHA_B).await; + assert!( + seq_first < seq_second, + "the fixture must allocate in this order to be the case under test" + ); + + // The later allocation commits first, which is exactly what an + // unserialized pair of writers can do. + second.commit().await.unwrap(); + + let page = db + .list_repo_push_events_keyset("repo-1", None, 50) + .await + .unwrap(); + assert_eq!( + page.iter().map(|e| e.seq).collect::>(), + vec![seq_second], + "only the committed row is visible, so the poller's cursor lands past \ + the uncommitted one" + ); + let cursor = page.last().unwrap().seq; + assert!(cursor > seq_first); + + first.commit().await.unwrap(); + + let total: i64 = + sqlx::query_scalar("SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-1'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(total, 2, "both rows are committed and visible"); + assert!( + db.list_repo_push_events_keyset("repo-1", Some(cursor), 50) + .await + .unwrap() + .is_empty(), + "the row that committed second is now unreachable: every later poll \ + asks for seq > {cursor} and it will never be delivered" + ); + } + + /// The writer waits on its own repo's lock and on no other repo's. + /// + /// Probed directly rather than inferred from timing between two writers: a + /// transaction owned by the test holds the advisory lock for one repo, so + /// "does the writer take this lock" becomes an observation rather than a + /// race. The negative half is the one that matters and it is asserted + /// first-class: a write for a different repo must not queue behind an + /// unrelated repo's push. + #[sqlx::test] + async fn a_push_write_waits_on_its_own_repo_lock_and_no_others(pool: sqlx::PgPool) { + use std::time::Duration; + + let db = super::Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + let mut holder = pool.begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)") + .bind("repo-held") + .execute(&mut *holder) + .await + .unwrap(); + + // Both failure modes are one assertion on purpose: a key too coarse + // makes this write either hang until the outer timeout or fail its own + // bounded lock wait, and either way the property that broke is the same + // one. + let free = tokio::time::timeout( + Duration::from_secs(10), + db.insert_repo_push_events(&[push_event("repo-free", "refs/heads/main", SHA_A)]), + ) + .await; + assert!( + matches!(free, Ok(Ok(()))), + "a write for another repo must not wait on this repo's lock; got {free:?}" + ); + + let blocked = tokio::time::timeout( + Duration::from_millis(500), + db.insert_repo_push_events(&[push_event("repo-held", "refs/heads/main", SHA_A)]), + ) + .await; + assert!( + blocked.is_err(), + "a write for a repo whose lock is held must wait rather than allocate \ + a sequence, or its row can commit after a later one and be stranded" + ); + let held_rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-held'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(held_rows, 0, "the blocked write must not have committed"); + + holder.rollback().await.unwrap(); + + tokio::time::timeout( + Duration::from_secs(10), + db.insert_repo_push_events(&[push_event("repo-held", "refs/heads/main", SHA_B)]), + ) + .await + .expect("the write must proceed once the lock is released") + .unwrap(); + } + + /// A wait that never ends returns an error rather than holding the push + /// open, and it spends its whole retry budget getting there. + /// + /// Both halves matter and neither implies the other. Without the bound the + /// user's `git push` hangs for as long as the lock is held, which on the + /// inline path is a pooled connection held with it. Without the retries a + /// single slow neighbour turns into a lost chunk, which is the loss the lock + /// exists to prevent, arriving by another road. + #[sqlx::test] + async fn a_lock_wait_that_never_clears_is_retried_and_then_returned(pool: sqlx::PgPool) { + use std::time::Duration; + + let db = super::Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + let mut holder = pool.begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)") + .bind("repo-held") + .execute(&mut *holder) + .await + .unwrap(); + + let lock_wait = Duration::from_millis(super::PUSH_LOCK_TIMEOUT_MS.into()); + let started = std::time::Instant::now(); + // A fixed ceiling, not one derived from the constants above: the point + // of this half is that the write terminates on its own, and an + // expectation computed from the same values it is checking would move + // with them. + let outcome = tokio::time::timeout( + Duration::from_secs(30), + db.insert_repo_push_events(&[push_event("repo-held", "refs/heads/main", SHA_A)]), + ) + .await + .expect("the write must give up on its own rather than wait on the lock forever"); + let elapsed = started.elapsed(); + + let err = outcome.expect_err("the write cannot have succeeded under a held lock"); + assert!( + err.to_string().contains("lock timeout"), + "the error must be the lock wait expiring, not something else the \ + retry classified wrongly; got: {err}" + ); + assert!( + elapsed >= lock_wait * 2, + "the write must wait out more than one lock timeout before giving \ + up; it returned after {elapsed:?}, which is a single attempt" + ); + + holder.rollback().await.unwrap(); + } + + /// Two writers against one repo, in flight at once: every row is delivered + /// by a cursor walk exactly once, and none is skipped. + /// + /// `tokio::spawn` and a `tokio::sync::Barrier`, never a thread: the + /// push-write statement counter is a thread-local that depends on + /// `#[sqlx::test]` building a single-threaded runtime, and a writer moved + /// off that thread splits the count for every test that measures it. + #[sqlx::test] + async fn concurrent_writers_are_each_delivered_exactly_once(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + const WRITERS: usize = 4; + let gate = std::sync::Arc::new(tokio::sync::Barrier::new(WRITERS)); + let mut handles = Vec::new(); + for i in 0..WRITERS { + let db = db.clone(); + let gate = gate.clone(); + handles.push(tokio::spawn(async move { + let event = push_event("repo-race", &format!("refs/heads/b{i}"), SHA_A); + gate.wait().await; + db.insert_repo_push_events(std::slice::from_ref(&event)) + .await + .unwrap(); + event.id + })); + } + let mut written = Vec::new(); + for handle in handles { + written.push(handle.await.unwrap()); + } + + // Page one row at a time, the way a poller that persists its cursor + // does, so a skipped row shows up as a missing id rather than being + // hidden inside one large page. + let mut seen = Vec::new(); + let mut cursor = None; + loop { + let page = db + .list_repo_push_events_keyset("repo-race", cursor, 1) + .await + .unwrap(); + let Some(row) = page.into_iter().next() else { + break; + }; + seen.push(row.id); + cursor = Some(row.seq); + } + + assert_eq!( + seen.len(), + WRITERS, + "the walk returned {} rows for {WRITERS} writers", + seen.len() + ); + let mut unique = seen.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), seen.len(), "no row may be delivered twice"); + let mut expected = written; + expected.sort(); + assert_eq!(unique, expected, "every writer's row must be reachable"); + } + + /// Upgrade path, the load-bearing one: `#[sqlx::test]` hands out a fresh + /// database that runs the entire chain, so the fresh-chain test above stays + /// green even if the v24 statements were appended to an already-applied + /// entry. Only a simulated existing node catches that. + #[sqlx::test] + async fn migration_v24_upgrade_path_adds_claims_head_commit_and_push_events( + pool: sqlx::PgPool, + ) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + // Roll the database back to the state of a node at the highest version + // below 24 that this build declares. + sqlx::query("DROP TABLE IF EXISTS status_claims") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("DROP TABLE IF EXISTS repo_push_events") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("ALTER TABLE pull_requests DROP COLUMN IF EXISTS head_commit") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("DELETE FROM schema_migrations WHERE version >= $1") + .bind(V24) + .execute(&db.pool) + .await + .unwrap(); + + let prior = MIGRATIONS + .iter() + .map(|m| m.version) + .filter(|v| *v < V24) + .max() + .expect("MIGRATIONS must declare a version below 24"); + let recorded_max: i64 = sqlx::query_scalar("SELECT MAX(version) FROM schema_migrations") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!( + recorded_max, prior, + "the simulated node must sit at the highest version below {V24}" + ); + + // A pull request written by the old node, before the column existed. + let legacy = sample_pr("repo-legacy", 1); + db.create_pr(&legacy).await.unwrap(); + + // Upgrade through the real entry point rather than hand-copied SQL. + db.migrate().await.unwrap(); + + assert!( + table_exists(&db, "status_claims").await, + "status_claims missing after upgrade on an existing node" + ); + assert!( + table_exists(&db, "repo_push_events").await, + "repo_push_events missing after upgrade on an existing node" + ); + assert!( + column_exists(&db, "pull_requests", "head_commit").await, + "head_commit missing after upgrade on an existing node" + ); + + let col: (String, String) = sqlx::query_as( + "SELECT data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'pull_requests' AND column_name = 'head_commit'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(col.0, "text"); + assert_eq!(col.1, "YES", "head_commit must be nullable"); + + // The pre-existing row survives with a null head. + let survived = db.get_pr("repo-legacy", 1).await.unwrap().unwrap(); + assert_eq!(survived.head_commit, None); + + // And the runtime paths work against the upgraded schema. + let claim = sample_claim("repo-legacy", SHA_A, "ci/build"); + let seq = db.insert_status_claim(&claim).await.unwrap(); + let claims = db.list_status_claims("repo-legacy", SHA_A).await.unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].id, claim.id); + assert_eq!(claims[0].seq, seq); + assert_eq!(claims[0].target_url, claim.target_url); + insert_push_event( + &db, + "repo-legacy", + "refs/heads/main", + SHA_A, + "2026-08-07T00:00:00+00:00", + ) + .await; + + let recorded: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 24") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(recorded.0, 1, "v24 must be recorded as applied"); + + // Idempotent re-run. + db.migrate().await.unwrap(); + } + + /// A pull request opened before anything pushed to its source branch has no + /// stored head, on both read paths. + #[sqlx::test] + async fn pr_created_before_any_push_has_no_head_commit(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + db.create_pr(&sample_pr("repo-1", 1)).await.unwrap(); + + let one = db.get_pr("repo-1", 1).await.unwrap().unwrap(); + assert_eq!( + one.head_commit, None, + "get_pr must read a null head as None" + ); + + let listed = db.list_prs("repo-1").await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!( + listed[0].head_commit, None, + "list_prs must read a null head as None" + ); + } + + /// The push-side update is one statement keyed on (repo, source branch, + /// open). Prove every arm of that WHERE clause in both directions in one + /// pass: the open PR on the branch moves, an open PR on a different branch + /// does not, and the closed and merged PRs on the *same* branch keep the + /// head they froze at. The status predicate is the whole freeze mechanism, + /// so a missing one shows up here as a moved head on a closed PR. + #[sqlx::test] + async fn set_open_pr_heads_moves_open_prs_on_the_branch_and_freezes_the_rest( + pool: sqlx::PgPool, + ) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + // #1 open on `feature` — must move. + db.create_pr(&sample_pr("repo-1", 1)).await.unwrap(); + // #2 open on `other` — must not move. + let mut other = sample_pr("repo-1", 2); + other.source_branch = "other".to_string(); + db.create_pr(&other).await.unwrap(); + // #3 closed on `feature`, frozen at SHA_A. + let closed = sample_pr("repo-1", 3); + db.create_pr(&closed).await.unwrap(); + set_head_directly(&db, &closed.id, SHA_A).await; + db.close_pr(&closed.id).await.unwrap(); + // #4 merged on `feature`, frozen at SHA_A. + let merged = sample_pr("repo-1", 4); + db.create_pr(&merged).await.unwrap(); + set_head_directly(&db, &merged.id, SHA_A).await; + db.merge_pr(&merged.id, "did:key:zMerger", None) + .await + .unwrap(); + + let moved = db + .set_open_pr_heads("repo-1", "feature", SHA_B) + .await + .unwrap(); + assert_eq!(moved, 1, "only the one open PR on `feature` may be updated"); + + assert_eq!( + db.get_pr("repo-1", 1).await.unwrap().unwrap().head_commit, + Some(SHA_B.to_string()), + "an open PR on the pushed branch must track the new head" + ); + assert_eq!( + db.get_pr("repo-1", 2).await.unwrap().unwrap().head_commit, + None, + "a PR on an unrelated branch must be untouched" + ); + assert_eq!( + db.get_pr("repo-1", 3).await.unwrap().unwrap().head_commit, + Some(SHA_A.to_string()), + "a closed PR's head is frozen" + ); + assert_eq!( + db.get_pr("repo-1", 4).await.unwrap().unwrap().head_commit, + Some(SHA_A.to_string()), + "a merged PR's head is frozen" + ); + } + + /// A same-named branch is the common case across repos, so a missing repo + /// predicate would be invisible to every single-repo assertion above. + #[sqlx::test] + async fn set_open_pr_heads_is_scoped_to_the_named_repo(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + db.create_pr(&sample_pr("repo-1", 1)).await.unwrap(); + db.create_pr(&sample_pr("repo-2", 1)).await.unwrap(); + + let moved = db + .set_open_pr_heads("repo-1", "feature", SHA_B) + .await + .unwrap(); + assert_eq!(moved, 1, "the sibling repo's PR must not be counted"); + + assert_eq!( + db.get_pr("repo-1", 1).await.unwrap().unwrap().head_commit, + Some(SHA_B.to_string()) + ); + assert_eq!( + db.get_pr("repo-2", 1).await.unwrap().unwrap().head_commit, + None, + "a push in one repo must not move a same-named branch's PR elsewhere" + ); + } + + /// The merge stamp writes the head it actually merged, overwriting whatever + /// a racing push left. Passing `None` (a merge that could not resolve the + /// source head) must preserve the stored value rather than null it out. + #[sqlx::test] + async fn merge_pr_stamps_the_supplied_head_and_none_preserves_it(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + let stamped = sample_pr("repo-1", 1); + db.create_pr(&stamped).await.unwrap(); + set_head_directly(&db, &stamped.id, SHA_A).await; + db.merge_pr(&stamped.id, "did:key:zMerger", Some(SHA_B)) + .await + .unwrap(); + let after = db.get_pr("repo-1", 1).await.unwrap().unwrap(); + assert_eq!(after.status, "merged"); + assert_eq!( + after.head_commit, + Some(SHA_B.to_string()), + "the merge stamps the head it merged, not the one the row carried" + ); + + let preserved = sample_pr("repo-1", 2); + db.create_pr(&preserved).await.unwrap(); + set_head_directly(&db, &preserved.id, SHA_A).await; + db.merge_pr(&preserved.id, "did:key:zMerger", None) + .await + .unwrap(); + assert_eq!( + db.get_pr("repo-1", 2).await.unwrap().unwrap().head_commit, + Some(SHA_A.to_string()), + "an unresolvable source head must not null a stored head" + ); + } + + /// The read-side fallback's write, which an UNAUTHENTICATED rollup read can + /// trigger. It has to be self-limiting in SQL, not by handler ordering: it + /// fills an absent head on an open pull request and refuses every other case, + /// so a concurrent push that already recorded a newer head cannot be rolled + /// back to a staler branch tip, and a closed pull request's frozen head (or + /// frozen absence) is never written at all. + #[sqlx::test] + async fn set_pr_head_if_absent_fills_only_an_absent_open_head(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + // Absent head, open: filled. + let fresh = sample_pr("repo-1", 1); + db.create_pr(&fresh).await.unwrap(); + assert_eq!(db.set_pr_head_if_absent(&fresh.id, SHA_A).await.unwrap(), 1); + assert_eq!( + db.get_pr("repo-1", 1).await.unwrap().unwrap().head_commit, + Some(SHA_A.to_string()) + ); + + // Head already present: refused, and the stored value stands. + assert_eq!( + db.set_pr_head_if_absent(&fresh.id, SHA_B).await.unwrap(), + 0, + "a stored head must never be overwritten by a read-side fallback" + ); + assert_eq!( + db.get_pr("repo-1", 1).await.unwrap().unwrap().head_commit, + Some(SHA_A.to_string()), + "a stored head must never be overwritten by a read-side fallback" + ); + + // Absent head but closed: refused. + let closed = sample_pr("repo-1", 2); + db.create_pr(&closed).await.unwrap(); + db.close_pr(&closed.id).await.unwrap(); + assert_eq!( + db.set_pr_head_if_absent(&closed.id, SHA_A).await.unwrap(), + 0 + ); + assert_eq!( + db.get_pr("repo-1", 2).await.unwrap().unwrap().head_commit, + None, + "a closed pull request's head must not be back-filled" + ); + + // Absent head but merged: refused. + let merged = sample_pr("repo-1", 3); + db.create_pr(&merged).await.unwrap(); + db.merge_pr(&merged.id, "did:key:zMerger", None) + .await + .unwrap(); + assert_eq!( + db.set_pr_head_if_absent(&merged.id, SHA_A).await.unwrap(), + 0 + ); + assert_eq!( + db.get_pr("repo-1", 3).await.unwrap().unwrap().head_commit, + None + ); + } + + /// The projection orders on `seq`, so prove the database hands out strictly + /// increasing values in insertion order rather than assuming it. + #[sqlx::test] + async fn status_claim_seq_is_monotonic_within_a_tuple(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + let mut seqs = Vec::new(); + let mut ids = Vec::new(); + for state in ["pending", "failure", "success"] { + let mut claim = sample_claim("repo-1", SHA_A, "ci/build"); + claim.state = state.to_string(); + // Identical server timestamp on every row: nothing but `seq` can + // recover the insertion order. + claim.created_at = "2026-08-07T00:00:00+00:00".to_string(); + seqs.push(db.insert_status_claim(&claim).await.unwrap()); + ids.push(claim.id); + } + + assert!( + seqs[0] < seqs[1] && seqs[1] < seqs[2], + "seq must strictly increase in insertion order, got {seqs:?}" + ); + + let claims = db.list_status_claims("repo-1", SHA_A).await.unwrap(); + assert_eq!( + claims.iter().map(|c| c.id.clone()).collect::>(), + ids, + "claims must read back in seq order" + ); + assert_eq!( + claims.iter().map(|c| c.seq).collect::>(), + seqs, + "the stored seq must be the one the insert reported" + ); + } + + /// The replay containment lives in the schema, not in the Rust that reads it. + /// + /// The capped insert probes for the digest before it writes, but that probe + /// is a fast path: it is a read, and a read cannot exclude a writer it has + /// not seen commit. This drives the raw insert past the probe entirely, so + /// what refuses is the unique index or nothing. Asserted here rather than + /// only through the handler because the handler's own replay tests stay green + /// on the probe alone, which is exactly how a guard rots into decoration. + #[sqlx::test] + async fn the_schema_refuses_a_second_claim_with_the_same_request_digest(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + let first = sample_claim("repo-digest", SHA_A, "ci/build"); + db.insert_status_claim(&first).await.unwrap(); + + // Different row in every other respect: a fresh id, another commit, a + // different context and verdict. Only the request identity repeats. + let mut replay = sample_claim("repo-digest", SHA_B, "ci/other"); + replay.state = "failure".to_string(); + replay.request_digest = first.request_digest.clone(); + + let err = db + .insert_status_claim(&replay) + .await + .expect_err("the database must refuse a second row carrying a digest it already has"); + let text = err.to_string(); + assert!( + text.contains("uq_status_claims_request_digest") || text.contains("duplicate key"), + "the refusal must come from the request-digest uniqueness, got: {text}" + ); + + let rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM status_claims WHERE repo_id = 'repo-digest'") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(rows, 1, "the refused write must leave no row"); + } + + /// The caps have to hold against writers racing each other, which is the only + /// case they are actually for: a sequential caller hitting a cap is a + /// misbehaving client, a burst of them is the fan-out the bound exists to + /// stop. Eight writers are released together onto a tuple seeded to `cap - 1`, + /// so exactly one of them may win. Counting rows under READ COMMITTED and then + /// inserting is not enough: each writer's count is taken before any of the + /// others commit, so every one of them reads `cap - 1` and inserts, and the + /// table ends up over the bound by the concurrency. + #[sqlx::test] + async fn capped_insert_holds_the_bound_against_concurrent_writers(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool.clone()); + db.migrate().await.unwrap(); + + const CAP: i64 = 3; + const WRITERS: usize = 8; + // Only the per-tuple bound is under test; the other two are left far out + // of reach so the refusal cannot come from them. + fn caps() -> super::ClaimCaps { + super::ClaimCaps { + per_tuple: CAP, + contexts_per_commit: 1_000, + per_repo_window: 1_000_000, + } + } + + for _ in 0..CAP - 1 { + db.insert_status_claim(&sample_claim("repo-race", SHA_A, "ci/build")) + .await + .unwrap(); + } + + let gate = std::sync::Arc::new(tokio::sync::Barrier::new(WRITERS)); + let mut handles = Vec::new(); + for _ in 0..WRITERS { + let db = db.clone(); + let gate = gate.clone(); + handles.push(tokio::spawn(async move { + let claim = sample_claim("repo-race", SHA_A, "ci/build"); + // Every writer reaches the capped insert at the same moment, so + // the count-then-insert windows overlap instead of queueing. + gate.wait().await; + db.insert_status_claim_capped(&claim, &caps()) + .await + .unwrap() + })); + } + + let mut accepted = 0; + for handle in handles { + if let super::ClaimInsert::Inserted(_) = handle.await.unwrap() { + accepted += 1; + } + } + + let rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM status_claims WHERE repo_id = 'repo-race'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + rows, CAP, + "the bound is {CAP} rows; {WRITERS} concurrent writers left {rows}" + ); + assert_eq!( + accepted, 1, + "exactly one writer may be told its claim was accepted" + ); + } + + /// The public commit-status read must not pull the signature material off + /// disk. `signing_string` and `request_body` are write-time provenance that + /// no read surface renders, and together they run to kilobytes per claim, so + /// selecting them costs the anonymous read path on every request. Asserted on + /// the query text, because a widened column list is invisible in the response + /// body: the extra bytes are read and then dropped. + #[test] + fn projection_selects_no_signature_columns() { + for banned in [ + "signature", + "signature_input", + "signing_string", + "request_body", + ] { + assert!( + !super::LATEST_STATUS_CLAIMS_SQL.contains(banned), + "the commit-status projection must not select `{banned}`: it is \ + write-time provenance no read surface renders" + ); + } + } + #[sqlx::test] async fn dequeue_stamps_attempted_at_on_every_row_it_hands_out(pool: sqlx::PgPool) { // The stamp is what stops a deferred row from holding the window, and diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index f4c0d3e3..60304411 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -14,7 +14,7 @@ use tracing::Level; use crate::api::{ agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles, - protect, pulls, register, replicas, repos, resolve, stars, tasks, visibility, webhooks, + protect, pulls, register, replicas, repos, resolve, stars, status, tasks, visibility, webhooks, }; use crate::auth; use crate::rate_limit; @@ -183,6 +183,50 @@ pub fn build_router(state: AppState) -> Router { state.clone(), ); + // ── Status claim writes — signature, plus the per-DID throttle AND the + // per-IP flood brake `creation_routes` carries. Not in `write_routes`, which + // has neither: every call here appends a row, so an unthrottled writer grows + // the claim log on demand and the three per-repo caps would be the only bound. + let status_write_routes = add_auth_layers( + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/statuses/{sha}", + post(status::create_status), + ) + .layer(middleware::from_fn(rate_limit::rate_limit_by_did)) + .layer(axum::Extension(state.rate_limiter.clone())), + state.clone(), + ) + // The one route that persists the signed request body, so the one route + // whose signature material carries it. Applied OUTSIDE `add_auth_layers` + // (outermost = runs first) because `require_signature` reads this marker to + // decide whether to carry the body; a layer added inside the auth pair runs + // after the middleware and is never seen. `create_status` refuses an absent + // body rather than storing an empty column, so getting this order wrong + // fails loudly instead of quietly writing unverifiable claims. + .layer(axum::Extension(auth::PersistsSignedBody)) + .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) + .layer(axum::Extension(rate_limit::IpRateLimiter { + limiter: state.create_ip_rate_limiter.clone(), + trust: state.push_limiter_trust, + })); + + // ── Status claim reads — open, behind `optional_signature` so the handler + // sees the caller when one signs and still answers a public repo for an + // anonymous reader. Without the layer the handler's optional auth extension + // is None for EVERY caller, including the owner, and every private repo + // reads as not-found. + let status_read_routes = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/commits/{sha}/status", + get(status::commit_status), + ) + .route( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/status", + get(status::pull_request_status), + ) + .layer(middleware::from_fn(auth::optional_signature)); + // Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git // routes only — all other API routes keep axum's default 2 MB cap. // HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb @@ -373,6 +417,10 @@ pub fn build_router(state: AppState) -> Router { "/api/v1/repos/{owner}/{repo}/events", get(events::list_repo_events), ) + .route( + "/api/v1/repos/{owner}/{repo}/push-events", + get(events::list_repo_push_events), + ) .route("/api/v1/agents", get(agents::list_agents)) .route("/api/v1/agents/{did}", get(agents::show_agent)) .route("/api/v1/agents/{did}/trust", get(agents::get_trust)) @@ -469,6 +517,8 @@ pub fn build_router(state: AppState) -> Router { .merge(profile_write_routes) .merge(creation_routes) .merge(write_routes) + .merge(status_write_routes) + .merge(status_read_routes) .merge(git_write_routes) .merge(git_read_routes) .merge(issue_write_routes) diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index be6fb7b8..9adc86bf 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -109,6 +109,153 @@ pub(crate) fn signed_request_as(did: &str, method: Method, uri: &str, body: Body .expect("request builder") } +/// Slice a region out of a module's own source (obtained with `include_str!`) +/// and drop full-line `//` comments from it. +/// +/// Several guards in `api/` assert that a piece of PRODUCTION code does or does +/// not contain something: that the receive-pack handler really calls the +/// push-event recorder, that the status module never acquires the repository. +/// Each guard carried its own copy of this parse, and three copies of a parser +/// is three places for the slicing to drift apart while every guard still reads +/// green. +/// +/// `start` and `end` are literal anchors. The region runs from the first +/// occurrence of `start` (inclusive; the top of the file when `None`) to the +/// next occurrence of `end` (exclusive; end of file when `None`). +/// +/// Returns `None` when an anchor is missing, rather than silently falling back +/// to a larger or empty region. Each caller supplies its own message for that +/// case and keeps its own assertion that the region really covers what it claims +/// to, which is what stops a bug in this one helper from turning every guard +/// that uses it into a vacuous pass. +/// +/// Comment lines are stripped so that a doc comment naming the very call a guard +/// searches for cannot stand in for the call itself. +pub(crate) fn scrape_source_region( + src: &str, + start: Option<&str>, + end: Option<&str>, +) -> Option { + let from = match start { + Some(anchor) => src.find(anchor)?, + None => 0, + }; + let rest = &src[from..]; + let to = match end { + // Search past the first CHARACTER, so an `end` anchor that also matches + // at the very start of the region cannot collapse it to nothing. A fixed + // one-byte skip does the same job on ASCII and lands mid-character on a + // multibyte first character, where the slice fails and the helper answers + // `None`, which is indistinguishable, to every caller, from a missing + // anchor. + Some(anchor) => { + let skip = rest.chars().next().map_or(0, char::len_utf8); + rest.get(skip..)?.find(anchor)? + skip + } + None => rest.len(), + }; + Some( + rest[..to] + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"), + ) +} + +#[cfg(test)] +mod scrape_source_region_tests { + use super::scrape_source_region; + + const SRC: &str = "prelude line\nfn target() {\n // calls_the_thing()\n calls_the_thing();\n}\nfn later() {\n calls_the_thing();\n}\n"; + + #[test] + fn stops_at_the_end_anchor_so_a_later_function_cannot_satisfy_a_guard() { + let body = scrape_source_region(SRC, Some("fn target()"), Some("\n}")).unwrap(); + assert!(body.contains("calls_the_thing();")); + assert!( + !body.contains("fn later()"), + "the region must stop at the first column-zero brace, got: {body}" + ); + } + + #[test] + fn strips_full_line_comments_so_a_doc_comment_cannot_stand_in_for_code() { + let commented = "fn target() {\n // calls_the_thing();\n}\n"; + let body = scrape_source_region(commented, Some("fn target()"), Some("\n}")).unwrap(); + assert!( + !body.contains("calls_the_thing"), + "a commented-out call must not survive the scrape, got: {body}" + ); + } + + #[test] + fn no_start_anchor_reads_from_the_top_of_the_file() { + let body = scrape_source_region(SRC, None, Some("fn later()")).unwrap(); + assert!(body.starts_with("prelude line")); + assert!(!body.contains("fn later()")); + } + + #[test] + fn no_end_anchor_reads_to_the_end_of_the_file() { + let body = scrape_source_region(SRC, Some("fn later()"), None).unwrap(); + assert!(body.trim_end().ends_with('}')); + } + + /// A missing anchor is None, never a silently wider or empty region: that is + /// what lets each caller keep its own "the thing I meant to scan is gone" + /// message instead of asserting against whatever the fallback produced. + #[test] + fn a_missing_anchor_is_none_rather_than_a_fallback_region() { + assert!(scrape_source_region(SRC, Some("fn absent()"), Some("\n}")).is_none()); + assert!(scrape_source_region(SRC, Some("fn target()"), Some("~absent~")).is_none()); + } + + /// A region whose first character is multibyte is scraped, not reported + /// missing. + /// + /// The end-anchor search skips the region's first CHARACTER. Skipping a + /// fixed byte instead lands mid-character here, the slice fails, and the + /// helper answers `None`, which every caller reads as "the anchor is gone" + /// and reports as a missing region. A guard that says "not found" + /// for input that is present is worse than one that fails loudly, and the + /// five callers sharing this helper all inherit it. + #[test] + fn a_multibyte_first_character_does_not_read_as_a_missing_anchor() { + let src = "é prelude\nfn target() {\n body();\n}\nfn later() {}\n"; + + let body = scrape_source_region(src, None, Some("\n}")) + .expect("a region starting on a multibyte character must be scraped"); + assert!( + body.starts_with("é prelude") && body.contains("body();"), + "the region must cover the multibyte start through the end anchor, got: {body}" + ); + assert!( + !body.contains("fn later()"), + "the region must still stop at the end anchor, got: {body}" + ); + + // The same through a multibyte START anchor, so the skip is measured + // from the region rather than from the top of the file. + let body = scrape_source_region(src, Some("é"), Some("\n}")) + .expect("a multibyte start anchor must be scraped"); + assert!(body.starts_with('é'), "got: {body}"); + } + + /// The end anchor is searched past the region's first character, so a + /// region whose own start also matches the end anchor is not collapsed to + /// nothing. + #[test] + fn an_end_anchor_matching_at_the_start_does_not_collapse_the_region() { + let src = "\n}\nfn target() {\n body();\n}\n"; + let body = scrape_source_region(src, None, Some("\n}")).unwrap(); + assert!( + body.contains("fn target()") && body.contains("body();"), + "got: {body}" + ); + } +} + #[cfg(test)] mod tests { use super::*;