@@ -2123,6 +2123,125 @@ pub async fn git_receive_pack(
21232123 }
21242124 }
21252125
2126+ // ── Internal-namespace push gate ────────────────────────────────
2127+ // `refs/gitlawb/*` carries node-managed metadata (request markers as
2128+ // blobs under `requests/`, issue JSON blobs under `issues/`). A pusher
2129+ // must never write there: an attacker-controlled ref pointing at a blob
2130+ // or tree is skipped by `rev-list --all` yet still served, bypassing
2131+ // the fail-closed under-withhold check. Refuse before intent is
2132+ // written so a denied push leaves no rows behind.
2133+ if let Some(bad) = ref_updates
2134+ .iter()
2135+ .map(|u| u.ref_name.as_str())
2136+ .find(|r| r.starts_with("refs/gitlawb/"))
2137+ {
2138+ tracing::warn!(
2139+ repo = %name,
2140+ ref_name = %bad,
2141+ "refusing push into internal refs/gitlawb/ namespace"
2142+ );
2143+ return Err(AppError::Forbidden(
2144+ "pushing to refs/gitlawb/* is not allowed".into(),
2145+ ));
2146+ }
2147+
2148+ // #26 Split PR 1: durable intent for this push, written BEFORE the
2149+ // per-repo lease, admission permits, and the receive_pack call. Holding
2150+ // no lease and no admission permit across the DB transaction means a
2151+ // stalled statement cannot exhaust the push admission pool while the git
2152+ // resource the permit meters is idle. Bounded by a 30s timeout that
2153+ // refuses the push without running Git on incomplete intent; atomic
2154+ // parent+children so a refused pre-Git request cannot strand a
2155+ // payload-only parent.
2156+ let signature_header = headers
2157+ .get("signature")
2158+ .and_then(|v| v.to_str().ok())
2159+ .unwrap_or("")
2160+ .to_string();
2161+ let signature_input = headers
2162+ .get("signature-input")
2163+ .and_then(|v| v.to_str().ok())
2164+ .unwrap_or("")
2165+ .to_string();
2166+ let content_digest = headers
2167+ .get("content-digest")
2168+ .and_then(|v| v.to_str().ok())
2169+ .unwrap_or("")
2170+ .to_string();
2171+ let request_id = uuid::Uuid::new_v4().to_string();
2172+ let now = Utc::now().to_rfc3339();
2173+ let request_bytes_hash = {
2174+ use sha2::{Digest, Sha256};
2175+ let mut h = Sha256::new();
2176+ h.update(&body);
2177+ h.finalize().to_vec()
2178+ };
2179+ let req_row = crate::db::ReceivePackRequest {
2180+ id: request_id.clone(),
2181+ repo_id: record.id.clone(),
2182+ pusher_did: auth.0.to_string(),
2183+ node_did: state.node_did.to_string(),
2184+ request_bytes: Vec::new(),
2185+ request_bytes_hash,
2186+ state: crate::db::request_state::RECEIVED.to_string(),
2187+ git_exit_ok: None,
2188+ parsed_report: None,
2189+ accepted_ordinal: None,
2190+ attempt_count: 0,
2191+ last_error: None,
2192+ next_attempt_at: None,
2193+ created_at: now.clone(),
2194+ completed_at: None,
2195+ signature_header: Some(signature_header.clone()),
2196+ signature_input: Some(signature_input.clone()),
2197+ content_digest: Some(content_digest.clone()),
2198+ };
2199+ let db_timeout = std::time::Duration::from_secs(30);
2200+ let db_result = tokio::time::timeout(
2201+ db_timeout,
2202+ state.db.insert_receive_pack_request_with_children(
2203+ &req_row,
2204+ &record.id,
2205+ &state.node_did.to_string(),
2206+ auth.0.as_str(),
2207+ &ref_updates,
2208+ &signature_header,
2209+ &signature_input,
2210+ &content_digest,
2211+ ),
2212+ )
2213+ .await;
2214+ // Both timeout expiry and DB error refuse the push before any lease or
2215+ // permit is held, so admission capacity is never pinned by this write.
2216+ // The insert is one transaction: timeout cancellation rolls back rather
2217+ // than leaving a partially committed intent. `db_result` is
2218+ // `Result<Result<..>, Elapsed>`: the outer `Err` is the timeout, the
2219+ // inner `Err` is a real insert failure — both must refuse without
2220+ // running git, or refs land with no request rows and nothing to
2221+ // reconcile them.
2222+ match db_result {
2223+ Ok(Ok(_)) => {}
2224+ Ok(Err(e)) => {
2225+ tracing::error!(
2226+ err = %e,
2227+ repo = %name,
2228+ "failed to persist durable post-receive intent; refusing push"
2229+ );
2230+ return Err(AppError::Overloaded(
2231+ "durable intent write failed, retry shortly".into(),
2232+ ));
2233+ }
2234+ Err(_) => {
2235+ tracing::error!(
2236+ repo = %name,
2237+ "durable intent write timed out; refusing push"
2238+ );
2239+ return Err(AppError::Overloaded(
2240+ "durable intent write failed, retry shortly".into(),
2241+ ));
2242+ }
2243+ }
2244+
21262245 // Per-repo in-process write lease (#174 U2/F3): SUPPLEMENTS the cluster-wide pg
21272246 // advisory lock. Acquire it BEFORE acquire_write (one consistent order everywhere,
21282247 // so the two serializers can never invert into a self-hang) so a second SAME-NODE
@@ -2172,17 +2291,34 @@ pub async fn git_receive_pack(
21722291 // delete+recreate under the same slug while the bare repo on disk is reused,
21732292 // and an id-keyed lease stops serializing exactly across that rotation.
21742293 let repo_key = crate::state::repo_identity_key(&record.owner_did, &record.name);
2175- let lease = state
2294+ // Intent is already durable at this point: every refusal below must
2295+ // terminalize it via `refuse_receive_pack_before_git` so no
2296+ // `received` + `prepared` aggregate strands (never purged, never
2297+ // promotable without git evidence, and poisoning its tuple via
2298+ // `has_competing_claimant`).
2299+ let lease = match state
21762300 .repo_write_leases
21772301 .acquire(&repo_key, lease_steal_after)
21782302 .await
2179- . ok_or_else ( || {
2303+ {
2304+ Some(l) => l,
2305+ None => {
21802306 tracing::warn!(
21812307 repo = %name,
21822308 "repo write-lease waiter cap reached; shedding with 503"
21832309 );
2184- AppError :: Overloaded ( "repo is busy with another push, retry shortly" . into ( ) )
2185- } ) ?;
2310+ if let Err(e) = state
2311+ .db
2312+ .refuse_receive_pack_before_git(&request_id, "repo write-lease waiter cap reached")
2313+ .await
2314+ {
2315+ tracing::warn!(err = %e, request_id = %request_id, "pre-git refusal cleanup failed");
2316+ }
2317+ return Err(AppError::Overloaded(
2318+ "repo is busy with another push, retry shortly".into(),
2319+ ));
2320+ }
2321+ };
21862322
21872323 // Admission permits are taken HERE, AFTER the per-repo lease and BEFORE acquire_write.
21882324 // Ordering is the fix (#174 P2 DoS): the lease is a block-and-wait serializer, so a
@@ -2207,119 +2343,42 @@ pub async fn git_receive_pack(
22072343 // pushes draw from the dedicated WRITE pool, separate from reads, and it is held for the
22082344 // whole op (moved into the AdmissionGuard below).
22092345 let caller_key = read_caller_key(&headers, peer, state.push_limiter_trust);
2210- let _caller_permit = acquire_read_caller_permit (
2346+ let _caller_permit = match acquire_read_caller_permit(
22112347 &state.git_write_per_caller,
22122348 caller_key.as_deref(),
22132349 name,
22142350 "receive-pack",
2215- ) ?;
2216- let _permit = git_permit ( & state. git_write_semaphore ) ?;
2217-
2218- // #26 Split PR 1: durable intent for this push, written BEFORE
2219- // the receive_pack call. Every ref update the pusher intends to
2220- // land gets a `prepared` row carrying the verified pusher DID,
2221- // the raw RFC 9421 signature header, signature-input, and
2222- // content-digest that authorized the push, plus the request id.
2223- //
2224- // The state is flipped to `applied` (Ok) or `cancelled` (Err)
2225- // AFTER receive_pack returns. The drain reads only `applied`
2226- // rows, so a row that never gets the post-Ok flip stays in
2227- // `prepared` (handler crash / dropped future) or `cancelled`
2228- // (receive_pack Err) and is never promoted to a push event, a
2229- // certificate, or an anchor.
2230- //
2231- // Moved BEFORE admission guard creation to prevent unbounded DB
2232- // writes from exhausting the admission pool. The write is bounded
2233- // by a timeout that releases permits on expiry.
2234- let signature_header = headers
2235- . get ( "signature" )
2236- . and_then ( |v| v. to_str ( ) . ok ( ) )
2237- . unwrap_or ( "" )
2238- . to_string ( ) ;
2239- let signature_input = headers
2240- . get ( "signature-input" )
2241- . and_then ( |v| v. to_str ( ) . ok ( ) )
2242- . unwrap_or ( "" )
2243- . to_string ( ) ;
2244- let content_digest = headers
2245- . get ( "content-digest" )
2246- . and_then ( |v| v. to_str ( ) . ok ( ) )
2247- . unwrap_or ( "" )
2248- . to_string ( ) ;
2249- // #26 Split PR 1 — request-level intent row. Written BEFORE
2250- // `smart_http::receive_pack` runs, in state `received`, carrying
2251- // the raw HTTP body the handler will hand to git and the SHA-256
2252- // of it. The recovery drain (step 3) and the on-disk reconcile
2253- // (already on this branch) both key off this row; a node crash
2254- // between this write and the outcomes commit leaves the row in
2255- // `received` and its children in `prepared`, which is the
2256- // recoverable state.
2257- let request_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
2258- let now = Utc :: now ( ) . to_rfc3339 ( ) ;
2259- let request_bytes_hash = {
2260- use sha2:: { Digest , Sha256 } ;
2261- let mut h = Sha256 :: new ( ) ;
2262- h. update ( & body) ;
2263- h. finalize ( ) . to_vec ( )
2351+ ) {
2352+ Ok(p) => p,
2353+ Err(e) => {
2354+ if let Err(ce) = state
2355+ .db
2356+ .refuse_receive_pack_before_git(&request_id, "admission per-source cap reached")
2357+ .await
2358+ {
2359+ tracing::warn!(err = %ce, request_id = %request_id, "pre-git refusal cleanup failed");
2360+ }
2361+ return Err(e);
2362+ }
22642363 };
2265- // Durable intent is minimal: only the digest is consumed by
2266- // recovery (marker correlation). The raw pack is never replayed
2267- // by this split, so it is not copied into the shared database —
2268- // every request lookup would otherwise re-materialize up to the
2269- // route's 2 GiB body as BYTEA/WAL/backup amplification.
2270- let req_row = crate :: db:: ReceivePackRequest {
2271- id : request_id. clone ( ) ,
2272- repo_id : record. id . clone ( ) ,
2273- pusher_did : auth. 0 . to_string ( ) ,
2274- node_did : state. node_did . to_string ( ) ,
2275- request_bytes : Vec :: new ( ) ,
2276- request_bytes_hash,
2277- state : crate :: db:: request_state:: RECEIVED . to_string ( ) ,
2278- git_exit_ok : None ,
2279- parsed_report : None ,
2280- accepted_ordinal : None ,
2281- attempt_count : 0 ,
2282- last_error : None ,
2283- next_attempt_at : None ,
2284- created_at : now. clone ( ) ,
2285- completed_at : None ,
2286- signature_header : Some ( signature_header. clone ( ) ) ,
2287- signature_input : Some ( signature_input. clone ( ) ) ,
2288- content_digest : Some ( content_digest. clone ( ) ) ,
2364+ let _permit = match git_permit(&state.git_write_semaphore) {
2365+ Ok(p) => p,
2366+ Err(e) => {
2367+ if let Err(ce) = state
2368+ .db
2369+ .refuse_receive_pack_before_git(&request_id, "git write pool saturated")
2370+ .await
2371+ {
2372+ tracing::warn!(err = %ce, request_id = %request_id, "pre-git refusal cleanup failed");
2373+ }
2374+ return Err(e);
2375+ }
22892376 };
2290- // Atomic parent+children: either the full intent exists or none
2291- // of it does, so a refused pre-Git request cannot strand a
2292- // payload-only parent. Bounded by timeout to prevent admission
2293- // pool exhaustion on slow DB operations.
2294- let db_timeout = std:: time:: Duration :: from_secs ( 30 ) ;
2295- let db_result = tokio:: time:: timeout (
2296- db_timeout,
2297- state. db . insert_receive_pack_request_with_children (
2298- & req_row,
2299- & record. id ,
2300- & state. node_did . to_string ( ) ,
2301- auth. 0 . as_str ( ) ,
2302- & ref_updates,
2303- & signature_header,
2304- & signature_input,
2305- & content_digest,
2306- ) ,
2307- )
2308- . await ;
2309-
2310- if let Err ( e) = db_result {
2311- // Log the error and refuse push - timeout vs other errors doesn't matter
2312- // for admission capacity preservation, both release the permit
2313- tracing:: error!(
2314- err = %e,
2315- repo = %name,
2316- "failed to persist durable post-receive intent; refusing push"
2317- ) ;
2318- return Err ( AppError :: Overloaded (
2319- "durable intent write failed, retry shortly" . into ( ) ,
2320- ) ) ;
2321- }
23222377
2378+ // Intent row and its digest comment live above (pre-lease): durable intent
2379+ // is minimal — only the digest is consumed by recovery (marker
2380+ // correlation). The raw pack is never replayed by this split, so it is
2381+ // not copied into the shared database.
23232382 tracing::debug!(repo = %name, "acquiring write lock");
23242383 // Bound the write acquire under `git_acquire_timeout_secs`. acquire_write's
23252384 // advisory-lock loop already caps at ~60s, but its per-iteration
@@ -2334,18 +2393,39 @@ pub async fn git_receive_pack(
23342393 // permit is a handler local here (moved into the AdmissionGuard only after this),
23352394 // so the early return on timeout drops it and frees the slot; shed a bounded 503.
23362395 let acquire_deadline = std::time::Duration::from_secs(state.config.git_acquire_timeout_secs);
2337- let guard = tokio:: time:: timeout (
2396+ let acquire_raw = tokio::time::timeout(
23382397 acquire_deadline,
23392398 state
23402399 .repo_store
23412400 .acquire_write(&record.owner_did, &record.name),
23422401 )
2343- . await
2344- . map_err ( |_elapsed| {
2345- tracing:: warn!( repo = %name, "acquire_write timed out; shedding with 503" ) ;
2346- AppError :: Overloaded ( "git service acquisition timed out, retry shortly" . into ( ) )
2347- } ) ?
2348- . map_err ( |e| acquire_write_app_error ( & e, name) ) ?;
2402+ .await;
2403+ let guard = match acquire_raw {
2404+ Err(_) => {
2405+ tracing::warn!(repo = %name, "acquire_write timed out; shedding with 503");
2406+ if let Err(e) = state
2407+ .db
2408+ .refuse_receive_pack_before_git(&request_id, "acquire_write timed out")
2409+ .await
2410+ {
2411+ tracing::warn!(err = %e, request_id = %request_id, "pre-git refusal cleanup failed");
2412+ }
2413+ return Err(AppError::Overloaded(
2414+ "git service acquisition timed out, retry shortly".into(),
2415+ ));
2416+ }
2417+ Ok(Err(e)) => {
2418+ if let Err(ce) = state
2419+ .db
2420+ .refuse_receive_pack_before_git(&request_id, "acquire_write failed")
2421+ .await
2422+ {
2423+ tracing::warn!(err = %ce, request_id = %request_id, "pre-git refusal cleanup failed");
2424+ }
2425+ return Err(acquire_write_app_error(&e, name));
2426+ }
2427+ Ok(Ok(g)) => g,
2428+ };
23492429 let disk_path = guard.path().to_path_buf();
23502430 tracing::debug!(repo = %name, path = %disk_path.display(), "running git receive-pack");
23512431 let body_len = body.len();
@@ -2358,7 +2438,17 @@ pub async fn git_receive_pack(
23582438 // reconcile already fails closed (leaves rows prepared for
23592439 // attended recovery) when a reflog is missing. A failed upgrade
23602440 // only degrades automatic recovery to attended recovery.
2361- if let Err ( e) = crate :: git:: store:: verify_recovery_prereqs ( & disk_path) {
2441+ // Bounded through the configured git binary: the unbounded
2442+ // synchronous variant would run several `git config` children with
2443+ // no timeout while holding both admission permits, the lease, and
2444+ // the repo write lock.
2445+ if let Err(e) = crate::git::store::verify_recovery_prereqs_bounded(
2446+ &state.git_bin,
2447+ &disk_path,
2448+ std::time::Duration::from_secs(10),
2449+ )
2450+ .await
2451+ {
23622452 tracing::warn!(err = %e, repo = %name, "recovery prereqs unavailable; proceeding with degraded automatic recovery");
23632453 }
23642454 // Move both admission permits into the guard so they release only after the spawned
0 commit comments