From 04263dbd495531468f43d99be28d8ffc7752b50f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:07:35 -0500 Subject: [PATCH 01/13] feat(db): add migration v24 for status claims, PR head, and push events Schema for the commit-status surface, all three objects in one versioned entry rather than three: an append-only status_claims table, a nullable head_commit column on pull_requests, and repo_push_events for the catch-up poll surface. Version 24 rather than 18 because origin/pr-173 and origin/fix/issue-135-ipfs-cid-tree-gate both claim through 23, and the migration runner keys only on version without comparing the recorded name, so a collision is a silent full skip rather than an error. status_claims orders on a database-assigned bigserial seq, not the uuid id. A uuid v4 is random, so using it as the timestamp tiebreak would let a retried pending claim beat a success roughly half the time when both land in the same rfc3339 tick. Each claim stores the producer's RFC 9421 signature headers and the canonical signed bytes. Append-only preserves rows, but only stored signature material preserves provenance, and a claim nobody can verify after the request is gone is not history a signed-attestation substrate can adopt later. The table is repo_push_events, not push_events: the latter already exists in v1 for agent trust scoring, so a second CREATE TABLE under IF NOT EXISTS is a silent no-op whose follow-on index then fails. The upgrade-path test is the load-bearing one. A sqlx::test provisions an empty database and runs the whole array, so the fresh-chain tests stay green even when DDL is appended to an already-applied entry. --- crates/gitlawb-node/src/api/pulls.rs | 1 + crates/gitlawb-node/src/db/mod.rs | 505 ++++++++++++++++++++++++++- 2 files changed, 504 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/api/pulls.rs b/crates/gitlawb-node/src/api/pulls.rs index 26be6109..a95e2aec 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, }; diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index c6ff644b..82823074 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -81,10 +81,43 @@ 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`. +#[derive(Debug, Clone, Serialize, 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. + 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` and the canonical bytes they covered. 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. + pub signature: String, + pub signature_input: String, + pub signed_payload: Vec, + /// Server-assigned rfc3339 timestamp. Display data only; ordering is `seq`. + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrReview { pub id: String, @@ -901,6 +934,69 @@ 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, + signed_payload BYTEA NOT NULL, + created_at TEXT NOT NULL + )"#, + // 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)", + // 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. + r#"CREATE TABLE IF NOT EXISTS repo_push_events ( + id TEXT NOT NULL PRIMARY KEY, + repo_id TEXT NOT NULL, + ref_name TEXT NOT NULL, + after_sha TEXT NOT NULL, + created_at TEXT NOT NULL + )"#, + // Keyset paging cursor: (created_at, id) ascending within a repo, so + // a poller with a since-cursor pages forward with an id tiebreak + // when two pushes share a timestamp. + "CREATE INDEX IF NOT EXISTS idx_repo_push_events_cursor ON repo_push_events(repo_id, created_at, id)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1773,7 +1869,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 +1881,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) @@ -1968,6 +2064,63 @@ impl Db { } } +// ── Status Claims ───────────────────────────────────────────────────────────── + +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. + // Consumed by the write handler in U3; tests are the only caller until then. + #[allow(dead_code)] + pub async fn insert_status_claim(&self, claim: &StatusClaim) -> Result { + let row = sqlx::query( + "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, target_url, description, + producer_did, authorizing_did, signature, signature_input, + signed_payload, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING seq", + ) + .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(&claim.producer_did) + .bind(&claim.authorizing_did) + .bind(&claim.signature) + .bind(&claim.signature_input) + .bind(&claim.signed_payload) + .bind(&claim.created_at) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("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, + signed_payload,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()) + } +} + // ── Webhooks ────────────────────────────────────────────────────────────────── impl Db { @@ -2867,11 +3020,31 @@ 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"), + signed_payload: r.get("signed_payload"), + 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 +4013,334 @@ 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"; + + 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(), + signed_payload: b"POST /api/v1/repos/o/r/statuses/aaa\n{}".to_vec(), + 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.signed_payload, full.signed_payload); + 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); + } + + /// 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 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" + ); + } + #[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 From af178314298a1cec72319bc5df0bc1f7b4269cc2 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:17:32 -0500 Subject: [PATCH 02/13] feat(pulls): track the source branch head on open pull requests The rollup needs an honest target commit, and a pull request row stores only branch names. Resolving the branch at read time is racy (the SHA moves between the resolve and the rollup) and gives a merged or deleted-branch pull request no answer at all, since the merge commit is computed and never persisted. The receive-pack path now sets head_commit for open pull requests whose source branch matches an updated ref, one statement keyed on repo, branch, and open status. Excluding closed and merged rows from the WHERE clause is what freezes the value. The merge path stamps the source head it actually consumed, which covers a push landing between the handler reading the row and committing the merge. A ref arriving on the push path is a full refs/heads/ name while source_branch stores the bare branch, so the normalisation is tested directly; a silently non-matching WHERE clause would make this a no-op that still reads green. Branch deletions are skipped rather than stored, since 40 zeros is a target no commit resolves to. The scenario tests drive the update helper directly, because a real receive-pack POST needs a pack file. That proves the mechanism and says nothing about the sink, so a source-scrape test pins the call site in the push handler. Removing that call was verified to turn it red. --- crates/gitlawb-node/src/api/pulls.rs | 174 ++++++++++++- crates/gitlawb-node/src/api/repos.rs | 351 +++++++++++++++++++++++++++ crates/gitlawb-node/src/db/mod.rs | 178 +++++++++++++- 3 files changed, 700 insertions(+), 3 deletions(-) diff --git a/crates/gitlawb-node/src/api/pulls.rs b/crates/gitlawb-node/src/api/pulls.rs index a95e2aec..853cdb84 100644 --- a/crates/gitlawb-node/src/api/pulls.rs +++ b/crates/gitlawb-node/src/api/pulls.rs @@ -217,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, @@ -230,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( @@ -425,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..0e7d78c4 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1010,6 +1010,12 @@ 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; + } + // Fire push webhooks — one per ref update if !ref_updates.is_empty() { let base_url = state @@ -1653,6 +1659,50 @@ struct RefUpdate { ref_name: String, } +/// 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 ref update, each keyed on (repo, source branch, open) — no +/// per-PR probing, no repo listing, no filesystem access, and the cost does not +/// grow with the number of open pull requests. 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]) { + 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; + }; + if let Err(e) = db.set_open_pr_heads(repo_id, branch, &update.new_sha).await { + tracing::warn!( + err = %e, + ref_name = %update.ref_name, + "failed to update stored pull request heads for a pushed branch" + ); + } + } +} + /// Parse git receive-pack pkt-line ref updates from the request body. /// Format per line: `<40-hex-old> <40-hex-new> [NUL capabilities]\n` fn parse_ref_updates(body: &[u8]) -> Vec { @@ -2552,6 +2602,307 @@ 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 start = src + .find("fn git_receive_pack(") + .expect("git_receive_pack not found (renamed or removed?)"); + let rest = &src[start..]; + let end = rest.find("\n}").map(|i| i + 2).unwrap_or(rest.len()); + let body: String = rest[..end] + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + 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())); + } + /// 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 diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 82823074..23a97459 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -1891,16 +1891,56 @@ 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. + pub async fn set_open_pr_heads( + &self, + repo_id: &str, + branch: &str, + new_sha: &str, + ) -> Result { + let result = sqlx::query( + "UPDATE pull_requests SET head_commit=$1 + WHERE repo_id=$2 AND source_branch=$3 AND status='open'", + ) + .bind(new_sha) + .bind(repo_id) + .bind(branch) + .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(()) @@ -4021,6 +4061,18 @@ mod migration_tests { 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 { @@ -4304,6 +4356,128 @@ mod migration_tests { ); } + /// 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 projection orders on `seq`, so prove the database hands out strictly /// increasing values in insertion order rather than assuming it. #[sqlx::test] From ac31868205e4138a7457e11f9f0a56a9b2948381 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:16:38 -0500 Subject: [PATCH 03/13] feat(api): add the owner-gated commit status write path POST /api/v1/repos/{owner}/{repo}/statuses/{sha} appends a status claim. Authorization runs 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, so a caller who cannot read the repo cannot learn it exists. Loading the repo and comparing the owner would answer 403 there and turn the endpoint into an existence oracle. That case is tested directly, since it is what separates a correct implementation from a plausible one. Three caps, all evaluated in the insert transaction and all refusing with 429: per producer and context, distinct contexts per commit, and total rows per repo. The per-tuple cap alone bounds nothing, because the context string and the SHA are both caller-chosen and the SHA is never existence-checked. The route group also carries the per-DID and per-IP limiters that the creation routes use; the plain write group carries neither and this endpoint appends on every call. Wider than the endpoint: require_signature now attaches the verified RFC 9421 material as a request extension, so a handler can persist what the signature actually covered. Rebuilding the canonical string in the handler would store a string nobody verified, and none of it survives the request. The write fails closed with a 500 when that material is absent, rather than storing an empty payload, because a claim nobody can re-verify is not history the later attestation substrate can adopt. Nine injected-defect mutations confirm the gates are load-bearing rather than decorative, including that removing the route group's merge leaves a group that compiles, lints, and does not exist at runtime. --- crates/gitlawb-node/src/api/mod.rs | 10 + crates/gitlawb-node/src/api/status.rs | 730 ++++++++++++++++++++++++++ crates/gitlawb-node/src/auth/mod.rs | 26 + crates/gitlawb-node/src/db/mod.rs | 121 ++++- crates/gitlawb-node/src/server.rs | 23 +- 5 files changed, 892 insertions(+), 18 deletions(-) create mode 100644 crates/gitlawb-node/src/api/status.rs diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 71bfa43c..7f2e1412 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; @@ -175,6 +176,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.rs"); let protect = include_str!("protect.rs"); let visibility = include_str!("visibility.rs"); let profiles = include_str!("profiles.rs"); @@ -195,6 +197,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) @@ -490,6 +499,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.rs"), "status.rs"), (include_str!("visibility.rs"), "visibility.rs"), (include_str!("webhooks.rs"), "webhooks.rs"), ]; diff --git a/crates/gitlawb-node/src/api/status.rs b/crates/gitlawb-node/src/api/status.rs new file mode 100644 index 00000000..87f447fc --- /dev/null +++ b/crates/gitlawb-node/src/api/status.rs @@ -0,0 +1,730 @@ +//! Commit status claim write path. +//! +//! POST /api/v1/repos/:owner/:repo/statuses/:sha — append one claim (owner only) +//! +//! 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. + +use axum::extract::{Extension, Path, State}; +use axum::http::StatusCode; +use axum::Json; +use chrono::Utc; +use serde::Deserialize; +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; +/// Total claim rows for one repo. 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. +const MAX_CLAIMS_PER_REPO: 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"]; + +const MAX_CONTEXT_CHARS: usize = 255; +const MAX_TARGET_URL_CHARS: usize = 2048; +const MAX_DESCRIPTION_CHARS: usize = 1024; + +/// 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" + ))); + }; + let (signature, signature_input, signed_payload) = ( + material.signature, + material.signature_input, + material.signing_string.into_bytes(), + ); + + 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, + signed_payload, + 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: MAX_CLAIMS_PER_REPO, + }; + match state.db.insert_status_claim_capped(&claim, &caps).await? { + crate::db::ClaimInsert::Inserted(seq) => { + Ok((StatusCode::CREATED, Json(StatusClaim { seq, ..claim }))) + } + crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!( + "claim limit reached for {which}" + ))), + } +} + +/// 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(), + )) + } +} + +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 { + 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"; + + 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(crate::auth::SignatureMaterial { + signature: "sig1=:dGVzdA==:".to_string(), + signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" + .to_string(), + signing_string: "\"@method\": POST".to_string(), + }); + req + } + + 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_eq!(claims[0].signature, "sig1=:dGVzdA==:"); + assert!(claims[0].signature_input.starts_with("sig1=(")); + assert_eq!(claims[0].signed_payload, b"\"@method\": POST"); + } + + /// 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 row limit across distinct well-formed SHAs; 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).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); + } + + /// 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" + ); + } + + /// 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" + ); + } + + /// `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, signed_payload, created_at) + SELECT md5(random()::text || g::text), $1, $2, 'success', $3, $4, $4, + '', '', ''::bytea, '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. + async fn seed_claims_across_shas(pool: &PgPool, repo_id: &str, producer: &str, n: i64) { + sqlx::query( + "INSERT INTO status_claims + (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, + signature, signature_input, signed_payload, 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, '2026-01-01T00:00:00Z' + FROM generate_series(1, $3) g", + ) + .bind(repo_id) + .bind(producer) + .bind(n) + .execute(pool) + .await + .expect("seed claims across shas"); + } +} diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 720fb3ae..e4ddd328 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -17,6 +17,25 @@ 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, +} + /// Whether `caller` is authorized to push to `record`. /// /// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical @@ -242,6 +261,13 @@ pub async fn require_signature(request: Request, next: Next) -> Response { 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(SignatureMaterial { + signature: sig_header, + signature_input: sig_input, + signing_string, + }); next.run(request).await } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 23a97459..b78b5cb3 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2106,21 +2106,19 @@ impl Db { // ── Status Claims ───────────────────────────────────────────────────────────── -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. - // Consumed by the write handler in U3; tests are the only caller until then. - #[allow(dead_code)] - pub async fn insert_status_claim(&self, claim: &StatusClaim) -> Result { - let row = sqlx::query( - "INSERT INTO status_claims - (id, repo_id, commit_sha, state, context, target_url, description, - producer_did, authorizing_did, signature, signature_input, - signed_payload, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - RETURNING seq", - ) +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, + signed_payload, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING seq"; + +/// The bind chain for [`INSERT_STATUS_CLAIM_SQL`], shared by the plain and the +/// capped insert so the two can never bind different columns. +fn status_claim_insert( + claim: &StatusClaim, +) -> sqlx::query::Query<'_, sqlx::Postgres, sqlx::postgres::PgArguments> { + sqlx::query(INSERT_STATUS_CLAIM_SQL) .bind(&claim.id) .bind(&claim.repo_id) .bind(&claim.commit_sha) @@ -2134,11 +2132,100 @@ impl Db { .bind(&claim.signature_input) .bind(&claim.signed_payload) .bind(&claim.created_at) - .fetch_one(&self.pool) - .await?; +} + +/// 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, + /// Total claim rows for one repo. + pub per_repo: i64, +} + +/// Outcome of a capped append. `CapExceeded` names the bound that refused, for +/// the caller's 429 message; it is not a database error. +pub enum ClaimInsert { + Inserted(i64), + 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(claim).fetch_one(&self.pool).await?; Ok(row.get::("seq")) } + /// Append one claim only if all three caps still admit it, with the counts + /// and the insert in one transaction so a concurrent writer cannot slip an + /// extra row past a bound that was counted a moment earlier. + pub async fn insert_status_claim_capped( + &self, + claim: &StatusClaim, + caps: &ClaimCaps, + ) -> Result { + let mut tx = self.pool.begin().await?; + + let tuple_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM status_claims + WHERE repo_id=$1 AND commit_sha=$2 AND producer_did=$3 AND context=$4", + ) + .bind(&claim.repo_id) + .bind(&claim.commit_sha) + .bind(&claim.producer_did) + .bind(&claim.context) + .fetch_one(&mut *tx) + .await?; + 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. + let row = sqlx::query( + "SELECT count(DISTINCT context) AS contexts, + COALESCE(bool_or(context = $3), false) AS present + FROM status_claims WHERE repo_id=$1 AND commit_sha=$2", + ) + .bind(&claim.repo_id) + .bind(&claim.commit_sha) + .bind(&claim.context) + .fetch_one(&mut *tx) + .await?; + let contexts: i64 = row.get("contexts"); + let present: bool = row.get("present"); + if !present && contexts >= caps.contexts_per_commit { + return Ok(ClaimInsert::CapExceeded( + "distinct contexts for this commit", + )); + } + + let repo_rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM status_claims WHERE repo_id=$1") + .bind(&claim.repo_id) + .fetch_one(&mut *tx) + .await?; + if repo_rows >= caps.per_repo { + return Ok(ClaimInsert::CapExceeded("claims for this repository")); + } + + let inserted = status_claim_insert(claim).fetch_one(&mut *tx).await?; + 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)] diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index f4c0d3e3..06331a8a 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,26 @@ 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(), + ) + .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, + })); + // 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 @@ -469,6 +489,7 @@ pub fn build_router(state: AppState) -> Router { .merge(profile_write_routes) .merge(creation_routes) .merge(write_routes) + .merge(status_write_routes) .merge(git_write_routes) .merge(git_read_routes) .merge(issue_write_routes) From 6663fed0640801ab943e3adb2c6f52f70137c787 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:53:53 -0500 Subject: [PATCH 04/13] feat(api): serve the combined commit status behind the visibility gate GET /api/v1/repos/{owner}/{repo}/commits/{sha}/status returns the projection over the claim history, computed per read rather than materialized. A materialized index gated only at write time is how this codebase previously kept serving a repo's slug, owner, branches, and SHAs after it was made private, so the regression test writes a claim while public, tightens visibility, and asserts an anonymous read finds no trace of it. Three outcomes stay distinct, which is the point of the unit. A caller who cannot read the repo gets the repo's own not-found, compared byte for byte against a missing repo so the endpoint is not an existence oracle. A commit nobody has reported on gets 200 with the explicit pending zero-count body, asserted exactly so no client can render it as green. A failed lookup propagates as a 500 carrying the db_error code, never as an empty success; nothing on this path uses ok-or-default. Latest-per-context is decided by the database-assigned sequence, not by the timestamp and not by the uuid. The ordering test seeds the winning row with the lexically smaller id and a far older timestamp, so it fails under either wrong key rather than passing by luck. The projection filters to claims authorized by the current owner, so an ownership transfer drops the prior owner's claims from the answer while the history keeps them. That filter is a set-membership test inside the query, which is the constraint the delegated-capability follow-on inherits. It expands the owner DID into its equivalent forms, and a test runs that expansion against did_matches over a ten-case matrix so the two cannot drift apart silently. --- crates/gitlawb-node/src/api/mod.rs | 4 + crates/gitlawb-node/src/api/status.rs | 719 +++++++++++++++++++++++++- crates/gitlawb-node/src/db/mod.rs | 39 ++ crates/gitlawb-node/src/server.rs | 13 + 4 files changed, 773 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 7f2e1412..3147a509 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -226,6 +226,10 @@ mod authz_guard { (protect, "list_protected_branches", "authorize_repo_read("), (labels, "list_labels", "authorize_repo_read("), (events, "list_repo_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("), // Bucket C — signer-self: the acting DID is matched/bound to auth.0 (tasks, "create_task", "did_matches("), (tasks, "claim_task", "did_matches("), diff --git a/crates/gitlawb-node/src/api/status.rs b/crates/gitlawb-node/src/api/status.rs index 87f447fc..db18e202 100644 --- a/crates/gitlawb-node/src/api/status.rs +++ b/crates/gitlawb-node/src/api/status.rs @@ -1,6 +1,7 @@ -//! Commit status claim write path. +//! 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. @@ -9,7 +10,7 @@ use axum::extract::{Extension, Path, State}; use axum::http::StatusCode; use axum::Json; use chrono::Utc; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::AuthenticatedDid; @@ -124,6 +125,116 @@ pub async fn create_status( } } +/// 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)?; + // 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. + let authorizing = authorizing_did_variants(&record.owner_did); + let claims = state + .db + .latest_status_claims(&record.id, &commit_sha, &authorizing) + .await?; + + let statuses: Vec = 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(); + + Ok(Json(CombinedStatus { + state: combined_state(&statuses).to_string(), + sha: commit_sha, + total_count: statuses.len(), + statuses, + reported_only: true, + })) +} + +/// 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" + } +} + +/// The set of stored `authorizing_did` values that count as the current owner, +/// reproducing [`crate::api::did_matches`] as a set so the filter can be a +/// membership test inside the projection query. `did:key` collapses its full and +/// bare forms; every other method matches exactly, because a bare base58 id must +/// never match across methods. +fn authorizing_did_variants(owner_did: &str) -> Vec { + let key_id = owner_did.strip_prefix("did:key:").unwrap_or(owner_did); + if key_id.contains(':') { + return vec![owner_did.to_string()]; + } + let full = format!("did:key:{key_id}"); + if full == owner_did { + vec![owner_did.to_string(), key_id.to_string()] + } else { + vec![owner_did.to_string(), full] + } +} + /// 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 { @@ -679,6 +790,610 @@ mod tests { ); } + // ── 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, signed_payload, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'','',''::bytea,$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); + } + + /// 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 set the projection filters on reproduces `did_matches` exactly, in both + /// directions and across the cross-method cases: the filter has to live inside + /// the query, so it cannot call the helper itself. + #[test] + fn authorizing_variants_agree_with_did_matches() { + let cases = [ + ("did:key:zABC", "did:key:zABC"), + ("did:key:zABC", "zABC"), + ("zABC", "did:key:zABC"), + ("zABC", "zABC"), + ("did:key:zABC", "did:key:zXYZ"), + ("did:key:zABC", "did:gitlawb:zABC"), + ("did:gitlawb:zABC", "did:key:zABC"), + ("did:web:example.com", "did:web:example.com"), + ("did:web:example.com", "example.com"), + ("", ""), + ]; + for (owner, candidate) in cases { + let in_set = super::authorizing_did_variants(owner) + .iter() + .any(|v| v == candidate); + assert_eq!( + in_set, + crate::api::did_matches(candidate, owner), + "variant set for owner {owner:?} disagrees with did_matches on {candidate:?}" + ); + } + } + /// `n` claims on one (repo, commit, producer, context) tuple, inserted in one /// statement so the cap tests stay fast. async fn seed_claims( diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index b78b5cb3..fd6f74eb 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2246,6 +2246,45 @@ impl Db { .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 whose authorizing + /// DID is in `authorizing_dids`. + /// + /// 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 a set-membership test 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 set, never move + /// the test into the read loop. + pub async fn latest_status_claims( + &self, + repo_id: &str, + commit_sha: &str, + authorizing_dids: &[String], + ) -> Result> { + let rows = sqlx::query( + "SELECT * FROM ( + SELECT DISTINCT ON (producer_did, context) + id,seq,repo_id,commit_sha,state,context,target_url,description, + producer_did,authorizing_did,signature,signature_input, + signed_payload,created_at + FROM status_claims + WHERE repo_id=$1 AND commit_sha=$2 AND authorizing_did = ANY($3) + ORDER BY producer_did, context, seq DESC + ) latest ORDER BY seq ASC", + ) + .bind(repo_id) + .bind(commit_sha) + .bind(authorizing_dids) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(row_to_status_claim).collect()) + } } // ── Webhooks ────────────────────────────────────────────────────────────────── diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 06331a8a..de0271e3 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -203,6 +203,18 @@ pub fn build_router(state: AppState) -> Router { 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), + ) + .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 @@ -490,6 +502,7 @@ pub fn build_router(state: AppState) -> Router { .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) From 7be9f08b3d20d1cdb9c499c9f7eacbbf881cfbc0 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:46:00 -0500 Subject: [PATCH 05/13] feat(api): add the pull request head rollup GET /api/v1/repos/{owner}/{repo}/pulls/{number}/status answers for the pull request's head. Both read surfaces now call one extracted projection, so "the rollup uses the same projection as the commit read" holds by construction rather than by two implementations agreeing today. A pull request row stores branch names, so the head has to come from somewhere. Preference order is the stored head, then a database-backed branch lookup for an open pull request that has none. No path in this module acquires the repository or lists refs from disk: that would download the whole repository from object storage on a cold node, and this read group carries no rate limiter. A source-read test enforces that, and asserts its own scan still covers the handler so it cannot shrink to vacuously passing. The read-side persist is self-limiting by SQL, not by handler ordering: the update is conditioned on the head being absent and the pull request open, so a push landing mid-request cannot be rolled back to a staler tip and a closed pull request is never back-filled. Reading the same pull request twice resolves the branch once. That property is invisible in the response. A variant that re-resolved on every call and then still preferred the stored head would return byte-identical bodies across the whole suite, so the test counts the work done rather than the results emitted, and injecting exactly that variant reddens only the counter assertion. An unresolvable head is not a fifth wire state. The state field stays inside the four values and the condition rides in head_resolved beside the pull request's own state, so the two cases differ on that boolean alone and both are tested. --- crates/gitlawb-node/src/api/mod.rs | 5 + crates/gitlawb-node/src/api/status.rs | 834 +++++++++++++++++++++++++- crates/gitlawb-node/src/db/mod.rs | 84 +++ crates/gitlawb-node/src/server.rs | 4 + 4 files changed, 918 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 3147a509..70c22815 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -230,6 +230,11 @@ mod authz_guard { // 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("), diff --git a/crates/gitlawb-node/src/api/status.rs b/crates/gitlawb-node/src/api/status.rs index db18e202..426062f8 100644 --- a/crates/gitlawb-node/src/api/status.rs +++ b/crates/gitlawb-node/src/api/status.rs @@ -168,16 +168,35 @@ pub async fn commit_status( crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?; let commit_sha = normalize_sha(&sha)?; - // 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. + 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 authorizing = authorizing_did_variants(&record.owner_did); let claims = state .db - .latest_status_claims(&record.id, &commit_sha, &authorizing) + .latest_status_claims(&record.id, commit_sha, &authorizing) .await?; - - let statuses: Vec = claims + Ok(claims .into_iter() .map(|c| StatusEntry { state: c.state, @@ -187,17 +206,137 @@ pub async fn commit_status( producer_did: c.producer_did, created_at: c.created_at, }) - .collect(); + .collect()) +} - Ok(Json(CombinedStatus { +/// 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(), - sha: commit_sha, 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 branch heads this node recorded for the +/// repo, the same `list_branch_cids` lookup the refs handler in +/// `crate::api::repos` uses. 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. +/// +/// 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 branch is never taken twice for one pull request. +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); + } + + // Keyed the way the push path writes the row: the normalized owner key and + // the repo's own name, never the alias the caller happened to put in the URL. + let slug = format!( + "{}/{}", + crate::db::normalize_owner_key(&record.owner_did), + record.name + ); + #[cfg(test)] + BRANCH_RESOLVES.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let heads = state.db.list_branch_cids(&slug).await?; + let resolved = heads + .iter() + .find(|h| { + h.ref_name + .strip_prefix("refs/heads/") + .unwrap_or(&h.ref_name) + == pr.source_branch + }) + // 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(|h| normalize_sha(&h.sha).ok()); + + let Some(sha) = resolved else { + return Ok(None); + }; + state.db.set_pr_head_if_absent(&pr.id, &sha).await?; + 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). @@ -1442,4 +1581,681 @@ mod tests { .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, + } + } + + /// The key `branch_cids` rows are written under on the push path: the + /// normalized owner key and the repo's own name, not the path alias. + fn branch_slug(repo: &RepoRecord) -> String { + format!( + "{}/{}", + crate::db::normalize_owner_key(&repo.owner_did), + repo.name + ) + } + + async fn seed_branch_head( + state: &crate::state::AppState, + repo: &RepoRecord, + branch: &str, + sha: &str, + ) { + state + .db + .upsert_branch_cid( + &branch_slug(repo), + &format!("refs/heads/{branch}"), + sha, + "bafyseed", + OWNER, + ) + .await + .expect("seed branch head"); + } + + /// 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. + /// 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 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 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 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 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!("status.rs"); + // Split on the tests module itself, not on any `#[cfg(test)]` attribute: + // 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, rest) = src + .split_once("\n#[cfg(test)]\nmod tests {") + .expect("module has a tests module"); + assert!( + !rest.is_empty() && 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("list_branch_cids("), + "the fallback's branch lookup must be the database-backed list_branch_cids" + ); + } + + /// 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 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/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index fd6f74eb..3d156ccc 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -1917,6 +1917,28 @@ impl Db { 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 @@ -4604,6 +4626,68 @@ mod migration_tests { ); } + /// 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] diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index de0271e3..4dfa7ab6 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -213,6 +213,10 @@ pub fn build_router(state: AppState) -> Router { "/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 From db3681e48d10d7d9188e3ddca0ba181c592e5cbe Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:29:48 -0500 Subject: [PATCH 06/13] feat(api): add the catch-up poll surface for missed push webhooks Webhook delivery is spawned, fire-and-forget, and never retried, so a checker whose endpoint was unreachable during a push simply never learns about the commit. Rather than add retry machinery and an unbounded queue pointed at an owner-supplied URL, delivery reliability becomes a read-side property: a push records an event, and a subscriber walks forward from a cursor to find every commit a webhook would have told it about. Containment is the load-bearing constraint. The row goes into repo_push_events, written only by the receive-pack path and read only by this repo-scoped, gate-checked handler. It must never go into received_ref_updates, which the unauthenticated global feed also reads; writing local pushes there would publish private-repo pushes on an anonymous surface. A test pushes to a private repo and asserts nothing appears on that feed, and retargeting the write reddens it. The cursor emits a canonical Z-form timestamp with fixed sub-second width rather than the default rfc3339. The default emits a +00:00 offset, and + decodes to a space in a query string, so a poller echoing the cursor back re-read the same page forever. The fixed width also makes the TEXT column's lexicographic order match time order for the keyset predicate. Found by seeding a timestamp collision, not by reading. The scenario tests drive the recorder directly, so a source-scrape test pins its call site in the push handler, the same way the stored-head update is pinned. Removing that call was verified to turn it red. --- crates/gitlawb-node/src/api/events.rs | 599 ++++++++++++++++++++++++++ crates/gitlawb-node/src/api/mod.rs | 4 + crates/gitlawb-node/src/api/repos.rs | 120 +++++- crates/gitlawb-node/src/db/mod.rs | 91 ++++ crates/gitlawb-node/src/server.rs | 4 + 5 files changed, 814 insertions(+), 4 deletions(-) diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 875e6261..e27a337e 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -327,6 +327,74 @@ pub async fn list_repo_events( )) } +/// GET /api/v1/repos/{owner}/{repo}/push-events?since=&since_id=&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 `(created_at, id)` pair the previous page returned as +/// `next_since` / `next_since_id`, and rows are read strictly after it. Passing +/// `since` alone (no `since_id`) is the first-poll form: an empty id sorts below +/// every real one, so the page starts at the first row of that timestamp. +/// +/// 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()) + .map(|v| v.max(0)) + .unwrap_or(50) + .clamp(0, MAX_VISIBLE_REF_UPDATES); + + // 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?; + + let since = params.get("since").map(String::as_str); + let since_id = params.get("since_id").map(String::as_str).unwrap_or(""); + let after = since.map(|ts| (ts, since_id)); + + let rows = state + .db + .list_repo_push_events_keyset(&record.id, after, 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(); + let next = rows.last().map(|e| (e.created_at.clone(), e.id.clone())); + let count = events.len(); + Ok(Json(serde_json::json!({ + "events": events, + "count": count, + "next_since": next.as_ref().map(|(ts, _)| ts.clone()), + "next_since_id": next.as_ref().map(|(_, id)| id.clone()), + }))) +} + #[cfg(test)] mod ref_updates_feed_tests { use crate::db::{ReceivedRefUpdate, RefCertificate, RepoRecord}; @@ -1493,3 +1561,534 @@ 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; + + 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() + } + + /// 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(), + 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, taken before the push, in the + // same canonical form the surface hands back. + let before = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true); + + // 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!("since={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-only cursor cannot page: it either repeats a row or + /// drops one. 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 id tiebreak must walk 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. It did not at first: a + /// `+00:00` offset decodes to a space in a query string, and the walk sat on + /// the same row forever. + #[sqlx::test] + async fn colliding_timestamps_page_once_each_with_the_id_tiebreak(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_since = first["next_since"] + .as_str() + .expect("next_since") + .to_string(); + assert!( + !next_since.contains('+'), + "the cursor is echoed into a query string, where `+` means a space; \ + emitting one makes a naive poller re-read the same page forever, got \ + {next_since}" + ); + + let cursor = format!( + "since={next_since}&since_id={}&limit=1", + first["next_since_id"].as_str().expect("next_since_id"), + ); + 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 id tiebreak 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!( + "since={}&since_id={}&limit=1", + second["next_since"].as_str().expect("next_since"), + second["next_since_id"].as_str().expect("next_since_id"), + ); + 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}" + ); + } + + /// 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", "since=2026-08-07T13:00:00.000000Z"), + ) + .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!( + body["next_since"].is_null() && body["next_since_id"].is_null(), + "an empty page must not invent a cursor, got {body}" + ); + } + + /// 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 70c22815..699b8370 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -226,6 +226,10 @@ 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. diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 0e7d78c4..4f729398 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1016,6 +1016,12 @@ pub async fn git_receive_pack( 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 @@ -1653,10 +1659,63 @@ 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 `(created_at, id)` rather than the timestamp alone. 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. +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`. Two reasons, both load-bearing for the cursor. The + // column is TEXT and the keyset predicate compares it lexicographically, so a + // single fixed width is what makes string order equal time order. And the + // value is handed back to the poller as a query parameter, where a literal + // `+` decodes to a space: a client that echoes the cursor unencoded would + // then re-read the page it just consumed, forever. + let created_at = + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, /* use_z */ true); + for update in ref_updates { + // A deletion carries an all-zero new SHA. Recording it would hand a + // poller a target that resolves to no commit. + if update.new_sha == ZERO_SHA { + continue; + } + let event = crate::db::RepoPushEvent { + id: uuid::Uuid::new_v4().to_string(), + repo_id: repo_id.to_string(), + ref_name: update.ref_name.clone(), + after_sha: update.new_sha.clone(), + created_at: created_at.clone(), + }; + if let Err(e) = db.insert_repo_push_event(&event).await { + tracing::warn!( + err = %e, + ref_name = %update.ref_name, + "failed to record a push event for the catch-up poll surface" + ); + } + } } /// The bare branch name behind a full ref, or `None` if the ref is not a @@ -2995,3 +3054,56 @@ 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 start = src + .find("fn git_receive_pack(") + .expect("git_receive_pack not found (renamed or removed?)"); + let rest = &src[start..]; + let end = rest.find("\n}").map(|i| i + 2).unwrap_or(rest.len()); + let body: String = rest[..end] + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + 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 start = src + .find("async fn record_push_events(") + .expect("record_push_events not found (renamed or removed?)"); + let rest = &src[start..]; + let end = rest.find("\n}").map(|i| i + 2).unwrap_or(rest.len()); + assert!( + !rest[..end].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/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 3d156ccc..cf168d41 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -158,6 +158,26 @@ 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, + 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, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefCertificate { pub id: String, @@ -2740,6 +2760,77 @@ impl Db { } } +// ── Repo Push Events ────────────────────────────────────────────────────────── + +impl Db { + /// Record one local push event. Idempotent on the row id. + pub async fn insert_repo_push_event(&self, event: &RepoPushEvent) -> Result<()> { + sqlx::query( + "INSERT INTO repo_push_events (id, repo_id, ref_name, after_sha, created_at) + VALUES ($1,$2,$3,$4,$5) + ON CONFLICT(id) DO NOTHING", + ) + .bind(&event.id) + .bind(&event.repo_id) + .bind(&event.ref_name) + .bind(&event.after_sha) + .bind(&event.created_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// One page of a repo's push events, OLDEST first, walked by a `(created_at, + /// id)` keyset cursor rather than an offset. + /// + /// `after` is the cursor a poller last saw; the page reads rows strictly + /// after it via the row-value predicate `(created_at, id) > (after_ts, + /// after_id)`, which matches the `ORDER BY` exactly. The id half is not + /// decoration: one multi-ref push stamps every one of its rows with the same + /// timestamp, so a timestamp-only cursor either repeats a row or drops one at + /// every page boundary that lands inside such a push. `(repo_id, created_at, + /// id)` is the index this predicate rides. + /// + /// 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<(&str, &str)>, + limit: i64, + ) -> Result> { + const COLS: &str = "id, repo_id, ref_name, after_sha, created_at"; + + // Positional params in bind order: repo_id, after_ts?, after_id?, limit. + let (cursor_clause, limit_param) = match after { + Some(_) => (" AND (created_at, id) > ($2, $3)", 4), + None => ("", 2), + }; + let sql = format!( + "SELECT {COLS} FROM repo_push_events WHERE repo_id = $1{cursor_clause} \ + ORDER BY created_at ASC, id ASC LIMIT ${limit_param}" + ); + + let mut q = sqlx::query(&sql).bind(repo_id.to_string()); + if let Some((ts, id)) = after { + q = q.bind(ts.to_string()).bind(id.to_string()); + } + 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"), + 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 { diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 4dfa7ab6..43c90e96 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -409,6 +409,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)) From 90b8294c2b2e2713a33ff8e4e1764c1e9da078df Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:16:53 -0500 Subject: [PATCH 07/13] fix(api): resolve the rollup head from push events, not pinned CIDs Two review findings. The fallback read branch_cids, whose only production writer sits inside a Pinata pin-success branch. With no Pinata configured that table is never written, so on a default node the fallback could never resolve and every open pull request without a stored head answered head_resolved false forever. It now reads repo_push_events, which the receive-pack path writes unconditionally for every ref update, with both predicates and the ordering in SQL against the existing keyset index. Still one database read per request, still no repository acquire. This is better, not complete: repo_push_events only carries pushes that land after this ships, so a pull request whose branch was last pushed before deployment still will not resolve until it is pushed again. There is no backfill. The best-effort persist propagated its error, so a transient database failure turned a rollup that had already resolved its answer into a 500. It now catches and logs, matching the sibling writes on the push path. The test asserts the persist genuinely fails before driving the read, so it cannot pass vacuously. Also corrected a doc comment that claimed the resolve branch is never taken twice for one pull request. Only the persist is once-per-pull- request; the resolve attempt repeats on every read until it succeeds. The accompanying test pins the corrected wording rather than a behavior change, since the old code counted the same way. The resolve counter is process-global while sqlx test databases are per-test, so unguarded tests were inflating the counts guarded tests assert on. The guard now covers every test that triggers a resolve. --- crates/gitlawb-node/src/api/status.rs | 340 ++++++++++++++++++++++---- crates/gitlawb-node/src/db/mod.rs | 32 +++ 2 files changed, 326 insertions(+), 46 deletions(-) diff --git a/crates/gitlawb-node/src/api/status.rs b/crates/gitlawb-node/src/api/status.rs index 426062f8..d8a92a53 100644 --- a/crates/gitlawb-node/src/api/status.rs +++ b/crates/gitlawb-node/src/api/status.rs @@ -284,18 +284,29 @@ pub(crate) static BRANCH_RESOLVES: std::sync::atomic::AtomicUsize = /// 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 branch heads this node recorded for the -/// repo, the same `list_branch_cids` lookup the refs handler in -/// `crate::api::repos` uses. 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. +/// 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 branch is never taken twice for one pull request. +/// 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, @@ -308,32 +319,40 @@ async fn rollup_head( return Ok(None); } - // Keyed the way the push path writes the row: the normalized owner key and - // the repo's own name, never the alias the caller happened to put in the URL. - let slug = format!( - "{}/{}", - crate::db::normalize_owner_key(&record.owner_did), - record.name - ); + // 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 heads = state.db.list_branch_cids(&slug).await?; - let resolved = heads - .iter() - .find(|h| { - h.ref_name - .strip_prefix("refs/heads/") - .unwrap_or(&h.ref_name) - == pr.source_branch - }) + 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(|h| normalize_sha(&h.sha).ok()); + .and_then(|sha| normalize_sha(&sha).ok()); let Some(sha) = resolved else { return Ok(None); }; - state.db.set_pr_head_if_absent(&pr.id, &sha).await?; + // 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)) } @@ -456,6 +475,7 @@ mod tests { 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(); @@ -1633,38 +1653,59 @@ mod tests { } } - /// The key `branch_cids` rows are written under on the push path: the - /// normalized owner key and the repo's own name, not the path alias. - fn branch_slug(repo: &RepoRecord) -> String { - format!( - "{}/{}", - crate::db::normalize_owner_key(&repo.owner_did), - repo.name - ) - } - + /// 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. Each call takes a strictly later timestamp than the last, so a + /// second call to the same branch reads as a later push rather than tying + /// with the first. 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 explicit because the fallback picks the most recent row for + /// the ref and `created_at` is the ordering key; letting two seeds share a + /// wall clock would leave which one wins to the uuid tiebreak. + async fn seed_push_event( + state: &crate::state::AppState, + repo: &RepoRecord, + branch: &str, + sha: &str, + created_at: &str, ) { state .db - .upsert_branch_cid( - &branch_slug(repo), - &format!("refs/heads/{branch}"), - sha, - "bafyseed", - OWNER, - ) + .insert_repo_push_event(&crate::db::RepoPushEvent { + id: uuid::Uuid::new_v4().to_string(), + 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 branch head"); + .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, ()> { @@ -1797,6 +1838,7 @@ mod tests { /// 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(); @@ -1954,6 +1996,7 @@ mod tests { /// 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(); @@ -1980,6 +2023,139 @@ mod tests { ); } + /// 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(), + 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. /// @@ -2048,6 +2224,70 @@ mod tests { ); } + /// 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. @@ -2203,8 +2443,15 @@ mod tests { ); } assert!( - body_of_module.contains("list_branch_cids("), - "the fallback's branch lookup must be the database-backed list_branch_cids" + 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" ); } @@ -2213,6 +2460,7 @@ mod tests { /// 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(); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index cf168d41..8dd88f18 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2780,6 +2780,38 @@ impl Db { Ok(()) } + /// 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. Ordering on + /// `(created_at, id)` rather than the timestamp alone matches the + /// `(repo_id, created_at, id)` index and is what makes "most recent" total — + /// one multi-ref push stamps every one of its rows with the same timestamp. + 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 created_at DESC, id 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 `(created_at, /// id)` keyset cursor rather than an offset. /// From ad71f79e6d8d25c6407930f2549e8f95da5bfd0f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:07:28 -0500 Subject: [PATCH 08/13] fix(status): make a stored claim actually re-verifiable The row stored the RFC 9421 signing string under a column called signed_payload. That string covers the body only through the content digest, so it proved someone signed a request with some digest but not that this claim row was what they signed. The whole append-only argument rests on a claim staying verifiable after the request is gone, so the row now carries the request body as well, in a column that says so, and a test re-verifies a claim from the row alone: body to digest, digest into the signing string, signature over that string under the producer's key. Its negative mutates the stored state and shows the same procedure refuses. Migration v24 is amended in place rather than stacked, since it has never shipped. The persisted material had no size bound and is caller-influenced, so each field is now bounded. The write response returned the whole claim, so the wire carried the stored bytes as a JSON integer array; it now returns a client-facing type and StatusClaim no longer derives Serialize, so the leak cannot reappear by accident. Carrying the body as Bytes rather than a Vec matters: the middleware runs on every signed request including receive-pack POSTs bounded at 2 GB, so copying would have doubled peak memory on every push to serve a field only the status write reads. --- crates/gitlawb-node/src/api/status.rs | 462 +++++++++++++++++++++++++- crates/gitlawb-node/src/auth/mod.rs | 25 +- crates/gitlawb-node/src/db/mod.rs | 56 +++- 3 files changed, 507 insertions(+), 36 deletions(-) diff --git a/crates/gitlawb-node/src/api/status.rs b/crates/gitlawb-node/src/api/status.rs index d8a92a53..e70c3824 100644 --- a/crates/gitlawb-node/src/api/status.rs +++ b/crates/gitlawb-node/src/api/status.rs @@ -41,10 +41,65 @@ pub struct CreateStatusRequest { /// 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, @@ -52,7 +107,7 @@ pub async fn create_status( Path((owner, name, sha)): Path<(String, String, String)>, material: Option>, Json(req): Json, -) -> Result<(StatusCode, 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 @@ -84,10 +139,26 @@ pub async fn create_status( the route is not behind require_signature" ))); }; - let (signature, signature_input, signed_payload) = ( + // 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", material.body.len(), MAX_REQUEST_BODY_BYTES)?; + + let (signature, signature_input, signing_string, request_body) = ( material.signature, material.signature_input, - material.signing_string.into_bytes(), + material.signing_string, + material.body.to_vec(), ); let claim = StatusClaim { @@ -106,7 +177,8 @@ pub async fn create_status( authorizing_did: auth.0, signature, signature_input, - signed_payload, + signing_string, + request_body, created_at: Utc::now().to_rfc3339(), }; @@ -116,9 +188,10 @@ pub async fn create_status( per_repo: MAX_CLAIMS_PER_REPO, }; match state.db.insert_status_claim_capped(&claim, &caps).await? { - crate::db::ClaimInsert::Inserted(seq) => { - Ok((StatusCode::CREATED, Json(StatusClaim { seq, ..claim }))) - } + crate::db::ClaimInsert::Inserted(seq) => Ok(( + StatusCode::CREATED, + Json(CreatedStatus::from_claim(claim, seq)), + )), crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!( "claim limit reached for {which}" ))), @@ -405,6 +478,16 @@ fn normalize_sha(sha: &str) -> Result { } } +/// 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()) @@ -516,13 +599,19 @@ mod tests { /// 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(crate::auth::SignatureMaterial { + req.extensions_mut().insert(sample_material()); + req + } + + /// Stand-in material, well inside every bound. + fn sample_material() -> crate::auth::SignatureMaterial { + crate::auth::SignatureMaterial { signature: "sig1=:dGVzdA==:".to_string(), signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" .to_string(), signing_string: "\"@method\": POST".to_string(), - }); - req + body: axum::body::Bytes::from_static(b"{}"), + } } async fn post_as( @@ -584,7 +673,8 @@ mod tests { // cannot be re-verified after the request is gone is not history. assert_eq!(claims[0].signature, "sig1=:dGVzdA==:"); assert!(claims[0].signature_input.starts_with("sig1=(")); - assert_eq!(claims[0].signed_payload, b"\"@method\": POST"); + 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 @@ -927,6 +1017,344 @@ mod tests { ); } + /// 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: 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" + ); + } + + /// 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 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(); + let resp = crate::server::build_router(state.clone()) + .oneshot(req) + .await + .unwrap(); + (resp, signed) + } + + /// 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 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. @@ -1010,8 +1438,8 @@ mod tests { sqlx::query( "INSERT INTO status_claims (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, - signature, signature_input, signed_payload, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,'','',''::bytea,$8)", + signature, signature_input, signing_string, request_body, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'','','',''::bytea,$8)", ) .bind(id) .bind(repo_id) @@ -1566,9 +1994,9 @@ mod tests { sqlx::query( "INSERT INTO status_claims (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, - signature, signature_input, signed_payload, created_at) + signature, signature_input, signing_string, request_body, created_at) SELECT md5(random()::text || g::text), $1, $2, 'success', $3, $4, $4, - '', '', ''::bytea, '2026-01-01T00:00:00Z' + '', '', '', ''::bytea, '2026-01-01T00:00:00Z' FROM generate_series(1, $5) g", ) .bind(repo_id) @@ -1587,11 +2015,11 @@ mod tests { sqlx::query( "INSERT INTO status_claims (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, - signature, signature_input, signed_payload, created_at) + signature, signature_input, signing_string, request_body, 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, '2026-01-01T00:00:00Z' + '', '', '', ''::bytea, '2026-01-01T00:00:00Z' FROM generate_series(1, $3) g", ) .bind(repo_id) diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index e4ddd328..61157633 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -34,6 +34,18 @@ pub struct SignatureMaterial { pub signature_input: String, /// The canonical bytes the signature covered. pub signing_string: String, + /// The buffered request body. 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. + /// + /// `Bytes`, not `Vec`, because every signed request pays for this field + /// and a receive-pack POST is capped at GITLAWB_MAX_PACK_BYTES (2 GB by + /// default). Cloning the buffer here would double the peak memory of every + /// push to carry a value only the status write path ever reads; a `Bytes` + /// clone is a refcount bump over the buffer the middleware already holds. + pub body: axum::body::Bytes, } /// Whether `caller` is authorized to push to `record`. @@ -257,17 +269,20 @@ pub async fn require_signature(request: Request, next: Next) -> Response { tracing::info!(did = %sig.key_id, "✓ authenticated request"); + let material = SignatureMaterial { + signature: sig_header, + signature_input: sig_input, + signing_string, + body: body_bytes.clone(), + }; + 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(SignatureMaterial { - signature: sig_header, - signature_input: sig_input, - signing_string, - }); + request.extensions_mut().insert(material); next.run(request).await } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 8dd88f18..d4f00fe1 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -91,12 +91,20 @@ pub struct PullRequest { /// 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`. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// 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. @@ -108,12 +116,21 @@ pub struct StatusClaim { pub producer_did: String, pub authorizing_did: String, /// The producer's RFC 9421 `Signature` header value, and below it the - /// `Signature-Input` and the canonical bytes they covered. 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. + /// `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 signed_payload: Vec, + pub signing_string: String, + pub request_body: Vec, /// Server-assigned rfc3339 timestamp. Display data only; ordering is `seq`. pub created_at: String, } @@ -986,7 +1003,13 @@ const MIGRATIONS: &[Migration] = &[ authorizing_did TEXT NOT NULL, signature TEXT NOT NULL, signature_input TEXT NOT NULL, - signed_payload BYTEA 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, created_at TEXT NOT NULL )"#, // Read path: every claim for one commit. @@ -2151,8 +2174,8 @@ impl Db { 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, - signed_payload, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + signing_string, request_body, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING seq"; /// The bind chain for [`INSERT_STATUS_CLAIM_SQL`], shared by the plain and the @@ -2172,7 +2195,8 @@ fn status_claim_insert( .bind(&claim.authorizing_did) .bind(&claim.signature) .bind(&claim.signature_input) - .bind(&claim.signed_payload) + .bind(&claim.signing_string) + .bind(&claim.request_body) .bind(&claim.created_at) } @@ -2279,7 +2303,7 @@ impl Db { let rows = sqlx::query( "SELECT id,seq,repo_id,commit_sha,state,context,target_url,description, producer_did,authorizing_did,signature,signature_input, - signed_payload,created_at + signing_string,request_body,created_at FROM status_claims WHERE repo_id=$1 AND commit_sha=$2 ORDER BY seq ASC", ) .bind(repo_id) @@ -2314,7 +2338,7 @@ impl Db { SELECT DISTINCT ON (producer_did, context) id,seq,repo_id,commit_sha,state,context,target_url,description, producer_did,authorizing_did,signature,signature_input, - signed_payload,created_at + signing_string,request_body,created_at FROM status_claims WHERE repo_id=$1 AND commit_sha=$2 AND authorizing_did = ANY($3) ORDER BY producer_did, context, seq DESC @@ -3351,7 +3375,8 @@ fn row_to_status_claim(r: sqlx::postgres::PgRow) -> StatusClaim { authorizing_did: r.get("authorizing_did"), signature: r.get("signature"), signature_input: r.get("signature_input"), - signed_payload: r.get("signed_payload"), + signing_string: r.get("signing_string"), + request_body: r.get("request_body"), created_at: r.get("created_at"), } } @@ -4360,7 +4385,9 @@ mod migration_tests { authorizing_did: "did:key:zOwner".to_string(), signature: "sig1=:YWJj:".to_string(), signature_input: "sig1=(\"@method\" \"@target-uri\");created=1754524800".to_string(), - signed_payload: b"POST /api/v1/repos/o/r/statuses/aaa\n{}".to_vec(), + signing_string: "\"@method\": POST\n\"@path\": /api/v1/repos/o/r/statuses/aaa" + .to_string(), + request_body: b"{\"state\":\"success\"}".to_vec(), created_at: "2026-08-07T00:00:00+00:00".to_string(), } } @@ -4458,7 +4485,8 @@ mod migration_tests { 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.signed_payload, full.signed_payload); + 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]; From 806f9027c05df4f7f3283ef72e74c7003187f70f Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:57:51 -0500 Subject: [PATCH 09/13] fix(status): make the caps hold and the projection mean one identity Five reviewers independently flagged the capped insert. It counted then inserted inside one transaction with a docstring promising a concurrent writer could not slip past, which READ COMMITTED does not deliver, and every cap test was sequential so deleting the transaction would have kept them green. A per-repo advisory lock now serializes writers on the bound they are both testing. The new test runs eight writers through a barrier at cap minus one: without the lock it left 4, 5, and 5 rows against a cap of 3 on consecutive runs, so the window was real. The projection compared producer_did as a raw column while the handler stored whatever spelling the signer used, so one owner writing as did:key:X and as X produced two entries for one context and the superseded claim kept voting. Identity is now canonicalized at write time, and the read filter is a single equality rather than a set. That fix had a trap. Applying normalize_owner_key directly, as the finding suggested, collapses to the bare key, which is not a parseable DID, and would have quietly undone the re-verifiability the previous commit established. canonical_did canonicalizes to the full did:key form instead and is written as a function of normalize_owner_key so the equivalence with did_matches still holds exactly, including the case where a naive prefix-add would merge two distinct identities. With that in place the ported copy of the DID collapse in the status module is gone rather than relocated, and did_matches itself now delegates, so the Rust gate, the stored column, and the SQL all trace to one definition. The projection also selected the signature columns it never renders, which the previous commit made worse by adding the request body. It has its own narrow type now, enforced by a source-read test. The per-repo cap counted for all time with nothing pruning the table, so a repo that reached it was permanently closed while 429 told clients to keep retrying. It is a rolling window now, which still bounds the fan-out a caller-chosen SHA allows, and makes the refusal true. Also folded two count queries that scanned the same rows into one. --- crates/gitlawb-node/src/api/mod.rs | 17 +- crates/gitlawb-node/src/api/status.rs | 233 ++++++++++++----- crates/gitlawb-node/src/db/mod.rs | 355 ++++++++++++++++++++++---- 3 files changed, 481 insertions(+), 124 deletions(-) diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 699b8370..f08f2b6a 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -68,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 diff --git a/crates/gitlawb-node/src/api/status.rs b/crates/gitlawb-node/src/api/status.rs index e70c3824..474173fe 100644 --- a/crates/gitlawb-node/src/api/status.rs +++ b/crates/gitlawb-node/src/api/status.rs @@ -24,10 +24,13 @@ 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; -/// Total claim rows for one repo. 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. -const MAX_CLAIMS_PER_REPO: i64 = 10_000; +/// 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 { @@ -185,7 +188,7 @@ pub async fn create_status( let caps = crate::db::ClaimCaps { per_tuple: MAX_CLAIMS_PER_TUPLE, contexts_per_commit: MAX_CONTEXTS_PER_COMMIT, - per_repo: MAX_CLAIMS_PER_REPO, + 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(( @@ -264,10 +267,9 @@ async fn project_claims( record: &crate::db::RepoRecord, commit_sha: &str, ) -> Result> { - let authorizing = authorizing_did_variants(&record.owner_did); let claims = state .db - .latest_status_claims(&record.id, commit_sha, &authorizing) + .latest_status_claims(&record.id, commit_sha, &record.owner_did) .await?; Ok(claims .into_iter() @@ -448,24 +450,6 @@ fn combined_state(statuses: &[StatusEntry]) -> &'static str { } } -/// The set of stored `authorizing_did` values that count as the current owner, -/// reproducing [`crate::api::did_matches`] as a set so the filter can be a -/// membership test inside the projection query. `did:key` collapses its full and -/// bare forms; every other method matches exactly, because a bare base58 id must -/// never match across methods. -fn authorizing_did_variants(owner_did: &str) -> Vec { - let key_id = owner_did.strip_prefix("did:key:").unwrap_or(owner_did); - if key_id.contains(':') { - return vec![owner_did.to_string()]; - } - let full = format!("did:key:{key_id}"); - if full == owner_did { - vec![owner_did.to_string(), key_id.to_string()] - } else { - vec![owner_did.to_string(), full] - } -} - /// 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 { @@ -959,9 +943,9 @@ mod tests { ); } - /// Seed the per-repo row limit across distinct well-formed SHAs; a claim - /// against a fresh SHA is exactly 429, which is the bound the caller-chosen - /// (never existence-checked) SHA would otherwise escape. + /// 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; @@ -969,7 +953,14 @@ mod tests { 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).await; + 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, @@ -981,6 +972,45 @@ mod tests { 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 @@ -1856,6 +1886,51 @@ mod tests { 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 @@ -1952,33 +2027,66 @@ mod tests { ); } - /// The set the projection filters on reproduces `did_matches` exactly, in both - /// directions and across the cross-method cases: the filter has to live inside - /// the query, so it cannot call the helper itself. + /// 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 authorizing_variants_agree_with_did_matches() { - let cases = [ - ("did:key:zABC", "did:key:zABC"), - ("did:key:zABC", "zABC"), - ("zABC", "did:key:zABC"), - ("zABC", "zABC"), - ("did:key:zABC", "did:key:zXYZ"), - ("did:key:zABC", "did:gitlawb:zABC"), - ("did:gitlawb:zABC", "did:key:zABC"), - ("did:web:example.com", "did:web:example.com"), - ("did:web:example.com", "example.com"), - ("", ""), - ]; - for (owner, candidate) in cases { - let in_set = super::authorizing_did_variants(owner) - .iter() - .any(|v| v == candidate); - assert_eq!( - in_set, - crate::api::did_matches(candidate, owner), - "variant set for owner {owner:?} disagrees with did_matches on {candidate:?}" - ); - } + fn status_module_holds_no_second_copy_of_the_did_collapse() { + let src = include_str!("status.rs"); + let (body_of_module, rest) = src + .split_once("\n#[cfg(test)]\nmod tests {") + .expect("module has a tests module"); + assert!( + !rest.is_empty() && 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 @@ -2010,8 +2118,16 @@ mod tests { } /// `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. - async fn seed_claims_across_shas(pool: &PgPool, repo_id: &str, producer: &str, n: i64) { + /// 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, @@ -2019,12 +2135,13 @@ mod tests { SELECT md5(random()::text || g::text), $1, substr(md5(g::text) || md5((g + 1)::text), 1, 40), 'success', 'ci/build', $2, $2, - '', '', '', ''::bytea, '2026-01-01T00:00:00Z' + '', '', '', ''::bytea, $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"); diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index d4f00fe1..1b2b9f0b 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -135,6 +135,21 @@ pub struct StatusClaim { 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, @@ -1016,6 +1031,9 @@ const MIGRATIONS: &[Migration] = &[ "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", @@ -1051,6 +1069,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. @@ -1062,7 +1108,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. @@ -2180,6 +2268,13 @@ const INSERT_STATUS_CLAIM_SQL: &str = "INSERT INTO status_claims /// 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( claim: &StatusClaim, ) -> sqlx::query::Query<'_, sqlx::Postgres, sqlx::postgres::PgArguments> { @@ -2191,8 +2286,8 @@ fn status_claim_insert( .bind(&claim.context) .bind(&claim.target_url) .bind(&claim.description) - .bind(&claim.producer_did) - .bind(&claim.authorizing_did) + .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) @@ -2200,6 +2295,23 @@ fn status_claim_insert( .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. @@ -2208,8 +2320,17 @@ pub struct ClaimCaps { pub per_tuple: i64, /// Distinct contexts for one (repo, commit). pub contexts_per_commit: i64, - /// Total claim rows for one repo. - pub per_repo: 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 @@ -2231,9 +2352,20 @@ impl Db { Ok(row.get::("seq")) } - /// Append one claim only if all three caps still admit it, with the counts - /// and the insert in one transaction so a concurrent writer cannot slip an - /// extra row past a bound that was counted a moment earlier. + /// Append one claim only if all three caps still admit it. + /// + /// 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, @@ -2241,16 +2373,34 @@ impl Db { ) -> Result { let mut tx = self.pool.begin().await?; - let tuple_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM status_claims - WHERE repo_id=$1 AND commit_sha=$2 AND producer_did=$3 AND context=$4", + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)") + .bind(&claim.repo_id) + .execute(&mut *tx) + .await?; + + // 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) - .bind(&claim.producer_did) + // 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", @@ -2259,31 +2409,28 @@ impl Db { // A context already present does not widen the fanout, so only a NEW // context is measured against the limit. - let row = sqlx::query( - "SELECT count(DISTINCT context) AS contexts, - COALESCE(bool_or(context = $3), false) AS present - FROM status_claims WHERE repo_id=$1 AND commit_sha=$2", - ) - .bind(&claim.repo_id) - .bind(&claim.commit_sha) - .bind(&claim.context) - .fetch_one(&mut *tx) - .await?; - let contexts: i64 = row.get("contexts"); - let present: bool = row.get("present"); if !present && contexts >= caps.contexts_per_commit { return Ok(ClaimInsert::CapExceeded( "distinct contexts for this commit", )); } - let repo_rows: i64 = - sqlx::query_scalar("SELECT count(*) FROM status_claims WHERE repo_id=$1") - .bind(&claim.repo_id) - .fetch_one(&mut *tx) - .await?; - if repo_rows >= caps.per_repo { - return Ok(ClaimInsert::CapExceeded("claims for this repository")); + // 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", + )); } let inserted = status_claim_insert(claim).fetch_one(&mut *tx).await?; @@ -2314,8 +2461,8 @@ impl Db { } /// The projection behind the commit-status read (KTD-2): the latest claim per - /// (producer, context) for one commit, restricted to claims whose authorizing - /// DID is in `authorizing_dids`. + /// (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 @@ -2323,33 +2470,27 @@ impl Db { /// the timestamp is producer-visible display data and the id is a random /// uuid, so neither orders the history. /// - /// The authorization filter is a set-membership test 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 set, never move - /// the test into the read loop. + /// 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_dids: &[String], - ) -> Result> { - let rows = sqlx::query( - "SELECT * FROM ( - SELECT DISTINCT ON (producer_did, context) - id,seq,repo_id,commit_sha,state,context,target_url,description, - producer_did,authorizing_did,signature,signature_input, - signing_string,request_body,created_at - FROM status_claims - WHERE repo_id=$1 AND commit_sha=$2 AND authorizing_did = ANY($3) - ORDER BY producer_did, context, seq DESC - ) latest ORDER BY seq ASC", - ) - .bind(repo_id) - .bind(commit_sha) - .bind(authorizing_dids) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(row_to_status_claim).collect()) + 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()) } } @@ -3381,6 +3522,17 @@ fn row_to_status_claim(r: sqlx::postgres::PgRow) -> StatusClaim { } } +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 = @@ -4876,6 +5028,97 @@ mod migration_tests { ); } + /// 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 From 612d9d2dbe37281ff73e44435c021a5c1c335cc3 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:29:43 -0500 Subject: [PATCH 10/13] fix(api): order the poll cursor on a sequence and batch the push writes The push-event cursor paged on an application-stamped wall clock, so a row stamped later could commit earlier and a poller past that point would never see the earlier row. A clock step backwards widens it. It now pages on a database-assigned sequence, the same decision status_claims already made, so the two surfaces do not disagree about what ordering means. The rollup's branch resolve had the same flaw and is fixed with it. The receive-pack path issued one round trip per ref for each of two writers, sequentially, on the user's push, with nothing bounding the ref count. Both are single statements now and the per-push fan-out is capped, with a warning when it truncates rather than silent loss. The head update batches through a VALUES join, with last-write-wins dedup on duplicate branches: git will not produce them, but a join over duplicates picks arbitrarily where the loop it replaced was deterministic. The cursor was unvalidated. A malformed value produced a wrong page instead of a refusal, limit zero returned a page with a null cursor, and a poller persisting that cursor silently restarted from the beginning of history. It is validated, the limit is clamped, and the returned cursor never moves backwards. This changes the query parameter names, which is a breaking wire change on an endpoint that has never shipped; every consumer was checked and only the route registration reads them. Three copies of the source-scraping parser became one shared helper. That refactor exposed a real gap: the empty-region mutation reddened four of the five guards, but the gossip-containment guard passed on an empty region because it only asserts a must-not. It now asserts its scan covered what it claims to cover, so a helper bug cannot make it vacuously green. status.rs is split into status/mod.rs and status/tests.rs, production logic unchanged. Four things read the old path and all were retargeted, including 22 mutation targets across four specs, each re-confirmed to still match exactly once. --- crates/gitlawb-node/src/api/events.rs | 368 ++- crates/gitlawb-node/src/api/mod.rs | 4 +- crates/gitlawb-node/src/api/repos.rs | 332 +- crates/gitlawb-node/src/api/status.rs | 3054 ------------------- crates/gitlawb-node/src/api/status/mod.rs | 530 ++++ crates/gitlawb-node/src/api/status/tests.rs | 2523 +++++++++++++++ crates/gitlawb-node/src/db/mod.rs | 297 +- crates/gitlawb-node/src/test_support.rs | 108 + 8 files changed, 4003 insertions(+), 3213 deletions(-) delete mode 100644 crates/gitlawb-node/src/api/status.rs create mode 100644 crates/gitlawb-node/src/api/status/mod.rs create mode 100644 crates/gitlawb-node/src/api/status/tests.rs diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index e27a337e..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,17 +339,45 @@ pub async fn list_repo_events( )) } -/// GET /api/v1/repos/{owner}/{repo}/push-events?since=&since_id=&limit= +/// 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 `(created_at, id)` pair the previous page returned as -/// `next_since` / `next_since_id`, and rows are read strictly after it. Passing -/// `since` alone (no `since_id`) is the first-poll form: an empty id sorts below -/// every real one, so the page starts at the first row of that timestamp. +/// 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 @@ -351,9 +391,8 @@ pub async fn list_repo_push_events( let limit = params .get("limit") .and_then(|v| v.parse::().ok()) - .map(|v| v.max(0)) .unwrap_or(50) - .clamp(0, MAX_VISIBLE_REF_UPDATES); + .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, @@ -365,13 +404,17 @@ pub async fn list_repo_push_events( let (record, _rules) = crate::api::authorize_repo_read(&state, &owner, &repo_name, caller, "/").await?; - let since = params.get("since").map(String::as_str); - let since_id = params.get("since_id").map(String::as_str).unwrap_or(""); - let after = since.map(|ts| (ts, since_id)); + // 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, after, limit) + .list_repo_push_events_keyset(&record.id, cursor, limit) .await?; let events: Vec = rows @@ -385,13 +428,14 @@ pub async fn list_repo_push_events( }) }) .collect(); - let next = rows.last().map(|e| (e.created_at.clone(), e.id.clone())); + // 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_since": next.as_ref().map(|(ts, _)| ts.clone()), - "next_since_id": next.as_ref().map(|(_, id)| id.clone()), + "next_cursor": next, }))) } @@ -1573,6 +1617,8 @@ mod push_events_tests { 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"; @@ -1669,6 +1715,32 @@ mod push_events_tests { .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( @@ -1683,6 +1755,8 @@ mod push_events_tests { .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(), @@ -1734,9 +1808,9 @@ mod push_events_tests { .await .unwrap(); - // The cursor a subscriber last polled at, taken before the push, in the - // same canonical form the surface hands back. - let before = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true); + // 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( @@ -1769,7 +1843,7 @@ mod push_events_tests { poll( &state, Some(OWNER), - &poll_uri("widget", &format!("since={before}")), + &poll_uri("widget", &format!("cursor={before}")), ) .await, ) @@ -1786,18 +1860,16 @@ mod push_events_tests { } /// Two ref updates in one push share a timestamp by construction, which is - /// the case a timestamp-only cursor cannot page: it either repeats a row or - /// drops one. 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 id tiebreak must walk both rows exactly - /// once and then terminate. + /// 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. It did not at first: a - /// `+00:00` offset decodes to a space in a query string, and the walk sat on - /// the same row forever. + /// that the emitted cursor survives that round trip. #[sqlx::test] - async fn colliding_timestamps_page_once_each_with_the_id_tiebreak(pool: PgPool) { + async fn colliding_timestamps_page_once_each(pool: PgPool) { let state = test_state(pool).await; state .db @@ -1845,21 +1917,9 @@ mod push_events_tests { "page size of one must return one row, got {first}" ); - let next_since = first["next_since"] - .as_str() - .expect("next_since") - .to_string(); - assert!( - !next_since.contains('+'), - "the cursor is echoed into a query string, where `+` means a space; \ - emitting one makes a naive poller re-read the same page forever, got \ - {next_since}" - ); + let next_cursor = first["next_cursor"].as_i64().expect("next_cursor"); - let cursor = format!( - "since={next_since}&since_id={}&limit=1", - first["next_since_id"].as_str().expect("next_since_id"), - ); + 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!( @@ -1870,7 +1930,7 @@ mod push_events_tests { assert_ne!( page1[0].0, page2[0].0, - "the id tiebreak must advance past the first row, not repeat it" + "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(); @@ -1884,9 +1944,8 @@ mod push_events_tests { ); let cursor2 = format!( - "since={}&since_id={}&limit=1", - second["next_since"].as_str().expect("next_since"), - second["next_since_id"].as_str().expect("next_since_id"), + "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!( @@ -1895,6 +1954,195 @@ mod push_events_tests { ); } + /// 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. @@ -1916,12 +2164,7 @@ mod push_events_tests { ) .await; - let resp = poll( - &state, - Some(OWNER), - &poll_uri("widget", "since=2026-08-07T13:00:00.000000Z"), - ) - .await; + let resp = poll(&state, Some(OWNER), &poll_uri("widget", "cursor=999999")).await; assert_eq!( resp.status(), StatusCode::OK, @@ -1933,9 +2176,26 @@ mod push_events_tests { "expected an empty page, got {body}" ); assert_eq!(body["count"].as_u64(), Some(0)); - assert!( - body["next_since"].is_null() && body["next_since_id"].is_null(), - "an empty page must not invent a cursor, got {body}" + 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}" ); } diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index f08f2b6a..d516ec06 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -173,7 +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.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"); @@ -509,7 +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.rs"), "status.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/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 4f729398..419dd756 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1681,6 +1681,31 @@ pub(crate) struct RefUpdate { /// 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. +/// Ceiling on how many ref updates from a single push turn into database work. +/// +/// `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 +/// without a cap one request decides how much work the node does. Well past any +/// real push: a repository-wide force-push of every branch is far below this. +/// Truncation is logged rather than silent. +pub(crate) const MAX_PUSH_FAN_OUT: usize = 256; + +/// The prefix of `ref_updates` this push is allowed to fan out into, with a +/// warning when the request declared more than that. +fn bounded_fan_out<'a>(ref_updates: &'a [RefUpdate], what: &str, repo_id: &str) -> &'a [RefUpdate] { + if ref_updates.len() <= MAX_PUSH_FAN_OUT { + return ref_updates; + } + tracing::warn!( + repo_id = %repo_id, + declared = ref_updates.len(), + cap = MAX_PUSH_FAN_OUT, + "push ref updates truncated at the per-push fan-out cap for {what}: {} declared", + ref_updates.len() + ); + &ref_updates[..MAX_PUSH_FAN_OUT] +} + pub(crate) async fn record_push_events( db: &crate::db::Db, repo_id: &str, @@ -1695,26 +1720,33 @@ pub(crate) async fn record_push_events( // then re-read the page it just consumed, forever. let created_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, /* use_z */ true); - for update in ref_updates { - // A deletion carries an all-zero new SHA. Recording it would hand a - // poller a target that resolves to no commit. - if update.new_sha == ZERO_SHA { - continue; - } - let event = crate::db::RepoPushEvent { - id: uuid::Uuid::new_v4().to_string(), - repo_id: repo_id.to_string(), - ref_name: update.ref_name.clone(), - after_sha: update.new_sha.clone(), - created_at: created_at.clone(), - }; - if let Err(e) = db.insert_repo_push_event(&event).await { - tracing::warn!( - err = %e, - ref_name = %update.ref_name, - "failed to record a push event for the catch-up poll surface" - ); - } + let events: Vec = + bounded_fan_out(ref_updates, "push events", repo_id) + .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 for the whole push. A loop here would put a database round + // trip per ref onto the push response, which is what the fan-out cap and this + // batch together exist to prevent. + if let Err(e) = db.insert_repo_push_events(&events).await { + tracing::warn!( + err = %e, + repo_id = %repo_id, + refs = events.len(), + "failed to record push events for the catch-up poll surface" + ); } } @@ -1736,13 +1768,15 @@ fn branch_from_ref(ref_name: &str) -> Option<&str> { /// Point every open pull request fed by this push at the branch's new tip. /// -/// One UPDATE per ref update, each keyed on (repo, source branch, open) — no -/// per-PR probing, no repo listing, no filesystem access, and the cost does not -/// grow with the number of open pull requests. 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. +/// One UPDATE for the whole push, 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 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]) { - for update in ref_updates { + let mut heads: Vec<(String, String)> = Vec::new(); + for update in bounded_fan_out(ref_updates, "stored pull request heads", repo_id) { // 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. @@ -1752,13 +1786,21 @@ async fn update_open_pr_heads(db: &crate::db::Db, repo_id: &str, ref_updates: &[ let Some(branch) = branch_from_ref(&update.ref_name) else { continue; }; - if let Err(e) = db.set_open_pr_heads(repo_id, branch, &update.new_sha).await { - tracing::warn!( - err = %e, - ref_name = %update.ref_name, - "failed to update stored pull request heads for a pushed branch" - ); - } + // 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. + heads.retain(|(existing, _)| existing != branch); + heads.push((branch.to_string(), update.new_sha.clone())); + } + + if let Err(e) = db.set_open_pr_heads_batch(repo_id, &heads).await { + tracing::warn!( + err = %e, + repo_id = %repo_id, + branches = heads.len(), + "failed to update stored pull request heads for a pushed branch" + ); } } @@ -2916,16 +2958,12 @@ mod tests { #[test] fn the_push_handler_actually_calls_the_head_update() { let src = include_str!("repos.rs"); - let start = src - .find("fn git_receive_pack(") - .expect("git_receive_pack not found (renamed or removed?)"); - let rest = &src[start..]; - let end = rest.find("\n}").map(|i| i + 2).unwrap_or(rest.len()); - let body: String = rest[..end] - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n"); + 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)"), @@ -2962,6 +3000,176 @@ mod tests { 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))); + } + + /// `parse_ref_updates` puts no ceiling on how many refs one request may + /// declare, so the fan-out has to bound itself. Beyond the cap the extra refs + /// are dropped, and the drop is logged rather than silent. A push that + /// quietly loses ref updates is worse than one that says so. + #[sqlx::test] + async fn a_push_beyond_the_fan_out_cap_truncates_and_says_so(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + let over = MAX_PUSH_FAN_OUT + 7; + let updates = many_ref_updates(over); + + let logs = capture_logs(); + record_push_events(&db, "repo-cap", &updates).await; + update_open_pr_heads(&db, "repo-cap", &updates).await; + let logged = logs.contents(); + + let rows: i64 = + sqlx::query_scalar("SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-cap'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!( + rows as usize, MAX_PUSH_FAN_OUT, + "the per-push fan-out must stop at the cap, got {rows} rows for a \ + {over}-ref push" + ); + assert!( + logged.contains("truncated") && logged.contains(&over.to_string()), + "truncation must be logged with the size that caused it, got: {logged}" + ); + } + + /// The cap is a ceiling, not a floor: an ordinary push is unaffected by it. + #[sqlx::test] + async fn a_push_under_the_cap_is_recorded_whole(pool: sqlx::PgPool) { + let db = pr_head_db(pool).await; + let updates = many_ref_updates(MAX_PUSH_FAN_OUT); + + let logs = capture_logs(); + record_push_events(&db, "repo-atcap", &updates).await; + let logged = logs.contents(); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-atcap'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!( + rows as usize, MAX_PUSH_FAN_OUT, + "a push exactly at the cap keeps every ref" + ); + assert!( + !logged.contains("truncated"), + "a push at the cap must not report a truncation, got: {logged}" + ); + } + /// 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 @@ -3069,16 +3277,12 @@ mod push_event_wiring_tests { #[test] fn the_push_handler_actually_records_push_events() { let src = include_str!("repos.rs"); - let start = src - .find("fn git_receive_pack(") - .expect("git_receive_pack not found (renamed or removed?)"); - let rest = &src[start..]; - let end = rest.find("\n}").map(|i| i + 2).unwrap_or(rest.len()); - let body: String = rest[..end] - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n"); + 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)"), @@ -3095,13 +3299,23 @@ mod push_event_wiring_tests { #[test] fn the_push_event_recorder_never_targets_the_gossip_table() { let src = include_str!("repos.rs"); - let start = src - .find("async fn record_push_events(") - .expect("record_push_events not found (renamed or removed?)"); - let rest = &src[start..]; - let end = rest.find("\n}").map(|i| i + 2).unwrap_or(rest.len()); + 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!( - !rest[..end].contains("insert_ref_update"), + !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.rs b/crates/gitlawb-node/src/api/status.rs deleted file mode 100644 index 474173fe..00000000 --- a/crates/gitlawb-node/src/api/status.rs +++ /dev/null @@ -1,3054 +0,0 @@ -//! 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. - -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" - ))); - }; - // 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", material.body.len(), MAX_REQUEST_BODY_BYTES)?; - - let (signature, signature_input, signing_string, request_body) = ( - material.signature, - material.signature_input, - material.signing_string, - material.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, - 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)), - )), - crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!( - "claim limit reached for {which}" - ))), - } -} - -/// 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 { - 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. - fn sample_material() -> crate::auth::SignatureMaterial { - crate::auth::SignatureMaterial { - signature: "sig1=:dGVzdA==:".to_string(), - signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" - .to_string(), - signing_string: "\"@method\": POST".to_string(), - body: 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_eq!(claims[0].signature, "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: 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" - ); - } - - /// 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 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(); - let resp = crate::server::build_router(state.clone()) - .oneshot(req) - .await - .unwrap(); - (resp, signed) - } - - /// 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 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, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,'','','',''::bytea,$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!("status.rs"); - let (body_of_module, rest) = src - .split_once("\n#[cfg(test)]\nmod tests {") - .expect("module has a tests module"); - assert!( - !rest.is_empty() && 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, created_at) - SELECT md5(random()::text || g::text), $1, $2, 'success', $3, $4, $4, - '', '', '', ''::bytea, '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, 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, $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. Each call takes a strictly later timestamp than the last, so a - /// second call to the same branch reads as a later push rather than tying - /// with the first. - 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 explicit because the fallback picks the most recent row for - /// the ref and `created_at` is the ordering key; letting two seeds share a - /// wall clock would leave which one wins to the uuid tiebreak. - 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(), - 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(), - 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!("status.rs"); - // Split on the tests module itself, not on any `#[cfg(test)]` attribute: - // 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, rest) = src - .split_once("\n#[cfg(test)]\nmod tests {") - .expect("module has a tests module"); - assert!( - !rest.is_empty() && 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/api/status/mod.rs b/crates/gitlawb-node/src/api/status/mod.rs new file mode 100644 index 00000000..a37f3c8d --- /dev/null +++ b/crates/gitlawb-node/src/api/status/mod.rs @@ -0,0 +1,530 @@ +//! 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. + +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" + ))); + }; + // 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", material.body.len(), MAX_REQUEST_BODY_BYTES)?; + + let (signature, signature_input, signing_string, request_body) = ( + material.signature, + material.signature_input, + material.signing_string, + material.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, + 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)), + )), + crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!( + "claim limit reached for {which}" + ))), + } +} + +/// 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..520f3e96 --- /dev/null +++ b/crates/gitlawb-node/src/api/status/tests.rs @@ -0,0 +1,2523 @@ +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. +fn sample_material() -> crate::auth::SignatureMaterial { + crate::auth::SignatureMaterial { + signature: "sig1=:dGVzdA==:".to_string(), + signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" + .to_string(), + signing_string: "\"@method\": POST".to_string(), + body: 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_eq!(claims[0].signature, "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: 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" + ); +} + +/// 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 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(); + let resp = crate::server::build_router(state.clone()) + .oneshot(req) + .await + .unwrap(); + (resp, signed) +} + +/// 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 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, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'','','',''::bytea,$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, created_at) + SELECT md5(random()::text || g::text), $1, $2, 'success', $3, $4, $4, + '', '', '', ''::bytea, '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, 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, $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. Each call takes a strictly later timestamp than the last, so a +/// second call to the same branch reads as a later push rather than tying +/// with the first. +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 explicit because the fallback picks the most recent row for +/// the ref and `created_at` is the ordering key; letting two seeds share a +/// wall clock would leave which one wins to the uuid tiebreak. +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/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 1b2b9f0b..741ab889 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -202,11 +202,17 @@ pub struct Webhook { #[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, } @@ -1045,21 +1051,65 @@ const MIGRATIONS: &[Migration] = &[ // 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: (created_at, id) ascending within a repo, so - // a poller with a since-cursor pages forward with an id tiebreak - // when two pushes share a timestamp. - "CREATE INDEX IF NOT EXISTS idx_repo_push_events_cursor ON repo_push_events(repo_id, created_at, id)", + // 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 { @@ -2030,21 +2080,73 @@ impl Db { /// 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 { - let result = sqlx::query( - "UPDATE pull_requests SET head_commit=$1 - WHERE repo_id=$2 AND source_branch=$3 AND status='open'", - ) - .bind(new_sha) - .bind(repo_id) - .bind(branch) - .execute(&self.pool) - .await?; + 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 bound the list (see `api::repos::MAX_PUSH_FAN_OUT`): every + /// pair costs two bind parameters, against a protocol ceiling of 65535. + /// + /// 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()) } @@ -2929,19 +3031,64 @@ impl Db { 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<()> { - sqlx::query( + self.insert_repo_push_events(std::slice::from_ref(event)) + .await + } + + /// Record a whole push's events in one statement. 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. + /// + /// Callers must bound the slice (see `api::repos::MAX_PUSH_FAN_OUT`): every + /// row costs five bind parameters, against a protocol ceiling of 65535. + pub async fn insert_repo_push_events(&self, events: &[RepoPushEvent]) -> Result<()> { + if events.is_empty() { + return Ok(()); + } + 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 ($1,$2,$3,$4,$5) - ON CONFLICT(id) DO NOTHING", - ) - .bind(&event.id) - .bind(&event.repo_id) - .bind(&event.ref_name) - .bind(&event.after_sha) - .bind(&event.created_at) - .execute(&self.pool) - .await?; + VALUES {values} + ON CONFLICT(id) DO NOTHING" + ); + + 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(&self.pool).await?; Ok(()) } @@ -2956,10 +3103,11 @@ impl Db { /// 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. Ordering on - /// `(created_at, id)` rather than the timestamp alone matches the - /// `(repo_id, created_at, id)` index and is what makes "most recent" total — - /// one multi-ref push stamps every one of its rows with the same timestamp. + /// 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, @@ -2968,7 +3116,7 @@ impl Db { let row = sqlx::query( "SELECT after_sha FROM repo_push_events WHERE repo_id = $1 AND ref_name = $2 - ORDER BY created_at DESC, id DESC LIMIT 1", + ORDER BY seq DESC LIMIT 1", ) .bind(repo_id) .bind(ref_name) @@ -2977,16 +3125,18 @@ impl Db { Ok(row.map(|r| r.get("after_sha"))) } - /// One page of a repo's push events, OLDEST first, walked by a `(created_at, - /// id)` keyset cursor rather than an offset. + /// 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 the row-value predicate `(created_at, id) > (after_ts, - /// after_id)`, which matches the `ORDER BY` exactly. The id half is not - /// decoration: one multi-ref push stamps every one of its rows with the same - /// timestamp, so a timestamp-only cursor either repeats a row or drops one at - /// every page boundary that lands inside such a push. `(repo_id, created_at, - /// id)` is the index this predicate rides. + /// 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, so it cannot disagree with the order + /// the rows actually became visible. `created_at` cannot do this job: 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 @@ -2994,24 +3144,24 @@ impl Db { pub async fn list_repo_push_events_keyset( &self, repo_id: &str, - after: Option<(&str, &str)>, + after: Option, limit: i64, ) -> Result> { - const COLS: &str = "id, repo_id, ref_name, after_sha, created_at"; + const COLS: &str = "id, seq, repo_id, ref_name, after_sha, created_at"; - // Positional params in bind order: repo_id, after_ts?, after_id?, limit. + // Positional params in bind order: repo_id, after?, limit. let (cursor_clause, limit_param) = match after { - Some(_) => (" AND (created_at, id) > ($2, $3)", 4), + Some(_) => (" AND seq > $2", 3), None => ("", 2), }; let sql = format!( "SELECT {COLS} FROM repo_push_events WHERE repo_id = $1{cursor_clause} \ - ORDER BY created_at ASC, id ASC LIMIT ${limit_param}" + ORDER BY seq ASC LIMIT ${limit_param}" ); let mut q = sqlx::query(&sql).bind(repo_id.to_string()); - if let Some((ts, id)) = after { - q = q.bind(ts.to_string()).bind(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()) @@ -3021,6 +3171,7 @@ impl Db { 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"), @@ -4675,6 +4826,64 @@ mod migration_tests { 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" + ); + } + /// 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 diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index be6fb7b8..747bf421 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -109,6 +109,114 @@ 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 byte, so an `end` anchor that also matches at the + // very start of the region cannot collapse it to nothing. + Some(anchor) => rest.get(1..)?.find(anchor)? + 1, + 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()); + } + + /// The end anchor is searched past the first byte, 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::*; From d3074a6c867d5db7e923aeea6dd6b5e65f5b1100 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:06:10 -0500 Subject: [PATCH 11/13] fix(status): make a replayed write idempotent and stop dropping refs Both defects came from an independent cross-model review, and neither was visible to the reviewers that share this session's model. A signed write was protected only by clock skew: no nonce, no idempotency key, no record of signatures already seen. So a captured request replayed inside the skew window inserted a new row with a new sequence number, and because the projection takes the latest claim per producer and context, a stale success outranked the failure that superseded it. The append-only design is what made this reversible rather than merely duplicative: a replay that only duplicated a row would be harmless, a replay that earns a fresh sequence flips the answer. An earlier reviewer saw the replay window and concluded append-only bounded it, which had the mechanism exactly backwards. An exact replay is now idempotent instead of an error. The row carries a digest over the signature, its input, and the body, a unique index enforces it in the database rather than a check-then-insert race, and a repeat returns the original claim. Erroring would have punished the legitimate case this design otherwise handles worst: a client whose response was lost retrying the same signed request and concluding its report failed when it had succeeded. The fan-out cap was mine, added while fixing unbounded work, and it truncated at 256 refs after receive-pack had already accepted them. A push whose pull request source branch sat past that point left the head permanently stale with no push event, announced only by a log line git had already contradicted. The writers chunk now, so nothing accepted is dropped, and the bound that remains refuses before the accept rather than after: chunking bounds a statement, not a request, and the work that scales with ref count sits upstream anyway, one protection query per ref before the service runs and one certificate and webhook per ref after, with body limits disabled on the git routes. Removing the cap also exposed a quadratic branch dedupe it had been keeping cheap; that is a map now. --- crates/gitlawb-node/src/api/repos.rs | 346 ++++++++++++++------ crates/gitlawb-node/src/api/status/mod.rs | 47 +++ crates/gitlawb-node/src/api/status/tests.rs | 177 +++++++++- crates/gitlawb-node/src/db/mod.rs | 174 +++++++++- 4 files changed, 624 insertions(+), 120 deletions(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 419dd756..645b0d50 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 @@ -1681,29 +1685,51 @@ pub(crate) struct RefUpdate { /// 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. -/// Ceiling on how many ref updates from a single push turn into database work. +/// How many ref updates of one push go into a single database statement. /// -/// `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 -/// without a cap one request decides how much work the node does. Well past any -/// real push: a repository-wide force-push of every branch is far below this. -/// Truncation is logged rather than silent. -pub(crate) const MAX_PUSH_FAN_OUT: usize = 256; - -/// The prefix of `ref_updates` this push is allowed to fan out into, with a -/// warning when the request declared more than that. -fn bounded_fan_out<'a>(ref_updates: &'a [RefUpdate], what: &str, repo_id: &str) -> &'a [RefUpdate] { - if ref_updates.len() <= MAX_PUSH_FAN_OUT { - return ref_updates; - } - tracing::warn!( - repo_id = %repo_id, - declared = ref_updates.len(), - cap = MAX_PUSH_FAN_OUT, - "push ref updates truncated at the per-push fan-out cap for {what}: {} declared", - ref_updates.len() - ); - &ref_updates[..MAX_PUSH_FAN_OUT] +/// 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( @@ -1720,33 +1746,36 @@ pub(crate) async fn record_push_events( // then re-read the page it just consumed, forever. let created_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, /* use_z */ true); - let events: Vec = - bounded_fan_out(ref_updates, "push events", repo_id) - .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(); + 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 for the whole push. A loop here would put a database round - // trip per ref onto the push response, which is what the fan-out cap and this - // batch together exist to prevent. - if let Err(e) = db.insert_repo_push_events(&events).await { - tracing::warn!( - err = %e, - repo_id = %repo_id, - refs = events.len(), - "failed to record push events for the catch-up poll surface" - ); + // 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) { + if let Err(e) = db.insert_repo_push_events(chunk).await { + tracing::warn!( + err = %e, + repo_id = %repo_id, + refs = chunk.len(), + "failed to record push events for the catch-up poll surface" + ); + } } } @@ -1768,15 +1797,20 @@ fn branch_from_ref(ref_name: &str) -> Option<&str> { /// Point every open pull request fed by this push at the branch's new tip. /// -/// One UPDATE for the whole push, keyed on (repo, source branch, open): no +/// 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 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. +/// 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(); - for update in bounded_fan_out(ref_updates, "stored pull request heads", repo_id) { + // 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. @@ -1790,17 +1824,26 @@ async fn update_open_pr_heads(db: &crate::db::Db, repo_id: &str, ref_updates: &[ // 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. - heads.retain(|(existing, _)| existing != branch); - heads.push((branch.to_string(), update.new_sha.clone())); + 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())); + } + } } - if let Err(e) = db.set_open_pr_heads_batch(repo_id, &heads).await { - tracing::warn!( - err = %e, - repo_id = %repo_id, - branches = heads.len(), - "failed to update stored pull request heads for a pushed branch" - ); + // 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" + ); + } } } @@ -3113,60 +3156,175 @@ mod tests { assert_eq!(head_of(&db, "repo-fan", 2).await, Some(sha_for(3))); } - /// `parse_ref_updates` puts no ceiling on how many refs one request may - /// declare, so the fan-out has to bound itself. Beyond the cap the extra refs - /// are dropped, and the drop is logged rather than silent. A push that - /// quietly loses ref updates is worse than one that says so. + /// 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_beyond_the_fan_out_cap_truncates_and_says_so(pool: sqlx::PgPool) { + 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 = MAX_PUSH_FAN_OUT + 7; + 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-cap", &updates).await; - update_open_pr_heads(&db, "repo-cap", &updates).await; + 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-cap'") - .fetch_one(db.pool()) - .await - .unwrap(); + 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!( - rows as usize, MAX_PUSH_FAN_OUT, - "the per-push fan-out must stop at the cap, got {rows} rows for a \ - {over}-ref push" + 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") && logged.contains(&over.to_string()), - "truncation must be logged with the size that caused it, got: {logged}" + !logged.contains("truncated"), + "nothing is dropped, so nothing is truncated, got: {logged}" ); } - /// The cap is a ceiling, not a floor: an ordinary push is unaffected by it. + /// 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_under_the_cap_is_recorded_whole(pool: sqlx::PgPool) { + async fn a_push_past_the_chunk_boundary_costs_one_statement_per_chunk(pool: sqlx::PgPool) { let db = pr_head_db(pool).await; - let updates = many_ref_updates(MAX_PUSH_FAN_OUT); + 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); - let logs = capture_logs(); - record_push_events(&db, "repo-atcap", &updates).await; - let logged = logs.contents(); + 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" + ); + } + + /// 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-atcap'", + "SELECT count(*) FROM repo_push_events WHERE repo_id = 'repo-atchunk'", ) .fetch_one(db.pool()) .await .unwrap(); assert_eq!( - rows as usize, MAX_PUSH_FAN_OUT, - "a push exactly at the cap keeps every ref" - ); - assert!( - !logged.contains("truncated"), - "a push at the cap must not report a truncation, got: {logged}" + rows as usize, PUSH_WRITE_CHUNK, + "a push of exactly one chunk keeps every ref" ); } diff --git a/crates/gitlawb-node/src/api/status/mod.rs b/crates/gitlawb-node/src/api/status/mod.rs index a37f3c8d..3e59281a 100644 --- a/crates/gitlawb-node/src/api/status/mod.rs +++ b/crates/gitlawb-node/src/api/status/mod.rs @@ -5,6 +5,10 @@ //! //! 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; @@ -157,6 +161,7 @@ pub async fn create_status( )?; bound("request body", material.body.len(), MAX_REQUEST_BODY_BYTES)?; + let digest = request_digest(&material); let (signature, signature_input, signing_string, request_body) = ( material.signature, material.signature_input, @@ -182,6 +187,7 @@ pub async fn create_status( signature_input, signing_string, request_body, + request_digest: digest, created_at: Utc::now().to_rfc3339(), }; @@ -195,12 +201,53 @@ pub async fn create_status( 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) -> 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(), + &material.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)] diff --git a/crates/gitlawb-node/src/api/status/tests.rs b/crates/gitlawb-node/src/api/status/tests.rs index 520f3e96..d82debb9 100644 --- a/crates/gitlawb-node/src/api/status/tests.rs +++ b/crates/gitlawb-node/src/api/status/tests.rs @@ -57,10 +57,20 @@ fn signed_with_material(did: &str, uri: &str, body: Body) -> axum::http::Request req } -/// Stand-in material, well inside every bound. +/// 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: "sig1=:dGVzdA==:".to_string(), + signature: format!("sig1=:dGVzdA=={nth}:"), signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" .to_string(), signing_string: "\"@method\": POST".to_string(), @@ -125,7 +135,7 @@ async fn owner_writes_a_claim_recording_both_dids(pool: PgPool) { 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_eq!(claims[0].signature, "sig1=:dGVzdA==:"); + 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"{}"); @@ -677,6 +687,22 @@ async fn post_really_signed( 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) @@ -686,11 +712,10 @@ async fn post_really_signed( .header("signature", signed.signature.clone()) .body(Body::from(body.to_vec())) .unwrap(); - let resp = crate::server::build_router(state.clone()) + crate::server::build_router(state.clone()) .oneshot(req) .await - .unwrap(); - (resp, signed) + .unwrap() } /// Re-verify a stored claim from the row alone, the way a third party @@ -853,6 +878,130 @@ async fn middleware_persists_the_material_the_client_actually_sent(pool: PgPool) assert_eq!(claim.context, "ci/wire"); } +/// 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. @@ -936,8 +1085,9 @@ async fn seed_claim( sqlx::query( "INSERT INTO status_claims (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, - signature, signature_input, signing_string, request_body, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,'','','',''::bytea,$8)", + 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) @@ -1569,9 +1719,11 @@ async fn seed_claims( sqlx::query( "INSERT INTO status_claims (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, - signature, signature_input, signing_string, request_body, created_at) + signature, signature_input, signing_string, request_body, request_digest, + created_at) SELECT md5(random()::text || g::text), $1, $2, 'success', $3, $4, $4, - '', '', '', ''::bytea, '2026-01-01T00:00:00Z' + '', '', '', ''::bytea, gen_random_uuid()::text, + '2026-01-01T00:00:00Z' FROM generate_series(1, $5) g", ) .bind(repo_id) @@ -1598,11 +1750,12 @@ async fn seed_claims_across_shas( sqlx::query( "INSERT INTO status_claims (id, repo_id, commit_sha, state, context, producer_did, authorizing_did, - signature, signature_input, signing_string, request_body, created_at) + 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, $4 + '', '', '', ''::bytea, gen_random_uuid()::text, $4 FROM generate_series(1, $3) g", ) .bind(repo_id) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 741ab889..4e346aa8 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -131,6 +131,13 @@ pub struct StatusClaim { 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, } @@ -1031,8 +1038,22 @@ const MIGRATIONS: &[Migration] = &[ -- 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. @@ -2106,8 +2127,11 @@ impl Db { /// 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 bound the list (see `api::repos::MAX_PUSH_FAN_OUT`): every - /// pair costs two bind parameters, against a protocol ceiling of 65535. + /// 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 @@ -2364,10 +2388,48 @@ impl Db { 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, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + 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. /// @@ -2377,10 +2439,11 @@ const INSERT_STATUS_CLAIM_SQL: &str = "INSERT INTO status_claims /// 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( - claim: &StatusClaim, -) -> sqlx::query::Query<'_, sqlx::Postgres, sqlx::postgres::PgArguments> { - sqlx::query(INSERT_STATUS_CLAIM_SQL) +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) @@ -2394,6 +2457,7 @@ fn status_claim_insert( .bind(&claim.signature_input) .bind(&claim.signing_string) .bind(&claim.request_body) + .bind(&claim.request_digest) .bind(&claim.created_at) } @@ -2437,8 +2501,15 @@ pub struct ClaimCaps { /// 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), } @@ -2450,11 +2521,18 @@ impl Db { // primitive the db tests drive directly. #[allow(dead_code)] pub async fn insert_status_claim(&self, claim: &StatusClaim) -> Result { - let row = status_claim_insert(claim).fetch_one(&self.pool).await?; + let row = status_claim_insert(INSERT_STATUS_CLAIM_SQL, claim) + .fetch_one(&self.pool) + .await?; Ok(row.get::("seq")) } - /// Append one claim only if all three caps still admit it. + /// 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 @@ -2480,6 +2558,13 @@ impl Db { .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 @@ -2535,7 +2620,19 @@ impl Db { )); } - let inserted = status_claim_insert(claim).fetch_one(&mut *tx).await?; + // 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)) @@ -2552,7 +2649,7 @@ impl Db { 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,created_at + 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) @@ -3051,8 +3148,10 @@ impl Db { /// order, which is exactly why the poll cursor can trust it; a value supplied /// here would defeat that. /// - /// Callers must bound the slice (see `api::repos::MAX_PUSH_FAN_OUT`): every - /// row costs five bind parameters, against a protocol ceiling of 65535. + /// 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. pub async fn insert_repo_push_events(&self, events: &[RepoPushEvent]) -> Result<()> { if events.is_empty() { return Ok(()); @@ -3669,6 +3768,7 @@ fn row_to_status_claim(r: sqlx::postgres::PgRow) -> StatusClaim { 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"), } } @@ -4691,6 +4791,12 @@ mod migration_tests { 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(), } } @@ -5237,6 +5343,46 @@ mod migration_tests { ); } + /// 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 From 5445dda3f521c67b6ce3319fdd6c88f78b0a65dd Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:07:47 -0500 Subject: [PATCH 12/13] fix(db): stop the push cursor skipping a concurrently committed row The sequence is a bigserial, so Postgres allocates it at INSERT and makes it visible at COMMIT, and nextval does not roll back. Two overlapping writes to one repo could therefore commit out of sequence order: one allocates, another allocates and commits, a poller reads the later row and advances, and the earlier row then becomes visible behind a cursor that has already passed it. Never delivered, no error, no gap signal, which is the one thing the catch-up surface exists to prevent. This is the second time this branch has traded one ordering hazard for another. The cursor moved off a wall clock because stamping and committing disagree; it moved onto a sequence where allocating and committing disagree. The insert now runs in an explicit transaction that takes a per-repo advisory lock, so the lock releases at commit and no writer can allocate until the previous row is visible. The wait is bounded, because the lock sits on a pooled connection on the inline push path and an unbounded wait would turn one busy repo into pool exhaustion for unrelated requests. Three attempts at a one second timeout leaves headroom under the pool's own acquire timeout. That bound needs the caller to stop swallowing failures. It warned and continued, so any error the lock introduced would silently drop those events forever while the push had already returned success, reaching the same outcome by another route. It now retries once and then logs at error level naming the repo, the refs, and the SHAs that were lost. The defect is pinned by a test that owns both transaction boundaries rather than a barrier. A barrier cannot reproduce it: before this change the write was a single autocommit statement, so allocate and commit were one round trip from the client and the interleaving window was never open to a caller of the public API. --- crates/gitlawb-node/src/api/repos.rs | 74 ++++- crates/gitlawb-node/src/db/mod.rs | 399 ++++++++++++++++++++++++++- 2 files changed, 464 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 645b0d50..bc85c33f 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1768,12 +1768,37 @@ pub(crate) async fn record_push_events( // event is written either way, because receive-pack has already accepted // them. for chunk in events.chunks(PUSH_WRITE_CHUNK) { - if let Err(e) = db.insert_repo_push_events(chunk).await { + 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(), - "failed to record push events for the catch-up poll surface" + lost = %lost, + "push events lost; the catch-up poll surface will never deliver them" ); } } @@ -3299,6 +3324,51 @@ mod tests { ); } + /// 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] diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 4e346aa8..0045ebe3 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -3126,6 +3126,31 @@ 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. /// @@ -3138,7 +3163,8 @@ impl Db { .await } - /// Record a whole push's events in one statement. Idempotent on the row ids. + /// 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 @@ -3148,14 +3174,43 @@ impl Db { /// 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. + /// 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 { @@ -3177,7 +3232,51 @@ impl Db { ON CONFLICT(id) DO NOTHING" ); - let mut q = sqlx::query(&sql); + 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) @@ -3187,8 +3286,9 @@ impl Db { .bind(&e.created_at); } count_push_write_statement(); - q.execute(&self.pool).await?; - Ok(()) + q.execute(&mut *tx).await?; + + tx.commit().await } /// The SHA of the most recent push this node recorded for one ref of a repo, @@ -3231,8 +3331,14 @@ impl Db { /// 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, so it cannot disagree with the order - /// the rows actually became visible. `created_at` cannot do this job: it is + /// 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. @@ -4990,6 +5096,285 @@ mod migration_tests { ); } + /// 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 From 61033af2618a603de7a4daf39718cd89d17997e5 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:07:10 -0500 Subject: [PATCH 13/13] fix(auth): carry the request body only where a route persists it The signature middleware handed every signed request a second handle to the buffered body, and only the status write reads it. The field is optional now and populated behind a marker the persisting route group applies. Be accurate about what that saves, because the first reading of this finding overstated it and the plan was corrected before implementation. The push handler takes its body as an extractor, so axum consumes the request and drops every extension at that point; the extra handle lived across the remaining middleware chain, not the handler's lifetime. The whole pack really is pinned for the whole receive-pack, but by the handler's own parameter, which is pre-existing and untouched here. Layer order is the failure mode worth guarding. A marker applied inside the auth layers is never seen by the middleware that reads it, so the body silently stops being captured while every test stays green. The mutation that proves this leaves the layer present on the same group and only moves it inside, and the production-router test still reddens. The handler also refuses an absent body rather than storing an empty column, so a lost marker fails loudly at the one place it matters. Four comments claimed a timestamp or a uuid tiebreak decided ordering when the code orders on the sequence. Three were named in review; the fourth turned up next to them, calling the timestamp format load-bearing for a cursor that no longer reads it. The mirror-dedup comments that correctly document timestamp ordering were checked and left alone. The shared scrape helper advanced by one byte to stop an end anchor matching at position zero, which reports a missing anchor for any region starting on a multibyte character. It advances by a character now. Not reachable today since every anchor is ASCII, but five guards share this helper and one that silently finds nothing is worse than one that fails. --- crates/gitlawb-node/src/api/repos.rs | 22 +-- crates/gitlawb-node/src/api/status/mod.rs | 24 +++- crates/gitlawb-node/src/api/status/tests.rs | 117 ++++++++++++++-- crates/gitlawb-node/src/auth/mod.rs | 142 ++++++++++++++++++-- crates/gitlawb-node/src/server.rs | 8 ++ crates/gitlawb-node/src/test_support.rs | 49 ++++++- 6 files changed, 322 insertions(+), 40 deletions(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index bc85c33f..c8769a07 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1681,10 +1681,12 @@ pub(crate) struct RefUpdate { /// 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 `(created_at, id)` rather than the timestamp alone. 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. +/// 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 @@ -1738,12 +1740,12 @@ pub(crate) async fn record_push_events( ref_updates: &[RefUpdate], ) { // Canonical UTC rfc3339 with a fixed sub-second width and a `Z` offset, not - // the default `+00:00`. Two reasons, both load-bearing for the cursor. The - // column is TEXT and the keyset predicate compares it lexicographically, so a - // single fixed width is what makes string order equal time order. And the - // value is handed back to the poller as a query parameter, where a literal - // `+` decodes to a space: a client that echoes the cursor unencoded would - // then re-read the page it just consumed, forever. + // 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 diff --git a/crates/gitlawb-node/src/api/status/mod.rs b/crates/gitlawb-node/src/api/status/mod.rs index 3e59281a..42ec0866 100644 --- a/crates/gitlawb-node/src/api/status/mod.rs +++ b/crates/gitlawb-node/src/api/status/mod.rs @@ -146,6 +146,20 @@ pub async fn create_status( 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)?; @@ -159,14 +173,14 @@ pub async fn create_status( material.signing_string.len(), MAX_SIGNING_STRING_CHARS, )?; - bound("request body", material.body.len(), MAX_REQUEST_BODY_BYTES)?; + bound("request body", body.len(), MAX_REQUEST_BODY_BYTES)?; - let digest = request_digest(&material); + let digest = request_digest(&material, &body); let (signature, signature_input, signing_string, request_body) = ( material.signature, material.signature_input, material.signing_string, - material.body.to_vec(), + body.to_vec(), ); let claim = StatusClaim { @@ -233,14 +247,14 @@ pub async fn create_status( /// 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) -> String { +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(), - &material.body, + body, ] { h.update((part.len() as u64).to_be_bytes()); h.update(part); diff --git a/crates/gitlawb-node/src/api/status/tests.rs b/crates/gitlawb-node/src/api/status/tests.rs index d82debb9..f04d45a3 100644 --- a/crates/gitlawb-node/src/api/status/tests.rs +++ b/crates/gitlawb-node/src/api/status/tests.rs @@ -74,7 +74,7 @@ fn sample_material() -> crate::auth::SignatureMaterial { signature_input: "sig1=(\"@method\" \"@path\" \"content-digest\");alg=\"ed25519\"" .to_string(), signing_string: "\"@method\": POST".to_string(), - body: axum::body::Bytes::from_static(b"{}"), + body: Some(axum::body::Bytes::from_static(b"{}")), } } @@ -564,7 +564,7 @@ async fn oversized_signature_material_is_rejected_with_400(pool: PgPool) { ( "request body", crate::auth::SignatureMaterial { - body: vec![b'b'; super::MAX_REQUEST_BODY_BYTES + 1].into(), + body: Some(vec![b'b'; super::MAX_REQUEST_BODY_BYTES + 1].into()), ..sample_material() }, ), @@ -611,6 +611,64 @@ async fn oversized_signature_material_is_rejected_with_400(pool: PgPool) { ); } +/// 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 @@ -878,6 +936,44 @@ async fn middleware_persists_the_material_the_client_actually_sent(pool: PgPool) 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. /// @@ -1820,9 +1916,11 @@ fn seed_pr(repo_id: &str, number: i64, source_branch: &str) -> crate::db::PullRe /// 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. Each call takes a strictly later timestamp than the last, so a -/// second call to the same branch reads as a later push rather than tying -/// with the first. +/// 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, @@ -1837,9 +1935,12 @@ async fn seed_branch_head( /// 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 explicit because the fallback picks the most recent row for -/// the ref and `created_at` is the ordering key; letting two seeds share a -/// wall clock would leave which one wins to the uuid tiebreak. +/// 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, diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index 61157633..03eca1a4 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -34,20 +34,36 @@ pub struct SignatureMaterial { pub signature_input: String, /// The canonical bytes the signature covered. pub signing_string: String, - /// The buffered request body. 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. + /// 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. /// - /// `Bytes`, not `Vec`, because every signed request pays for this field - /// and a receive-pack POST is capped at GITLAWB_MAX_PACK_BYTES (2 GB by - /// default). Cloning the buffer here would double the peak memory of every - /// push to carry a value only the status write path ever reads; a `Bytes` - /// clone is a refcount bump over the buffer the middleware already holds. - pub body: axum::body::Bytes, + /// `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 @@ -269,11 +285,19 @@ 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: body_bytes.clone(), + body, }; let mut request = Request::from_parts(parts, Body::from(body_bytes)); @@ -564,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/server.rs b/crates/gitlawb-node/src/server.rs index 43c90e96..60304411 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -197,6 +197,14 @@ pub fn build_router(state: AppState) -> Router { .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(), diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 747bf421..9adc86bf 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -142,9 +142,16 @@ pub(crate) fn scrape_source_region( }; let rest = &src[from..]; let to = match end { - // Search past the first byte, so an `end` anchor that also matches at the - // very start of the region cannot collapse it to nothing. - Some(anchor) => rest.get(1..)?.find(anchor)? + 1, + // 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( @@ -204,8 +211,40 @@ mod scrape_source_region_tests { assert!(scrape_source_region(SRC, Some("fn target()"), Some("~absent~")).is_none()); } - /// The end anchor is searched past the first byte, so a region whose own - /// start also matches the end anchor is not collapsed to nothing. + /// 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";