Skip to content

Commit 10c185a

Browse files
committed
fix(node): close review round on pre-git refusal lifecycle, namespace gate, and promotion merge
1 parent 41534a9 commit 10c185a

6 files changed

Lines changed: 726 additions & 170 deletions

File tree

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 208 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)