@@ -2130,14 +2130,10 @@ pub async fn git_receive_pack(
21302130 // or tree is skipped by `rev-list --all` yet still served, bypassing
21312131 // the fail-closed under-withhold check. Refuse before intent is
21322132 // written so a denied push leaves no rows behind.
2133- //
2134- // Also match the bare `refs/gitlawb` name (no trailing slash): a push
2135- // of that exact ref would not be caught by `starts_with("refs/gitlawb/")`
2136- // and could land on a repo with no internal refs yet, bypassing the gate.
21372133 if let Some(bad) = ref_updates
21382134 .iter()
21392135 .map(|u| u.ref_name.as_str())
2140- .find(|r| *r == "refs/gitlawb" || r.starts_with("refs/gitlawb/" ))
2136+ .find(|r| ref_is_internal_namespace(r ))
21412137 {
21422138 tracing::warn!(
21432139 repo = %name,
@@ -2201,6 +2197,11 @@ pub async fn git_receive_pack(
22012197 content_digest: Some(content_digest.clone()),
22022198 };
22032199 let db_timeout = std::time::Duration::from_secs(30);
2200+ // Arm the drop-guard BEFORE the insert resolves: on timeout the
2201+ // cancellation may land after the commit did, stranding a `received`
2202+ // aggregate the early return below would otherwise leave behind. The
2203+ // refuse is a no-op on an id that never committed.
2204+ let _intent_guard = IntentDropGuard::new(state.db.clone(), request_id.clone());
22042205 let db_result = tokio::time::timeout(
22052206 db_timeout,
22062207 state.db.insert_receive_pack_request_with_children(
@@ -2295,17 +2296,10 @@ pub async fn git_receive_pack(
22952296 // delete+recreate under the same slug while the bare repo on disk is reused,
22962297 // and an id-keyed lease stops serializing exactly across that rotation.
22972298 let repo_key = crate::state::repo_identity_key(&record.owner_did, &record.name);
2298- // Intent is already durable at this point: every refusal below must
2299- // terminalize it via `refuse_receive_pack_before_git` so no
2300- // `received` + `prepared` aggregate strands (never purged, never
2301- // promotable without git evidence, and poisoning its tuple via
2302- // `has_competing_claimant`).
2303- //
2304- // Drop-guard: if the handler exits (early return, cancellation, panic)
2305- // between here and the `receive_pack_raw_with_reflog` call below, the
2306- // guard terminalizes the intent on drop so it never strands in
2307- // `received` state.
2308- let _intent_guard = IntentDropGuard::new(state.db.clone(), request_id.clone());
2299+ // Drop-guard: armed above (before the insert resolved) so the
2300+ // insert-timeout cancellation window is covered too. Explicit refusals
2301+ // below still call `refuse_receive_pack_before_git` inline for a
2302+ // synchronous terminal state; the guard is the backstop for drops.
23092303 let lease = match state
23102304 .repo_write_leases
23112305 .acquire(&repo_key, lease_steal_after)
@@ -2538,10 +2532,12 @@ pub async fn git_receive_pack(
25382532 // still relies on marker + tuple/timestamp + history guards. Marker
25392533 // namespace hiding was verified above by `verify_recovery_prereqs`.
25402534 let reflog_action = format!("gitlawb-request:{request_id}");
2541- // Disarm the drop-guard: git is about to start, so a dropped future
2542- // from here forward is a mid-git disconnect (not a pre-git shed), and
2543- // the mark_uncertain path below handles that case.
2544- _intent_guard.disarm();
2535+ // Phase-flip the drop-guard: git is about to start, so a dropped future
2536+ // from here forward is a mid-git disconnect (not a pre-git shed). The
2537+ // guard then marks children uncertain and terminalizes the parent, which
2538+ // the mark_uncertain path below cannot do on a drop — it never runs, and
2539+ // the detached reaper does process teardown only, no DB writes.
2540+ _intent_guard.notify_git_started();
25452541 let (receive_raw, exit_ok) = match smart_http::receive_pack_raw_with_reflog(
25462542 &state.git_bin,
25472543 &disk_path,
@@ -2858,6 +2854,15 @@ pub async fn git_receive_pack(
28582854 }
28592855 };
28602856
2857+ // A resolved outcome commit leaves no `received` parent behind, so drops
2858+ // from here on need no guard cleanup — disarm to avoid a wasteful spawned
2859+ // txn per push. On persistent commit failure the guard stays in its
2860+ // git-started phase: a later drop still terminalizes the aggregate for
2861+ // startup reconcile instead of stranding it.
2862+ if outcome_commit_ok {
2863+ _intent_guard.disarm();
2864+ }
2865+
28612866 // On non-zero exit, return an error to the caller. The outbox
28622867 // rows have already been handled above (per-ref fates applied).
28632868 // The client-visible body does NOT include wire-supplied ref
@@ -3834,64 +3839,123 @@ pub async fn get_icaptcha_proof(
38343839
38353840// ── Drop guard for stranded intents ──────────────────────────────────────
38363841
3837- /// Drop-guard that terminalizes a durable intent if the handler exits (via
3838- /// early return, cancellation, or panic) after the intent insert but before
3839- /// `receive_pack` runs. A dropped future between the insert and git leaves a
3840- /// `received` parent + `prepared` children that nothing retires: purge
3841- /// excludes `received` even at 8 days, the drain skips it, and reconcile
3842- /// cannot prove a landing git never ran. The stranded children also poison
3843- /// `has_competing_claimant` checks. Disarm this guard once git starts.
3842+ /// Phase-aware drop-guard that terminalizes a durable intent when the handler
3843+ /// future is dropped. Two uncovered spans need two cleanups:
3844+ /// - Dropped before git starts (lease park, `acquire_write`, prereq checks,
3845+ /// marker write, or a commit landing inside the insert-timeout cancellation
3846+ /// window): no git evidence can exist, so refuse the aggregate outright —
3847+ /// parent to `rejected_at_git`, `prepared` children to `cancelled`.
3848+ /// - Dropped after git starts (the whole `receive_pack` await and the outcome
3849+ /// commit): git may have landed refs, so mark `prepared` children `uncertain`
3850+ /// and terminalize the parent, mirroring the `receive_pack` Err arm for a
3851+ /// drop that can never reach it. Startup reconcile still promotes proved
3852+ /// children of a `rejected_at_git` parent.
3853+ ///
3854+ /// Arm before the intent insert resolves (the refuse is a no-op on an
3855+ /// uncommitted id), move to git-started immediately before awaiting
3856+ /// `receive_pack`, and disarm once the outcome commit resolves. A sync Drop
3857+ /// cannot await, so cleanup is spawned like `KillGroupOnDrop` and
3858+ /// `RepoWriteGuard` do: prefer the current runtime handle, and with no
3859+ /// runtime (off-runtime drop, process teardown) log loudly instead of
3860+ /// panicking on a bare `tokio::spawn`.
38443861struct IntentDropGuard {
38453862 db: std::sync::Arc<crate::db::Db>,
38463863 request_id: String,
3847- armed : std::sync::Arc<std::sync::atomic::AtomicBool >,
3864+ phase : std::sync::Arc<std::sync::atomic::AtomicU8 >,
38483865}
38493866
3867+ const INTENT_PHASE_PRE_GIT: u8 = 0;
3868+ const INTENT_PHASE_GIT_STARTED: u8 = 1;
3869+ const INTENT_PHASE_DONE: u8 = 2;
3870+
38503871impl IntentDropGuard {
38513872 fn new(db: std::sync::Arc<crate::db::Db>, request_id: String) -> Self {
38523873 Self {
38533874 db,
38543875 request_id,
3855- armed : std::sync::Arc::new(std::sync::atomic::AtomicBool ::new(true )),
3876+ phase : std::sync::Arc::new(std::sync::atomic::AtomicU8 ::new(INTENT_PHASE_PRE_GIT )),
38563877 }
38573878 }
38583879
3880+ /// Call immediately before awaiting `receive_pack`: from here a drop is a
3881+ /// mid-git disconnect, not a pre-git shed.
3882+ fn notify_git_started(&self) {
3883+ self.phase.store(
3884+ INTENT_PHASE_GIT_STARTED,
3885+ std::sync::atomic::Ordering::Release,
3886+ );
3887+ }
3888+
38593889 fn disarm(&self) {
3860- self.armed
3861- .store(false , std::sync::atomic::Ordering::Release);
3890+ self.phase
3891+ .store(INTENT_PHASE_DONE , std::sync::atomic::Ordering::Release);
38623892 }
38633893}
38643894
38653895impl Drop for IntentDropGuard {
38663896 fn drop(&mut self) {
3867- if self.armed.load(std::sync::atomic::Ordering::Acquire) {
3868- let db = self.db.clone();
3869- let request_id = self.request_id.clone();
3870- tokio::spawn(async move {
3871- if let Err(e) = db
3872- .refuse_receive_pack_before_git(
3873- &request_id,
3874- "handler dropped before git started",
3875- )
3876- .await
3877- {
3878- tracing::warn!(
3879- err = %e,
3880- request_id = %request_id,
3881- "drop-guard cleanup failed"
3882- );
3897+ let phase = self.phase.load(std::sync::atomic::Ordering::Acquire);
3898+ if phase == INTENT_PHASE_DONE {
3899+ return;
3900+ }
3901+ let db = self.db.clone();
3902+ let request_id = self.request_id.clone();
3903+ match tokio::runtime::Handle::try_current() {
3904+ Ok(handle) => {
3905+ if phase == INTENT_PHASE_GIT_STARTED {
3906+ handle.spawn(async move {
3907+ if let Err(e) = db.mark_receive_pack_interrupted(&request_id).await {
3908+ tracing::warn!(
3909+ err = %e,
3910+ request_id = %request_id,
3911+ "drop-guard post-git cleanup failed"
3912+ );
3913+ }
3914+ });
3915+ } else {
3916+ handle.spawn(async move {
3917+ if let Err(e) = db
3918+ .refuse_receive_pack_before_git(
3919+ &request_id,
3920+ "handler dropped before git started",
3921+ )
3922+ .await
3923+ {
3924+ tracing::warn!(
3925+ err = %e,
3926+ request_id = %request_id,
3927+ "drop-guard cleanup failed"
3928+ );
3929+ }
3930+ });
38833931 }
3884- });
3932+ }
3933+ Err(_) => {
3934+ tracing::warn!(
3935+ request_id = %request_id,
3936+ phase,
3937+ "intent drop-guard fired off-runtime; cannot spawn cleanup, intent may strand"
3938+ );
3939+ }
38853940 }
38863941 }
38873942}
38883943
38893944// ── Pkt-line parsing ──────────────────────────────────────────────────────
38903945
3946+ /// True for refnames inside the node-managed namespace: the `refs/gitlawb/`
3947+ /// subtree plus the bare `refs/gitlawb` name (no trailing slash), which
3948+ /// `starts_with("refs/gitlawb/")` alone misses and which lands on repos with
3949+ /// no internal refs yet. Extracted so the gate predicate is unit-testable;
3950+ /// the handler denies any push naming such a ref before intent is written.
3951+ fn ref_is_internal_namespace(ref_name: &str) -> bool {
3952+ ref_name == "refs/gitlawb" || ref_name.starts_with("refs/gitlawb/")
3953+ }
3954+
38913955/// `Clone` so `git_receive_pack` can hand the parsed updates to the detached
38923956/// replication tail at the durability boundary while the certificate and webhook
38933957/// loops below still iterate their own copy (#174 U5).
3894- #[derive(Clone)]
3958+ #[derive(Clone, Debug )]
38953959pub(crate) struct RefUpdate {
38963960 pub(crate) old_sha: String,
38973961 pub(crate) new_sha: String,
@@ -11540,4 +11604,69 @@ mod tests {
1154011604 "and the unvetted push still maps no CID"
1154111605 );
1154211606 }
11607+
11608+ /// A ref-update command line that is not valid UTF-8 refuses the whole
11609+ /// push. Git accepts refnames with bytes >= 0x80, so silently skipping
11610+ /// such a line would plant a ref the namespace gate never sees and no
11611+ /// durable child row covers. Removing the Err arm (skip-and-continue)
11612+ /// turns the expect_err red.
11613+ #[test]
11614+ fn parse_ref_updates_refuses_non_utf8_command_line() {
11615+ // Well-formed section first: one command + flush parses fine.
11616+ let good_payload = format!("{} {} refs/heads/main\n", "0".repeat(40), "1".repeat(40));
11617+ let good_body = format!("{:04x}{}0000", good_payload.len() + 4, good_payload);
11618+ let updates = parse_ref_updates(good_body.as_bytes()).expect("well-formed section parses");
11619+ assert_eq!(updates.len(), 1, "one command line yields one update");
11620+ assert_eq!(updates[0].ref_name, "refs/heads/main");
11621+
11622+ // Same framing, but the refname carries a raw 0xFF byte (note: `b"\xff"`
11623+ // is one raw byte; `"\xff"` in a str literal would be U+00FF instead).
11624+ let mut payload = Vec::new();
11625+ payload.extend_from_slice(b"0000000000000000000000000000000000000000 ");
11626+ payload.extend_from_slice(b"1111111111111111111111111111111111111111 ");
11627+ payload.extend_from_slice(b"refs/gitlawb/issues/\xffx\n");
11628+ let mut body = format!("{:04x}", payload.len() + 4).into_bytes();
11629+ body.extend_from_slice(&payload);
11630+ body.extend_from_slice(b"0000");
11631+ let err =
11632+ parse_ref_updates(&body).expect_err("a non-UTF-8 command line must refuse the push");
11633+ assert!(
11634+ matches!(err, AppError::BadRequest(_)),
11635+ "undecodable ref-update line is a client error (400), got {err:?}"
11636+ );
11637+ }
11638+
11639+ /// The internal-namespace predicate covers the bare `refs/gitlawb` name
11640+ /// and the whole subtree, and nothing else. The handler denies any push
11641+ /// naming such a ref before intent is written; narrowing the predicate
11642+ /// (e.g. dropping the bare-name arm) turns these red.
11643+ #[test]
11644+ fn internal_namespace_gate_covers_bare_name_and_subtree() {
11645+ // Denied: bare name, subtree root, exempt namespaces, attacker refs.
11646+ for denied in [
11647+ "refs/gitlawb",
11648+ "refs/gitlawb/",
11649+ "refs/gitlawb/requests/550e8400-e29b-41d4-a716-446655440000",
11650+ "refs/gitlawb/issues/abc123",
11651+ "refs/gitlawb/evil",
11652+ ] {
11653+ assert!(
11654+ ref_is_internal_namespace(denied),
11655+ "push gate must deny {denied}"
11656+ );
11657+ }
11658+ // Allowed: content refs, lookalike prefixes, empty.
11659+ for allowed in [
11660+ "refs/heads/main",
11661+ "refs/tags/v1",
11662+ "refs/gitlawbfoo",
11663+ "refs/heads/gitlawb/x",
11664+ "",
11665+ ] {
11666+ assert!(
11667+ !ref_is_internal_namespace(allowed),
11668+ "push gate must allow {allowed}"
11669+ );
11670+ }
11671+ }
1154311672}
0 commit comments