Skip to content

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

Open
beardthelion wants to merge 25 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 25 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 optional per-IP “write brake” for authenticated non-creation writes via GITLAWB_WRITE_RATE_LIMIT (0 disables), applied to key REST and GraphQL mutation routes while keeping /graphql/ws unaffected.
  • Bug Fixes
    • Improved push handling so in-flight receive-pack processing continues even if the client disconnects mid-hook.
  • Documentation
    • Updated .env.example with new runtime knobs (GITLAWB_METRICS_ADDR, GITLAWB_SHUTDOWN_GRACE_SECS, GITLAWB_TIGRIS_BUCKET, GITLAWB_IPFS_API) and the write-rate policy description.
  • Tests
    • Expanded integration tests for purge safety, throttling correctness/trusted proxy bucketing, and disconnect-resilient push completion.

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

📝 Walkthrough

Walkthrough

Changes

The pull request adds a purge-spam admin command with dry-run and execute modes, safe repository checks, locking, archive handling, and integration tests. It also adds configurable per-client-IP rate limiting for authenticated non-creation writes across REST and GraphQL HTTP routes, while making git push processing survive client disconnects.

Spam purge administration

Layer / File(s) Summary
Admin command and storage contracts
crates/gitlawb-node/src/{config,main,db}.rs, crates/gitlawb-node/src/git/*
Adds CLI dispatch, repository listing 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, validates bare-repository paths, rejects unsafe paths, and rechecks candidates.
Deletion execution and validation
crates/gitlawb-node/src/admin.rs, crates/gitlawb-node/src/db/mod.rs
Implements dry-run and execution flows with locked deletion, disk containment checks, archive cleanup, failure accounting, and integration coverage.

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 the write-limit setting, application-state field, startup parsing, cleanup, and test 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.
Rate-limit behavior and push validation
crates/gitlawb-node/src/{api/repos,rate_limit}.rs
Tests throttling, bucket separation, disabled limits, route coverage, proxy-key handling, cleanup, and completion after disconnect.

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#87: Modifies GraphQL middleware composition used before the write brake.
  • Gitlawb/node#152: Uses the same trusted-proxy IP throttling machinery on git receive paths.
  • Gitlawb/node#180: Adds related per-client-IP rate-limiter wiring for repository creation routes.

Suggested labels: crate:node, subsystem:identity, kind:feature, kind:security, subsystem:api

Suggested reviewers: jatmn

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has substance, but it does not follow the required template and omits required sections like Summary, Kind of change, and verification steps. Rewrite the PR description using the repository template, including Summary, Motivation & context, Kind of change, What changed, and How a reviewer can verify.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: write-surface rate limiting and the purge-spam admin tool.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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.

t added 4 commits July 15, 2026 16:43
acquire_write took the session-scoped advisory lock through the shared
pool and returned the lock-owning connection to the pool, so release
unlocked on a different connection (silent no-op) and a concurrent
try_lock_repo could be handed the idle lock-owning connection and
reentrantly re-grab the lock, defeating the purge tool's mutual
exclusion. Pin a dedicated PoolConnection for the guard's lifetime and
unlock on it, mirroring RepoLockGuard.

Load-bearing contention test (single-connection pool to force the
reentrant grab deterministic): RED (Ok(Some)) before, GREEN after.
Extract an async ObjectStore trait (exists/upload/download/delete) that
TigrisClient implements, and hold Option<Arc<dyn ObjectStore>> in
RepoStore instead of Option<TigrisClient>. A dyn async trait needs
async-trait (added as a direct dep; already transitively present), which
E0038 would otherwise reject. Pure refactor — no behavior change; full
suite stays green. Enables an in-test fake for the purge Tigris work (U3).

Cargo.lock also picks up the pre-existing 0.5.0->0.5.1 version sync
(#185: the committed lock was stale vs the manifests) that any cargo run
regenerates.
purge-spam built its RepoStore with no object store, so on a Tigris
deployment it rechecked emptiness against possibly-stale local disk
(deleting a repo with live remote refs) and left the archive behind (to
be re-downloaded into a later same-owner/name repo). Give the admin
command the configured object store, refresh the local copy from the
authoritative archive under the per-repo lock before the recheck
(fail-closed on a store error via a new skipped_store_error), and delete
the archive on a successful purge (failures counted in a new
archive_failed, never folded into success). When no bucket is configured
the None path is byte-for-byte unchanged.

Three load-bearing guards verified RED->GREEN on the Tigris-blind purge:
remote-refs-not-deleted, archive-deleted, fail-closed-on-unreachable.
Two regressions the connection-pinning fix (010c625) introduced, caught
in pre-push review:

- Pool pressure: acquire_write held its pinned connection through the
  whole 60s lock-retry backoff; pre-change the retry probed via the pool
  and returned the connection between attempts. Under a same-repo write
  burst every spinner now held a pool connection through its sleeps and
  could starve the shared pool. Restructure the loop to return the
  connection between FAILED attempts and keep it only on the winning one.
- Lock leak: on the object-store download-error early-return the pinned
  connection dropped back to the pool with the advisory lock still held
  (no guard Drop runs pre-construction, and sqlx does not release session
  locks on connection return), blocking every future write to that repo
  until the pool reaped the connection. Unlock on the pinned connection
  before bailing.

Add acquire_write_guard_excludes_try_lock_across_connections: a
multi-connection test proving the exclusion holds across sessions (the
production topology), which also catches a pin-but-don't-lock regression
the single-connection test cannot.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both fixed at 8842272.

P1a — advisory-lock session pinned. RepoWriteGuard now holds a dedicated PoolConnection for its lifetime and unlocks on that same session, matching RepoLockGuard. Verified by execution: a contention test goes RED (reentrant Ok(Some) grab) on the old code and GREEN after the pin, and a second multi-connection test asserts a live guard makes try_lock_repo return Ok(None) from a different pooled connection — the real writer-vs-purge topology, not just the same-connection reentrancy.

Two things surfaced while pinning the connection, both fixed in the same push. The retry loop now returns the connection to the pool between failed attempts rather than holding it through the 60s backoff (otherwise a same-repo write burst starves the pool), and the object-store-download error path unlocks before bailing (otherwise the lock orphaned on the returned connection until the pool reaped it, since sqlx doesn't release session locks on connection return). Tradeoff worth flagging: a held write now pins one pool connection for the operation's duration, so db_max_connections should comfortably exceed peak concurrent writes.

P1b — purge is object-store-authoritative. The admin command now builds the configured object store (and refuses to run local-only when a bucket is set), refreshes the local copy from the archive under the per-repo lock before the emptiness recheck, and deletes the archive on a successful purge. It fails closed on any store error (a new skipped_store_error) rather than deleting on a stale-local view, and archive-delete failures are counted separately (archive_failed) so a surviving archive never reads as clean success. Three guards run RED→GREEN: a repo with live remote refs but an empty local copy is skipped, the archive is deleted on success, and an unreachable store skips rather than deletes. Fail-closed means a transient store outage skips the run and the operator re-runs — deliberately, since deleting a live repo on a stale view is the worse failure. (The emptiness recheck downloads and counts refs rather than probing exists, since init uploads an archive for every repo, so presence isn't emptiness.)

The archive delete needed a seam, so TigrisClient now sits behind an ObjectStore trait — a pure refactor, suite green.

Full workspace suite green, fmt + clippy clean.

@beardthelion
beardthelion requested a review from jatmn July 15, 2026 22:47

@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.

Actionable comments posted: 1

Caution

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

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

236-238: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not require a local cache hit before the authoritative recheck.

on_disk_ref_count treats a missing local repository as non-empty, so remote-only empty archives are filtered out before refresh_from_archive can download and verify them. Separate owner/exclusion scoping from emptiness selection, then refresh and check under the lock. Add a missing-local/empty-archive test.

🤖 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/admin.rs` around lines 236 - 238, Update the
spam-candidate flow around select_spam_candidates so candidate selection does
not require an on-disk repository or use on_disk_ref_count as the emptiness
filter; retain only owner/exclusion scoping, then call refresh_from_archive and
perform the authoritative empty check while holding the lock. Add a test
covering a remote-only empty archive with no local repository, ensuring it is
selected, refreshed, and verified.
crates/gitlawb-node/src/git/repo_store.rs (1)

79-99: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Object-store uploads can resurrect an archive after purge.

All archive PUT paths must participate in the same repository-lock protocol as purge-spam archive deletion.

  • crates/gitlawb-node/src/git/repo_store.rs#L79-L99: acquire and retain the repository lock through the lazy migration HEAD/PUT.
  • crates/gitlawb-node/src/git/repo_store.rs#L286-L295: initialize and upload under the repository lock instead of spawning an unlocked PUT.
  • crates/gitlawb-node/src/git/repo_store.rs#L301-L315: require a lock guard/token or acquire the lock internally before uploading.
🤖 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 79 - 99, Ensure every
archive upload uses the repository-lock protocol shared with purge-spam
deletion: in crates/gitlawb-node/src/git/repo_store.rs lines 79-99, retain the
lock across the lazy migration existence check and upload; in lines 286-295,
initialize and upload while holding that lock rather than spawning an unlocked
PUT; and in lines 301-315, require a lock guard/token or acquire the repository
lock internally before performing the upload.
🤖 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.

Inline comments:
In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 177-205: Update RepoWriteGuard and RepoLockGuard so dropping
either guard automatically releases its advisory lock instead of relying solely
on release(). Ensure the unlock runs before the pooled connection is returned,
using an appropriate Drop-safe mechanism or transaction-scoped advisory locks,
while preserving explicit release behavior.

---

Outside diff comments:
In `@crates/gitlawb-node/src/admin.rs`:
- Around line 236-238: Update the spam-candidate flow around
select_spam_candidates so candidate selection does not require an on-disk
repository or use on_disk_ref_count as the emptiness filter; retain only
owner/exclusion scoping, then call refresh_from_archive and perform the
authoritative empty check while holding the lock. Add a test covering a
remote-only empty archive with no local repository, ensuring it is selected,
refreshed, and verified.

In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 79-99: Ensure every archive upload uses the repository-lock
protocol shared with purge-spam deletion: in
crates/gitlawb-node/src/git/repo_store.rs lines 79-99, retain the lock across
the lazy migration existence check and upload; in lines 286-295, initialize and
upload while holding that lock rather than spawning an unlocked PUT; and in
lines 301-315, require a lock guard/token or acquire the repository lock
internally before performing the upload.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e562d9be-a41b-488d-b6ef-e2576c26346e

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/admin.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/gitlawb-node/src/main.rs

Comment thread crates/gitlawb-node/src/git/repo_store.rs Outdated
t added 5 commits July 16, 2026 09:38
RepoWriteGuard and RepoLockGuard now hold their pooled connection as an
Option: release() takes it (returns it to the pool) while a Drop impl closes
the connection when release() was never reached — an early ?, a panic, or the
handler future cancelled on client disconnect. Closing the connection ends the
Postgres session so the server frees the session-scoped advisory lock, instead
of returning a lock-holding connection to the pool where it would block every
later write to that repo until the connection is reaped (up to ~30min at the
pool's default max_lifetime).

acquire_write now wraps the winning connection in the guard BEFORE the
freshness download, so a cancellation during that await is covered by the
guard's Drop rather than orphaning the lock on a bare connection.

Tests observe the leak by disabling the pool's ambient reaping (which otherwise
masks it ~1.8s after drop), so the lock stays held until the fix's own unlock.
RED before / GREEN after for both guards and the acquire-side cancel; each
protection line proven load-bearing by revert (INV-21).
git_receive_pack now runs the acquire -> receive-pack -> release core in a
spawned task the handler awaits but whose cancellation it does not propagate.
The pack body is fully buffered before the handler runs, so the task is
self-contained: a client that disconnects mid-apply drops the handler future
without cancelling the task, and the push still applies, uploads, and releases
its lock in order. Drop-safety (the guard's close-on-drop) remains the backstop;
this closes the dominant window where a disconnect both bricked the lock and
abandoned a received push. The post-push bookkeeping tail stays in the
cancellable handler (a disconnect still forgoes it, as today).

Settled OQ1 by execution: a guard abandoned inside a detached task when the
tokio runtime tears down does not panic in Drop (5/5 runs), so detached-task
shutdown tracking stays deferred. Kept the check as a regression guard.

Disconnect regression drives a real push with a sleeping pre-receive hook and
drops the handler mid-hook: RED on the inline handler (the git group is killed,
ref absent), GREEN once detached.
The lazy-migration upload in acquire(), init()'s background upload, and
release_after_write() (fork's upload) previously PUT to the object store with
no lock, so an in-flight upload could resurrect an archive purge-spam had just
deleted under the same lock, and init's background upload could clobber a
freshly-pushed archive. All three now go through upload_locked(): take the
per-repo lock, re-check the local dir exists under it (a purge removes the dir
under its lock, so a post-purge uploader finds it gone and skips), upload, then
release. The background migration/init uploads skip on contention and self-heal;
fork's foreground upload waits (bounded) instead, since a skipped fork upload
has no later retry.

Tests (recording object-store double): init upload skips under a held lock,
fork upload waits then PUTs once after release, a late upload after a
purge-style delete does not resurrect the archive, and an uncontended upload
PUTs once. Load-bearing per INV-21 — neutralizing the lock+dir-check turns the
three contention/resurrect tests RED.
On a multi-machine (object-store) deployment a spam repo can exist only as a
Tigris archive with no local copy. Selection filtered on a ref count that
returns non-empty for a missing local path, so such a repo was never a
candidate and the tool it was hardened for could not reach it. Selection now
distinguishes missing-local (Option::None) from a one-ref repo and admits a
missing-local row as a *remote-unverified* candidate only when an object store
is configured (via RepoStore::has_object_store); partition_for_delete passes
those through rather than re-dropping them on the local ref count. The execute
loop is unchanged — it already refreshes from the archive and rechecks emptiness
under the per-repo lock — so a remote-only empty archive is deleted, one with
refs is refreshed and skipped, and missing-both fails closed (refresh no-ops,
the local recheck reports non-empty). Storeless deployments keep the old
fail-closed behavior. Dry-run marks remote-only candidates distinctly and counts
them in a new PurgeSummary field.

Tests (recording object-store double): remote-only empty archive deleted,
remote-only archive-with-refs skipped, missing-both fail-closed, storeless
missing-local not a candidate. Load-bearing per INV-21 — reverting the
store-configured admission turns the three remote-reach tests RED.
…nv wording

- .env.example: the write brake wraps the whole /graphql route, so its docs now
  say it covers all GraphQL HTTP requests (queries and the playground GET), not
  only mutations, with WebSocket subscriptions called out as excluded.
- main.rs: collapse the identical create/write/push per-IP limiter setup into
  build_ip_limiter (resolve-env, build bounded, warn-on-zero), preserving the
  exact limits/defaults and the present-but-unparseable warning in
  rate_limit_from_env; each limiter now also info-logs its configured value.
- api/repos.rs: the nine per-IP write-brake tests share a send_from helper
  mirroring rate_limit.rs's post_from/post_with, replacing the repeated
  request-build + ConnectInfo + oneshot + status boilerplate. Behavior identical.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Pushed at 2b10fa0. All three Majors are addressed, each fix carrying a regression that fails on the pre-fix behavior and passes after (RED then GREEN by execution). Full workspace suite green, clippy -D warnings and fmt clean, MSRV 1.91 checks.

Guards are Drop-safe; receive-pack survives a client disconnect (1d011be, 2f679d0). Both guards now hold their pooled connection as an Option. release() returns it to the pool, and a Drop impl closes it on any path that skips release() (an early ?, a panic, or the handler future cancelled on disconnect), so the server frees the session advisory lock rather than returning a lock-holding connection to the pool. I confirmed the leak was real by execution first: the default sqlx test pool reaps the connection about 1.8s after drop and masks it, so the test pins a no-reap pool to observe the lock stay held until the fix's own close. acquire_write also wraps the connection in the guard before the freshness download, closing the same window at the acquire site. Building on that, git_receive_pack runs the acquire/receive/release core in a task detached from the request future. The pack body is fully buffered before the handler runs, so a client that disconnects mid-apply still gets the push applied, uploaded, and unlocked in order. The regression drives a real push with a sleeping pre-receive hook and drops the handler mid-hook: RED (git group killed, ref absent) on the inline handler, GREEN once detached. I also settled by execution that a guard abandoned in a detached task at runtime teardown does not panic in Drop (5/5 runs), so shutdown-tracking for fire-and-forget tasks stays a follow-up.

Every archive upload holds the per-repo lock (c3fedad). The lazy-migration upload in acquire(), init()'s background upload, and release_after_write() (fork) now share one locked-upload path: take the per-repo lock, re-check the local dir exists under it, upload, release. Background uploads skip on contention and self-heal; the fork upload waits (bounded), since a skipped fork upload has no retry. Tests with a recording object-store double cover skip-under-lock, the resurrect case (a late upload after a purge removed the dir finds it gone and does not revive the archive), and the uncontended PUT.

purge-spam reaches repos that exist only as an archive (f3cf6fc). Selection now distinguishes a missing local copy from a one-ref repo and admits a missing-local row as a remote-unverified candidate only when an object store is configured; partition_for_delete passes those through instead of re-dropping them on the local ref count. The execute loop is unchanged, since it already refreshes from the archive and rechecks emptiness under the lock, so a remote-only empty archive is deleted, one with refs is refreshed and skipped, and missing-both fails closed. Storeless deployments keep the prior behavior.

Each new guard, lock, and selection test is proven load-bearing by reverting the exact production line and watching it go RED.

Also in this push: the .env.example write-limit text now says the brake covers all GraphQL HTTP requests on /graphql, not only mutations, and the duplicated per-IP limiter setup and repeated write-brake test boilerplate are collapsed into helpers.

One item I'll file separately rather than fold in: the per-repo advisory-lock key is hashed with DefaultHasher, whose algorithm is not guaranteed stable across Rust releases, so a mixed-toolchain fleet could compute different keys for the same repo and silently void cross-machine exclusion. Changing it moves every node's lock key and needs a rollout.

@jatmn ready for another look.

@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.

Actionable comments posted: 3

🤖 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.

Inline comments:
In `@crates/gitlawb-node/src/admin.rs`:
- Around line 174-192: Update local_refs_on_disk to reserve None for paths that
do not exist; treat an existing path lacking a valid bare-repository HEAD or
objects directory as non-empty (for example, Some(1)) so it cannot be admitted
as remote-unverified or replaced by refresh_from_archive. Preserve the existing
unsafe-name fail-closed behavior and update the related test assertion
accordingly, including the analogous logic near the second referenced call site.

In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 2737-2747: Update the git_receive_pack disconnect test to
synchronize with hook entry before aborting the handler: provide a signal the
detached hook emits when it starts, await that signal, then cancel or drop the
handler and retain the assertion that the push completes afterward. Remove
reliance on the fixed 800ms timeout as the disconnect trigger while preserving
the existing post-hook completion wait.
- Around line 930-966: Move the post-push tail currently following the
receive-pack task— including touch_repo, push records, certificates, webhooks,
and replication—inside the detached tokio::spawn task after a successful
receive_pack and lock release, so it continues after client disconnects. Update
the task result flow to perform this bookkeeping there, and leave the handler
responsible only for awaiting the task outcome and constructing the response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb675107-30e9-4c35-8906-71aeb518aecf

📥 Commits

Reviewing files that changed from the base of the PR and between 8842272 and 2b10fa0.

📒 Files selected for processing (5)
  • .env.example
  • crates/gitlawb-node/src/admin.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • .env.example
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/git/repo_store.rs

Comment on lines +174 to +192
/// Local ref state for selection: `Some(n)` when a local bare repo exists (n
/// refs), `None` when there is no local copy. `None` is what lets selection
/// distinguish a missing-local repo (a remote-unverified candidate when a store
/// is configured) from a one-ref repo — both of which `ref_count_on_disk`
/// collapses to a non-zero count. An unsafe name or an unreadable repo fails
/// closed to `Some(1)` so it is skipped, never admitted as remote-unverified.
fn local_refs_on_disk(repos_dir: &Path, owner_did: &str, name: &str) -> Option<usize> {
// Fail closed on an unsafe repo name BEFORE building any on-disk path (a
// peer-mirror row can carry a `../` name). Report it as non-empty so it is
// never a candidate — never as missing (which could admit it remote-unverified).
if let Err(e) = crate::git::repo_store::validate_repo_name(name) {
warn!(name = %name, err = %e,
"purge-spam: unsafe repo name — treating as non-empty (skipped)");
return Some(1);
}
let path = store::repo_disk_path(repos_dir, owner_did, name);
if !path.join("HEAD").is_file() || !path.join("objects").is_dir() {
// No local bare repo at the expected path.
return None;

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Treat existing invalid paths as unsafe, not remote-only.

Lines 190-192 return None for an existing non-bare directory. With object storage configured, that admits it as remote-unverified and allows refresh_from_archive to replace its contents. Reserve None for genuinely absent paths; fail closed for existing, unreadable, or escaping paths. Update the test currently asserting None.

Proposed fix
     let path = store::repo_disk_path(repos_dir, owner_did, name);
-    if !path.join("HEAD").is_file() || !path.join("objects").is_dir() {
-        // No local bare repo at the expected path.
-        return None;
+    match std::fs::symlink_metadata(&path) {
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
+        Err(_) => return Some(1),
+        Ok(_) => {}
+    }
+    if !path_within(&path, repos_dir)
+        || !path.join("HEAD").is_file()
+        || !path.join("objects").is_dir()
+    {
+        return Some(1);
     }
-            None,
-            "a non-git dir under a git ancestor must report no local repo..."
+            Some(1),
+            "an existing non-git path must fail closed..."

Also applies to: 1017-1033

🤖 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/admin.rs` around lines 174 - 192, Update
local_refs_on_disk to reserve None for paths that do not exist; treat an
existing path lacking a valid bare-repository HEAD or objects directory as
non-empty (for example, Some(1)) so it cannot be admitted as remote-unverified
or replaced by refresh_from_archive. Preserve the existing unsafe-name
fail-closed behavior and update the related test assertion accordingly,
including the analogous logic near the second referenced call site.

Comment on lines +930 to +966
// Detach the acquire → receive-pack → release core from THIS handler future.
// The pack `body` is already fully buffered, so the spawned task is
// self-contained: a client disconnect drops the handler future but does NOT
// cancel the task, so a fully-received push still completes server-side —
// applies the pack, uploads, and releases the lock in order. What a
// disconnect forgoes is the cancellable post-push bookkeeping below, not the
// push itself. (KTD-3, R3.)
let repo_store = state.repo_store.clone();
let owner_did = record.owner_did.clone();
let repo_name = record.name.clone();
let receive_result = tokio::spawn(async move {
let guard = repo_store
.acquire_write(&owner_did, &repo_name)
.await
.map_err(|e| {
tracing::error!(repo = %repo_name, err = %e, "acquire_write failed");
AppError::Git(e.to_string())
})?;
let disk_path = guard.path().to_path_buf();
tracing::debug!(repo = %repo_name, path = %disk_path.display(), "running git receive-pack");
let result = smart_http::receive_pack(&disk_path, body, git_timeout).await;
// Always release the advisory lock — even on error — to prevent stale
// locks from blocking subsequent pushes. Only upload to Tigris when the
// push succeeded; uploading a half-applied repo would propagate corruption.
guard.release(result.is_ok()).await;
Ok::<_, AppError>(result)
})
.await
.map_err(|e| {
tracing::error!(repo = %name, err = %e, "receive-pack task panicked");
AppError::Internal(anyhow::anyhow!("receive-pack task failed: {e}"))
})??;

// Recompute the on-disk path for the post-push tail below — the guard that
// exposed it now lives inside the detached task. Identical to the path
// acquire_write resolved (repos_dir/owner_slug/name.git).
let disk_path = store::repo_disk_path(&state.config.repos_dir, &record.owner_did, &record.name);

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant ranges with line numbers
sed -n '840,990p' crates/gitlawb-node/src/api/repos.rs | cat -n

printf '\n====\n'

# Show the later post-push section referenced by the comment
sed -n '980,1420p' crates/gitlawb-node/src/api/repos.rs | cat -n

Repository: Gitlawb/node

Length of output: 30066


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the tests mentioned in the scratchpad and the surrounding helper usage.
sed -n '2580,3185p' crates/gitlawb-node/src/api/repos.rs | cat -n

Repository: Gitlawb/node

Length of output: 27406


Detach the accepted-push tail from the request future. receive-pack is already offloaded, but touch_repo, push records, certificates, webhooks, and replication still run in the handler path. A disconnect can therefore leave the repo contents updated while the post-push metadata and fan-out never happen. Move that tail into the detached task (or a durable job) and keep the handler to response construction only.

🤖 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 930 - 966, Move the
post-push tail currently following the receive-pack task— including touch_repo,
push records, certificates, webhooks, and replication—inside the detached
tokio::spawn task after a successful receive_pack and lock release, so it
continues after client disconnects. Update the task result flow to perform this
bookkeeping there, and leave the handler responsible only for awaiting the task
outcome and constructing the response.

Comment on lines +2737 to +2747
// Drive the handler, then DROP it mid-hook (the "client disconnect").
let handler = super::git_receive_pack(
State(state.clone()),
AxPath((owner.to_string(), format!("{name}.git"))),
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
body,
);
let _ = tokio::time::timeout(Duration::from_millis(800), handler).await;

// Wait past the hook's sleep so a surviving (detached) push can finish.
tokio::time::sleep(Duration::from_millis(3000)).await;

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the test and relevant hook logic.
rg -n "fully_received_push_completes_after_client_disconnect|git_receive_pack|tokio::spawn|timeout\\(Duration::from_millis\\(800\\)|sleep\\(Duration::from_millis\\(3000\\)" crates/gitlawb-node/src/api/repos.rs

# Show the surrounding region for the test.
sed -n '2680,2795p' crates/gitlawb-node/src/api/repos.rs

Repository: Gitlawb/node

Length of output: 5233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the receive-pack handler implementation around its control flow.
sed -n '850,1025p' crates/gitlawb-node/src/api/repos.rs

# Look for any hook-entry markers or synchronization primitives in the test file.
rg -n "marker|notify|watch|channel|oneshot|broadcast|hook.*start|pre-receive" crates/gitlawb-node/src/api/repos.rs

Repository: Gitlawb/node

Length of output: 11070


Synchronize the disconnect with hook start. The 800ms timeout can still fire before git_receive_pack reaches the detached tokio::spawn, so the test may drop the handler before the hook even begins. Wait on a hook-entry signal first, then abort the handler and assert the push still completes.

🤖 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 2737 - 2747, Update the
git_receive_pack disconnect test to synchronize with hook entry before aborting
the handler: provide a signal the detached hook emits when it starts, await that
signal, then cancel or drop the handler and retain the assertion that the push
completes afterward. Remove reliance on the fixed 800ms timeout as the
disconnect trigger while preserving the existing post-hook completion wait.

@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] Preserve the post-push completion work after a disconnect
    crates/gitlawb-node/src/api/repos.rs:930
    The spawned task only covers acquire_write → receive_pack → release; all of the successful-push work remains in the request future after the join (timestamp, push/trust records, ref certificates, webhooks, pinning/replication, and ref-update broadcast). When a client disconnects while a fully buffered pack is applying, the task can commit and upload the ref but cancellation drops that tail. That leaves the committed Git state without the metadata and fan-out that every other accepted push produces. Move that completion work into the detached/durable path once receive-pack succeeds.

  • [P1] Do not overwrite an existing non-repository directory during purge
    crates/gitlawb-node/src/admin.rs:189
    local_refs_on_disk returns None both for a missing path and for an existing path without bare-repo markers. With Tigris configured, None promotes the latter to a remote_unverified candidate; execution calls refresh_from_archive, whose extraction replaces the existing path, and can then delete it. Reserve None for a verified NotFound; existing, unreadable, non-bare, or escaping paths must fail closed so purge cannot replace operator data or a corrupted local repo.

  • [P1] Serialize repository recreation with the purge delete
    crates/gitlawb-node/src/admin.rs:406
    The purge lock excludes writers, but create_repo never takes that lock. Once this code deletes the old row, another request for the same owner/name can pass its existence check, initialize the same directory, and insert a new row while purge still removes the directory and archive. The newly created repo is then left pointing at missing or stale storage. Make creation/recreation participate in the same namespace lock or retain a transactional tombstone until the destructive tail has finished.

  • [P1] Do not leave bounties attached to a deleted repository name
    crates/gitlawb-node/src/db/mod.rs:1349
    The new deletion path deliberately leaves bounties intact even though they are keyed only by (repo_owner, repo_name), not a repository id. If the owner recreates that name, the new repository immediately exposes the old open/claimed bounty and its readers can claim it through the normal bounty endpoint. Preserve the financial record without making it live on a distinct repo—for example by associating it with an immutable repo id or tombstoning it during purge.

  • [P1] Avoid deleting slug-scoped records belonging to a surviving mirror row
    crates/gitlawb-node/src/db/mod.rs:1337
    list_repos_by_owner_did deliberately returns every physical row, while canonical did:key: and bare-owner mirror rows share the normalized owner/name slug. Deleting one empty physical row therefore deletes branch_cids, sync queue entries, received updates, and anchors for a non-empty mirror row that survives the purge. Scope this cascade to an unambiguous repository identity, or only remove slug-scoped data after confirming no other row still owns that slug.

  • [P1] Keep database capacity available while holding advisory locks
    crates/gitlawb-node/src/git/repo_store.rs:167
    Every successful acquire_write now pins a connection from the shared application pool for the entire receive-pack and archive upload—up to the 600-second git timeout. Twenty concurrent pushes to distinct repos consume the default 20-connection pool, so unrelated DB-backed requests fail their acquire timeout. Use a dedicated/reserved lock pool or enforce push concurrency that leaves capacity for normal handlers.

  • [P2] Make purge work with the supported one-connection pool
    crates/gitlawb-node/src/admin.rs:372
    try_lock_repo holds a pool connection for the advisory lock, then delete_repo_by_id starts a transaction through the same pool before the guard is released. GITLAWB_DB_MAX_CONNECTIONS=1 is accepted by configuration, so the admin command deterministically waits for its own held connection and skips every deletion. Reserve a second connection by configuration or restructure the lock/transaction ownership so this supported setting can execute a purge.

  • [P2] Either enforce the shutdown grace or stop documenting that it is enforced
    .env.example:20
    This newly documented setting promises that in-flight requests are cut off after the grace period, but main.rs only constructs the duration, drains Axum with an unbounded await, then explicitly discards it. A stuck git or storage request can prevent shutdown indefinitely. Bound the drain and force termination/503 handling at expiry, or document the actual behavior.

  • [P2] Return a retry delay compatible with the new one-hour write window
    crates/gitlawb-node/src/rate_limit.rs:274
    The new write bucket retains timestamps for an hour, yet every rejection advertises Retry-After: 60. A compliant client retries after one minute and will commonly receive repeated 429s for almost another hour, increasing rejected load on the very write surface this change protects. Compute the delay from the oldest live timestamp (or omit Retry-After rather than provide an incorrect value).

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