Skip to content

feat(node): per-IP write-surface rate brake + purge-spam admin tool#196

Open
beardthelion wants to merge 63 commits into
mainfrom
feat/write-sink-brake-coverage
Open

feat(node): per-IP write-surface rate brake + purge-spam admin tool#196
beardthelion wants to merge 63 commits into
mainfrom
feat/write-sink-brake-coverage

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

What

Two related pieces of the creation-surface abuse-resistance work:

  1. A per-IP write-surface rate brake. Every authenticated non-creation write sink now sits behind a per-client-IP flood brake on its own bucket: pull/issue comments, labels, stars, merges, protect/unprotect, replicas, visibility, agent deregister, tasks, bounties, profile, and GraphQL mutations. Previously these route groups mounted with no rate limit at all, so a single-source flood on any of them was uncounted (only repo/agent creation, git-push, and peer-sync were braked). The bucket is configured by GITLAWB_WRITE_RATE_LIMIT (default 600/hr, 0 disables), keyed on the resolved client IP via the existing GITLAWB_TRUSTED_PROXY boundary.

  2. A purge-spam admin subcommand for cleaning up a specific empty spam-burst that predates the mirror bot. Dry-run by default; --execute deletes.

Key decisions

  • Separate bucket from creation, not shared: a write flood cannot drain the repo-create budget and vice versa (same rationale as the existing sync_trigger vs peer_write split). A shared bucket would also self-throttle a legitimate session doing repo + issues + comments.
  • IP-only, no per-DID on the write groups: a did:key farm never trips a per-DID limit (the same reason git-receive-pack deliberately omits it), and a per-DID brake would false-positive busy legitimate agents.
  • GraphQL POST braked at the HTTP layer. An HTTP-layer brake cannot tell a query POST from a mutation POST, so it counts every /graphql request; /graphql/ws subscriptions stay unbraked. MutationRoot was the largest previously-uncounted write vector.
  • purge-spam safety: per-repo empty verification (zero git refs, with a bare-repo-marker guard so git's upward repo discovery can't read a parent checkout's refs and delete a live repo), a hard exclusion gate evaluated before the empty check, a re-verify immediately before delete (TOCTOU), and on-disk dir removal so DB and disk stay consistent. Dry-run is the default and prints the candidate list with ref-count evidence.

Verification

  • cargo test --workspace green; cargo fmt --all --check and cargo clippy --workspace --all-targets -D warnings clean.
  • Real-node deny path driven through a client: a write sink returns 429 before signature verification (brake outermost), and under-limit writes reach the handler.
  • Adversarial TrustedProxy coverage: a spoofed leftmost X-Forwarded-For hop cannot rotate the bucket key, distinct trusted hops get distinct buckets, and an absent header falls back to the socket peer.
  • The load-bearing tests were RED-verified (removing the brake, the exclusion gate, the bare-repo guard, or the SQL normalization each turns the relevant test red).

Scope

This is the write-surface brake coverage plus the burst cleanup. iCaptcha enforcement, storage/size quotas, and moving the gate toward enforce-by-default are separate follow-ups, not in this PR.

Summary by CodeRabbit

  • New Features
    • Added an out-of-band purge-spam admin command with dry-run and optional execution.
    • Added authenticated per-IP “write brake” for non-creation write actions via GITLAWB_WRITE_RATE_LIMIT (0 disables), applied to key REST and GraphQL mutation routes while leaving GraphQL WebSocket traffic unaffected.
  • Bug Fixes
    • Improved push/issue/PR flows to avoid recording side effects when durable uploads fail or clients disconnect.
    • Rate-limit rejections now include an accurate Retry-After window on applicable endpoints.
  • Documentation
    • Updated .env.example with metrics, storage, IPFS, lock-pool sizing, and the write-rate throttling policy.
  • Tests
    • Expanded coverage for purge safety, throttling behavior, trusted-proxy bucketing, disconnect resilience, and durability failure handling.

t added 7 commits July 13, 2026 14:07
…utes

Introduces a dedicated write_rate_limiter bucket (GITLAWB_WRITE_RATE_LIMIT,
default 600/hr, 0 disables), separate from the creation brake so a write flood
cannot drain the creation budget (KTD-1). Attaches rate_limit_by_ip to
write_routes, mirroring creation_routes. Per-DID deliberately not paired (a DID
farm never trips it).

Tests (route-level, execution-verified against Postgres): a write_routes sink
(PUT /star) is 429'd before auth; an exhausted write bucket does not throttle
repo creation (separate-bucket property).
Attaches rate_limit_by_ip (write bucket) to task_write_routes,
issue_write_routes, bounty_write_routes, profile_write_routes, and the /graphql
POST/GET surface (KTD-5 — an HTTP-layer brake counts every /graphql request;
/graphql/ws subscriptions stay unbraked). write_ip_limiter is now built once and
shared. Every non-git, non-creation authenticated write group is braked.

Tests (execution-verified against Postgres): /graphql POST is 429'd, and a
representative REST write sink (issue comment) is 429'd, by the write brake.
…rake

Middleware-level tests complementing the client_key unit coverage: a spoofer
varying the client-controlled leftmost X-Forwarded-For hop cannot rotate its
bucket key (keyed on the trusted rightmost hop); distinct trusted client hops
get distinct buckets (no cross-starvation); and a request with no trusted header
in a proxy mode falls back to the socket-peer key — the documented collapse,
asserted by name so any change is deliberate.

Operator-warning on missing-header deferred deliberately: a per-request warning
is a log-noise risk and the security-load-bearing fallback (peer key, never
skip) is what these tests lock down.
Adds a purge-spam subcommand (clap #[command(subcommand)] split; no-subcommand
path still runs the node unchanged). Candidate selection is signature-based, not
per-DID: a repo qualifies only if owned by the named burst DID AND verified empty
(zero git refs) per repo. A hard exclusion gate runs BEFORE the empty check, so
an empty repo owned by the content-bearing user or the mirror-bot DID is never a
candidate. Dry-run is the default; deletion needs an explicit --execute flag and
operates per-repo, never 'all repos of DID X'.

Tests (RED->GREEN, exclusion gate confirmed load-bearing by targeting an excluded
DID directly): empty target listed; target-with-refs spared; empty excluded/
intern repos spared (must-assert); dry-run deletes nothing; execute removes only
the empty target on disk.
P1 from code review: on_disk_ref_count shelled 'git for-each-ref' with only the
repo path as cwd and no --git-dir, so a path that exists but is not a bare repo
let git discover a parent .git (repos_dir may sit inside the operator's checkout)
and read a DIFFERENT repo's refs — possibly 0, deleting a real repo. Now require
the bare-repo markers (HEAD file + objects/ dir) before trusting any count;
anything else fails closed (skipped).

Tests: a non-git dir under a git ancestor (zero-ref) now fails closed — verified
RED without the guard (read the ancestor's 0 refs); target-DID-never-excluded
invariant pinned; write_flood test anchored to a genuinely-drained bucket so its
assert_ne can't pass vacuously.
…rage

Addresses the remaining code-review findings:
- purge-spam DID matching now uses did:key normalization on both sides
  (list_repos_by_owner_did via OWNER_KEY_CASE_SQL, is_excluded and the target
  scope via normalize_owner_key), so short/full form is consistent and an
  excluded identity is protected in either form. Adds a short-form exclusion test.
- Extract the per-IP write brake into a chainable WriteBraked helper so a future
  write group can't silently ship unbraked (kept separate from add_auth_layers
  by design; documented why).
- Mark --repos-dir / --database-url global so admin subcommands accept them after
  the subcommand (was an 'unexpected argument' parse error); verified.
- Document GITLAWB_WRITE_RATE_LIMIT in .env.example incl. the per-IP NAT-aggregate
  caveat so operators can size it.
- Coverage: adoption-floor (under-limit write passes), write-limit=0 disables,
  task/bounty/profile route-level 429s, /graphql/ws stays unbraked.

Full suite 927 pass / 0 fail; fmt + clippy -D warnings clean.
- purge-spam execute now re-verifies emptiness immediately before delete (TOCTOU:
  a push between selection and delete no longer risks deleting a now-non-empty
  repo) and removes the on-disk bare repo dir so DB and disk stay consistent; a
  per-repo delete failure warns and continues rather than aborting the batch.
- Tests (RED-verified where they cover new behavior): partition_for_delete skips
  a repo no longer empty; execute removes the on-disk dir (RED without the
  removal); list_repos_by_owner_did finds a short-form burst row (RED without the
  SQL normalization); every braked write group passes under-limit (per-group
  grant path, not just the star representative).
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request adds a safe purge-spam admin command, dedicated repository locking with generic archive storage, durable-write failure handling, and configurable authenticated per-IP write throttling across REST and GraphQL HTTP routes.

Changes

Spam purge administration

Layer / File(s) Summary
Admin command and purge data contracts
crates/gitlawb-node/src/{config,main,db}.rs, crates/gitlawb-node/src/git/*
Adds CLI dispatch, repository enumeration and cascade deletion, generic object storage, and dedicated advisory-lock support.
Candidate selection and path safety
crates/gitlawb-node/src/admin.rs
Normalizes DIDs, applies exclusions before emptiness checks, validates bare repositories, rejects unsafe paths, and rechecks candidates.
Locked deletion and purge validation
crates/gitlawb-node/src/admin.rs
Implements dry-run and execution flows with advisory locks, authoritative archive checks, disk containment checks, archive cleanup, failure accounting, and integration tests.

Repository storage and write lifecycle

Layer / File(s) Summary
Object storage and advisory-lock implementation
crates/gitlawb-node/src/git/{tigris,repo_store}.rs, crates/gitlawb-node/src/db/mod.rs
Migrates repository archives to ObjectStore and pins repository locks to a dedicated connection pool.
Durable write outcome handling
crates/gitlawb-node/src/api/{repos,issues,pulls}.rs, crates/gitlawb-node/src/error.rs
Prevents dependent side effects after durable upload failure and allows push processing to finish after client disconnects.

Authenticated write rate limiting

Layer / File(s) Summary
Write limiter configuration and state
.env.example, crates/gitlawb-node/src/{main,state,auth,test_support}.rs
Defines write, metrics, storage, IPFS, and lock-pool settings, then wires limiter construction, cleanup, and fixtures.
Write middleware route wiring
crates/gitlawb-node/src/server.rs
Applies the per-IP brake to authenticated REST write groups and GraphQL HTTP while excluding GraphQL WebSockets.
Retry-after and client-IP validation
crates/gitlawb-node/src/{rate_limit,api/repos}.rs
Computes window-derived Retry-After values and tests proxy-key handling, route coverage, disabled limits, and push behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AdminCLI
  participant PurgeRunner
  participant Db
  participant RepoStore
  AdminCLI->>PurgeRunner: purge-spam
  PurgeRunner->>Db: list target repositories
  PurgeRunner->>RepoStore: acquire lock and refresh archive
  PurgeRunner->>Db: delete repository and descendants
  PurgeRunner->>RepoStore: remove local directory and archive
Loading
sequenceDiagram
  participant Client
  participant Router
  participant WriteLimiter
  participant WriteRoute
  Client->>Router: authenticated write request
  Router->>WriteLimiter: resolve IP and check write bucket
  WriteLimiter->>WriteRoute: forward permitted request
Loading

Possibly related PRs

  • Gitlawb/node#54: Directly overlaps the Tigris hydration, durable upload, and handler side-effect flow.
  • Gitlawb/node#87: Overlaps GraphQL HTTP middleware composition while keeping WebSocket routing separate.
  • Gitlawb/node#165: Directly overlaps the smart-HTTP receive-pack control flow.

Suggested labels: sev:medium, kind:security, subsystem:api

Suggested reviewers: jatmn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the two main changes: the write-surface rate brake and the purge-spam admin tool.
Description check ✅ Passed The description covers the main changes, rationale, verification, and scope; only template-specific extras like checkboxes and Closes # are missing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/write-sink-brake-coverage

Comment @coderabbitai help to get the list of available commands.

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:feature New capability or surface subsystem:identity DID/UCAN, http-sig auth, push authorization labels Jul 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/main.rs (1)

440-455: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

write_rate_limiter is missing from the periodic cleanup loop.

Every other limiter (rate_limiter, create_ip_rate_limiter, push_rate_limiter, sync_trigger_rate_limiter, peer_write_rate_limiter) is swept here, but the newly added write_rate_limiter is not. It is bounded, so no unbounded leak, yet stale keys are only evicted lazily on a capacity miss instead of proactively like its peers — an inconsistency worth closing.

🧹 Proposed fix
         let peer_write_rl = state.peer_write_rate_limiter.clone();
+        let write_rl = state.write_rate_limiter.clone();
         let db = state.db.clone();
                         peer_write_rl.cleanup().await;
+                        write_rl.cleanup().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/main.rs` around lines 440 - 455, Include the
state.write_rate_limiter in the periodic cleanup task alongside the other cloned
rate limiters. Clone it before spawning the task and invoke its cleanup method
in the five-minute tokio::select! interval, preserving the existing cleanup
behavior for all other limiters.
🧹 Nitpick comments (1)
crates/gitlawb-node/src/api/repos.rs (1)

2629-2966: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting a shared test-request helper to reduce duplication.

All 9 new tests repeat the same Request::builder() + ConnectInfo insertion + oneshot() + status-assert boilerplate. rate_limit.rs in this same PR already factored this pattern into post_from/post_with helpers — a similar helper here (e.g., send_from(&router, method, uri, body, peer)) would cut most of the repetition across write_route_is_rate_limited_by_ip, write_flood_does_not_drain_creation_budget, graphql_post_is_rate_limited_by_ip, issue_comment_is_rate_limited_by_ip, under_limit_write_is_not_throttled, write_rate_limit_zero_disables_the_brake, task_bounty_profile_writes_are_rate_limited, graphql_ws_is_not_braked, and every_write_group_passes_under_limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 2629 - 2966, Reduce
duplicated request setup across the listed rate-limit tests by adding a shared
test helper such as send_from that accepts the router, HTTP method, URI, body,
and peer, inserts ConnectInfo, sends via oneshot, and returns the response or
status. Update each named test to use this helper while preserving its existing
request bodies, methods, URIs, and status assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/gitlawb-node/src/main.rs`:
- Around line 440-455: Include the state.write_rate_limiter in the periodic
cleanup task alongside the other cloned rate limiters. Clone it before spawning
the task and invoke its cleanup method in the five-minute tokio::select!
interval, preserving the existing cleanup behavior for all other limiters.

---

Nitpick comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 2629-2966: Reduce duplicated request setup across the listed
rate-limit tests by adding a shared test helper such as send_from that accepts
the router, HTTP method, URI, body, and peer, inserts ConnectInfo, sends via
oneshot, and returns the response or status. Update each named test to use this
helper while preserving its existing request bodies, methods, URIs, and status
assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e186f361-729a-419c-b633-a90e12b77a44

📥 Commits

Reviewing files that changed from the base of the PR and between ad7c2b2 and 27b4a2a.

📒 Files selected for processing (11)
  • .env.example
  • crates/gitlawb-node/src/admin.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/auth/mod.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/rate_limit.rs
  • crates/gitlawb-node/src/server.rs
  • crates/gitlawb-node/src/state.rs
  • crates/gitlawb-node/src/test_support.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — fix before merge

H1. Read/fork handlers run blocking git subprocesses directly on the tokio runtime (no spawn_blocking, no timeout)

  • fork_repocrates/gitlawb-node/src/api/repos.rs:1567-1575 invokes std::process::Command::new("git").args(["clone","--mirror",…]).output() inside the async handler. output() blocks the calling worker thread; git clone --mirror of a non-trivial repo can take minutes and there is no timeout, unlike every other git walk in this file which correctly uses tokio::task::spawn_blocking (repos.rs:67, 110, 661, 1143) or tokio::process::Command (info/refs, upload-pack, receive-pack).
  • Read metadata handlerslist_commits/get_tree/get_blob call store::{resolve_head, log, ls_tree, read_file} (api/repos.rs:360-361, 397, 443, 481) which are synchronous std::process::Command::output() calls (crates/gitlawb-node/src/git/store.rs:92-231). A slow disk or large repo blocks a worker thread for seconds; with a finite worker pool a handful of concurrent /commits, /tree, /blob, or a single /fork can starve the whole runtime, including /health and /ready (same runtime in build_router).
  • Impact: availability/reliability regression, not just latency. git_service_timeout_secs is bypassed on these paths.
  • Fix: wrap the blocking store calls (and the fork clone) in tokio::task::spawn_blocking and bound with tokio::time::timeout(git_service_timeout_secs, …), returning AppError::Timeout.

H2. write_rate_limiter is never reaped by the background cleanup loop

  • crates/gitlawb-node/src/main.rs:440-455 — the periodic task clones exactly five limiters (rate_limiter, create_ip_rate_limiter, push_rate_limiter, sync_trigger_rate_limiter, peer_write_rate_limiter) and calls .cleanup() on each. state.write_rate_limiter (declared at main.rs:329-333, wired into AppState at :404) is omitted, even though it protects the entire authenticated non-creation write surface + /graphql (server.rs:84-98, 110, 154+).
  • The RateLimiter design makes the inline capacity sweep amortized and explicitly relies on the background loop for full reclamation (rate_limit.rs:36-39, 94-114). With no background sweep, write_rate_limiter only reclaims expired keys once its map hits the 200,000-key cap. Until then every distinct client IP persists as a stale Window for the full 1-hour window and beyond → steady memory growth (bounded at the cap) and a latent correctness cliff: once at cap, the amortized inline sweep becomes the only reclaimer and a distinct-IP write flood serializes behind O(max_keys) scans while legitimate new IPs get spuriously 429'd between sweeps.
  • Fix: add let write_rl = state.write_rate_limiter.clone(); and write_rl.cleanup().await; inside the cleanup tick beside the other five.

Medium — should fix

M1. fork_repo / create_repo orphan on-disk repos (and a spent proof / uploaded object) on late DB failure

  • fork_repo ordering (api/repos.rs:1555 → 1567 → 1585 → 1605): spend proof → git clone --mirror to disk_pathrelease_after_write (Tigris upload) → db.create_repo. If create_repo fails, the on-disk mirror and the already-uploaded Tigris object are left with no DB row, and the single-use iCaptcha proof is already consumed.
  • create_repo (api/repos.rs:208 → 210-219 → 236): proof.consume then repo_store.init (blocking git init) happen before db.create_repo. A DB failure orphans the freshly-initialized directory. init itself is also a blocking git call on the async runtime (same class as H1).
  • Fix: make disk materialization and proof spend adjacent to a guaranteed-successful DB write, and on db.create_repo failure best-effort remove_dir_all(disk_path) (and prefer deferring the Tigris upload until after the row commits).

M2. TrustedProxy IP trust is config-asserted, not network-enforced — header rotation defeats every per-IP brake

  • crates/gitlawb-node/src/rate_limit.rs:198-218 (client_key): in Fly/XForwardedFor modes the limiter key is taken entirely from Fly-Client-IP or the rightmost X-Forwarded-For hop, and only falls back to the socket peer when the header is absent. There is no check that the socket peer (ConnectInfo) is actually the operator's proxy. If GITLAWB_TRUSTED_PROXY is set but the node's HTTP port is reachable directly (misconfigured firewall, exposed Fly port, second ingress, internal attacker), a client can set Fly-Client-IP: <random> / append any rightmost X-Forwarded-For hop and get a fresh bucket per request, evading the creation/write/push/sync brakes (the only thing stopping a single-source spam/push flood). The unit tests cover header parsing but never assert "peer not the proxy → key on peer."
  • Fix: honor the forwarded header only when ConnectInfo peer is in a configured proxy CIDR/IP allowlist; otherwise key on the socket peer (fail closed). Add a test for "trust mode set, request from a non-proxy IP."
  • Note (validation): exploitability is deployment-dependent (Fly's edge does overwrite Fly-Client-IP), so rated Medium not Blocker.

M3. purge-spam --execute deletes via remove_dir_all on an unsanitized repo_name (path traversal → arbitrary repo deletion)

  • crates/gitlawb-node/src/git/store.rs:450-453repo_disk_path sanitizes owner_did (replace([':', '/'], "_")) but joins repo_name verbatim via format!("{repo_name}.git") with no ../slash/dot validation.
  • crates/gitlawb-node/src/admin.rs:243-244 calls std::fs::remove_dir_all(store::repo_disk_path(&repos_dir, &c.owner_did, &c.name)) on that path. The fail-closed marker guard (admin.rs:128) only requires a HEAD file + objects/ dir — i.e. any existing legitimate bare repo satisfies it. API names are validated (api/repos.rs:189-197), but smart_http_repo_name explicitly notes repos are "creatable via the peer mirror path, which skips API name validation" (api/repos.rs:490-494), so a burst-DID row with a ../ name can be planted, then deleted outside repos_dir.
  • Fix: validate repo_name (and re-validate owner_did) the same way the serving paths do (repo_store::validate_repo_name/validate_path_components), and assert the resolved path is canonically inside repos_dir before any remove_dir_all. Ideally make repo_disk_path itself reject unsafe names.
  • Note: a destructive remove_dir_all should never run on a DB-sourced, unsanitized name. End-to-end exploitability is narrow (requires the burst DID + marker check at the traversed path), so rated Low/Medium — but it is a clear defense-in-depth defect.

M4. purge-spam TOCTOU: emptiness re-checked once as a batch snapshot, not immediately before each delete — races a live push

  • crates/gitlawb-node/src/admin.rs:221-235partition_for_delete (:224-226) re-verifies emptiness for all candidates up front, producing to_delete; the actual delete_repo_by_id runs later in a loop (:235). Between the snapshot and a given delete, a concurrent git-receive-pack on the running node can add a ref, making the repo non-empty — yet it is still deleted (silent data loss). The comment claims it "re-verify[s] emptiness immediately before deleting," but the recheck is a single batch pass, not a per-candidate check immediately before delete_repo_by_id.
  • The execute path also does not acquire the node's acquire_write advisory lock (api/repos.rs:927), so it races a live push: a push can leave orphaned Tigris objects / ref-certificate rows referencing a now-deleted repos.id, and remove_dir_all can race an in-flight receive-pack writing objects/.
  • Fix: re-verify ref_count_on_disk immediately before each delete_repo_by_id (or wrap recheck+delete of each repo in a repo_store.acquire_write guard / DB transaction re-checking a ref signal). Document that the tool must be run against a quiesced node for the target DID.

M5. iCaptcha gate fails open in enforce mode when the public key cannot load

  • crates/gitlawb-node/src/icaptcha.rs:302-304 — inside decide, if v.key.is_none() { return Decision::Allow; } runs before the mode match, so in Mode::Enforce a missing key (iCaptcha unreachable at startup, DNS/BGP block, bad ICAPTCHA_PUBKEY) allows every create/registration request with no proof. init() (icaptcha.rs:160-166) leaves key = None and only warns. Since the gate defaults to off, the default deployment already has no captcha; enforce-mode is now trivially bypassable by denying the key fetch, directly weakening the abuse protection this PR leans on after PoW removal.
  • Fix: in enforce mode fail closed (reject) when key.is_none(); or refuse to start in enforce mode without a key. At minimum document that enforce+no-key == fail-open.

M6. purge-spam deletes the DB row, then removes the dir; a disk failure is swallowed while the deleted counter is already incremented

  • crates/gitlawb-node/src/admin.rs:235-248 — on Ok(n) the row is counted as deleted before remove_dir_all, which on error only warn!s. The final "deleted N repo row(s)" summary therefore reports success even when the on-disk repo survives → DB/disk drift and a misleading operator-facing count. No retry / non-zero exit signal.
  • Fix: remove the on-disk dir first (or track disk-failure separately from the DB-deleted count) and surface "N deleted, M failed."

M7. delete_repo_by_id (and purge-spam) leave dependent rows orphaned

  • crates/gitlawb-node/src/db/mod.rs:1288-1294DELETE FROM repos WHERE id = $1 with no cascade. Child tables keyed on repo_id (branch_cids, ref_certificates, visibility_rules, repo_stars, encrypted_blobs, received_ref_updates, repo_replicas, arweave_anchors, webhooks, pull_requests) keep orphan rows that accumulate and can be re-joined if a repo with the same id is later created.
  • Fix: add ON DELETE CASCADE (or explicit child deletes) for the repo_id FKs, ideally within the same transaction as the row delete.

Low / Info — nice to have

  • L1. Rate-limiter bounded-map DoS. rate_limit.rs:96-114 rejects new keys when at the 200k cap; a distinct-key flood (IPv6 variation or the M2 header rotation) then 429s legitimate new clients for up to a full window. Consider evicting expired entries to admit new keys, or a metric on cap-full rejections.
  • L2. Limiter state is per-process / in-memory (rate_limit.rs:24-41). In multi-machine Fly deployments the effective per-IP/per-DID limit is N × configured and a restart wipes buckets. Known tradeoff — document explicitly; consider shared counters for the spam-relevant paths.
  • L3. Non-atomic create_repo. api/repos.rs:203-236 check-then-insert with no UNIQUE(owner_did, name); the idx_repos_owner_key_name is a plain index (db/mod.rs:474-490). A concurrent create race surfaces a raw UNIQUE(disk_path) violation mapped to a generic 500 instead of RepoExists (409).
  • L4. Read handlers mask git errors as empty 200 OK. api/repos.rs:361 (store::log(...).unwrap_or_default()) and the get_tree siblings coerce real backend failures into empty lists, unlike get_blob (api/repos.rs:398-410) which classifies errors. Map Err to AppError::Git/NotFound.
  • L5. parse_ref_updates silently tolerates malformed/truncated bodies (api/repos.rs:1651-1699) → empty Vec<RefUpdate>; receive-pack then proceeds with zero ref updates (skipping branch-protection/owner-push checks). Return 400 on a non-empty body that parses short.
  • L6. Push bookkeeping errors swallowed (api/repos.rs:959, 977, 982, 999-1005 let _ = / only-warn!) — transient DB blips silently lose record_push/update_trust_score. At least raise log severity / add a durable outbox.
  • L7. git_info_refs receive-pack brake skipped when client_key returns None (api/repos.rs:556 / rate_limit.rs:198): if no trusted header and no ConnectInfo peer, the if let Some(key) guard is skipped and the request proceeds unthrottled. Unreachable in prod (socket peer always present) but a divergent contract from rate_limit_by_ip; document or reject instead of skip.
  • L8. Malformed rate-limit env vars silently fall back to defaults. main.rs:304-307, 325-328, 343-346 parse GITLAWB_CREATE/WRITE/PUSH_RATE_LIMIT with .ok().and_then(parse).unwrap_or(default); a fat-fingered value silently disables the intended (stricter) brake with no warning, unlike the clap-validated siblings. Warn (or fail fast) on present-but-unparseable values.
  • L9. .env.example docs drift. Missing GITLAWB_IPFS_API, GITLAWB_TIGRIS_BUCKET, GITLAWB_METRICS_ADDR, GITLAWB_SHUTDOWN_GRACE_SECS (parsed in config.rs:88,161,188,194). Reverse direction is clean. Add with defaults.
  • L10. Weak dry-run safety test. admin.rs:471-492 (dry_run_deletes_nothing) seeds repos with an empty repos_dir, so run_purge_spam hits the no-candidate early return before any delete logic — it does not exercise the if !execute { return } guard when candidates exist. Add a dry-run test with a real empty on-disk target repo so a regression that deletes in dry-run is caught.
  • L11. Unauthenticated /metrics (main.rs:828-875, config.rs:187 warns it is unauthenticated). If bound to a non-loopback addr it leaks peer/repo/push counts. Operator-controlled; note in hardening guidance.
  • L12. Git error strings returned to clients (AppError::Git(e.to_string()) sites, e.g. api/repos.rs:398-410). Can surface internal paths; return opaque messages, keep detail in server-side logs.

…ve_dir_all

A peer-mirror row skips API name validation, so a burst-owned repo can carry
a '../' name; repo_disk_path joined it verbatim and the emptiness marker check
passed on the traversed target, so purge-spam selected and remove_dir_all'd a
repo OUTSIDE repos_dir. Validate the name via repo_store::validate_repo_name at
selection (fail closed -> non-candidate) and assert canonical containment inside
repos_dir immediately before the destructive remove. Adversarial RED->GREEN: a
victim bare repo reachable via '../../victim' is deleted before, survives after;
symlink-escape covered by path_within.
delete_repo_by_id was a bare DELETE FROM repos with no cascade and the schema
has no FKs, so every child row (ref_certificates, pull_requests + pr_reviews/
pr_comments, webhooks, push_events, protected_branches, repo_stars, repo_replicas,
repo_labels, visibility_rules, encrypted_blobs, repo_icaptcha_proofs, agent_tasks,
and the slug-keyed branch_cids/sync_queue/received_ref_updates/arweave_anchors)
was orphaned and could re-join a later repo reusing the id. Wrap parent+child
deletes in one transaction, resolving the owner_short/name slug for the repo-TEXT
children. bounties (financial) and issue_comments (unmappable) are left intact by
design. RED->GREEN test seeds a repo_id child, a slug child, and a PR grandchild.
The PR's new write_rate_limiter was the one limiter omitted from main()'s
hand-maintained cleanup list, so its key map never shed expired entries until
the 200k cap. Move the sweep into AppState::cleanup_rate_limiters (co-located
with the limiter fields, single source of truth) and call it from the loop, so
a newly-added limiter is reaped rather than silently dropped. Guard test seeds a
reclaimable entry into write_rate_limiter and asserts the sweep reaps it — RED
if write_rate_limiter is dropped from cleanup_rate_limiters (verified).
purge-spam took no write lock, so a concurrent receive-pack could land a ref
between the emptiness recheck and the delete, silently losing a live push.
Add RepoStore::try_lock_repo — a lock-only, non-blocking counterpart to
acquire_write (no Tigris I/O) whose guard holds a dedicated pooled connection so
the advisory lock and its unlock run on the same session. purge now locks each
candidate, rechecks emptiness UNDER the lock, deletes, and releases; a repo held
by a live writer is skipped, not force-deleted. RED->GREEN: a locked empty repo
survives purge, then deletes once the writer releases (neuter-check confirms the
lock is load-bearing).
…etes

The Ok(n) arm incremented the deleted total before remove_dir_all, whose failure
only warn!'d, so the summary reported N deleted even when the on-disk dir
survived (DB/disk drift shown as clean success). run_purge_spam now returns a
PurgeSummary and counts a failed (or containment-refused) on-disk removal in
disk_failed, never folding it into deleted; the summary line surfaces
'M on-disk removal(s) failed'. RED->GREEN: a read-only parent dir forces the
removal to fail; deleted=1 AND disk_failed=1 with the dir surviving (neuter-check
confirms the counter is load-bearing).
…y defaulting

GITLAWB_CREATE/WRITE/PUSH_RATE_LIMIT parsed with .ok().and_then(parse).unwrap_or,
so a typo (e.g. 5O with a letter O) silently reverted to the default and left the
operator believing a stricter cap was in force. Route all three through
rate_limit_from_env, which distinguishes absent/empty (default, silent) from
present-but-unparseable (default, WARN). Pure resolve_rate_limit is unit-tested
across absent/empty/valid/zero/unparseable — 0 stays valid (its own ==0 warn
handles the disable case).
…DOWN_GRACE_SECS in .env.example

Four config knobs read in config.rs were absent from .env.example, so operators
had no template for them. Add each in its matching section with the config
default (INV-24: operator docs match the config the code reads, in the same PR
that touches the file).
…present

dry_run_deletes_nothing ran against an empty repos_dir, so select_spam_candidates
found nothing and run_purge_spam hit the no-candidate early return — the
if !execute { return } guard was never exercised with candidates present (a
vacuous guard, INV-21). Materialize a real empty bare repo so a genuine candidate
exists, then assert dry-run leaves both the row and the on-disk dir. Verified
load-bearing: removing the !execute guard turns the test RED (dry-run deletes).
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Pushed the fixes at ffdc167. Every behavior change is test-first with the deny/failure case as the observed evidence; full suite 525 pass, clippy -D warnings and fmt clean.

Fixed in this PR

  • [H2] Reap write_rate_limiter in the cleanup loop — the PR's new limiter was the one omitted from the hand-maintained list, so its key map never shed until the 200k cap. Moved the sweep into AppState::cleanup_rate_limiters, co-located with the limiter fields (single source of truth). Guard test seeds a reclaimable entry and asserts the sweep reaps it; RED if write_rate_limiter is dropped from the sweep (verified). 4943430
  • [M3] Reject path-traversal repo names before remove_dir_all — a peer-mirror row skips API name validation, so a ../ name resolved onto a real bare repo outside repos_dir and got selected + deleted. Validate the name at selection (fail closed) via repo_store::validate_repo_name, and assert canonical containment right before the destructive remove. RED->GREEN: a victim repo outside repos_dir is deleted before, survives after; symlink-escape covered by a separate path_within test. 98150db
  • [M7] Delete child rows transactionally in delete_repo_by_id (db/mod.rs) — was a bare DELETE FROM repos with no FKs, orphaning ~14 child tables incl. PR grandchildren. Now one transaction deletes children (by repo_id and the owner_short/name slug) then the parent. bounties (financial) and issue_comments (no issues table to map) are intentionally left. RED->GREEN seeds a repo_id child, a slug child, and a PR review. 1688bdc
  • [M4] Hold the per-repo advisory lock across purge recheck+delete — added RepoStore::try_lock_repo, a lock-only counterpart to acquire_write (no Tigris I/O) whose guard holds a dedicated pooled connection so lock+unlock stay on one session. Purge locks each candidate, rechecks emptiness under the lock, deletes, releases; a repo held by a live writer is skipped. RED->GREEN: locked repo survives purge, deletes after release. 1305167
  • [M6] Count disk-removal failures separately from deletesrun_purge_spam returns a PurgeSummary; a failed (or containment-refused) on-disk removal is counted in disk_failed, never folded into deleted, so the summary can't report clean success while a dir survives. aab70ec
  • [L8] Warn on unparseable rate-limit env varsGITLAWB_CREATE/WRITE/PUSH_RATE_LIMIT now distinguish absent/empty (silent default) from present-but-unparseable (default + warn), so a typo can't silently disable the intended cap. cecca67
  • [L9] Document GITLAWB_IPFS_API/TIGRIS_BUCKET/METRICS_ADDR/SHUTDOWN_GRACE_SECS in .env.example. f4d5bec
  • [L10] Strengthen the dry-run test to run with a real on-disk candidate present, so the !execute guard is actually exercised; RED if the guard is removed. 6da0cf5

Deferred to follow-up issues (pre-existing, outside this PR's diff)

store.rs, icaptcha.rs, and the fork/create ordering are untouched by this PR, so I split these rather than widen scope. Glad to fold any in here instead if you'd rather.

Declined

  • [L1] The bounded map already evicts expired keys then admits at the cap (rate_limit.rs:96-114, test expired_keys_evicted_to_admit_new_when_full); the reclaim gap is bounded by sweep_interval (<=1s under the 3600s window), not a full window. No change.

@jatmn ready for another look.

@beardthelion
beardthelion requested a review from jatmn July 15, 2026 03:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.env.example (1)

143-152: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document all GraphQL HTTP requests covered by this limiter.

The route wiring in crates/gitlawb-node/src/server.rs applies the brake to the whole /graphql route, so queries and the playground GET also consume this bucket—not only mutations. Update the text to say “GraphQL HTTP requests” or narrow the middleware.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 143 - 152, Update the write rate-limiting
documentation near GITLAWB_WRITE_RATE_LIMIT to state that the limiter covers all
GraphQL HTTP requests on the /graphql route, including queries and the
playground GET, rather than describing only GraphQL mutations. Keep the existing
rate-limit scope and configuration details unchanged.
🧹 Nitpick comments (3)
.env.example (1)

19-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the dotenv keys in linter order.

dotenv-linter reports that GITLAWB_METRICS_ADDR should appear before GITLAWB_PORT; moving it will keep configuration checks clean.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example at line 19, Move the GITLAWB_METRICS_ADDR entry in .env.example
so it appears before GITLAWB_PORT, preserving the existing key definitions and
values.

Source: Linters/SAST tools

crates/gitlawb-node/src/main.rs (1)

292-345: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated per-IP limiter setup (create/write/push) into a helper.

The create/write/push blocks each repeat "resolve env limit → build RateLimiter::new_bounded → warn if zero" verbatim, just with different var names/messages. A small helper collapses the duplication and makes adding a fourth limiter trivial.

♻️ Proposed refactor
+fn build_ip_limiter(limit: usize, var: &str, label: &str) -> rate_limit::RateLimiter {
+    let limiter = rate_limit::RateLimiter::new_bounded(
+        limit,
+        std::time::Duration::from_secs(3600),
+        200_000,
+    );
+    if limit == 0 {
+        tracing::warn!("{var}=0 — per-IP {label} rate limiting disabled");
+    }
+    limiter
+}
+
     let create_limit = rate_limit_from_env("GITLAWB_CREATE_RATE_LIMIT", 120);
-    let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
-        create_limit,
-        std::time::Duration::from_secs(3600),
-        200_000,
-    );
-    if create_limit == 0 {
-        tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
-    }
+    let create_ip_rate_limiter =
+        build_ip_limiter(create_limit, "GITLAWB_CREATE_RATE_LIMIT", "creation");

     let write_limit = rate_limit_from_env("GITLAWB_WRITE_RATE_LIMIT", 600);
-    let write_rate_limiter = rate_limit::RateLimiter::new_bounded(
-        write_limit,
-        std::time::Duration::from_secs(3600),
-        200_000,
-    );
-    if write_limit == 0 {
-        tracing::warn!("GITLAWB_WRITE_RATE_LIMIT=0 — per-IP write rate limiting disabled");
-    }
+    let write_rate_limiter = build_ip_limiter(write_limit, "GITLAWB_WRITE_RATE_LIMIT", "write");

     let push_limit = rate_limit_from_env("GITLAWB_PUSH_RATE_LIMIT", 600);
-    let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
-        push_limit,
-        std::time::Duration::from_secs(3600),
-        200_000,
-    );
-    if push_limit == 0 {
-        tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
-    }
+    let push_rate_limiter = build_ip_limiter(push_limit, "GITLAWB_PUSH_RATE_LIMIT", "push");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/main.rs` around lines 292 - 345, Extract the repeated
create, write, and push per-IP limiter initialization into a shared helper that
accepts the environment variable name, default limit, and disabled-warning
context, then returns the bounded RateLimiter. Replace the three inline
rate_limit_from_env/new_bounded/warn sequences with helper calls while
preserving their existing limits, environment overrides, warning behavior, and
bucket duration/capacity.
crates/gitlawb-node/src/git/repo_store.rs (1)

428-446: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Make repo lock release drop-safe
RepoLockGuard::release() drops pg_advisory_unlock errors, and the manual guard still depends on every caller remembering .release(). Switching to sqlx::postgres::PgAdvisoryLock/PgAdvisoryLockGuard (or adding a logged, drop-safe fallback) would make leaked locks much harder to miss.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/repo_store.rs` around lines 428 - 446, Update
RepoLockGuard and RepoLockGuard::release so advisory locks are drop-safe: use
sqlx’s PgAdvisoryLock/PgAdvisoryLockGuard where compatible, or add a Drop-based
fallback that attempts cleanup and logs unlock failures. Ensure callers no
longer depend solely on remembering release(), while preserving unlocking on the
same connection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In @.env.example:
- Around line 143-152: Update the write rate-limiting documentation near
GITLAWB_WRITE_RATE_LIMIT to state that the limiter covers all GraphQL HTTP
requests on the /graphql route, including queries and the playground GET, rather
than describing only GraphQL mutations. Keep the existing rate-limit scope and
configuration details unchanged.

---

Nitpick comments:
In @.env.example:
- Line 19: Move the GITLAWB_METRICS_ADDR entry in .env.example so it appears
before GITLAWB_PORT, preserving the existing key definitions and values.

In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 428-446: Update RepoLockGuard and RepoLockGuard::release so
advisory locks are drop-safe: use sqlx’s PgAdvisoryLock/PgAdvisoryLockGuard
where compatible, or add a Drop-based fallback that attempts cleanup and logs
unlock failures. Ensure callers no longer depend solely on remembering
release(), while preserving unlocking on the same connection.

In `@crates/gitlawb-node/src/main.rs`:
- Around line 292-345: Extract the repeated create, write, and push per-IP
limiter initialization into a shared helper that accepts the environment
variable name, default limit, and disabled-warning context, then returns the
bounded RateLimiter. Replace the three inline
rate_limit_from_env/new_bounded/warn sequences with helper calls while
preserving their existing limits, environment overrides, warning behavior, and
bucket duration/capacity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0ed69896-8eea-4a8f-802d-9ceb9db33022

📥 Commits

Reviewing files that changed from the base of the PR and between 27b4a2a and ffdc167.

📒 Files selected for processing (7)
  • .env.example
  • crates/gitlawb-node/src/admin.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/rate_limit.rs
  • crates/gitlawb-node/src/state.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/gitlawb-node/src/admin.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep the writer's advisory-lock session in its guard
    crates/gitlawb-node/src/git/repo_store.rs:156
    acquire_write obtains a session-scoped advisory lock through &self.pool, then immediately returns that connection to the pool; RepoWriteGuard::release later also uses an arbitrary pooled connection. The new purge guard can therefore receive the writer's idle lock-owning session and re-acquire the lock reentrantly, pass its final empty check, and remove the DB row and bare directory while a receive-pack/merge/issue write is still using its guard. Retain the acquired PoolConnection for RepoWriteGuard (as RepoLockGuard does) and add contention coverage that holds a real acquire_write guard while purge runs.

  • [P1] Make purge-spam verify and remove the configured Tigris object
    crates/gitlawb-node/src/main.rs:625
    The admin command deliberately constructs its RepoStore with None, so selection and both rechecks inspect only the local cache and deletion removes only that cache plus the DB row. On a Tigris deployment, a locally empty but remotely updated repo can be deleted despite current remote refs; the archive also remains after a successful purge and can be downloaded into a later repo with the same owner/name. Initialize the configured Tigris client for this command, refresh/fail closed under the per-repo lock, and delete or otherwise tombstone the archive with explicit failure handling.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep the full iCaptcha origin in the advertised-URL trust check
    crates/icaptcha-client/src/lib.rs:91
    resolve_solver_url now compares only the hostname when accepting a node-advertised URL and deciding whether to attach GITLAWB_ICAPTCHA_API_KEY. A hostile node can advertise https://<configured-iCaptcha-host>:8443/...; that is a different origin but passes both checks, so the client posts the operator's bearer token to the alternate service. The base implementation deliberately included the effective port in this comparison. Restore normalized scheme/host/effective-port matching and retain a cross-port regression test.

  • [P1] Do not drop the iCaptcha PoW protocol fields
    crates/icaptcha-client/src/lib.rs:197
    This removes Challenge.pow, the PoW solver, and the powNonce sent to /v1/answer. A PoW-enabled service therefore receives only { token, answer } and rejects the proof attempt, leaving gl unable to complete any protected create, fork, or registration flow. This is a rollback of the base client protocol without a corresponding service migration; preserve the optional PoW solve-and-submit path.

  • [P1] Ensure a newly created repository is uploaded after releasing its creation lock
    crates/gitlawb-node/src/git/repo_store.rs:334
    create_repo holds the per-repository lock while calling init, while init starts a non-waiting upload_locked(..., false) task. When that task runs before the request releases the lock, it sees the lock as contended and exits permanently. The API still returns 201, but the new empty repository has no object-store archive, so another node (or the creator after local loss) cannot hydrate it until a later write happens. Schedule a retry/upload after releasing the lock, or upload through the already-held guard.

  • [P1] Propagate failed fork archive uploads instead of publishing an unavailable fork
    crates/gitlawb-node/src/api/repos.rs:1699
    release_after_write discards upload_locked's boolean outcome, and fork_repo always inserts the DB row and returns 201. With Tigris enabled, an upload failure or bounded lock-acquire failure leaves a fork only on the local node; cache misses on other nodes then resolve the DB record to no repository. Return the upload result and fail the fork before publishing its row when durability was not achieved.

  • [P2] Serialize forks with purge-spam just as repository creation is serialized
    crates/gitlawb-node/src/api/repos.rs:1661
    The new create-vs-purge lock is not used by fork_repo. If the burst DID forks into the owner/name being purged after its row is removed but before the purge finishes removing disk/archive state, the purge can remove the newly cloned fork or its archive and the fork can subsequently insert a dangling row. Hold the same namespace lock across the conflict check, clone/upload, and row insertion.

  • [P1] Prevent an in-flight cache download from restoring a purged repository
    crates/gitlawb-node/src/git/repo_store.rs:157
    acquire_fresh performs the object-store download and decompress_repo publishes it to disk without the advisory lock used by purge. A request that begins a GET before purge deletes the archive can complete after purge releases its lock and swap the deleted repository back into its old path. That leaves stale data on disk and can block or contaminate a same-name recreation. Make the cache publish participate in the repository lock, or revalidate after download before publishing it.

  • [P2] Do not present a locally-applied issue create as a retry-safe failure
    crates/gitlawb-node/src/api/issues.rs:48
    On an archive-upload failure the handler writes the issue ref locally, returns a retryable 5xx, and instructs the client to retry. Each retry creates a new UUID and ref; if the first local state survives (for example while the store remains unavailable), the retry uploads both issues. Add an idempotency/deduplication mechanism for this recovery path, or use a recovery response that does not imply a safe replay.

t added 12 commits July 20, 2026 22:41
…lock (#196)

create_repo holds the per-repo lock across init, so the wait=false spawn
try-locked against its creator and died permanently, leaving a new repo
with no object-store archive until its first later access. RED observed:
init_upload_waits_out_creators_lock_then_uploads failed (PUT count 0)
against the old code; the bounded-skip negative is retained as
init_upload_gives_up_after_bounded_lock_wait.
…le upload fails (#196)

The failed-upload 500 previously left refs/gitlawb/issues/{id} on disk,
so a client retry minted a second issue once the store recovered. The
delete now runs via RepoWriteGuard::release_with_failure_cleanup while
the advisory lock is still held, closing the unlock-to-delete window a
concurrent write could have archived the orphan ref through. RED
observed on both new tests (orphan ref survived; retry yielded 2
issues); the retry test pins the store double's exists()=false contract
so it cannot pass via a stale-archive revert.
…the target-namespace lock (#196)

fork_repo now takes the per-repo advisory lock before its conflict check
and holds it through clone, upload, and row insert, 503ing on contention
exactly as create_repo does. The upload runs under the held guard via
upload_under_guard, PUT bounded by release_upload_timeout, returning
false only for an attempted upload that failed or timed out (no-store
nodes fork unchanged); on failure the mirror is removed and the fork
500s before any row is built. release_after_write is retired; its tests
migrated onto the new method. RED observed on the serialization,
durability, and contention tests; defect injection (row insert reordered
above the upload) turned the durability test RED and was reverted.
…pinning it across the network (#196)

acquire's cache-miss and every acquire_fresh refresh now coalesce on a
per-repo download mutex, download to a temp sibling with no advisory
lock held, then try-lock, re-check the archive exists under the lock,
and swap - so an in-flight read can no longer resurrect a repository
purge just deleted (RED observed on both the cache-miss and
refresh-over-existing repros), a post-purge reader degrades instead of
publishing, and concurrent cold reads download once. The hit path stays
lock-free (deterministic held-lock fence) and no lock-pool connection is
held while a download is parked.
…oad path (#196)

Round-two review found the durability fixes reintroduced the
unbounded-work-under-lock class they were meant to close: upload_locked's
PUT (now reachable under the lock via init's wait=true), the read-path
download, and the under-lock exists() re-check all ran with no timeout
while pinning the advisory lock and its lock-pool connection. All three
now wrap the call in timeout(release_upload_timeout) and fail closed on
elapse, matching RepoWriteGuard::release's existing INV-22 pattern.
upload_under_guard and upload_locked's duplicated resolve/recheck/upload
body is extracted into upload_dir_locked, threading the load-bearing
dir-gone signal (fork needs success, lazy migration needs
skip-and-retry) through a parameter rather than aligning it. RED
observed: upload_locked_put_stall_is_bounded failed (lock never freed)
against the un-timeout'd PUT.
…swaps, and lock contention (#196)

Four read-path gaps the review surfaced. A TempDownloadDir RAII guard
now cleans the temp download dir if the handler future is cancelled on
client disconnect (the read handlers have no spawn shield, and a
post-download disconnect leaked the full populated dir), plus an
entry-time sweep of stale siblings. A cold or refresh reader that
downloaded while a concurrent writer published fresher on-disk state now
discards its stale temp and serves the writer's copy instead of swapping
over it (detected by presence for the cold path, mtime for the refresh
path). The takeover path re-registers its download-mutex entry so late
readers coalesce instead of starting a duplicate download. And the
try_lock Ok(None) degrade arm - the KTD-3 invariant that a reader
degrades rather than resurrecting under a live writer/purge - is now
tested by holding the advisory lock via an out-of-pool observer. RED
observed on both stale-swap tests.
…#196)

The fork tail held the target-namespace advisory lock across two
unbounded operations. The source acquire, which on a cold source
triggers a full object-store download plus a nested lock-pool acquire on
the same writer-sized pool, now runs before the target lock is taken (it
is read-only on a different namespace and nothing under the lock depends
on it) so a cold-source fork can no longer demand two connections from a
one-per-writer pool at once. The git clone --mirror now runs via
tokio::process with kill_on_drop under a timeout(git_service_timeout_secs)
ceiling, cleaning the partial mirror and releasing the lock on elapse.
RED observed by executing the pool-size-1 cold-source fork with the
source acquire moved back under the lock: PoolTimedOut, source never
publishes, clone fails.
…e submodule (#196)

repo_store.rs had grown past 2800 lines. The download-coordination code
(DownloadOutcome, TempDownloadDir, download_published,
download_and_publish, remove_download_entry) moves to a private
repo_store/download.rs child module, which reaches the parent's private
fields and methods without widening visibility beyond pub(super) on the
two items the parent names. Pure move: no behavior change, the same 44
git::repo_store tests pass unchanged.
…ntry on cancel (#196)

Two gaps a second-model adversarial pass caught that the in-family
review missed. acquire_write's under-lock exists()/download() ran with
no timeout, so a stalled Tigris GET pinned the write lock and its
lock-pool connection forever (the local-copy fallback only fires on an
error, never a hang) - both are now wrapped in
timeout(release_upload_timeout), a timeout mapping to the existing
download-error fallback, which also makes the prior 'bound every
under-lock call' claim true. The download_locks map entry was removed
only on normal return, not on a cancelled cold read, so a disconnecting
caller could grow the map across public repos; a DownloadEntryGuard now
prunes it in Drop (outer map moved to std::sync::Mutex, held only for
map ops, never across an await). RED observed on both new tests,
including a real mid-download cancellation.
… failures (#196)

The bounded-clone rework cleaned up the mirror on the timeout and spawn
arms but not on a clone that creates the destination then exits nonzero,
so a retry hit 'destination already exists' and could never complete
that fork name. And because upload_under_guard is now required and runs
before the row insert, a create_repo failure reliably orphaned the
durable archive too, not just the local dir. Both failure arms now
remove the mirror, delete the fork's archive (no-op on a store-less
node), release the lock, and return. RED observed on both new tests (a
non-empty-dest clone-128 repro and a decoy-row insert-failure repro).
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Pushed the fixes (head 8d2fec1). Against your findings:

iCaptcha origin check and PoW fields — these don't apply to this branch. It never touched crates/icaptcha-client; what you diffed was against a stale base. I've merged current main, so resolve_solver_url keeps the normalized scheme/host/effective-port match (origin_key) and the PoW solve/submit path is intact in icaptcha-client/src/lib.rs.

create_repo starves its own init upload — fixed. init's background upload now waits for the lock (upload_locked(wait=true)) instead of try-locking against create's held lock and exiting permanently. init_upload_waits_out_creators_lock_then_uploads fails (PUT count 0) against the old code.

fork publishes despite a failed upload, and isn't serialized with purge — both fixed by one change: the fork tail now holds the target-namespace advisory lock across the conflict check, clone, upload, and row insert. upload_under_guard is #[must_use], and the handler returns 5xx before any row is built when the durable upload fails (503 on lock contention, matching create_repo). Covered by fork_durable_upload_failure_fails_before_row_insert and fork_serializes_against_target_namespace_lock; reordering the insert above the upload turns the durability test red.

in-flight cache download resurrects a purged repo — fixed. Cache-miss and refresh downloads extract to a temp dir with no lock held, then take the per-repo lock, re-check the archive still exists, and swap; a download racing a purge discards instead of republishing. download_does_not_resurrect_a_purged_repo and the refresh variant.

issue-create retry duplicates the issue — fixed. The failed-upload arm deletes the local ref before the advisory unlock, so a retry yields exactly one issue. issue_create_failed_upload_rolls_back_local_ref and _retry_does_not_duplicate.

While in here I also closed the lock-lifetime gap the durability work exposed: no advisory lock is now held across a network transfer on the read path, and every under-lock object-store call (upload, download, exists, write-acquire) is bounded by release_upload_timeout. Full workspace suite green.

@beardthelion
beardthelion requested a review from jatmn July 21, 2026 14:06

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Serialize mirror ingestion with the purge child-record cascade
    crates/gitlawb-node/src/db/mod.rs:1386
    delete_repo_by_id decides that no normalized owner/name sibling exists, then deletes every slug-scoped record and tombstones the slug's bounties. upsert_mirror_repo does not participate in the purge advisory lock or otherwise lock that namespace, so peer sync can insert a surviving bare-owner mirror (and its sync/branch/anchor state) after this point-in-time check but before those deletes execute. The purge then removes live state from that mirror and can make its active bounty unclaimable. Serialize the mirror upsert with this deletion, or use a transaction/constraint that prevents this phantom before cascading slug-scoped records.

  • [P1] Roll back every locally applied write when its durable upload fails
    crates/gitlawb-node/src/api/issues.rs:296
    close_issue changes the issue ref and then uses plain guard.release; when the Tigris upload times out or fails, it returns a retryable 5xx but leaves the changed ref in the local cache. Normal issue reads use RepoStore::acquire's local fast path, so this node continues to report the issue closed even though the client was told the operation failed; the stale state can persist across restart until a later write refreshes the archive. create_issue already uses release_with_failure_cleanup for this exact reason. Restore the previous ref (and apply equivalent rollback to the merge and receive-pack paths) before releasing the write lock on a failed durable upload.

  • [P2] Do not let cancelled cold reads recreate abandoned download directories
    crates/gitlawb-node/src/git/repo_store/download.rs:226
    The timeout/cancellation path drops TempDownloadDir, but TigrisClient::download has already started decompress_repo in spawn_blocking. Dropping the await detaches that blocking task; it can finish afterward and rename its extraction back into the just-removed .tmp-download.* path. That directory is only swept on a later request for the same repository, so cancelled cold reads across distinct repositories accumulate full extracted repositories on disk. Tie temp cleanup to completion of the blocking extraction, or add bounded, reliable cleanup for these abandoned directories.

t added 11 commits July 22, 2026 12:18
…ry lock

delete_repo_by_id ran a point-in-time sibling check then cascaded slug-scoped
deletes; upsert_mirror_repo (peer sync) did not serialize against that window,
so a colliding mirror could land mid-purge and get half-wiped. Both now take a
transaction-scoped pg_advisory_xact_lock keyed on the normalized slug. (F1, #196)
…receive-pack)

close_issue, merge_pr, and the receive-pack task applied a local ref write then
released with an empty failure cleanup, so a failed Tigris upload left stale
state that the local-fast-path read served despite the client getting a 5xx.
Each now captures the prior ref and restores it via release_with_failure_cleanup
when the upload fails, matching create_issue. (F2, #196)
On a download timeout the TempDownloadDir guard dropped while the detached
spawn_blocking extraction kept running and could rename its output back into
the just-removed temp path; that dir was only reclaimed on a later same-repo
request, so cold reads across distinct repos accumulated extracted repos on
disk. The timeout arm now hands the owned download future to a detached janitor
that awaits extraction completion before removing the temp dir, off the
critical path so waiters are still freed immediately. (F3, #196)
Auto-deref (&mut tx) on the lock_repo_slug calls and rustfmt of the new test
lines. No behavior change.
…flight task, compensate a timed-out upload with the rolled-back tree, aggregate partial ref restores, race-test the anchor lock, fail-closed snapshot test
@beardthelion

Copy link
Copy Markdown
Collaborator Author

All three addressed at 93d9a10, with the branch merged up to current main (b484a24). Every fix below was verified load-bearing the same way: revert the production line, watch its test fail, restore, watch it pass. Full workspace suite is 1115 green, clippy and fmt clean.

Purge vs mirror ingest. delete_repo_by_id and upsert_mirror_repo now take a pg_advisory_xact_lock on the same normalized slug inside their transactions, so the sibling-check-then-cascade window is closed. The race test parks a real upsert_mirror_repo call on the held lock and asserts it has not landed mid-park; removing the lock from either side fails the test. I also derived the writer set from the cascade's own table list rather than stopping at the mirror row: upsert_branch_cid, enqueue_sync, insert_ref_update, and record_arweave_anchor all write purge-cascaded tables and now take the same lock (single lock per transaction, nothing else held, so no ordering cycle). The anchor writer has its own race test.

Rollback on failed durable upload. close_issue restores the prior status, merge_pr the prior target ref, and receive-pack the full pre-push snapshot including deleting push-created refs, all via release_with_failure_cleanup while the lock is held. Each path has a failing-upload test observed failing with its rollback neutered. Two hardenings on top: a snapshot failure now refuses the push before any mutation (a listing error must never read as an empty snapshot, which would turn the rollback into delete-every-ref; there is a handler-level test driving a corrupt repo through receive-pack), and on the timeout arm specifically, after rolling back we attempt one bounded re-upload of the rolled-back tree under the same lock. That closes the window where a PUT whose body had fully landed commits post-state after the client was told the write failed; a probe store test observes the compensating call seeing the rolled-back tree, and that the definite-error arm does not compensate.

Cancelled cold reads. The download task now owns the temp dir, the per-repo mutex, and the locks-map entry, releasing all three only when the task settles. That covers timeout, error, and client disconnect with one path, and late readers coalesce onto the in-flight download instead of starting duplicates (the pre-fix code could stack N full downloads of the same object under timeout waves). Resurrection and coalescing tests exist in timeout and cancel variants; all fail against the pre-fix file.

One pre-existing gap I am disclosing rather than fixing here: peer-supplied repo identifiers stored in full-DID form write rows the purge cascade's normalized-slug WHERE never matches, so such rows survive a purge. The new locks serialize those writers but cannot close the stored-form mismatch at this layer, and normalizing at write time changes what the sync worker reads back, so it wants its own change with that reader walk done properly. Happy to file it as a follow-up issue.

The merge from main had two conflicts (api/repos.rs, db/mod.rs), resolved so main's owner_did threading runs inside this branch's disconnect-surviving push task and the slug-locked ref-update insert. Ready for another look.

@beardthelion
beardthelion requested a review from jatmn July 22, 2026 23:16

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Rebase without rolling the released version back
    .release-please-manifest.json:2
    This is rebase drift, not part of the feature: the base commit is already tagged v0.7.0, but this head resets every package and release-please manifest to 0.6.0 and removes the 0.7.0 changelog entry. Merging it would make binaries report an older, already-published version and make the next release calculation start from invalid release state. Rebase onto the current base and retain the 0.7.0 release artifacts.

  • [P1] Keep the gitlawb-core dependency-purity gate
    .github/workflows/pr-checks.yml:249
    This is also rebase drift: the PR removes the only required job that enforces the exhaustive normal-dependency allowlist for gitlawb-core, and deletes both the checker and its policy file with no replacement. Future heavyweight or supply-chain dependencies added to the core crate will then pass normal CI and silently propagate to every shipped CLI/node consumer. Restore the gate when rebasing.

  • [P1] Do not use the repository root mtime as the fresh-read write generation
    crates/gitlawb-node/src/git/repo_store/download.rs:395
    acquire_fresh downloads an archive without the advisory lock and then assumes a changed bare-repository root directory mtime proves a writer published during that window. Ordinary pushes update nested refs such as refs/heads/main and leave the bare repo root mtime unchanged, so the reader can take the lock after a successful writer, remove that writer's local checkout, and rename its older archive over it. Subsequent local reads serve stale refs until another refresh/write. Use a reliable version/generation check or keep the download/publish sequence ordered with writers.

  • [P1] Abort close and merge when their rollback snapshot cannot be captured
    crates/gitlawb-node/src/api/issues.rs:296
    issue_ref_oid(...).ok().flatten() (and the equivalent target-ref snapshot in api/pulls.rs) suppresses a snapshot error and proceeds with the mutation. If the following durable upload fails, the cleanup closure has no prior ref to restore, yet the handler returns 5xx while the local fast path keeps serving the closed issue or merged branch; a later write can upload that state. This is the same durable-write split that git_receive_pack now correctly fails closed for, so capture the snapshot or reject before mutating.

  • [P1] Do not treat an unordered compensating PUT as rollback convergence
    crates/gitlawb-node/src/git/repo_store.rs:855
    After an upload timeout, this rolls the local refs back and sends one compensating PUT, but the timed-out S3 request may have reached the service and can commit after that compensation. These unversioned writes have no ordering or conditional precondition, so the first PUT can become the final archive even though the client received a failure and local disk was restored. A cold node then resurrects the rejected write. Establish completion/ordering with the original PUT, or use a versioned/conditional storage protocol before claiming the rollback is safe.

  • [P2] Bound object-store refresh while purge owns the repository lock
    crates/gitlawb-node/src/git/repo_store.rs:678
    run_purge_spam calls refresh_from_archive while it holds the per-repository advisory lock, but that helper awaits both exists and download without a timeout. A stalled S3 request therefore pins the repo lock and its dedicated pool connection indefinitely, blocking writes for that repository and eventually exhausting the lock pool under several stalls. Apply the existing bounded timeout and fail closed/skip the candidate on expiry.

  • [P2] Cascade slug-scoped children using the same normalized representation writers persist
    crates/gitlawb-node/src/db/mod.rs:1463
    The purge cascade deletes only the normalized bare slug, while enqueue_sync and insert_ref_update use that normalization only for their advisory-lock key and persist the peer-supplied repo string verbatim. A known peer can enqueue did:key:<purge-target>/name; the purge of that target deletes <bare>/name but leaves the pending queue entry, and the sync worker later clones and upserts the repository again. Normalize before storing these child keys or delete both equivalent forms as part of the cascade.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:feature New capability or surface subsystem:identity DID/UCAN, http-sig auth, push authorization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants