Skip to content

Commit 0269c4a

Browse files
committed
fix(node): phase-aware intent guard, mirror import prune, terminal-state merge
1 parent 3952e9c commit 0269c4a

5 files changed

Lines changed: 693 additions & 147 deletions

File tree

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

Lines changed: 178 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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`.
38443861
struct 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+
38503871
impl 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

38653895
impl 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)]
38953959
pub(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

Comments
 (0)