Compare commits

...
Author SHA1 Message Date
beardthelion 370a364b0f test(node): cover the sweep's boot wiring and prove an interrupted push never reaches the upload
Two claims from earlier commits were argued rather than executed. Both are now
observed.

The legacy-CID sweep's spawn sat inline in main() where no test could reach it,
so the batch size, the inter-batch delay, the detachment and the shutdown select
were all reasoned. The block moves verbatim into `spawn_legacy_cid_sweep`, which
a test calls directly. It seeds rows whose keys are already canonical, so the
cost gate skips each and the persisted cursor reads out walk progress cleanly,
then asserts the cursor stops at the configured batch boundary, the call returns
without blocking, the task is still alive inside its delay, and shutdown ends it.
Never spawning, reading the batch or delay from the wrong knob, or removing the
shutdown branch each turns it red. What remains reasoned is the single call line
inside main(), down from a twenty-six line block.

The claim that a disconnect-interrupted push never uploads to Tigris was
structural: the tests build the store with no Tigris client, so a counter inside
the client arm could never move and would have proved nothing. The counter now
sits at the upload decision, before the client is consulted, and the disconnect
test asserts zero while the success test asserts exactly one. That positive
control is what makes the zero meaningful, and it is itself load-bearing:
releasing the success path with failure semantics drops it to zero, and adding a
Drop impl that uploads raises the disconnect count to one.

The counter observes reaching the upload site, not an S3 request. TigrisClient
wraps a concrete SDK client with no injectable endpoint, and plumbing one through
would be out of proportion to the risk here.
2026-07-27 21:44:31 -05:00
beardthelion 03aee267e6 fix(node): shed 503 from the issue and PR write paths when the lock pool is exhausted
The lock-pool work gave `acquire_write` a distinct exhaustion error and mapped it
to 503 with Retry-After, but only on the push handler. The three other callers,
both issue writes and the PR merge, still turned it into a generic git error and
returned 500. Those are exactly the callers that hold no concurrency permit, so
they are the likeliest to meet an exhausted pool in the first place.

All three now go through the push handler's helper rather than repeating the
mapping, so the log level and the message cannot drift between the four sites.
Each has a test that occupies the single connection of a one-slot pool with a
guard on a different repo, so the failure is pool capacity rather than
advisory-lock contention, and asserts status 503 plus retry-after through
IntoResponse. Reverting the mapping at one site turns only that site's test red.

Worth knowing for review, and left alone deliberately: close_issue takes the
write guard before its author check, so this shed is reachable by any
authenticated caller whether or not they could close the issue. That ordering
predates this change and reordering the handler is not this commit's business,
but it does mean the 503 discloses that the repo's write pool is busy.
2026-07-27 21:14:55 -05:00
beardthelion c519d54fc1 fix(node): keep the incompleteness marker until a source is really recorded, and let the sweep retry
Four follow-ups from an adversarial pass over the previous commits.

The marker clear ran unconditionally inside `record_pin_source`'s transaction,
but its INSERT is `ON CONFLICT DO NOTHING` guarded by the cap, so it records
nothing when the repo is already a source. That is the common case, not a rare
one: the pin path calls `record_pin_source` for every already-pinned object, and
on a requeue pass that is the whole-repo enumeration. So a re-push from the repo
that was already recorded cleared a marker set by a different repo's failure and
the public copy went back to 404ing. The clear is now gated on the insert
actually adding a row. A genuine record from a third repo still clears the
marker, which needs a per-(oid, repo) table to close and is documented rather
than papered over.

The sweep advanced its cursor before every skip, which stops a wedge but made a
transient skip permanent: once the walk reached the end of the table the cursor
parked there and no later boot read another row. On a Tigris-backed node the
common skip is "repo is not on local disk", so the sweep would repair whatever
happened to be warm at boot and then never run again, and the new advertise
filter turned those rows from "advertised but 404" into "never advertised, never
repaired". Repairs now report whether a skip was retryable or terminal, and a run
that skipped anything retryable rewinds the cursor once, after the walk, so the
next run re-walks. A permanently unrepairable row is terminal and does not
trigger a rewind, so this cannot become a hot loop.

The sweep also reached its repo paths through the raw join helper, skipping the
three-layer traversal barrier `RepoStore::local_path` applies. A test with a repo
row named `../../escapee` showed it reading and repairing bytes from outside
`repos_dir`. That barrier is now extracted and shared, so the two callers cannot
drift. And the repair's synchronous `git cat-file` moved under `spawn_blocking`,
matching the existing treatment of the withheld walk, so a wedged read no longer
parks a worker thread for the full service timeout.

Finally the resolver's two source-set queries ran ahead of the work-budget peek
that exists to shed a spent caller before doing work. The peek now runs first. A
throttled caller whose set turns out complete gets 429 where it used to get 404,
which is the honest answer since its search never ran, and it removes an oracle
that let a throttled caller tell a complete source set from an incomplete one.
2026-07-27 18:29:01 -05:00
beardthelion 1c82060c2b fix(node): stop the lock pool from being occupied by spinners and racing the reaper
Three regressions from the previous four commits, each demonstrated before being
fixed.

The advisory-lock retry loop checked its connection out before spinning, so a
caller that lost the lock held a pool connection through all sixty one-second
attempts. The old pool-per-query shape returned it between iterations, so a
spinner occupied nothing. That matters more than the pool sizing suggested:
`acquire_write` has three non-push callers (two in issues, one in pulls) that
hold no concurrency permit, and the issue routes carry no rate limit, so any
self-minted DID could fire enough concurrent closes at one repo to strand the
pool and fail authenticated pushes on every repo. The checkout now happens per
attempt and a losing attempt returns the connection before sleeping, so only the
winner retains one. Lock-pool exhaustion also carries its own error now and sheds
503 with Retry-After instead of surfacing as a 500.

Holding the lock on one connection then meant a client disconnect released it
early: the handler future dies mid-receive-pack, the guard drops without
`release`, and `after_release` frees the lock while the detached reaper is still
giving the process group its SIGTERM grace. A second writer was admitted 90ms
after the drop, well inside that window, which is the invariant smart_http
already documents. The write guard now rides the AdmissionGuard into the reaper,
the same seam the admission permits already use, so the lock outlives the group.
The success path still reclaims the guard and releases it synchronously, and a
dropped guard performs no Tigris upload, so an interrupted push cannot publish a
half-applied repo.

The requeue loop's exit on an exhausted re-read discarded a push that coalesced
during the retry window: `requeue_or_release` had cleared the dirty bit without
marking the guard released, so `Drop` removed the key and no pass was ever
attempted. It now falls through with nothing to replicate and lets the tail's
atomic check-and-clear decide. A lap only happens when a push actually coalesced
and each pays a full bounded re-read, so a sustained outage still terminates,
which is tested with ten thousand injected faults under a watchdog rather than
argued.

Also clamps the lock pool's derived size, which previously followed
max_concurrent_git_pushes up to its million-connection ceiling, and documents the
node's total connection budget on that knob.
2026-07-27 17:47:56 -05:00
beardthelion 73429c7a56 fix(node): sweep legacy provider-CID pins instead of waiting for a re-push
`repair_legacy_provider_cid` had exactly one trigger: the already-pinned skip
branch inside `pin_new_objects`, which a push only reaches when it re-carries the
object. Normal git negotiation omits objects the node already has, so on an
upgraded node that push generally never arrives. Meanwhile `list_pinned_cids`
kept advertising the stored provider CID that this branch's `/ipfs/{cid}`
deliberately withholds, so the node handed out CIDs it would then refuse to
serve, and previously pinned data stayed unresolvable.

Two halves, both needed. A detached background sweep walks `pinned_cids` in
bounded batches with an inter-batch delay, resolves each row's repo from its
recorded provenance, and reuses the existing repair (and its cost gate, so a row
already keyed on a raw CIDv1 reads no bytes). Migration v16 adds a single-row
cursor table so a restart mid-walk resumes rather than rewinding; the cursor
advances on every row read, before any skip, so an unrepairable row cannot wedge
the walk. A row whose bytes are gone, whose provenance is NULL, or whose repo is
not on local disk is left exactly as it is. And `list_pinned_cids` now omits rows
whose key is not a raw CIDv1, so the window before the sweep catches up
advertises nothing the resolver withholds.

The sweep resolves paths directly rather than through `repo_store.acquire`, which
would pull cold repos back from Tigris and turn a repair pass into a bulk
restore. Defaults are deliberately quiet: 64 rows per batch, 60s between batches.

Ten tests cover the repair, the bytes-gone and unrepairable arms, the cost gate,
the batch bound, cursor resumption, both states of one row through the advertise
filter, and the degenerate empty and no-legacy-rows tables. Removing the filter,
the batch bound, the cursor advance, the bytes-gone early return, or the
inter-batch sleep each turns a test red.

One pre-existing test used placeholder resolver keys that the new filter drops;
its values are now real raw CIDv1s with every assertion unchanged.
2026-07-27 15:53:26 -05:00
beardthelion fccdfc1820 fix(node): record a failed pin-source write so the resolver keeps its fallback
The resolver treated a non-empty, below-cap pin-source set as proof that every
source had been recorded, and skipped the bounded scan on that basis. But
`record_pin_source` is best effort at every call site: the ipfs_pin sites were
warn-only once their retries exhausted, and both pinata sites had no retry at
all. An object first pinned from a private repo and later pushed from a public
one whose source write failed keeps a set naming only the private source, so
`GET /ipfs/{cid}` returned 404 for an object the public repo would have served.

Migration v15 adds `pinned_cids.pin_sources_incomplete`, NOT NULL DEFAULT FALSE
so every existing row reads as complete. A record that fails outright sets it; a
later successful record clears it, inside the same transaction as the insert so
the two paths cannot drift. `needs_scan` now ORs the marker in, short-circuiting
after the existing empty and at-cap checks so it costs nothing on the serve path.
Pinata's two bare calls now share `retry_db_record` with ipfs_pin. The ipfs_pin
first-pin path additionally records the CID and its source in one transaction;
Pinata's remains two retried calls.

INV-10 is preserved: a complete set still tail-404s with no preload, asserted on
the existing preload counter. The fallback is not an authorization bypass, and
the test for that asserts the scan actually ran before checking the denial, so
the denial cannot pass vacuously. Dropping the marker term from the gate, forcing
it true, or removing the clear each turns a test red.

The marker is per-object rather than per-(object, repo): a successful record from
one repo clears a marker set by another's failure. That is the conservative
direction, since the marker only ever adds the fallback back.
2026-07-27 14:40:03 -05:00
beardthelion 8e6e2ad0fe fix(node): retry the requeue re-read so a coalesced push is not silently dropped
`requeue_or_release` clears the dirty flag atomically with the decision to loop,
which is correct and stays as it is. The problem was what the pass did next: a
failed `get_repo_by_id` fell into an Err arm that yielded no rules, and
`list_visibility_rules(...).ok()` collapsed its own error the same way. With no
rules, `replication_withheld_set` returns `(false, None)`, the pass skipped its
work entirely, and the loop exited having consumed the dirty bit that stood for
the coalesced push. There is no reconciliation sweep to re-derive it, so that
push's objects were never pinned and its withheld blobs never sealed.

`requeue_refresh_state` now separates the three cases the old code collapsed.
`Ok(Some)` proceeds. `Ok(None)` means the repo really is gone and releases
without touching the retry budget. `Err` from either read is transient and is
retried with doubling backoff up to three attempts. A rules-read error is no
longer indistinguishable from a repo that has no rules.

Exhausting the retries still drops the pass, which is the pre-existing residual,
but it now logs at error level with the repo id and attempt count instead of
disappearing behind a warning. Six tests cover the retried-then-lands path, the
bounded-exhaustion path, repo-gone, the rules-read arm, freshness of the applied
rules, and the coalescing property that had to survive. Reverting the Err arm,
restoring the `.ok()`, raising the bound, or downgrading the error log each turns
a test red.
2026-07-27 13:52:44 -05:00
beardthelion 9a70d75d13 fix(node): own the repo write lock on one connection so cancellation cannot leak it
`acquire_write` took `pg_try_advisory_lock` through the shared pool, then awaited
the Tigris exists/download path before constructing `RepoWriteGuard`. Two bugs
fell out of that. The `tokio::time::timeout` this branch added around the call
can drop the future inside that window, after the lock is held and before the
only unlock (`RepoWriteGuard::release`) exists, wedging every later push to the
repo. And because a session advisory lock belongs to the connection that took
it, running the lock and the unlock through the pool lets them land on different
connections: the unlock returns false and leaks, while a competing acquire that
draws the holding connection re-enters the lock and two writers run against the
same repo concurrently.

`RepoStore` now owns a dedicated lock pool built with an `after_release` hook
that runs `pg_advisory_unlock_all()`. `acquire_write` checks out one connection,
takes the lock on it, and moves it into the guard, which unlocks on that same
connection. sqlx's `PoolConnection::drop` spawns `return_to_pool`, which runs the
hook, so a connection dropped by cancellation still clears its locks; the unlock
is asynchronous with respect to the drop, which the tests account for. The pool
is separate from the main query pool because a push holds its connection for the
whole receive-pack and `db_max_connections` (20) sits below
`max_concurrent_git_pushes` (32).

Seven tests cover the cancellation window, the same-session release, and the
serialization the pool-mismatch bug was breaking. Both the cancellation
regression and the concurrent-writer case were observed red before the fix, and
removing the `after_release` hook or moving the unlock back to the pool turns
them red again.
2026-07-27 13:22:45 -05:00
t 62a99270a9 fix(node): bound the post-push pin and repair reads so a wedge cannot pin the coalescing key
A cross-model adversarial pass found that run_post_push_replication holds the
per-repo coalescing key until requeue_or_release, but pin_new_objects reached it
only after two unbounded git reads: the U7 legacy-repair read and the
pre-existing pin read, both plain Command::output with no teardown. A wedged
cat-file on a stuck backend hung the task forever, so the key was held until
process death and later pushes only marked it dirty without spawning a
replacement (the same class this PR closed on the /ipfs serve path).

Add read_object_bounded (the bounded twins under one shared deadline) and use it
at both sites; a timeout skips that object and lets the task proceed to
requeue_or_release. Adds a wedge-reap regression proven load-bearing two ways.
2026-07-20 21:06:10 -05:00
t fba3c6fda3 fix(review): share one deadline across the /ipfs size+content reads
Code review found each candidate's size and content reads were each granted a
full git_service_timeout, so a single served candidate could hold the /ipfs
walk permit for 2x the timeout. Share one deadline across both stages (mirrors
build_filtered_pack) so the read holds admission for one timeout total, and
correct the admission-bound comment: the worst case is O(candidates x timeout)
bounded by the walk concurrency cap, not a single deadline.
2026-07-20 20:27:35 -05:00
t 45e269b2ae fix(node): opportunistically repair legacy provider-CID pins
Before this PR the pin path stored the provider CID (Kubo dag-pb / Pinata) in
pinned_cids.cid, which the new resolver recomputes-and-404s, while
/api/v1/ipfs/pins still advertises the stale value. Repair a legacy row in the
already-pinned skip branch: gate on a cheap stored-CID codec check (only a
non-raw-codec CID reads bytes, so raw rows keep the DB-only skip cost),
recompute the raw CID, and rewrite it while stashing the old value in a new v14
legacy_provider_cid column (distinct from pinata_cid, which gates the Pinata
pin-skip). Rows whose bytes are gone stay withheld. Documents that the deferred
one-shot sweep, not this opportunistic path, fully retires the window.
2026-07-20 20:11:42 -05:00
t b774c435a6 fix(node): retry pin-source and pinned-CID recording on transient errors
The three warn-only record sites in the detached post-push task dropped a
transient DB failure silently, leaving a permanently incomplete pin-source set
that makes the resolver 404 a valid public copy. Wrap them in a bounded retry
(inserts are idempotent via ON CONFLICT DO NOTHING); on exhaustion the warn
still fires so behavior degrades to today's. Documents the residual crash/outage
window that only a reconciliation sweep retires.
2026-07-20 19:50:23 -05:00
t 0a1618f7fb fix(node): split /ipfs work accounting off the route rate limiter
The route middleware charged ipfs_rate_limiter once per request while the
legacy scan and provenance walk charged the same bucket per probe/walk, so a
one-probe request cost two tokens and GITLAWB_IPFS_RATE_LIMIT=1 admitted the
request at the route then 429'd it at the pre-scan peek.

Add a bounded ipfs_work_rate_limiter, move the in-scan, per-walk, and pre-scan
charges onto it, and leave the route limiter as the pure once-per-request
brake. The work-budget capacity derives from the route limit with a floor of
one full legacy-probe budget per window (no new operator knob), so a
default-config deep search never self-throttles. Adds it to the periodic sweep,
updates all three AppState construction sites, and rewrites the contradictory
limiter comments.
2026-07-20 19:41:57 -05:00
t c2fce67bae fix(node): wire GITLAWB_IPFS_MAX_REPOS_WALKED to the legacy-probe budget
The knob was parsed and documented as the per-request /ipfs fan-out cap but
never read in production: AppState seeded the legacy-probe budget from a fixed
256 constant, so setting the knob did nothing and an operator could still incur
256 acquire/probe operations per request.

Seed ipfs_max_legacy_probes from the knob (default raised to 256 to preserve
shipped behavior); leave the history-walk ceiling on its MAX_PIN_SOURCES+1
constant so a provenanced request is never truncated into a false 503. Updates
the README and .env.example to the shipped behavior and adds cap-honored and
plumbing tests.
2026-07-20 19:17:12 -05:00
t 3351e09c08 fix(node): requeue coalesced pushes instead of dropping them
Per-repo coalescing dropped every push that arrived while a repo's encryption
task was in flight, and no reconciliation ever processed it, so a withheld
blob added by the coalesced push had its recovery copy permanently absent. The
drop also silently skipped the coalesced push's local-IPFS pin work.

EncryptInflight becomes a dirty-flag map; the detached task loops, and the
atomic check-and-clear sits at the task tail, unconditional on the
has_path_scoped_rule gate and walk success (a public or rules-free repo would
otherwise exit before it ran). Each requeue re-reads repo state fresh and
re-enumerates the pin half through the fail-closed full scan, never bare
list_all_objects, so a coalesced rule change is honored and no withheld or
dangling object leaks. At-most-one-task-per-repo is preserved.
2026-07-20 19:04:55 -05:00
t 757edf560c fix(node): own /ipfs admission for the life of the walk
get_by_cid held the global walk permit and the per-source permit as handler
locals, so dropping the request future released both while the spawn_blocking
probe, visibility walk, and object read kept running; a cancel-spam or timeout
client could exceed MAX_CONCURRENT_IPFS_WALKS and the per-source cap, and the
bare cat-file probe/read children had no teardown at all.

Move the gated serve pipeline into a detached task that owns an AdmissionGuard,
awaited by the handler, so admission releases only after the bounded work is
gone; back the /ipfs probe and read with run_bounded_git twins for
process-group teardown. Adds cancel-mid-walk, timeout-reap, and cancel-spam
regressions plus two load-bearing INV-22 rows. Also reflows one U1 test line to
satisfy cargo fmt.
2026-07-20 18:34:09 -05:00
t 86828046ea fix(node): hold read admission through the filtered-pack reaper
The path-scoped upload-pack branch kept the read and per-caller permits as
handler locals and called upload_pack_excluding with no AdmissionGuard, so a
cancelled filtered clone released admission while KillGroupOnDrop was still
reaping the git group, bypassing the read and per-caller concurrency caps.

drive_git_child now returns the disarmed guard on success so one guard rides
both build_filtered_pack stages (rev-list then pack-objects); the handler builds
the guard from the permits as the plain path does. Adds a disconnect regression
and an INV-22 gate row, both proven load-bearing.
2026-07-20 17:54:49 -05:00
t d26cf6dd1d fix(node): close the /ipfs pin-source griefing hole via a bounded scan fallback
An attacker who pins a public object from MAX_PIN_SOURCES repos before the
legitimate public source registers fills pin_repo_sources (record_pin_source is
first-N-wins and drops later sources silently); the buried public source is then
never reached because a non-empty provenance set suppressed the legacy scan, so
anon GET /ipfs/{cid} 404s forever for a public object (re-breaks F1).

U1: db::pin_sources_at_cap reports whether pin_repo_sources is at MAX_PIN_SOURCES
for an oid (the only observable signal that a servable source may have been
dropped, since the write cap never overshoots).

U2: get_by_cid falls back to the bounded legacy scan on a provenance miss when the
set is empty OR at_cap. The scan gates every repo through the real per-caller gate,
so it finds the buried public copy. A complete (non-full) set still fast-404s, so
ordinary denials never fan out to O(repos) (INV-10/F3); the fallback honors the
is_throttled peek.

Regression ipfs_cid_buried_public_source_still_serves_via_scan_fallback: 404 before,
200 after; pin_sources_at_cap_flips_at_max covers the boundary.
2026-07-17 10:15:56 -05:00
t 94ca744269 fix(node): align ipfs_pin return with pinata; add Retry-After to SearchIncomplete
- ipfs_pin::pin_new_objects returns the provider Hash (not the raw resolver key),
  matching the pinata twin's contract; the DB cid stays the raw content CID. The
  return is logging-only here, so this is drift-avoidance, not a functional fix.
- AppError::SearchIncomplete now carries Retry-After: 1 like Overloaded, so both
  retryable 503s from the /ipfs handler advertise the retry hint consistently.
2026-07-17 10:15:56 -05:00
t 2eea354188 fix(node): tie the /ipfs walk ceiling to MAX_PIN_SOURCES + 1
A provenanced object has a bounded source set (first-pinner + up to
MAX_PIN_SOURCES additional). With the walk ceiling at 16 == MAX_PIN_SOURCES, an
authorizing public source sorting after 16 path-scoped denials was never reached:
the ceiling truncated the search and returned a false 503 for a readable object.
Raise the ceiling to MAX_PIN_SOURCES + 1 so the whole bounded provenance set is
always tried; the legacy scan stays bounded by MAX_LEGACY_PROBES_PER_REQUEST.
2026-07-17 10:15:56 -05:00
t c767658b7b fix(node): bound the /ipfs cat-file probes under the walk permit; backfill pinata provenance
P2-E: the object-type probe and the F6 size+read ran bare git cat-file inside
spawn_blocking while the /ipfs walk permit was held, so a wedged cat-file could
pin the global walk slot for the request's life. Bound both under
git_service_timeout_secs; on timeout free the slot (truncated -> retryable 503)
and skip the repo.

P2-D: the pinata already-pinned branch now backfills NULL first-pinner
provenance in lockstep with the ipfs_pin skip branch (consistency; the object
was already resolvable via the pin_repo_sources union).
2026-07-17 10:15:56 -05:00
t 8afc9eaf97 fix(node): close grok P1 findings on the #173/#174 IPFS integration
P1-A: gate_and_serve held the /ipfs walk permit across a bare repo_store.acquire;
wrap it in git_acquire_timeout_secs (mirroring #174's own handler) so a cold/hung
Tigris acquire cannot pin the global walk slot. On expiry skip the repo and mark
the search truncated (retryable 503, not a false 404).

P1-B: the local-IPFS pin path recorded Kubo's provider Hash as pinned_cids.cid;
for objects above the block size that is a dag-pb root that does not hash the raw
content, so GET /ipfs/{cid} listed then 404'd them under the F2 integrity check.
Record the locally-computed raw-content CID, mirroring the pinata twin.
2026-07-17 10:15:56 -05:00
t 433c8915bf feat(node): integrate the #173 IPFS CID tree-gate onto the #174 walk-concurrency base
Rebased onto #174 (fix/served-git-concurrency-cap). #173's incremental history
cannot replay commit-by-commit because #174 rewrote the same handler files in
parallel; this single commit carries the fully-integrated tree (identical to the
verified merge result), with the follow-up fixes as separate commits on top.

Brings in #173: GET /ipfs/{cid} CID->oid resolution via pinned_cids, per-caller
path-scoped blob/tree/commit-tag gating (#135/#173 F1-F6), and the pin-source
provenance table. Adapts #173's tree/commit-tag walks to #174's run_bounded_git
so the held /ipfs walk-concurrency permit is duration-safe (F5).
2026-07-17 10:15:56 -05:00
t 413d6cf0bc fix(node): reject unsupported git service before the read slot; document the new admission knobs (#174)
P2-1: git_info_refs treated every service other than git-receive-pack as a read op
and did the DB/visibility/Tigris work under a read permit, validating the service
string only downstream in smart_http. So an unauthenticated `?service=anything` to
a public repo consumed a read slot + storage work before rejection. Validate the
service is exactly git-upload-pack or git-receive-pack immediately after parsing,
returning 400 before the pre-DB shed and the permit. Handler-layer regression:
with the read pool exhausted, `?service=git-explode` returns 400 (validated first),
not 503 — removing the validation makes it 503 (RED).

U5/INV-24 docs: document the new knobs from U2/U3 in .env.example and the README
env-var table — GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS, GITLAWB_MAX_CONCURRENT_IPFS_WALKS,
GITLAWB_IPFS_WALK_PER_SOURCE, GITLAWB_IPFS_MAX_REPOS_WALKED, GITLAWB_IPFS_RATE_LIMIT.
Each is already enforced on the path it names (U2/U3 tests), so the docs match the
implemented routing.
2026-07-15 13:16:40 -05:00
t 394768711e fix(node): bound the post-push encryption task set by per-repo coalescing without dropping recovery copies (#174)
After a successful path-scoped push the handler spawns a DETACHED task that runs
pin_new_objects then withheld_recipients_gated, which acquire_owned().await's on
git_encrypt_semaphore — it DEFERS (blocks) when the pool is full. The semaphore
caps active walks but nothing capped how many detached tasks were spawned and
PARKED on that await: N rapid path-scoped pushes spawned N tasks, each holding
cloned object lists/rules/paths/keys — an unbounded parked-waiter set (P2-2).

2a54c15 deliberately chose defer-not-shed because dropping the walk loses the
withheld-blob recovery copy and there is no reconciliation sweep to rebuild it.
So bound the OUTSTANDING-TASK set by per-repo coalescing rather than shedding:
EncryptInflight (a bounded Arc<Mutex<HashSet<repo_id>>>) + try_begin — before the
spawn, if a task for the repo is already in-flight skip the duplicate (the pending
walk covers the newer objects); otherwise spawn and move an RAII
EncryptInflightGuard into the task that removes the key on drop (completion, error,
or panic-unwind). This bounds the set to <=1 pending task per repo and never drops
work; withheld_recipients_gated's defer is unchanged.

The second detached post-push spawn (Pinata replication, also runs pin_new_objects)
is scoped out with rationale: it parks on no semaphore (no unbounded waiter set)
and does per-push per-ref work (branch->CID, gossip, subscription, Arweave,
peer-notify) keyed to this push's ref_updates — coalescing it would DROP a later
push's announcements, a correctness regression.

Regressions (mutation-verified RED->GREEN): the outstanding set is bounded to 1
per repo under saturation (never-coalesce -> 32 tasks RED); a coalesced repo's key
is released when its task ends so it is reprocessed, never permanently skipped
(guard-drop no-op -> repo locked out, recovery copy lost forever, RED — the exact
durability regression the fix prevents); distinct repos each admit; cold-set first
push admits; plus an inv22_gates structural tripwire that fails if the handler gate
is removed.
2026-07-15 13:10:04 -05:00
t f424ccbbcd fix(node): gate the /ipfs walk with bounded concurrency admission + per-source cap + route rate limit (#174)
GET /ipfs/{cid} (get_by_cid, publicly reachable via optional_signature) ran
allowed_blob_set_for_caller_bounded in spawn_blocking inside a per-repo loop with
NO global/per-source admission and NO route rate limit — the surface had zero
concurrency or rate control. A permissionless caller could fan out concurrent
full-history git walks (each up to the walk timeout) exhausting blocking-pool
threads and PIDs outside every served-git pool (P1-3).

Acquire a global git_ipfs_walk permit (try_acquire_owned, shed 503) plus a
per-source sub-cap (with_default_max_keys, reject-before-insert, keyed via
client_key/TrustedProxy so XFF can't spoof it) ONCE after CID validation, held as
handler locals across the whole request — including every spawn_blocking walk, so
the slot reflects real blocking-thread occupancy (a tokio timeout cannot cancel
spawn_blocking). None key -> global pool only. Cap repos walked per request so one
request can't serialize N full-history walks. Attach rate_limit_by_ip +
IpRateLimiter to the /ipfs route (mirrors the git/create/peer routers; the
extension is required or the middleware is a silent no-op). Visibility/authz
semantics unchanged.

New knobs: GITLAWB_MAX_CONCURRENT_IPFS_WALKS (32), GITLAWB_IPFS_WALK_PER_SOURCE
(4), GITLAWB_IPFS_MAX_REPOS_WALKED (64), GITLAWB_IPFS_RATE_LIMIT (600/hr, 0
disables) — all range-validated and enforced on-path.

Handler-layer regressions (all mutation-verified RED->GREEN): shed-at-capacity
503 (delete acquire -> falls through), per-source cap sheds same source / admits
another, None-key arm sheds on the global pool, map self-bounds reject-before-insert,
the walk permit is held while the spawn_blocking walk runs (available_permits()==0;
dropping it before the loop -> RED), repos-walked cap (remove break -> 2 walks),
and the route IP rate limit actually fires 429 (drop the Extension -> RED no-op).
2026-07-15 12:42:55 -05:00
t 8d17038e5b fix(node): bound the served-git storage-acquisition phase with a release-on-expiry deadline (#174)
The served-git admission permit is acquired, then RepoStore::acquire*/advisory-lock
runs before the bounded git runner starts — with no local deadline. acquire/acquire_fresh
await Tigris HEAD/GET, and acquire_write's advisory-lock loop awaits a per-iteration
pg_try_advisory_lock that can block indefinitely on a hung Postgres pool. Since
GITLAWB_GIT_SERVICE_TIMEOUT_SECS only starts once git spawns, a stalled backend pins
the permit and drains the pool until every later request 503s (P1-2).

Wrap each git-path acquire (git_info_refs read/advert, git_upload_pack, git_receive_pack)
and the /ipfs per-repo acquire loop in tokio::time::timeout(git_acquire_timeout_secs, ..).
On expiry the git paths return AppError::Overloaded (503 + Retry-After); the permit is a
handler-local at that point (moved into the AdmissionGuard only after acquire) so the early
return frees the slot. The /ipfs per-repo acquire keeps its fail-closed `continue` on
timeout (never serves an un-acquired repo). No repo_store.rs signature change: the outer
timeout cancels a mid-sleep/mid-fetch_one future, so no in-loop deadline is needed.

New knob GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS (default 30, range 1..), modeled on
git_service_timeout_secs, kept separate because acquisition and git execution are distinct
cost centers.

Handler-layer regression receive_pack_acquire_deadline_sheds_and_releases_permit: holds the
same pg advisory lock acquire_write derives on a second connection so the real loop must
retry, drives git-receive-pack with the deadline at 2s and write pool size 1, and asserts
503 at ~2s + the permit recovers + a follow-up admits once the lock frees. Removing the
timeout wrapper turns it RED (blocks ~31s to the test ceiling on the ~59s advisory loop).
2026-07-15 12:00:02 -05:00
t 72e899dae0 fix(node): hold served-git admission until the process group is reaped on the plain spawn paths (#174)
On the plain (non-path-scoped) info_refs / upload-pack / receive-pack paths the
global + per-source admission permits were handler-locals that dropped the
instant the handler future dropped on a client disconnect, while KillGroupOnDrop
reaps the git process group in a DETACHED task (SIGTERM -> grace -> SIGKILL ->
reap). So a disconnect-spammer admitted replacements while prior groups were
still alive, defeating the process cap and the per-source cap during teardown.
be0cdd6 already fixed this for the path-scoped upload-pack walk; this closes the
residual plain paths (P1-a).

Introduce AdmissionGuard, a move-only holder of the permits, threaded through
info_refs/upload_pack/receive_pack/run_git_service into drive_git_child's
KillGroupOnDrop. On disconnect the guard moves into the detached reaper and drops
only after the group is ESRCH-confirmed reaped; on success/timeout disarm()
returns it for the earliest provably-free drop. The rev-list/pack-objects
visibility-walk callers hold no admission and pass None.

Thread git_bin through upload_pack/receive_pack (they hardcoded "git") so the
plain POST path is drivable by an injected fake git, matching the existing
injectable seam on info_refs/run_git_service/upload_pack_excluding.

Handler-layer regression upload_pack_plain_permit_held_through_group_reap_after_disconnect:
isolates the global read pool (size 1, per-source + rate limiter permissive), a
SIGTERM-ignoring descendant keeps the group alive across the reap window, and
asserts available_permits()==0 held on disconnect + a cross-source replacement
sheds 503, then frees after ESRCH. Reverting the guard-into-reaper move (release
immediately) turns it RED at the held-through-reap assertion. Plus a None-key arm.
2026-07-15 12:00:02 -05:00
t 5749df6e67 test(node): INV-22 completeness guard for the served-git concurrency surface (#174)
A source-scan tripwire that fails when a new site reintroduces the concurrency-cap
class PR #174 fixed, or when one of the five gates (U1-U5) is removed. INV-22: a
permit held per op recovers only if every path is duration-bounded and reaps the
group before releasing admission, and every detached git task carries admission.

Lives in tests/ (a separate crate) on purpose — a guard scanning the file it lives
in would match its own identifier literals and pass vacuously; scanning src/ from
here keeps every check load-bearing. Checks: run_bounded_git confirms child exit via
child_terminated_without_reaping (U1); KillGroupOnDrop launches the reaper via
Handle::try_current (U2); git_receive_pack acquires state.git_write_per_caller (U4);
the encryption walk runs through withheld_recipients_gated over git_encrypt_semaphore
(U5); and the bounded recipients walk is invoked exactly once — only inside the gated
helper — so a new spawn_blocking bypass (count > 1) fails (P1-e non-bypass).

Proven load-bearing: a synthetic second call site turns the count tripwire RED, and
each named gate maps to a fix whose own regression already goes RED on revert.
2026-07-14 20:33:01 -05:00
t 2a54c159e6 fix(node): bound concurrent post-push encryption walks with an admission pool (#174)
After a successful path-scoped push the handler released its write permit and
then ran a DETACHED tokio task whose spawn_blocking(withheld_blob_recipients_bounded)
performed another full-history git walk under no admission. N fast completed
pushes spawned N concurrent full-history walks past GITLAWB_MAX_CONCURRENT_GIT_PUSHES
(which bounds only the in-handler phase), and the per-child bounding from 71389e6
caps each walk's duration but not their count (P1-e).

Add git_encrypt_semaphore, a pool of its own (sized from max_concurrent_git_pushes,
no new knob — Q1) so a long background walk never holds a foreground write slot and
a handler holding a write permit can't self-deadlock. Route the walk through a new
withheld_recipients_gated helper that acquires the pool before the walk and DEFERS
(blocks) when full rather than shedding — dropping the walk would lose the
withheld-blob recovery copy, so durability stays fail-closed.

Regression: encrypt_walk_defers_when_pool_exhausted drives the gated helper with the
pool exhausted and asserts the walk blocks and does not run its rev-list, then runs
once a permit frees. RED before (the walk runs regardless of the pool), GREEN after.
The detached push task calls this exact helper.
2026-07-14 20:28:52 -05:00
t 12643573f6 fix(node): cap concurrent receive-pack pushes per source IP (#174)
The authenticated git-receive-pack POST acquired only the global write semaphore
— no per-source sub-cap, unlike the read and anon-advert pools. Owner enforcement
defaults off, so one host minting disposable did:key identities could open
max_concurrent_git_pushes slow POSTs from a single source IP and 503 every other
source's push; the 600/hour push limiter bounds arrival rate, not in-flight
concurrency (P1-d).

Add git_write_per_caller (a PerCallerConcurrency sized like the advert cap,
max_concurrent_git_pushes/8), and acquire it before the global write permit,
keyed on the resolved source IP via read_caller_key — never the signed DID (a DID
farm defeats a DID key). This required adding the PeerAddr + HeaderMap extractors
to git_receive_pack, which it lacked (without them the key is None and the cap is
inert). Neutralized the shared acquire helper's log wording now that it serves
both read and write paths.

Regression: receive_pack_per_source_write_cap_sheds_capped_source_not_others — a
source at its write sub-cap sheds Overloaded/503 (proving the extractors resolve a
key), while a different source is not shed. RED before (the capped source proceeds
past admission to a git error instead of shedding), GREEN after; the existing
receive-pack advert cap test still passes.
2026-07-14 20:20:49 -05:00
t be0cdd6a9e fix(node): hold upload-pack admission through the walk on disconnect (#174)
On the path-scoped upload-pack path the per-source and global read permits were
handler locals, so a client disconnect dropped the handler future and released
both permits while the awaited spawn_blocking withheld-blob walk kept running
(spawn_blocking can't be cancelled). A permitless caller could disconnect-spam to
exceed the read cap while real git work continued (P1-b).

Move both permits into the blocking task and return them out: on success they
flow back so the serve phase keeps them; on a dropped future the returned tuple
(with the permits) is discarded only when the blocking task completes, so
admission tracks the walk's real duration.

Regression: upload_pack_permit_held_through_walk_after_disconnect drives the
handler to mid-walk (fake git hangs on rev-list), disconnects, and asserts the
global read slot stays held (available_permits == 0). RED before (the slot frees
to 1 the instant the future drops), GREEN after. The test roots the repo store at
its own TempDir so it is isolated per run.
2026-07-14 20:09:40 -05:00
t ac59bd7e01 fix(node): reap the served-git process group on disconnect, not just SIGTERM (#174)
On a client disconnect KillGroupOnDrop sent a lone SIGTERM with no escalation or
reap, so a group member that ignores SIGTERM (a wedged pack-objects, a malicious
helper) survived while the handler released its concurrency permit — letting
disconnect-spam accumulate live git processes and defeat the load shed (P1-c).
The timeout path already ran the full teardown.

Make the disconnect teardown as strong as the timeout path: the guard now owns
the child and, on drop, launches a detached reaper that runs the same
TERM -> grace -> SIGKILL -> reap-the-group sequence (reap_group_on_timeout).
Owning the tokio Child in the reaper keeps it the sole reaper of the leader, so
it never races tokio's own orphan reaper (a raw waitpid(-pgid) would).

Regression: run_git_service_sigkills_a_sigterm_ignoring_child_on_disconnect, RED
before (the descendant survives the lone SIGTERM), GREEN after; all existing
teardown/timeout/disarm tests still pass (the three hand-built-guard tests were
updated to the owned-child shape).

Residual: capacity (the handler's semaphore permit) still releases at disconnect
rather than strictly after the reaper confirms the group dead, so accumulation is
now bounded to ~one teardown window (<=4s) rather than eliminated; gating the
permit on the reap needs threading it through the serve handlers and is left for
a follow-up (it also touches the receive-pack permit lifetime U4 changes).
2026-07-14 19:50:41 -05:00
t 3cc01890fb fix(node): keep the visibility-walk watchdog armed until the child is reaped (#174)
A group member (or the leader itself) that closes stdout before the deadline made
the stdout drain return EOF early, which stood the watchdog down via done_tx
before it could ever fire; child.wait() then blocked on the still-live child,
pinning the walk thread and its read/write permit past
GITLAWB_GIT_SERVICE_TIMEOUT_SECS. This is distinct from the descendant case
already covered: there the leader sleeps until the deadline so the watchdog does
time out; here the drain-EOF races ahead of it.

Stand the watchdog down only once the child has actually terminated, detected
without reaping (waitid + WNOWAIT) so the leader's pid stays unreaped and its
pgid un-recycled until the watchdog finishes its teardown and is joined. Past the
deadline the watchdog still owns the full SIGTERM -> grace -> SIGKILL sequence.

Regression: run_bounded_git_reaps_a_leader_that_closes_stdout_then_hangs, RED
before (blocks on child.wait past the recv budget), GREEN after; the three
existing teardown tests still pass (no recycle-hazard regression).
2026-07-14 19:27:12 -05:00
t a030eefb70 test(node): retry the ETXTBSY exec race in build_filtered_pack timeout tests (#174)
build_filtered_pack_times_out_a_hung_rev_list and _a_hung_pack_objects
spawn a freshly-written fake git directly, bypassing the fake_git_run_with_pids
retry the rest of the module uses. Under cargo test fork-storm load a concurrent
worker forks while the fake's write fd is still open, so execve returns ETXTBSY
and the raw io::Error surfaces where the test asserts GitServiceTimeout, failing
test (stable) intermittently.

Route both tests through build_filtered_pack_or_exec_race_retry, which retries
ONLY errno 26 (ETXTBSY) and keeps the outer 10s watchdog per attempt, so a
genuinely-missing async timeout bound still trips the watchdog and a wrong error
type still fails at the assertion. Production paths unchanged.
2026-07-13 16:01:49 -05:00
t cf97405bc1 test(node): execution-cover the delta-path push-scan timeout wiring (#174)
The finding-2 wiring test covered only list_all_objects (the full-scan fallback).
Add delta_path_exec_fns_time_out_on_a_hung_git covering the common push path:
peeled_object_type (cat-file), rev_list_delta (rev-list), and list_all_objects_with_type
(cat-file, which all_blob_oids delegates to). Each must return within the watchdog
budget on a hung git; reverting any to a bare Command::output() blocks past the recv
budget (RED-verified).
2026-07-13 10:55:57 -05:00
t 4410b834df docs(node): correct concurrency + timeout docs to match the final git routing (#174)
jatmn P2 (INV-24): the config help, .env.example, and README described routing the
handler no longer does.

- The read pool serves upload-pack and the UPLOAD-PACK info/refs advertisement only;
  the anon receive-pack info/refs advertisement draws from its own dedicated pool
  (sized like the write pool but disjoint), not the read pool and not the write pool.
- The write pool is the authenticated push POST only.
- The per-caller read cap is keyed on the resolved source IP, never the DID: a
  signature does not move a caller off it (the docs claimed a per-DID budget, which
  would tell a NATed authenticated client it escapes the shared cap when it does not).
- git_service_timeout_secs now also bounds the push-side pin-candidate discovery
  (rev-list / cat-file), reaped via process-group teardown, alongside the info/refs
  advertisements and the withheld-blob walk.
2026-07-13 10:46:24 -05:00
t 9412aa9c5b fix(node): bound and reap push-candidate git children under the write permit (#174)
Route push_delta's candidate-discovery git children (cat-file / rev-list) through the
bounded run_bounded_git (deadline + process-group SIGKILL teardown) instead of bare
Command::output(). A hung scan no longer pins the git-receive-pack write permit past
the deadline or a client disconnect. Threads git_bin + a shared per-scan deadline
(git_service_timeout_secs) through resolve_candidates_for_push and all_blob_oids.
2026-07-13 10:41:31 -05:00
t 7a6bd359eb fix(node): SIGKILL a SIGTERM-ignoring member of the visibility-walk process group (#174)
jatmn/CodeRabbit P1: the walk watchdog stood down as soon as the group LEADER was
reaped. A leader that exits cleanly on SIGTERM while a background member ignores
SIGTERM and closes its inherited pipes let the main drain unblock, the leader be
reaped, and the watchdog skip its SIGKILL escalation — so the handler returned while
the descendant kept running. jatmn's suggested "continue until ESRCH" could not work:
the main thread reaping the leader is what frees the pgid, so continuing to kill(-pgid)
after that races a recycled group.

Restructure the coordination instead: the main thread now defers reaping the leader
until the watchdog thread returns. The watchdog always escalates through an
unconditional group SIGKILL (no leader-reap short-circuit), and because the leader is
still unreaped while every kill(-pgid) fires, the pgid cannot have been recycled. The
grace before SIGKILL is a fixed budget (only paid on the exceptional timeout path).

Regression run_bounded_git_sigkills_a_sigterm_ignoring_descendant_after_leader_exits:
a leader that exits 0 on SIGTERM spawns an sh -c descendant (its own $$, not a ( )
subshell's parent pid) that ignores SIGTERM and closes its pipes; the descendant must
be dead when the runner returns. RED on the old teardown (survives), GREEN now. The
existing leader-ignores-SIGTERM and hung-walk teardown tests still pass.
2026-07-13 10:11:41 -05:00
t 473924dcfe fix(node): compile the bounded visibility git runner on non-Unix targets (#174)
jatmn P1: run_bounded_git was gated #[cfg(unix)], but its callers
(assert_all_refs_are_commits, blob_paths) and the module are compiled on all
targets, so a non-Unix build (the continue-on-error Windows release target) could
not resolve the function.

Add a #[cfg(not(unix))] twin with the same signature and result semantics. It bounds
a single child (no process-group teardown, which is Unix-only): threads feed stdin
and drain stderr, the main thread drains stdout, and a watchdog thread kills the
child at the deadline. The child is shared with the watchdog behind a mutex the main
thread does not hold while draining, so the watchdog can always acquire it to kill,
and killing closes stdout to unblock the drain. Best-effort (reaps only the direct
child), which is why the hardened group-aware path stays Unix-only.

Verified the twin type-checks by compiling its (portable-std) body on the native
target; the full non-Unix link is CI's Windows job.
2026-07-13 09:49:33 -05:00
t f19f2813a2 fix(node): give the receive-pack advert its own pool so it can't shed authed pushes (#174)
jatmn P1: the anon-reachable GET info/refs?service=git-receive-pack drew from the
same git_write_semaphore as the authenticated git-receive-pack POST. The per-source
advert cap reserves no global slots, so ~8 sources (each within the per-IP push rate
limit) could hold all 32 write permits across acquire_fresh + the advertisement, and
the next authenticated push then 503s at admission. That is an anonymous caller
shedding an authenticated push across the auth boundary (INV-10).

Add a dedicated git_push_advert_semaphore, disjoint from the write pool. Both the
pre-DB early-shed peek and the held acquire for the receive-pack advertisement now
draw from it; the write pool is left exclusively for the POST. An advert flood can at
worst exhaust the advert pool (each source still capped by git_push_advert_per_caller
and the per-IP push rate limiter); the authenticated push pool is untouched.

Test receive_pack_advertisement_draws_from_dedicated_advert_pool proves at the handler
layer that the advert sheds when its own pool is saturated AND survives when the write
pool is saturated (the reservation). Both flip under revert, verified independently for
the pre-DB peek and the held acquire.
2026-07-13 09:44:53 -05:00
t 79b9ee5c89 fix(node): shed the served-git pool before the DB and cap per-source before it (#174)
CodeRabbit review: the held git_permit was acquired at the top of git_info_refs
and git_upload_pack, before the per-source cap, so one source could occupy the
global pool during the DB/visibility window before its sub-cap rejected the
excess. Move the held git_permit to after acquire_read_caller_permit (still
before acquire_fresh/git, so INV-10's bound on the fresh Tigris acquire holds),
and add a cheap pre-DB early shed (available_permits() == 0 -> 503, holds no
permit) so the #62 shed-before-DB property is preserved without holding a permit
across the DB work.

The receive-pack POST is unchanged (owner-only; its top-of-handler git_permit is
deliberate for acquire_write bounding).

Tests: the two #62 shed-before-DB tests now exercise the explicit early check
(load-bearing: disable it and they fall through to the DB); three new
*_per_source_cap_sheds_with_global_capacity tests prove the per-source sub-cap
sheds a capped source even with global capacity free (load-bearing on the caller
cap). Full workspace green.
2026-07-12 14:40:59 -05:00
t 454e59ba53 docs(node): the withheld-walk is bounded by the service timeout, not a fixed const (#174)
Follow-up to threading the configured timeout into the walk: the walk now honors
GITLAWB_GIT_SERVICE_TIMEOUT_SECS on both the serve and replication paths, so the
README no longer says a fixed internal deadline.
2026-07-12 02:12:37 -05:00
t c35452ed41 test(node): vet the withheld-walk bound end to end; use the configured timeout (#174)
Close the reasoned-not-run gaps from the code review by making the walk's git
binary and timeout injectable, then driving the missing branches with a real
handler and a fake git instead of reasoning about them.

- Add state.git_bin and *_bounded variants of the walk entry points taking
  (git_bin, timeout); the served handlers (upload-pack serve, receive-pack
  replication and full-scan and encrypt-pin, and the ipfs gate) now pass the
  operator-configured GITLAWB_GIT_SERVICE_TIMEOUT_SECS, so the whole walk is bounded
  by the same budget as the other served-git ops rather than a fixed constant. The
  git_bin-less wrappers stay for the real-git classification tests.

Newly vetted by execution (not reasoning):
- receive-pack replication path is bounded: replication_withheld_set with an injected
  hung git returns within the budget and fails closed, so it cannot pin the write
  permit git_receive_pack holds across it.
- a hung withheld-blob walk on the upload-pack POST returns 504 (real handler, real
  repo on disk, injected hung git), proving the GitServiceTimeout -> git_service_app_error
  wiring end to end.
- the watchdog status-gate: a child that exits successfully is not reported as a
  timeout even when the watchdog fired (mutation-checked: drop the guard -> RED).
- SIGKILL escalation: a SIGTERM-ignoring child is still reaped via SIGKILL and the
  group is gone; a truly uninterruptible D-state child (unreapable by any signal) is
  the documented residual, matching the async teardown.
- the advertisement per-source cap sizing never derives 0.

Full gitlawb-node suite 515 green.
2026-07-12 02:12:08 -05:00
t e750c3042d fix(review): cap the receive-pack advertisement per source so anon cannot starve the write pool (#174)
U2 moved the receive-pack info/refs advertisement onto git_write_semaphore to keep
an anonymous read flood from starving the push handshake. But the advertisement is
anon-reachable on public repos and holds its write permit across the slow
acquire_fresh Tigris download, and the only per-source brake on it was the push
RATE limiter, not a concurrency cap. So a multi-source flood of receive-pack
advertisements could hold the write pool's slots across those downloads and shed
authenticated pushes (both the advertisement and the owner-gated git-receive-pack
POST draw from the same pool). U2 thus introduced the first anonymous consumer of
the write pool the state doc promised anon could never reach; the plan's residual
note (no worse than the POST) was wrong, because the POST is owner-gated and the
advertisement is not.

Add git_push_advert_per_caller, a per-source concurrency sub-cap on the receive-pack
advertisement keyed on the resolved source IP (the same PerCallerConcurrency
mechanism U1 uses for reads), sized to an eighth of the write pool so a single
source holds at most that share and saturating the pool takes many distinct source
IPs, each also braked by the per-IP push rate limiter. The upload-pack advertisement
keeps its read-pool per-caller cap; the owner-gated POST is unchanged. Correct the
state doc for git_write_semaphore accordingly.

Handler-layer proof: a source at its receive-pack advertisement cap sheds 503 (RED
before the acquisition, 500-not-503), while a different source and the upload-pack
advertisement are unaffected. Full suite 510 green.
2026-07-12 00:56:24 -05:00
t 87a7e40513 fix(review): close the withheld-walk watchdog reused-pgid race (#174)
Code review found run_bounded_git's watchdog could return a spurious 504 and
signal a recycled process group. The watchdog runs off a wall clock on its own
thread; done_tx.send() only fires after child.wait() reaps the leader, so a walk
that finished within microseconds of the deadline took the watchdog's Timeout
branch, discarded a fully-captured successful result, and returned GitServiceTimeout
(a 504 for a walk that actually completed). Worse, the Timeout branch SIGTERMed
-pgid unconditionally after the leader was reaped, so a recycled pgid could be
signalled, the exact hazard smart_http guards via disarm-after-wait.

Set a reaped AtomicBool the instant the main thread reaps the child; the watchdog
checks it before every kill and stands down if the leader is already reaped. Gate
the timeout verdict on !status.success(), so a child that exited on its own is never
reported as a timeout even if the watchdog fired late. Add the survived-SIGKILL warn
smart_http's reap already emits, for operator visibility on a wedged (D-state) git.

The hung-walk test stays green (a killed child exits by signal, not success, so it
still surfaces GitServiceTimeout and reaps the group) and the 32 real-git
classification tests stay green (a fast walk is never spuriously killed).
2026-07-12 00:36:42 -05:00
t ab0ea240fc docs(node): correct the git-service-timeout coverage note (#174)
GITLAWB_GIT_SERVICE_TIMEOUT_SECS bounds the info/refs advertisement too:
smart_http::info_refs drives it through drive_git_child under this timeout, with a
passing test proving the 504. The old note claimed it does not. It also claimed the
withheld-blob path is unbounded; after the blob_paths seam bound (this PR) the walk
is bounded and reaped, by a fixed internal deadline rather than this env var, so the
line now states that precisely instead of overclaiming this setting covers it.
2026-07-12 00:20:07 -05:00
t 71389e621a fix(node): bound the withheld-blob walk at the shared blob_paths seam (#174)
The withheld-blob classification walk (blob_paths) fanned out blocking git children
with no deadline and no process-group teardown: git for-each-ref, git cat-file, git
rev-list, a git ls-tree per commit, and an uncounted git rev-parse (via
store::head_commit). A hung or pathologically slow child pinned the caller's
served-git permit for the whole hang, and on client disconnect the spawn_blocking
task and its git children ran on, orphaned. blob_paths is the shared core of five
callers: the upload-pack serve path (holds a read permit) AND, inside
git_receive_pack, the post-push replication and encrypt-then-pin walks (hold the
write permit U2 reserves for pushes). So the same unbounded walk could pin either
pool, and leaving the write-side twin unbounded would have made U2's reservation a
claim that does not match behavior.

Bound every git child at the blob_paths spawn seam on the blocking side: each child
runs in its own process group with a watchdog thread that SIGTERMs (then SIGKILLs)
the group on one shared deadline spanning the whole walk, and retains admission
until the group is reaped. This is the blocking-side counterpart of
smart_http::drive_git_child (spawn_blocking cannot be cancelled by an async
timeout). blob_paths stays sync, so all five callers keep their signatures and the
32 classification tests are unchanged; because every caller funnels through
blob_paths, one seam bounds both the serve and replication paths. The previously
unbounded store::head_commit child becomes a bounded git rev-parse inside the walk.
A walk that hits its deadline carries GitServiceTimeout, which the serve handler now
maps to 504 rather than a generic 500.

Proof: a fake git that hangs on rev-list makes blob_paths return GitServiceTimeout
within the watchdog budget (not block on the child) and the recorded process-group
leader is reaped, not orphaned; neutralizing the watchdog kill makes it hang past
the budget (RED). The 32 real-git classification tests stay green through the
refactor, including detached-HEAD, non-standard-ref, and deleted-in-history cases.
2026-07-12 00:20:07 -05:00
t e444bc0ae5 fix(node): draw the receive-pack advertisement from the write pool (#174)
git_info_refs acquired git_read_semaphore for BOTH services, so the push handshake
(GET /info/refs?service=git-receive-pack) competed in the global read pool. An
anonymous clone flood could exhaust that pool and shed a legitimate push with 503
during its required advertisement phase, before it ever reached git_write_semaphore
on the POST. The write pool exists precisely so anonymous reads cannot shed an
authenticated push, but only the POST drew from it.

Select the pool by service: the receive-pack advertisement (phase one of a push)
now draws from git_write_semaphore, like the git-receive-pack POST, so a saturated
read pool cannot starve it. The per-IP push_rate_limiter that already brakes the
advertisement stays as the anti-flood control, and the advertisement stays
reader-visible with no new auth requirement. Because the receive-pack branch is now
a write-path op, it no longer consumes a read per-caller slot.

Handler-layer proofs: with the read pool at zero the receive-pack advertisement
survives while the upload-pack advertisement sheds; with the write pool at zero the
receive-pack advertisement sheds while upload-pack is unaffected; and a receive-pack
advertisement from an IP whose read per-caller budget is full still gets through
(mutation-checked, RED when the skip is neutralized).
2026-07-11 23:54:21 -05:00
t 4983b854d1 fix(node): key the per-caller read cap on source IP, not the signed DID (#174)
read_caller_key returned the authenticated DID when a caller signed, dropping the
source-IP key. Public read routes accept any valid did:key via optional_signature
with no admission step, so one host could mint N disposable DIDs and hold
max_concurrent_reads_per_caller slots under each, multiplying its budget N-fold and
filling the global read pool, while the same host unsigned was capped on its IP.

Key the read sub-cap on the resolved source IP for every caller, signed or not,
mirroring the push path's IpRateLimiter which already throttles on source IP for
this exact DID-farm reason. Drops the now-unused caller_did parameter at both call
sites (git_info_refs, git_upload_pack).

Inverts info_refs_per_caller_cap_keys_on_did_not_ip into
info_refs_per_caller_cap_keys_on_ip_not_did: fill one source IP's slot, then two
requests signed under different DIDs from that same IP both shed 503 (farm
defeated), while a signed request from a different IP keeps its own budget. RED on
the DID-keyed tree, GREEN after.
2026-07-11 23:44:13 -05:00
t 362ee3462e fix(node,git): bound the rev-list stage under drive_git_child so a hung enumeration can't pin a git permit (#174) 2026-07-11 13:02:24 -05:00
t 3b1fa54b73 docs(config): add GITLAWB_MAX_CONCURRENT_GIT_OPS to .env.example (#174)
The read-pool knob was referenced by the push and per-caller entries'
comments but had no example line of its own, so operators couldn't
discover it from the template. Add it with the config default (128).
2026-07-10 16:05:29 -05:00
t 5069cd13ca fix(node,concurrency): resolve #174 code-review findings
Review of the served-git concurrency cap found no P0/P1; these are the
verified P2 follow-ups.

config: the max_concurrent_git_ops doc overclaimed that "every capped path
is duration-bounded." The rev-list object enumeration in the withheld-blob
path still runs in an uncancellable spawn_blocking, so a stuck rev-list can
hold its slot until git exits. Scope the guarantee to the streaming stages
and name the residual. Also tighten the fairness claim: the receive-pack
advertisement shares the read pool (a shed advertisement is a cheap retryable
GET); only the push POST is on the isolated write pool.

api/repos: add info_refs_per_caller_cap_keys_on_did_not_ip, the missing
handler proof that a signed caller is keyed by its DID, not its source IP.
Filling the DID slot sheds a request from a free IP; collapsing read_caller_key
to its IP arm turns the assertion green-not-503 (mutation-verified RED).

api/repos: extract acquire_read_caller_permit so both read handlers share one
shed path instead of a duplicated match block.

rate_limit: recover from a poisoned PerCallerConcurrency mutex instead of
panicking. The critical section is pure counter arithmetic and cannot poison
the lock, but a panic there would brick the limiter for every caller.

505 tests pass; clippy -D warnings and fmt clean.

Part of #174.
2026-07-10 13:45:38 -05:00
t 6336657efc docs(node,config): reconcile the concurrency-cap comments with the fix (#62)
The max_concurrent_git_ops and git_service_timeout_secs doc-comments (and .env.example) described the info/refs and withheld-blob paths as unbounded follow-up gaps. Both are now closed (#174): the comments reflect the read/write pool split, the per-caller sub-cap, and that every capped path is duration-bounded with process-group teardown. Verified the pattern-doc pre-ship checklist: every git_permit / write-permit / per-caller site holds only a timeout+teardown git path, and all three size knobs are range(1..=1_048_576).

Closes the #174 work. No behavior change.
2026-07-10 13:20:18 -05:00
t 57c6e01c26 fix(node,git): bound and reap the withheld-blob pack build (#62)
The filtered-pack path ran the whole rev-list + pack-objects build inside a spawn_blocking, so an outer tokio timeout could not cancel the blocking thread and a client disconnect orphaned the git child while the permit freed (jatmn P1b, the second gap path). Split it: rev-list enumeration stays blocking off the runtime (rev_list_keep), but the streaming pack-objects stage now runs under the shared drive_git_child on the async side, so it is duration-bounded (GitServiceTimeout -> 504) and its process group is reaped on disconnect. build_filtered_pack becomes async and takes a git_bin seam + timeout; upload_pack_excluding threads the git_service_timeout through.

A watchdog-bounded test proves a hung pack-objects times out (rev-list fast, pack-objects hangs). The refactor's happy path is covered by the existing filtered-pack correctness and real-git partial-clone/fetch tests, all still green; disconnect/group-teardown is the shared drive_git_child code proven by the run_git_service tests. 504 tests pass.

Part of #174.
2026-07-10 13:16:41 -05:00
t 2c264e08e3 fix(node,git): bound and reap the info/refs advertisement (#62)
info_refs ran a bare Command::output() with no timeout and no process-group teardown, so a hung git pinned its concurrency slot indefinitely and a client disconnect orphaned the child (jatmn P1b). Extract the timeout + process_group(0) + KillGroupOnDrop core from run_git_service into a shared drive_git_child, and route info_refs through it with an injectable git_bin. A hung advertisement now aborts with GitServiceTimeout (mapped to 504); disconnect reaps the group.

run_git_service's teardown tests all pass through the shared core (proving the group teardown info_refs inherits), the real-git filter tests cover the advertisement happy path, and a new watchdog-bounded test proves a hung advertisement times out. 503 tests pass.

Part of #174.
2026-07-10 13:09:34 -05:00
t fc4c206c3a fix(node,git): per-caller concurrency sub-cap on the read pool (#62)
Adds PerCallerConcurrency, a bounded-keyed in-flight limiter (distinct from the request-rate RateLimiter) so no single caller monopolizes the served-git read pool. Each caller (per-DID when signed via optional_signature, else per-source-IP via client_key) may hold at most max_concurrent_reads_per_caller concurrent reads; over that it sheds 503. The key map is self-bounding (a key is dropped when its in-flight count hits zero) with a reject-before-insert max_keys backstop so a key farm can't grow it (INV-15). Applied in git_upload_pack and both info/refs advertisements, acquired after the visibility gate so a denied request never consumes a slot (KTD7).

Primitive unit-tested (cap + self-bounding + reject-before-insert) and mutation-checked. Handler-layer SC2: same-caller sheds while a different caller passes, proven on BOTH git_info_refs and git_upload_pack with independent mutation probes; plus a None-key bypass test. Per-source-IP keying is trust-config dependent, documented on the config knob. 502 tests pass.

Part of #174.
2026-07-10 12:50:46 -05:00
t 2434dde42f fix(node,git): isolate authed pushes in a dedicated write pool (#62)
git-receive-pack now draws from a separate git_write_semaphore (max_concurrent_git_pushes) instead of the shared pool, so a flood of anonymous reads can no longer shed an authenticated push at admission (jatmn P1a). The shared field is renamed git_read_semaphore and continues to gate upload-pack and both info/refs advertisements. The write permit stays above acquire_write so it precedes the Tigris fresh-acquire (INV-10).

Handler-layer tests: write-pool shed (503), and a cross-boundary proof that an exhausted read pool does NOT shed a push; both mutation-checked (routing receive-pack back to the read pool flips each RED). 497 tests pass.

Part of #174.
2026-07-10 12:29:30 -05:00
t db4de844ad feat(node,config): add write-pool and per-caller read-cap knobs (#62)
Add max_concurrent_git_pushes (default 32) and max_concurrent_reads_per_caller (default 16), both clap range(1..=1_048_576) so an oversized value is a clean CLI error, not a Semaphore::new boot panic. The per-caller knob documents that per-source-IP keying is only as granular as GITLAWB_TRUSTED_PROXY. Wiring lands in the following commits; these are the config surface for the #174 concurrency-fairness fix.

Resolves jatmn P1a/P1b groundwork on #174.
2026-07-10 12:18:07 -05:00
t 65ba1374af feat(node,git): cap concurrent served git ops with a 503 load-shed (#62)
PR3 of the #62 served-git hardening stack (timeout #165 and teardown
wiring #150 are merged). A bounded semaphore caps how many upload-pack /
receive-pack / info-refs operations run at once; past the cap a request
is shed with a clean 503 + Retry-After before spawning another git
subprocess, instead of exhausting the PID/thread table. A permit is
acquired at the top of each of the three handlers and held for the whole
op, releasing on return.

The cap is a portable backstop: the compose pids_limit is absent on Fly,
whose 500-connection cap is a different axis. Size --max-concurrent-git-ops
(GITLAWB_MAX_CONCURRENT_GIT_OPS, default 128) below the process budget.
Range 1..=1_048_576 so 0 (shed everything) and an oversized value that
would panic tokio's Semaphore at boot are clean CLI errors.

Known gap, tracked separately: info/refs and the withheld-blob
(upload_pack_excluding) path are not duration-bounded and do not reap
their git child on client disconnect, so a hung git on those two paths
holds its slot until it exits and live git can briefly exceed the cap.
The main pack path (run_git_service) tears its group down on drop.

Tests: Overloaded maps to 503 + Retry-After; the config knob defaults
and rejects out-of-range; git_permit sheds at capacity and releases; and
each of the three endpoints sheds with 503 when the semaphore is
exhausted (load-bearing: drop the permit line and the endpoint test goes
red).
2026-07-10 12:10:30 -05:00
28 changed files with 18520 additions and 738 deletions

No files matched your search

+60 -2
View File
@@ -109,15 +109,73 @@ GITLAWB_MAX_PACK_BYTES=2147483648
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
# worker and, on push, the repo write lock. Does NOT cover the info/refs
# advertisement or the withheld-blob path, which remain unbounded. Default 600.
# worker and, on push, the repo write lock. Also bounds both info/refs
# advertisements, the withheld-blob pack build, and the push-side candidate
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
# Default 600.
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
# Max seconds the storage-ACQUISITION phase of a served git op may run before the
# request is shed with a 503, separate from the git-run timeout above. A
# concurrency permit is taken before this phase and GITLAWB_GIT_SERVICE_TIMEOUT_SECS
# only starts once git spawns, so without this a stalled backend (a hung Tigris
# HEAD/GET, or a hung pg advisory-lock iteration on push) pins the permit and drains
# the pool until every later request 503s. On expiry the permit is released
# (fail-closed). Kept separate because acquisition and git execution are distinct
# cost centers. Must be positive; set very large to effectively disable. Default 30.
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS=30
# Max concurrent git READ ops (upload-pack + the upload-pack info/refs
# advertisement) served at once, a global pool separate from the push pool below.
# The anon receive-pack info/refs advertisement has its OWN pool (see below), not
# this one. Over-cap sheds a clean 503 + Retry-After. Anonymous reads draw from
# here, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER (below) so one
# caller cannot monopolize the pool. Default 128.
GITLAWB_MAX_CONCURRENT_GIT_OPS=128
# Max concurrent git-receive-pack (push) POST operations, in a pool separate from
# the read pool (GITLAWB_MAX_CONCURRENT_GIT_OPS) so anonymous reads cannot shed an
# authenticated push at admission. The anon receive-pack info/refs advertisement
# runs in a SEPARATE pool of the same size (disjoint from this one), so an
# advertisement flood cannot shed a push either. Over-cap sheds a 503 +
# Retry-After. Default 32.
GITLAWB_MAX_CONCURRENT_GIT_PUSHES=32
# Max concurrent read ops (upload-pack + the upload-pack info/refs advertisement)
# a single caller may hold, so one caller cannot monopolize the read pool. Keyed
# on the resolved SOURCE IP, never the DID: a signature does not move a caller off
# this cap. The source-IP key is only as granular as GITLAWB_TRUSTED_PROXY below:
# left unset, a node behind an edge/NAT keys all callers on the edge IP and this
# collapses to one global cap. Set GITLAWB_TRUSTED_PROXY for per-client keying; a
# high-fanout caller (CI behind one NAT) then needs the operator to raise this.
# Default 16.
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
# Max receive-pack requests (info/refs advertisement + push POST) per client
# IP per hour. 0 disables. Default 600.
GITLAWB_PUSH_RATE_LIMIT=600
# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
# so it is bounded to keep a permissionless caller from fanning out unbounded
# concurrent walks and exhausting blocking-pool threads + PIDs.
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
# the served-git pools). Over-cap sheds a 503. Default 32.
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
# Default 4.
GITLAWB_IPFS_WALK_PER_SOURCE=4
# Max legacy (NULL-provenance) repos probed per single /ipfs request, bounding the
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
# truncated scan sheds a retryable 503, never a false 404. Default 256.
GITLAWB_IPFS_MAX_REPOS_WALKED=256
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
# from the concurrency caps above). 0 disables. Default 600.
GITLAWB_IPFS_RATE_LIMIT=600
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
Generated
+6 -5
View File
@@ -3300,7 +3300,7 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"gitlawb-core",
@@ -3312,7 +3312,7 @@ dependencies = [
[[package]]
name = "gitlawb-attest"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3329,7 +3329,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"base64",
@@ -3356,7 +3356,7 @@ dependencies = [
[[package]]
name = "gitlawb-node"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"alloy",
"anyhow",
@@ -3412,7 +3412,7 @@ dependencies = [
[[package]]
name = "gl"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"alloy",
"anyhow",
@@ -3824,6 +3824,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tracing",
]
+8 -1
View File
@@ -343,13 +343,20 @@ Important node settings:
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. |
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
Production note: change the default Postgres password before exposing a node publicly.
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
---
## Optional node staking
+45
View File
@@ -64,6 +64,19 @@ impl Cid {
}
}
/// True when `s` parses as a CIDv1 with the raw codec — the exact shape
/// [`Cid::from_git_object_bytes`] produces and the `/ipfs` resolver looks up.
/// A legacy provider CID (Kubo dag-pb, Pinata CIDv0) parses to a different
/// version or codec and returns `false`, marking it an opportunistic-repair
/// candidate. Decidable from the string alone (no object bytes), so the pin path
/// can gate the byte-read/recompute cost on it and leave non-legacy rows at the
/// existing DB-only skip cost. An unparseable string is non-canonical (`false`).
pub fn is_raw_cidv1(s: &str) -> bool {
s.parse::<CidGeneric<64>>()
.map(|c| c.version() == cid::Version::V1 && c.codec() == RAW)
.unwrap_or(false)
}
impl fmt::Display for Cid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
@@ -177,6 +190,38 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn is_raw_cidv1_classifies_codec_from_string() {
// The canonical resolver key: CIDv1 + raw codec → not a repair candidate.
let raw = Cid::from_git_object_bytes(b"blob 5\0hello");
assert!(
is_raw_cidv1(raw.as_str()),
"from_git_object_bytes output is CIDv1/raw"
);
// A CIDv0 (Pinata dag-pb legacy shape) → repair candidate.
assert!(
!is_raw_cidv1("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"),
"a CIDv0 dag-pb value is a legacy-repair candidate"
);
// A CIDv1 with the dag-pb codec (the Kubo above-block-size root) over the
// same multihash → still a repair candidate (codec, not just version).
let parsed = raw.as_str().parse::<CidGeneric<64>>().unwrap();
const DAG_PB: u64 = 0x70;
let dagpb = CidGeneric::<64>::new_v1(DAG_PB, *parsed.hash()).to_string();
assert!(
!is_raw_cidv1(&dagpb),
"a CIDv1 dag-pb value is a legacy-repair candidate"
);
// Garbage is non-canonical.
assert!(
!is_raw_cidv1("not-a-cid"),
"an unparseable string is non-canonical"
);
}
#[test]
fn sha256_hex_of_empty_input_is_well_known() {
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
File diff suppressed because it is too large. Load diff
+172 -2
View File
@@ -61,11 +61,14 @@ pub async fn create_issue(
let json_str = serde_json::to_string(&issue)
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
// git 500 (#173 F1). This path holds no admission permit, so it reaches the pool
// unthrottled; reuse the push handler's mapping so the two cannot drift.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
let disk_path = guard.path().to_path_buf();
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
@@ -229,11 +232,13 @@ pub async fn close_issue(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Same capacity shed as create_issue above (#173 F1): an exhausted write-lock
// pool is a 503 + Retry-After, not a 500 git error.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
let disk_path = guard.path().to_path_buf();
// Owner OR issue author may close. The author lives in the issue's git-JSON
@@ -279,3 +284,168 @@ pub async fn close_issue(
Ok(Json(issue))
}
/// #173 F1 follow-up: the two issue write paths reach `acquire_write` holding NO
/// admission permit (unlike the push handler, which is capped by the git-push
/// semaphore), so they are the callers most likely to meet an exhausted write-lock
/// POOL under load. An exhausted pool is a capacity signal, so both must shed
/// 503 + Retry-After (`AppError::Overloaded`) the way the push handler does, not
/// report the generic 500 git error that says nothing about retrying.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = Utc::now();
crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// State whose repo store draws write locks from a ONE-connection pool with a
/// short checkout timeout, so a single held guard exhausts it promptly rather
/// than at the pool default.
async fn one_connection_lock_pool_state(pool: &PgPool) -> AppState {
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store = crate::git::repo_store::RepoStore::new(
std::path::PathBuf::from("/tmp/gitlawb-issues-lockpool"),
None,
crate::git::repo_store::build_lock_pool(pool, 1, std::time::Duration::from_secs(1)),
);
state
}
/// The shed must be a real 503 carrying Retry-After, not just an internal enum
/// variant: assert on the rendered response so a remapping of `Overloaded` is
/// caught here too.
fn assert_sheds_503_with_retry_after(err: AppError, what: &str) {
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"{what}: an exhausted write-lock pool must shed 503, not a 500 git error"
);
assert_eq!(
resp.headers()
.get("retry-after")
.map(|v| v.to_str().unwrap()),
Some("1"),
"{what}: a capacity shed must tell the client when to retry"
);
}
/// RED-before/GREEN-after for `create_issue`. Both directions: the shed while the
/// only lock-pool connection is held by a guard on a DIFFERENT repo (so this is
/// pool capacity, not advisory-lock contention on this repo), and the must-not
/// case once that connection is back.
#[sqlx::test]
async fn create_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zISSUECREATELOCKPOOLAAAAAAAAAAAAAAAAAAAA";
let state = one_connection_lock_pool_state(&pool).await;
state
.db
.create_repo(&seed_repo(owner, "lp-create"))
.await
.expect("seed repo");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-create".to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
assert_sheds_503_with_retry_after(err, "create_issue");
// MUST-NOT: with the pool free again the call is not shed as capacity (it
// fails later on the nonexistent on-disk repo, which is a git 500).
held.release(false).await;
let admitted = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-create".to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, create_issue must not be shed as capacity; got {:?}",
admitted.err()
);
}
/// RED-before/GREEN-after for `close_issue`, same two directions.
#[sqlx::test]
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
let state = one_connection_lock_pool_state(&pool).await;
state
.db
.create_repo(&seed_repo(owner, "lp-close"))
.await
.expect("seed repo");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((
owner.to_string(),
"lp-close".to_string(),
"deadbeef".to_string(),
)),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
assert_sheds_503_with_retry_after(err, "close_issue");
held.release(false).await;
let admitted = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((
owner.to_string(),
"lp-close".to_string(),
"deadbeef".to_string(),
)),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, close_issue must not be shed as capacity; got {:?}",
admitted.err()
);
}
}
+21 -3
View File
@@ -200,9 +200,11 @@ mod authz_guard {
(issues, "create_issue", "authorize_repo_read("),
(bounties, "create_bounty", "authorize_repo_read("),
(repos, "fork_repo", "authorize_repo_read("),
// get_by_cid gates each iterated repo row directly via visibility_check
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
(ipfs, "get_by_cid", "visibility_check("),
// get_by_cid resolves each candidate (provenance path + legacy scan) through
// the shared `gate_and_serve` (#173 round 2); the gate markers themselves are
// asserted below. This row proves the delegation is real — the gate is
// actually reached, not dead code.
(ipfs, "get_by_cid", "gate_and_serve("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
@@ -236,6 +238,22 @@ mod authz_guard {
"visibility::require_owner must use did_matches for DID-safe owner matching"
);
// The CID read surface (#173) enforces its gate inside the shared
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
// the markers must live there (the get_by_cid row above only proves delegation).
// The repo's own "/" visibility check (KTD2a — never authorize_repo_read's fuzzy
// re-resolve) and the quarantine hard-drop BEFORE visibility (INV-11) are both
// load-bearing: removing either re-opens a leak on the provenance path.
let gate_body = fn_body(ipfs, "gate_and_serve");
assert!(
gate_body.contains("visibility_check("),
"gate_and_serve must gate the CID read surface via visibility_check (KTD2a)"
);
assert!(
gate_body.contains("if quarantined"),
"gate_and_serve must hard-drop a quarantined repo before the visibility gate (INV-11)"
);
for (src, func, marker) in rows {
let body = fn_body(src, func);
assert!(
+117 -1
View File
@@ -209,11 +209,14 @@ pub async fn merge_pr(
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
}
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
// unthrottled; reuse the push handler's mapping so the two cannot drift.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
let merge_result = store::merge_branch(
@@ -424,3 +427,116 @@ pub async fn list_comments(
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
/// 500 git error that says nothing about retrying.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = Utc::now();
crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
/// capacity, not advisory-lock contention on this repo), and the must-not case
/// once that connection is back.
#[sqlx::test]
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool.clone()).await;
// One lock-pool connection with a short checkout timeout, so a single held
// guard exhausts it promptly rather than at the pool default.
state.repo_store = crate::git::repo_store::RepoStore::new(
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
None,
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
);
let repo = seed_repo(owner, "lp-merge");
let repo_id = repo.id.clone();
state.db.create_repo(&repo).await.expect("seed repo");
let now = Utc::now().to_rfc3339();
state
.db
.create_pr(&PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.clone(),
number: 1,
title: "lp".to_string(),
body: None,
author_did: owner.to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
})
.await
.expect("seed open PR");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-merge".to_string(), 1)),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"merge_pr: an exhausted write-lock pool must shed 503, not a 500 git error"
);
assert_eq!(
resp.headers()
.get("retry-after")
.map(|v| v.to_str().unwrap()),
Some("1"),
"merge_pr: a capacity shed must tell the client when to retry"
);
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
// fails later on the nonexistent on-disk repo, which is a git 500).
held.release(false).await;
let admitted = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-merge".to_string(), 1)),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, merge_pr must not be shed as capacity; got {:?}",
admitted.err()
);
}
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -516,10 +516,28 @@ mod tests {
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
shutdown_tx: tokio::sync::watch::channel(false).0,
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
encrypt_inflight: crate::state::EncryptInflight::new(),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_ipfs_walk_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_bin: "git".to_string(),
}
}
+502 -3
View File
@@ -172,9 +172,9 @@ pub struct Config {
/// its process group torn down, in seconds. Bounds a git that neither
/// finishes nor disconnects. Must be positive; set it very large to
/// effectively disable the bound. Default: 600s (10 min), generous for large
/// clones. Does not cover the ref advertisement (`info/refs`) or the
/// withheld-blob fetch path (`upload_pack_excluding`, a blocking
/// `spawn_blocking` a tokio timeout cannot cancel); both remain unbounded.
/// clones. Also bounds the ref advertisement (`info/refs`) and the withheld-blob
/// pack build (`upload_pack_excluding`'s pack-objects stage), which now share the
/// same timeout + process-group teardown (#174).
#[arg(
long,
env = "GITLAWB_GIT_SERVICE_TIMEOUT_SECS",
@@ -183,6 +183,27 @@ pub struct Config {
)]
pub git_service_timeout_secs: u64,
/// Maximum wall-clock time the storage-acquisition phase of a served git
/// operation may run before the request is shed with a 503, in seconds. This
/// bounds `RepoStore::{acquire,acquire_fresh,acquire_write}` — the Tigris
/// HEAD/GET on a read/advert acquire and the advisory-lock retry loop (incl. a
/// per-iteration `pg_try_advisory_lock` that can block on a hung Postgres pool)
/// on a write acquire. A concurrency permit is taken BEFORE this phase, and
/// `git_service_timeout_secs` only starts once git spawns, so without this the
/// acquire phase is unbounded: a stalled backend pins the permit and drains the
/// pool until every later request 503s. On expiry the permit is released and a
/// bounded 503 + Retry-After is returned (fail-closed). Kept separate from
/// `git_service_timeout_secs` because acquisition and git execution are distinct
/// cost centers — one shared budget would let a slow acquire starve git. Must be
/// positive; set it very large to effectively disable the bound. Default: 30s.
#[arg(
long,
env = "GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS",
default_value_t = 30,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub git_acquire_timeout_secs: u64,
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
/// (connections open lazily). Size against the database server's
/// max_connections, remembering admin tooling opens its own pool.
@@ -234,6 +255,198 @@ pub struct Config {
value_parser = clap::value_parser!(u64).range(1..)
)]
pub db_retry_max_secs: u64,
/// Maximum number of served git operations (upload-pack / receive-pack /
/// info-refs) allowed to run concurrently. Beyond this the node sheds the
/// request with a clean 503 + Retry-After instead of spawning another git
/// subprocess and risking PID/thread exhaustion. Portable backstop: the
/// compose `pids_limit` is not present on Fly, whose connection-concurrency
/// cap is a different axis (500 connections each fan out to git +
/// pack-objects + threads). Size below the process budget with headroom.
///
/// This is the READ pool (`git_read_semaphore`): upload-pack and the UPLOAD-PACK
/// `info/refs` advertisement only. The authenticated push POST draws from a
/// separate write pool (`max_concurrent_git_pushes`) that anonymous reads can
/// never reach, and each read caller is additionally bounded by
/// `max_concurrent_reads_per_caller`, so an anonymous flood cannot shed the actual
/// push nor monopolize reads (#174). The anon-reachable RECEIVE-PACK `info/refs`
/// advertisement draws from its OWN dedicated pool (sized like the write pool but
/// disjoint), so an advertisement flood can never occupy a permit the
/// authenticated push POST needs at admission (#174).
///
/// A permit is held for the whole op. Every git subprocess that STREAMS is
/// duration-bounded and reaps its process group on disconnect: upload-pack,
/// receive-pack, and both info/refs advertisements run under
/// `git_service_timeout_secs` with `process_group(0)` teardown, and the
/// withheld-blob (`upload_pack_excluding`) pack-objects stage plus the push-side
/// candidate-discovery children (`rev-list` / `cat-file`) now run under the same
/// bounded runner with process-group teardown, so a stuck git child no longer
/// holds its slot indefinitely (#174 closed the duration/cancellation gaps this
/// comment previously tracked).
///
/// Default: 128. Must be between 1 and 1_048_576; the ceiling keeps the value
/// well under tokio's `Semaphore` permit limit so an oversized value is a
/// clean CLI error rather than a boot-time panic.
#[arg(
long,
env = "GITLAWB_MAX_CONCURRENT_GIT_OPS",
default_value_t = 128,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub max_concurrent_git_ops: usize,
/// Maximum number of concurrent `git-receive-pack` (push) operations. The
/// authenticated push POST draws from this dedicated pool, separate from
/// `max_concurrent_git_ops` (reads), so a flood of anonymous reads cannot shed an
/// authenticated push at admission (#174). The anon-reachable receive-pack
/// `info/refs` advertisement runs in a SEPARATE pool of the same size (derived
/// from this knob), disjoint from this one, so an advertisement flood cannot
/// occupy a POST's slot either (#174). Beyond this a push sheds a clean 503 +
/// Retry-After.
///
/// Default: 32. Must be between 1 and 1_048_576 (the ceiling keeps the value
/// under tokio's `Semaphore` permit limit so an oversized value is a clean CLI
/// error rather than a boot-time panic).
///
/// CONNECTION BUDGET. A push holds a Postgres connection from the node's separate
/// advisory-lock pool for the whole receive-pack, and that pool is sized from this
/// knob (this value + 8, clamped to 64 in `main.rs`). The node's total ceiling is
/// therefore `db_max_connections` (default 20) + the lock pool (default 40), i.e.
/// 60 by default, and at most `db_max_connections` + 64. Size BOTH against the
/// database server's `max_connections`: `db_max_connections`' own doc predates the
/// lock pool and no longer covers most of the node's connections. The +8 headroom
/// is shared with the three non-push `acquire_write` callers (`api/issues.rs` x2,
/// `api/pulls.rs`). Raising this knob past the clamp does NOT buy more lock-pool
/// connections; pushes beyond it wait briefly and then shed a 503 + Retry-After.
#[arg(
long,
env = "GITLAWB_MAX_CONCURRENT_GIT_PUSHES",
default_value_t = 32,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub max_concurrent_git_pushes: usize,
/// Maximum concurrent read operations (`upload-pack` and the upload-pack
/// `info/refs` advertisement) a single caller may hold at once, so one caller
/// cannot monopolize the `max_concurrent_git_ops` read pool (#174). Callers are
/// keyed on the RESOLVED SOURCE IP, never the DID — a signature does not move a
/// caller off this cap, so an authenticated client cannot mint DIDs to escape it.
/// IMPORTANT: the source-IP key is only as granular as `GITLAWB_TRUSTED_PROXY`.
/// Left unset (the default), a node behind an edge/NAT keys all callers on the
/// edge IP, so this cap collapses to a single global cap rather than per-client.
/// Set `GITLAWB_TRUSTED_PROXY` to key on the real client; a high-fanout caller (a
/// CI fleet behind one NAT) then needs the operator to raise this. Over-cap for a
/// caller sheds a clean 503 + Retry-After.
///
/// Default: 16. Must be between 1 and 1_048_576.
#[arg(
long,
env = "GITLAWB_MAX_CONCURRENT_READS_PER_CALLER",
default_value_t = 16,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub max_concurrent_reads_per_caller: usize,
/// Maximum number of concurrent `GET /ipfs/{cid}` requests that may run their
/// visibility walk at once. The publicly-reachable `/ipfs/{cid}` route runs
/// `allowed_blob_set_for_caller_bounded` in `spawn_blocking` — a full-history
/// git walk (up to `git_service_timeout_secs`) — for each candidate repo. It
/// draws from THIS pool, not any served-git pool: a distinct public cost center
/// on a distinct surface, so sharing a git pool would let anonymous /ipfs
/// traffic shed authenticated git ops (the auth-boundary trap). A permit is
/// held for the whole request (across the repo loop) so it reflects real
/// blocking-thread occupancy, not merely the tokio wait. Beyond this the request
/// sheds a clean 503 + Retry-After. Must be between 1 and 1_048_576; the ceiling
/// keeps the value under tokio's `Semaphore` permit limit so an oversized value
/// is a clean CLI error rather than a boot-time panic. Default: 32.
#[arg(
long,
env = "GITLAWB_MAX_CONCURRENT_IPFS_WALKS",
default_value_t = 32,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub max_concurrent_ipfs_walks: usize,
/// Maximum concurrent `/ipfs/{cid}` walk requests a single source may hold at
/// once, so one source cannot monopolize `max_concurrent_ipfs_walks` (#174).
/// Callers are keyed on the RESOLVED SOURCE IP (`client_key`/`GITLAWB_TRUSTED_PROXY`),
/// never the DID — `/ipfs` accepts any `did:key` via `optional_signature` with no
/// admission step, so keying on the DID would let one host mint disposable DIDs to
/// multiply its budget. A request with no resolvable key (no trusted header, no
/// peer) is bounded by the global pool only, never this sub-cap. Over-cap sheds a
/// clean 503 + Retry-After. Must be between 1 and 1_048_576. Default: 4.
#[arg(
long,
env = "GITLAWB_IPFS_WALK_PER_SOURCE",
default_value_t = 4,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub ipfs_walk_per_source: usize,
/// Per-request ceiling on the number of legacy (NULL-provenance) repos the
/// `/ipfs/{cid}` resolver's scan fallback will PROBE (`acquire` + `git cat-file
/// -t`) before giving up. The provenance path targets one repo; the legacy scan,
/// absent this bound, fans one anonymous request out to O(repos) subprocess spawns
/// and cold-cache fetches for a CID enumerable from the public pins index (#173,
/// INV-10). A truncated scan surfaces as a retryable 503, never a false 404. Wired
/// into `AppState::ipfs_max_legacy_probes` at construction; the history-walk ceiling
/// stays constant (`MAX_HISTORY_WALKS_PER_REQUEST`) and is NOT governed by this knob
/// (a smaller value would falsely 503 a provenanced request). Must be between 1 and
/// 1_048_576. Default: 256.
#[arg(
long,
env = "GITLAWB_IPFS_MAX_REPOS_WALKED",
default_value_t = crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST as usize,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub ipfs_max_repos_walked: usize,
/// Per-client-IP rate limit for `GET /ipfs/{cid}`, in requests per hour. The
/// route is publicly reachable (`optional_signature`) and each request can drive
/// a full-history git walk, so it carries a per-IP flood brake in addition to the
/// concurrency cap above (a rate limit bounds request *rate*, the semaphore
/// bounds concurrent slow holds — different axes). Keyed on the resolved client
/// IP via `GITLAWB_TRUSTED_PROXY`. `0` disables. Default: 600.
///
/// This is the pure once-per-request ROUTE brake. The resolver's internal
/// per-probe/per-walk WORK budget is a SEPARATE bucket whose capacity is DERIVED
/// from this value (`AppState::ipfs_work_budget`), not a knob of its own; `0` here
/// disables that derived bucket too.
#[arg(long, env = "GITLAWB_IPFS_RATE_LIMIT", default_value_t = 600)]
pub ipfs_rate_limit: usize,
/// Rows the legacy provider-CID repair sweep reads per batch (U4, #173).
///
/// The sweep walks every `pinned_cids` row on the node once, repairing rows that
/// releases before this branch keyed on a PROVIDER CID (Kubo dag-pb / Pinata CIDv0)
/// instead of the raw-content resolver key. This bounds one batch, so the sweep can
/// never turn into a single unbounded table scan competing with request traffic.
/// Conservative on purpose: paired with the inter-batch delay below the default is
/// ~64 rows per minute, which finishes a large pin set in hours of idle background
/// work rather than one expensive burst. Must be between 1 and 100_000.
#[arg(
long,
env = "GITLAWB_PIN_REPAIR_SWEEP_BATCH",
default_value_t = 64,
value_parser = clap::builder::RangedU64ValueParser::<i64>::new().range(1..=100_000)
)]
pub pin_repair_sweep_batch: i64,
/// Seconds the legacy provider-CID repair sweep sleeps between batches (U4, #173).
///
/// Each batch costs an indexed range scan plus, for the legacy rows in it, a
/// `git cat-file` per row. The delay is what keeps that off the DB's and the disk's
/// critical path: the sweep is repairing rows that have been unresolvable since the
/// upgrade, so finishing slowly is fine and finishing fast at the cost of live
/// traffic is not. `0` disables the pause (test and one-off operational use only).
/// Must be between 0 and 86_400.
#[arg(
long,
env = "GITLAWB_PIN_REPAIR_SWEEP_DELAY_SECS",
default_value_t = 60,
value_parser = clap::builder::RangedU64ValueParser::<u64>::new().range(0..=86_400)
)]
pub pin_repair_sweep_delay_secs: u64,
}
impl Config {
@@ -272,4 +485,290 @@ mod tests {
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).is_err()
);
}
#[test]
fn max_concurrent_git_ops_defaults_and_rejects_out_of_range() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).max_concurrent_git_ops,
128
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-git-ops", "8"])
.max_concurrent_git_ops,
8
);
// 0 permits would shed every served-git request with a 503; clap must reject it.
assert!(Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-ops", "0"]).is_err());
// Above the ceiling would panic tokio's Semaphore::new at boot (permits >
// usize::MAX >> 3); clap must reject it as a clean CLI error instead.
assert!(
Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-ops", "1048577"])
.is_err()
);
// The ceiling itself is accepted.
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-git-ops", "1048576"])
.max_concurrent_git_ops,
1_048_576
);
}
#[test]
fn max_concurrent_git_pushes_defaults_and_rejects_out_of_range() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).max_concurrent_git_pushes,
32
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "8"])
.max_concurrent_git_pushes,
8
);
// 0 permits would shed every push with a 503; clap must reject it.
assert!(
Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "0"]).is_err()
);
// Above the ceiling would panic tokio's Semaphore::new at boot; clap rejects it.
assert!(
Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "1048577"])
.is_err()
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "1048576"])
.max_concurrent_git_pushes,
1_048_576
);
}
#[test]
fn max_concurrent_ipfs_walks_defaults_and_rejects_out_of_range() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).max_concurrent_ipfs_walks,
32
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "4"])
.max_concurrent_ipfs_walks,
4
);
// 0 permits would shed every /ipfs walk with a 503; clap must reject it.
assert!(
Config::try_parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "0"]).is_err()
);
// Above the ceiling would panic tokio's Semaphore::new at boot; clap rejects it.
assert!(
Config::try_parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "1048577"])
.is_err()
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "1048576"])
.max_concurrent_ipfs_walks,
1_048_576
);
}
/// U4 (#173): the repair sweep's bounds are conservative by default and a batch of
/// 0 (a sweep that walks nothing and never terminates) is a CLI error, not a
/// runtime hang. The delay does accept 0, for tests and one-off operational runs.
#[test]
fn pin_repair_sweep_knobs_default_conservatively() {
let c = Config::parse_from(["gitlawb-node"]);
assert_eq!(c.pin_repair_sweep_batch, 64);
assert_eq!(c.pin_repair_sweep_delay_secs, 60);
assert!(Config::try_parse_from(["gitlawb-node", "--pin-repair-sweep-batch", "0"]).is_err());
assert!(
Config::try_parse_from(["gitlawb-node", "--pin-repair-sweep-batch", "100001"]).is_err()
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--pin-repair-sweep-batch", "8"])
.pin_repair_sweep_batch,
8
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--pin-repair-sweep-delay-secs", "0"])
.pin_repair_sweep_delay_secs,
0
);
assert!(
Config::try_parse_from(["gitlawb-node", "--pin-repair-sweep-delay-secs", "86401"])
.is_err()
);
}
#[test]
fn ipfs_walk_per_source_defaults_and_rejects_out_of_range() {
assert_eq!(Config::parse_from(["gitlawb-node"]).ipfs_walk_per_source, 4);
assert_eq!(
Config::parse_from(["gitlawb-node", "--ipfs-walk-per-source", "2"])
.ipfs_walk_per_source,
2
);
// 0 would shed every /ipfs walk from a keyed source; clap must reject it.
assert!(Config::try_parse_from(["gitlawb-node", "--ipfs-walk-per-source", "0"]).is_err());
assert!(
Config::try_parse_from(["gitlawb-node", "--ipfs-walk-per-source", "1048577"]).is_err()
);
}
#[test]
fn ipfs_max_repos_walked_defaults_and_rejects_out_of_range() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).ipfs_max_repos_walked,
256
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "8"])
.ipfs_max_repos_walked,
8
);
// 0 would walk no repos (serve nothing); clap must reject it.
assert!(Config::try_parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "0"]).is_err());
assert!(
Config::try_parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "1048577"]).is_err()
);
}
/// The `GITLAWB_IPFS_MAX_REPOS_WALKED` knob must actually reach the legacy-probe
/// budget it advertises (R5, KTD5): production seeds `ipfs_max_legacy_probes` from
/// this helper, so the knob is a no-op unless the helper reflects it. RED while the
/// helper returns the hardcoded `MAX_LEGACY_PROBES_PER_REQUEST` (256 regardless of
/// the knob), GREEN once it reads the knob.
#[test]
fn ipfs_max_repos_walked_wires_the_legacy_probe_budget() {
use crate::state::AppState;
// Knob set to 1 → a one-probe legacy budget.
let one = Config::parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "1"]);
assert_eq!(
AppState::ipfs_legacy_probe_budget(&one),
1,
"the knob must control the legacy-probe budget, not be ignored"
);
// Unset knob preserves the shipped 256-probe behaviour.
let default = Config::parse_from(["gitlawb-node"]);
assert_eq!(
AppState::ipfs_legacy_probe_budget(&default),
256,
"the default knob keeps the shipped 256-probe budget"
);
assert_eq!(
AppState::ipfs_legacy_probe_budget(&default),
crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
"the default budget equals the constant it replaced"
);
// Ceiling guard: the knob never governs the history-walk ceiling, which must
// stay at MAX_PIN_SOURCES + 1 or a provenanced full source set false-503s.
assert!(
crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST > crate::db::MAX_PIN_SOURCES as u32,
"the history-walk ceiling is independent of the repos-walked knob"
);
}
/// The `/ipfs` work-budget capacity is DERIVED from the route limit (R6, KTD6), with
/// a hard floor of one full legacy search per window (the effective
/// `ipfs_max_legacy_probes`). This guards the derived default so a single
/// default-config deep search never self-throttles mid-scan and recreates the F6
/// admit-then-429 for a legitimate caller. A `RateLimiter` sized to the derived
/// budget must admit the whole probe budget back to back.
#[test]
fn ipfs_work_budget_derives_from_route_limit_and_clears_the_probe_floor() {
use crate::state::AppState;
// Default config: derived work budget = max(route 600, probe budget 256) = 600,
// comfortably above the 256-probe floor.
let default = Config::parse_from(["gitlawb-node"]);
let budget = AppState::ipfs_work_budget(&default);
assert_eq!(budget, 600, "default derives max(route 600, probe 256)");
assert!(
budget >= AppState::ipfs_legacy_probe_budget(&default) as usize,
"the work budget must clear one full legacy search per window"
);
// Tight route limit (1): the floor lifts the work budget to the probe budget
// (256), NOT down to 1 — a single deep search still completes its full scan.
let tight = Config::parse_from(["gitlawb-node", "--ipfs-rate-limit", "1"]);
assert_eq!(
AppState::ipfs_work_budget(&tight),
256,
"a tight route limit is floored at the 256-probe budget, not clamped to 1"
);
// Raised probe budget lifts the floor with it (the work budget tracks the
// effective probe budget, not the constant).
let raised = Config::parse_from([
"gitlawb-node",
"--ipfs-rate-limit",
"10",
"--ipfs-max-repos-walked",
"1000",
]);
assert_eq!(
AppState::ipfs_work_budget(&raised),
1000,
"the floor tracks the operator-raised legacy-probe budget"
);
// 0 route limit disables the derived bucket too (a 0-capacity limiter admits all).
let disabled = Config::parse_from(["gitlawb-node", "--ipfs-rate-limit", "0"]);
assert_eq!(
AppState::ipfs_work_budget(&disabled),
0,
"route limit 0 disables the derived work bucket alongside the route brake"
);
// Behavioral floor: a limiter sized to the derived (tight-route) budget admits
// the whole probe budget back to back for one source, then sheds the next.
let budget = AppState::ipfs_work_budget(&tight);
let limiter =
crate::rate_limit::RateLimiter::new(budget, std::time::Duration::from_secs(3600));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
for i in 0..budget {
assert!(
limiter.check("1.2.3.4").await,
"probe {i} of one full default-config scan must be admitted (no mid-scan throttle)"
);
}
assert!(
!limiter.check("1.2.3.4").await,
"the probe past the derived budget is shed"
);
});
}
#[test]
fn max_concurrent_reads_per_caller_defaults_and_rejects_out_of_range() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).max_concurrent_reads_per_caller,
16
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--max-concurrent-reads-per-caller", "4"])
.max_concurrent_reads_per_caller,
4
);
// 0 would shed every read from a keyed caller; clap must reject it.
assert!(
Config::try_parse_from(["gitlawb-node", "--max-concurrent-reads-per-caller", "0"])
.is_err()
);
assert!(Config::try_parse_from([
"gitlawb-node",
"--max-concurrent-reads-per-caller",
"1048577"
])
.is_err());
assert_eq!(
Config::parse_from([
"gitlawb-node",
"--max-concurrent-reads-per-caller",
"1048576"
])
.max_concurrent_reads_per_caller,
1_048_576
);
}
}
+577 -11
View File
@@ -872,8 +872,118 @@ const MIGRATIONS: &[Migration] = &[
"CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_ref ON ref_certificates(repo_id, ref_name)",
],
},
Migration {
version: 11,
name: "pinned_cids_cid_index",
stmts: &[
// GET /ipfs/{cid} resolves an incoming CID -> git oid via pinned_cids.cid
// (#173); index it so the per-request lookup is not a table scan. This is
// a NEW versioned migration (not appended to the applied v1 bundle) so a
// node already past v1 actually gets the index. Non-unique on purpose: cid
// is a function of raw content, so a UNIQUE index could reject a legitimate
// record_pinned_cid insert, and colliding rows serve byte-identical content.
"CREATE INDEX IF NOT EXISTS idx_pinned_cids_cid ON pinned_cids(cid)",
],
},
Migration {
version: 12,
name: "pinned_cids_repo_provenance",
stmts: &[
// Record the repository a pin came from so GET /ipfs/{cid} resolves a
// provenanced pin straight to its ONE source repo instead of scanning every
// repo (#173, jatmn round 2 — bounds the anonymous fan-out and removes the
// updated_at-ordering false-404). NEW versioned migration (never appended to
// the applied v1 pinned_cids table) so a node past v1 gets the column.
// Nullable: pins recorded before this migration have no provenance and fall
// back to the legacy repo scan; new pins carry repo_id and resolve to one
// repo. Indexed for the resolver's oid -> repo_id lookup.
"ALTER TABLE pinned_cids ADD COLUMN IF NOT EXISTS repo_id TEXT",
"CREATE INDEX IF NOT EXISTS idx_pinned_cids_repo_id ON pinned_cids(repo_id)",
],
},
Migration {
version: 13,
name: "pin_repo_sources",
stmts: &[
// F1 (#173, jatmn round 8): a shared object (a blob/tree/commit common to
// forks and mirrors) can be pinned from more than one repo. `pinned_cids`
// keeps only the FIRST pinner's `repo_id`, so a shared object first pinned
// from a private/quarantined repo 404s by CID even when a later PUBLIC repo
// also pinned it. Record EVERY pin-path source so `GET /ipfs/{cid}` can try
// each. NEW versioned migration (never appended to an applied block, INV-7).
// Bounded per object at insert time (MAX_PIN_SOURCES) so an adversary pushing
// one object from N repos cannot make resolution O(repos) (R2, INV-10).
"CREATE TABLE IF NOT EXISTS pin_repo_sources (
sha256_hex TEXT NOT NULL,
repo_id TEXT NOT NULL,
PRIMARY KEY (sha256_hex, repo_id)
)",
"CREATE INDEX IF NOT EXISTS idx_pin_repo_sources_sha ON pin_repo_sources(sha256_hex)",
],
},
Migration {
version: 14,
name: "pinned_cids_legacy_provider_cid",
stmts: &[
// R8 (#173, jatmn round 10): the opportunistic legacy provider-CID repair
// rewrites `pinned_cids.cid` from a stored PROVIDER CID (Kubo dag-pb /
// Pinata CIDv0) to the raw-content resolver key and stashes the OLD value
// here, so the rewrite is auditable and the row's legacy origin survives.
// Distinct from `pinata_cid` on purpose: `has_pinata_cid` gates the Pinata
// pin-skip, so parking a Kubo-legacy CID there would make Pinata forever
// skip re-pinning that object. NEW versioned migration (never appended to an
// applied block, INV-7) so a node past v13 actually gets the column.
// Nullable: only a repaired row sets it.
"ALTER TABLE pinned_cids ADD COLUMN IF NOT EXISTS legacy_provider_cid TEXT",
],
},
Migration {
version: 15,
name: "pinned_cids_sources_incomplete",
stmts: &[
// U3 (#173): `record_pin_source` is BEST EFFORT at every call site, so a
// non-empty, below-cap source set is not proof that every source was
// recorded. An object first pinned from a private repo and later pushed
// from a PUBLIC repo whose record failed keeps a set naming only the
// private source, and the resolver used to call that set complete and 404
// an object the public repo would serve. Record the miss DURABLY here so
// `GET /ipfs/{cid}` keeps the bounded scan fallback for exactly those
// objects. Not inferable from row counts or timestamps: neither can tell
// "no other source exists" from "a source failed to record", which is the
// whole distinction. NEW versioned migration (never appended to an applied
// block, INV-7). NOT NULL DEFAULT FALSE so every pre-existing row reads as
// complete and ordinary denials stay off the O(repos) path (INV-10).
"ALTER TABLE pinned_cids ADD COLUMN IF NOT EXISTS pin_sources_incomplete BOOLEAN NOT NULL DEFAULT FALSE",
],
},
Migration {
version: 16,
name: "pin_repair_sweep_cursor",
stmts: &[
// U4 (#173): the legacy provider-CID repair sweep walks `pinned_cids` in
// bounded batches over an ordered `sha256_hex` cursor. The cursor has to be
// DURABLE, or a restart rewinds the walk to the start of the table and an
// upgraded node with a large pin set never finishes repairing it. One row
// (`id = 1`, enforced by the CHECK) rather than a key-value table: there is
// exactly one sweep and no second consumer, and a real constraint beats a
// convention nobody can enforce. NEW versioned migration (never appended to
// an applied block, INV-7). No default row is inserted: an absent row is the
// "never swept" state, which the empty-string cursor start already means, so
// there is no first-run special case to get wrong.
"CREATE TABLE IF NOT EXISTS pin_repair_sweep (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
cursor TEXT NOT NULL
)",
],
},
];
/// Max distinct source repos recorded per pinned object (F1, #173 jatmn round 8).
/// Bounds both the resolver's per-OID source loop and the `pin_repo_sources` growth,
/// so an adversary re-pushing one object from many repos cannot make resolution
/// O(repos) (R2, INV-10).
pub const MAX_PIN_SOURCES: i64 = 16;
// ── Repos ─────────────────────────────────────────────────────────────────────
pub(crate) fn normalize_owner_key(did: &str) -> &str {
@@ -1046,6 +1156,22 @@ impl Db {
Ok(row.map(row_to_repo))
}
/// Fetch a repo by its stable `id`. Used by the `/ipfs/{cid}` provenance path,
/// which resolves a pin straight to its ONE source repo (#173) instead of
/// scanning `list_all_repos`. `id` is exact, so unlike `get_repo`'s fuzzy
/// owner/name match there is no mirror-vs-canonical disambiguation.
pub async fn get_repo_by_id(&self, id: &str) -> Result<Option<RepoRecord>> {
let row = sqlx::query(
"SELECT id, name, owner_did, description, is_public, default_branch,
created_at, updated_at, disk_path, forked_from, machine_id
FROM repos WHERE id = $1 LIMIT 1",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(row_to_repo))
}
#[allow(dead_code)]
pub async fn list_repos(&self, owner_did: &str) -> Result<Vec<RepoRecord>> {
let rows = sqlx::query(
@@ -2166,20 +2292,387 @@ impl Db {
Ok(row.get::<i64, _>("cnt") > 0)
}
pub async fn record_pinned_cid(&self, sha256_hex: &str, cid: &str) -> Result<()> {
/// Every git oid a pinned CID maps to (`pinned_cids.cid` -> `sha256_hex`).
/// `GET /ipfs/{cid}` resolves the content-addressed CID a client sends back to
/// the object's git oid this way: a real pin CID digests the raw object
/// content, not the git oid, so the digest cannot be `git cat-file`d directly
/// (#173). The index is unique on the git oid but NON-unique on cid, so two
/// distinct oids can share one content-CID (a tree and a blob whose raw bytes
/// collide, or byte-identical content pinned under two oids). Returning every
/// candidate lets the handler try each rather than pick one arbitrarily and
/// false-404 when the chosen one is withheld or absent while another is
/// readable (#173). Empty when the CID was never pinned on this node.
pub async fn oids_for_cid(&self, cid: &str) -> Result<Vec<String>> {
let rows = sqlx::query("SELECT sha256_hex FROM pinned_cids WHERE cid = $1")
.bind(cid)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| r.get::<String, _>("sha256_hex"))
.collect())
}
/// Record a pinned object's CID and the repository it was pinned from
/// (`repo_id`, #173). On conflict the `COALESCE` backfills a NULL provenance
/// from a known source while keeping first-pinner-owns: an existing non-NULL
/// `repo_id` is never rewritten by a later push of the same oid, but a legacy
/// pin (or a pin recorded before provenance existed) whose `repo_id` is NULL
/// gets it filled the next time the object is re-pinned with a known source.
/// `cid`/`pinned_at` are left untouched on conflict. `repo_id` is `None` only
/// for a legacy pin with no known source; those fall back to the resolver's scan.
///
/// The production first-pin path now goes through [`Self::record_pinned_cid_with_source`]
/// (U3, #173) so the pin and its source land atomically; this remains the seam for
/// seeding legacy, source-less rows in tests.
#[cfg_attr(not(test), allow(dead_code))]
pub async fn record_pinned_cid(
&self,
sha256_hex: &str,
cid: &str,
repo_id: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at)
VALUES ($1, $2, $3)
ON CONFLICT(sha256_hex) DO NOTHING",
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT(sha256_hex) DO UPDATE SET
repo_id = COALESCE(pinned_cids.repo_id, EXCLUDED.repo_id)",
)
.bind(sha256_hex)
.bind(cid)
.bind(Utc::now().to_rfc3339())
.bind(repo_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// The resolver key currently stored for a pinned object (`pinned_cids.cid`),
/// or `None` for an unpinned oid. The opportunistic legacy-repair path reads
/// it to decide candidacy from the codec of the string alone (no object bytes)
/// before it recomputes anything.
pub async fn cid_for_oid(&self, sha256_hex: &str) -> Result<Option<String>> {
let row = sqlx::query("SELECT cid FROM pinned_cids WHERE sha256_hex = $1")
.bind(sha256_hex)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get::<String, _>("cid")))
}
/// Rewrite a legacy provider-CID row to the raw-content resolver key, stashing
/// the old provider value in `legacy_provider_cid` (#173 R8, KTD8). Before this
/// branch the pin path stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in
/// `cid`; the `/ipfs` resolver recomputes the raw CID and 404s a mismatched key
/// even though `list_pinned_cids` still advertises it. The `WHERE cid =
/// $old_provider_cid` guard makes a concurrent double-repair a no-op (the second
/// writer sees the already-rewritten key and matches nothing) and never touches
/// a row keyed on a different value. Stashed in `legacy_provider_cid`, NOT
/// `pinata_cid`: the latter gates the Pinata pin-skip (`has_pinata_cid`), so a
/// Kubo-legacy CID parked there would make Pinata permanently skip the object.
pub async fn repair_legacy_provider_cid(
&self,
sha256_hex: &str,
raw_cid: &str,
old_provider_cid: &str,
) -> Result<()> {
sqlx::query(
"UPDATE pinned_cids
SET cid = $2, legacy_provider_cid = $3
WHERE sha256_hex = $1 AND cid = $3",
)
.bind(sha256_hex)
.bind(raw_cid)
.bind(old_provider_cid)
.execute(&self.pool)
.await?;
Ok(())
}
/// One ordered batch of `pinned_cids` rows strictly after `cursor`, for the U4
/// legacy provider-CID repair sweep. Returns `(sha256_hex, cid)` ordered by
/// `sha256_hex` (the table's primary key, so the walk rides the PK index) and
/// capped at `limit` rows, which is what BOUNDS the sweep: one pass can never read
/// more than a batch, however large the pin set is.
///
/// Deliberately NOT filtered to legacy rows in SQL. "Is this a raw CIDv1" is a
/// multibase+codec decode (`is_raw_cidv1`), which Postgres cannot express, and a
/// prefix-match approximation would silently mis-classify keys under a different
/// multihash. The caller applies the real predicate, so `limit` bounds rows READ
/// (the DB cost), not rows repaired.
pub async fn pinned_cids_after(
&self,
cursor: &str,
limit: i64,
) -> Result<Vec<(String, String)>> {
let rows = sqlx::query(
"SELECT sha256_hex, cid FROM pinned_cids
WHERE sha256_hex > $1
ORDER BY sha256_hex
LIMIT $2",
)
.bind(cursor)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.get::<String, _>("sha256_hex"), r.get::<String, _>("cid")))
.collect())
}
/// Where the U4 repair sweep's walk left off, or `""` before it has ever run.
/// Empty string sorts below every hex oid, so a first run and a rewound run are
/// the same code path (`sha256_hex > ''` is the whole table).
pub async fn pin_repair_cursor(&self) -> Result<String> {
let row = sqlx::query("SELECT cursor FROM pin_repair_sweep WHERE id = 1")
.fetch_optional(&self.pool)
.await?;
Ok(row
.map(|r| r.get::<String, _>("cursor"))
.unwrap_or_default())
}
/// Persist the sweep's walk position. Written after every batch, so a restart
/// resumes rather than re-walking the table from the beginning. A rewrite is a
/// plain upsert: the sweep is the single writer, and re-repairing an
/// already-repaired row is a no-op anyway (the codec cost gate spares it).
pub async fn set_pin_repair_cursor(&self, cursor: &str) -> Result<()> {
sqlx::query(
"INSERT INTO pin_repair_sweep (id, cursor) VALUES (1, $1)
ON CONFLICT (id) DO UPDATE SET cursor = EXCLUDED.cursor",
)
.bind(cursor)
.execute(&self.pool)
.await?;
Ok(())
}
/// The repository a pinned object was recorded from (`pinned_cids.repo_id`),
/// or `None` for a legacy pin (recorded before provenance existed) or an
/// unpinned oid. `GET /ipfs/{cid}` uses this to gate+serve the ONE source
/// repo instead of scanning every repo (#173).
pub async fn provenance_for_oid(&self, sha256_hex: &str) -> Result<Option<String>> {
let row = sqlx::query("SELECT repo_id FROM pinned_cids WHERE sha256_hex = $1")
.bind(sha256_hex)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| r.get::<Option<String>, _>("repo_id")))
}
/// Backfill the source repo on an already-pinned object whose provenance is
/// NULL (a legacy pin recorded before provenance existed, #173, jatmn). The
/// `AND repo_id IS NULL` guard keeps first-pinner-owns: an existing non-NULL
/// provenance is left untouched. Touches only `repo_id` and never re-pins the
/// object's bytes, so it is safe to call on the already-pinned skip path.
pub async fn backfill_pin_provenance(&self, sha256_hex: &str, repo_id: &str) -> Result<()> {
sqlx::query(
"UPDATE pinned_cids SET repo_id = $2 WHERE sha256_hex = $1 AND repo_id IS NULL",
)
.bind(sha256_hex)
.bind(repo_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Record a repository as a source for a pinned object (F1, #173 jatmn round 8),
/// bounded to about `MAX_PIN_SOURCES` distinct repos per object. The count guard
/// lives inside the INSERT (a single statement), which suppresses a re-push of the
/// SAME `(oid, repo)` via `ON CONFLICT DO NOTHING`. It does NOT hard-serialize
/// concurrent inserts of DIFFERENT repos for the same object: under Postgres READ
/// COMMITTED each concurrent writer's count subquery reads a snapshot that omits the
/// others' uncommitted rows, so N concurrent pushers can each see `count < cap` and
/// overshoot by up to N-1 rows. The overshoot is a small constant (bounded by
/// concurrent-pusher count, never O(repos)), and the RESOLVER read side
/// (`pin_sources_for_oid`) caps the ADDITIONAL sources at `MAX_PIN_SOURCES` (always
/// keeping the first-pinner), so the INV-10 bound on serve-time work holds at
/// `O(MAX_PIN_SOURCES + 1)` regardless of a table overshoot.
///
/// A record that ACTUALLY ADDS a source row also CLEARS the
/// `pin_sources_incomplete` marker for the object, in the SAME transaction as the
/// insert (U3, #173), so the clear cannot drift across the four call sites or land
/// without the row it describes.
///
/// The clear is gated on `rows_affected() > 0` because the INSERT is a no-op in two
/// ordinary cases: the `(oid, repo)` pair already exists (`ON CONFLICT DO NOTHING`)
/// and the source set is at cap (the count guard). The skip path calls this for
/// EVERY already-pinned object, and on a requeue pass that list is the whole-repo
/// enumeration, so an unconditional clear meant the next coalesced push from a repo
/// already in the set wiped the marker for every object in the repo without
/// recording anything (round 11 regression). The residual, which the gate does not
/// close: the marker is per-object, not per-(object, repo), so a GENUINE record from
/// a third repo C still clears a marker that repo A's failed record set. That is the
/// deliberate cost of a single boolean; closing it needs a per-(oid, repo) marker
/// table, and it fails in the safe direction (the marker only ever ADDS the scan
/// fallback, never removes a source the resolver already tries).
pub async fn record_pin_source(&self, sha256_hex: &str, repo_id: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
let inserted = sqlx::query(
"INSERT INTO pin_repo_sources (sha256_hex, repo_id)
SELECT $1, $2
WHERE (SELECT count(*) FROM pin_repo_sources WHERE sha256_hex = $1) < $3
ON CONFLICT DO NOTHING",
)
.bind(sha256_hex)
.bind(repo_id)
.bind(MAX_PIN_SOURCES)
.execute(&mut *tx)
.await?
.rows_affected();
if inserted > 0 {
sqlx::query(
"UPDATE pinned_cids SET pin_sources_incomplete = FALSE
WHERE sha256_hex = $1 AND pin_sources_incomplete",
)
.bind(sha256_hex)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// Record a first pin and its source ATOMICALLY (U3, #173). The first-pin path
/// used to run `record_pinned_cid` and `record_pin_source` as two independent
/// best-effort calls, so the pin could land while its source did not, leaving a
/// source set that is silently missing its own first pinner. One transaction
/// removes that window entirely: either both rows land or neither does, and a
/// total failure leaves the object unpinned so the next push retries it.
///
/// The marker clear carries the same `rows_affected` gate as `record_pin_source`.
/// It is not load-bearing here: this path runs only when `is_pinned` said no row
/// exists, and `mark_pin_sources_incomplete` is a no-op without a `pinned_cids` row,
/// so there is no marker to wrongly clear. The gate is kept for the one window that
/// is not covered by that argument, a concurrent pinner landing the row between the
/// `is_pinned` check and this upsert, and so the two clears cannot drift apart.
pub async fn record_pinned_cid_with_source(
&self,
sha256_hex: &str,
cid: &str,
repo_id: &str,
) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT(sha256_hex) DO UPDATE SET
repo_id = COALESCE(pinned_cids.repo_id, EXCLUDED.repo_id)",
)
.bind(sha256_hex)
.bind(cid)
.bind(Utc::now().to_rfc3339())
.bind(repo_id)
.execute(&mut *tx)
.await?;
let inserted = sqlx::query(
"INSERT INTO pin_repo_sources (sha256_hex, repo_id)
SELECT $1, $2
WHERE (SELECT count(*) FROM pin_repo_sources WHERE sha256_hex = $1) < $3
ON CONFLICT DO NOTHING",
)
.bind(sha256_hex)
.bind(repo_id)
.bind(MAX_PIN_SOURCES)
.execute(&mut *tx)
.await?
.rows_affected();
if inserted > 0 {
sqlx::query(
"UPDATE pinned_cids SET pin_sources_incomplete = FALSE
WHERE sha256_hex = $1 AND pin_sources_incomplete",
)
.bind(sha256_hex)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// Mark this object's pin-source set as KNOWN INCOMPLETE (U3, #173). Called when a
/// `record_pin_source` exhausts its retries, which is the only moment the node
/// knows a source it meant to record is missing. `GET /ipfs/{cid}` reads it to keep
/// the bounded scan fallback for that object, so a public copy that would serve is
/// no longer 404'd. A no-op when no `pinned_cids` row exists (the first-pin path is
/// transactional, so there is no half-recorded pin to describe).
pub async fn mark_pin_sources_incomplete(&self, sha256_hex: &str) -> Result<()> {
sqlx::query("UPDATE pinned_cids SET pin_sources_incomplete = TRUE WHERE sha256_hex = $1")
.bind(sha256_hex)
.execute(&self.pool)
.await?;
Ok(())
}
/// Whether this object's pin-source set is KNOWN INCOMPLETE (U3, #173): a
/// `record_pin_source` for it failed outright and no later record has repaired the
/// set. `false` for an unpinned oid and for every row predating the column, so the
/// common path is unchanged and an ordinary denial never fans out (INV-10).
pub async fn pin_sources_incomplete(&self, sha256_hex: &str) -> Result<bool> {
let flag: Option<bool> = sqlx::query_scalar(
"SELECT pin_sources_incomplete FROM pinned_cids WHERE sha256_hex = $1",
)
.bind(sha256_hex)
.fetch_optional(&self.pool)
.await?;
Ok(flag.unwrap_or(false))
}
/// Every source repository recorded for a pinned object (F1, #173 jatmn round 8):
/// the union of the first-pinner `pinned_cids.repo_id` and the `pin_repo_sources`
/// rows, deduped and ordered for a deterministic resolver walk.
///
/// The first-pinner (a single row by `pinned_cids`' PK on `sha256_hex`) is ALWAYS
/// included; the `LIMIT MAX_PIN_SOURCES` caps only the ADDITIONAL `pin_repo_sources`
/// rows. This keeps the resolver's per-source work a bounded `O(MAX_PIN_SOURCES + 1)`
/// ceiling (INV-10) while never letting the cap evict the original source. A prior
/// version applied the `LIMIT` to the whole UNION with a lexicographic `ORDER BY`,
/// which let an attacker 404 a legacy public CID (first-pinner in `pinned_cids` but
/// not yet in `pin_repo_sources`) by pushing the same object from `MAX_PIN_SOURCES`
/// repos whose grindable ids sort before it, evicting the public source from the
/// window. Empty for a legacy pin with no known source (it falls back to the repo
/// scan) or an unpinned oid.
pub async fn pin_sources_for_oid(&self, sha256_hex: &str) -> Result<Vec<String>> {
let rows = sqlx::query(
"SELECT repo_id FROM pinned_cids
WHERE sha256_hex = $1 AND repo_id IS NOT NULL
UNION
SELECT repo_id FROM (
SELECT repo_id FROM pin_repo_sources
WHERE sha256_hex = $1
ORDER BY repo_id
LIMIT $2
) capped
ORDER BY repo_id",
)
.bind(sha256_hex)
.bind(MAX_PIN_SOURCES)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| r.get::<String, _>("repo_id"))
.collect())
}
/// Whether `pin_repo_sources` is at the `MAX_PIN_SOURCES` cap for this oid, i.e.
/// the provenance source set returned by [`Self::pin_sources_for_oid`] may be
/// INCOMPLETE. `record_pin_source` stops inserting at exactly `MAX_PIN_SOURCES`
/// rows and drops later sources silently, so a full table is the only observable
/// signal that a servable source (e.g. a later public pinner) may have been
/// dropped. `get_by_cid` uses this to decide whether a provenance miss should fall
/// back to the bounded legacy scan (which gates every repo through the real
/// visibility gate and so finds a dropped public source) rather than 404 — closing
/// the pin-source griefing hole where 16 attacker sources bury a public one. `>=`
/// (not `==`) is defensive against any future overshoot.
pub async fn pin_sources_at_cap(&self, sha256_hex: &str) -> Result<bool> {
let count: i64 =
sqlx::query_scalar("SELECT count(*) FROM pin_repo_sources WHERE sha256_hex = $1")
.bind(sha256_hex)
.fetch_one(&self.pool)
.await?;
Ok(count >= MAX_PIN_SOURCES)
}
pub async fn record_encrypted_blob(
&self,
repo_id: &str,
@@ -2250,6 +2743,17 @@ impl Db {
Ok(row.map(|r| r.get("recipients_tag")))
}
/// Every pinned object this node ADVERTISES (`GET /api/v1/ipfs/pins`).
///
/// U4 (#173): rows still keyed on a legacy PROVIDER CID (Kubo dag-pb / Pinata
/// CIDv0, written by releases before this branch) are withheld from the listing.
/// The `/ipfs/{cid}` resolver recomputes the raw-content CID from the object bytes
/// and refuses any row whose stored key does not match, so advertising the legacy
/// key hands a client a CID this node deliberately will not serve. The background
/// repair sweep rewrites those rows to the raw key, and each one reappears here the
/// moment it is repaired. Filtering is done in Rust because the raw-CIDv1 test is a
/// multibase+codec decode (`is_raw_cidv1`), not something SQL can express; it is the
/// SAME predicate the repair path uses as its cost gate, so the two cannot drift.
pub async fn list_pinned_cids(&self) -> Result<Vec<PinnedCidRecord>> {
let rows = sqlx::query(
"SELECT sha256_hex, cid, pinned_at, pinata_cid FROM pinned_cids ORDER BY pinned_at DESC",
@@ -2258,6 +2762,7 @@ impl Db {
.await?;
Ok(rows
.into_iter()
.filter(|r| gitlawb_core::cid::is_raw_cidv1(r.get::<&str, _>("cid")))
.map(|r| PinnedCidRecord {
sha256_hex: r.get("sha256_hex"),
cid: r.get("cid"),
@@ -2279,18 +2784,34 @@ impl Db {
}
/// Record the Pinata CID for a git object.
/// Inserts the row if it doesn't exist (objects pinned directly to Pinata
/// without a prior local IPFS pin get cid = pinata_cid).
pub async fn record_pinata_cid(&self, sha256_hex: &str, pinata_cid: &str) -> Result<()> {
///
/// `raw_cid` is the locally-computed raw-content CID (`Cid::from_git_object_bytes`,
/// CIDv1/raw/sha2-256), the resolver key `GET /ipfs/{cid}` looks up; `pinata_cid`
/// is the provider CID Pinata returned (a dag-pb/UnixFS CID for gateway retrieval).
/// Inserts the row if it doesn't exist (an object pinned directly to Pinata with
/// no prior local IPFS pin gets `cid = raw_cid`, never the provider CID — a dag-pb
/// provider CID must never become an alias that serves raw bytes that do not hash
/// to it, #173). On conflict `cid` is left untouched: a prior local pin already
/// stored the correct raw CID, and the COALESCE backfills a NULL provenance from a
/// known source while keeping first-pinner-owns.
pub async fn record_pinata_cid(
&self,
sha256_hex: &str,
raw_cid: &str,
pinata_cid: &str,
repo_id: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid)
VALUES ($1, $2, $3, $4)
ON CONFLICT(sha256_hex) DO UPDATE SET pinata_cid = EXCLUDED.pinata_cid",
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid, repo_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(sha256_hex) DO UPDATE SET pinata_cid = EXCLUDED.pinata_cid,
repo_id = COALESCE(pinned_cids.repo_id, EXCLUDED.repo_id)",
)
.bind(sha256_hex)
.bind(pinata_cid) // fallback local cid if row is new
.bind(raw_cid) // resolver-key cid: locally-computed raw CID, never the provider CID
.bind(Utc::now().to_rfc3339())
.bind(pinata_cid)
.bind(repo_id)
.execute(&self.pool)
.await?;
Ok(())
@@ -5037,6 +5558,51 @@ mod ref_certificate_tests {
);
}
/// INV-7: upgrade-path test — an existing node already past v1 must still get
/// the `pinned_cids.cid` index. It ships as its OWN v11 migration (not appended
/// to the applied v1 bundle), so dropping the index + its `schema_migrations`
/// row and re-running migrations must recreate it, exercising the real code
/// path rather than hand-copying the SQL.
#[sqlx::test]
async fn v11_pinned_cids_cid_index_applies_on_upgrade(pool: PgPool) {
async fn index_exists(pool: &PgPool) -> bool {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM pg_indexes WHERE indexname = 'idx_pinned_cids_cid')",
)
.fetch_one(pool)
.await
.unwrap()
}
let db = Db::for_testing(pool.clone());
db.run_migrations().await.unwrap();
assert!(
index_exists(&pool).await,
"fresh migration chain creates the index"
);
// Simulate a node at v10 (pre-v11): drop the index and its migration record.
sqlx::query("DROP INDEX IF EXISTS idx_pinned_cids_cid")
.execute(&pool)
.await
.unwrap();
sqlx::query("DELETE FROM schema_migrations WHERE version = 11")
.execute(&pool)
.await
.unwrap();
assert!(
!index_exists(&pool).await,
"precondition: index and its migration record removed"
);
// Re-run migrations: v11 re-applies and recreates the index on the upgrade.
db.run_migrations().await.unwrap();
assert!(
index_exists(&pool).await,
"v11 must recreate idx_pinned_cids_cid on an upgrading node"
);
}
/// INV-7: upgrade-path test — seed a database at v9 with duplicate
/// ref_certificates, then let the real v10 migration fire via
/// run_migrations(). This exercises the migration code path rather than
+46 -1
View File
@@ -41,12 +41,18 @@ pub enum AppError {
#[error("incomplete: {0}")]
Incomplete(String),
#[error("search incomplete: {0}")]
SearchIncomplete(String),
#[error("git error: {0}")]
Git(String),
#[error("git service timed out: {0}")]
Timeout(String),
#[error("server overloaded: {0}")]
Overloaded(String),
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
@@ -138,6 +144,15 @@ impl IntoResponse for AppError {
AppError::Incomplete(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
}
// A bounded search that could not complete (the CID resolver hit its
// legacy-probe or walk ceiling), distinct from the 404 that asserts a
// definitive not-found: absence was NOT proven, so the caller should
// retry rather than treat it as gone (#173, F2). 503, retryable.
AppError::SearchIncomplete(msg) => (
StatusCode::SERVICE_UNAVAILABLE,
"search_incomplete",
msg.clone(),
),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
// 504, distinct from the 500 git_error and from the read-gate's 404 /
// the auth 401, so the client can tell a deadline from a failure.
@@ -147,6 +162,12 @@ impl IntoResponse for AppError {
DB_UNAVAILABLE_CODE,
DB_UNAVAILABLE_MESSAGE.into(),
),
// 503 with a Retry-After (attached after this match — the shared tail
// can't carry per-variant headers). This is the single place Overloaded
// becomes a response, so it can never ship a 503 without the retry hint.
AppError::Overloaded(msg) => {
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", msg.clone())
}
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
AppError::Internal(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -160,7 +181,21 @@ impl IntoResponse for AppError {
"message": message,
}));
(status, body).into_response()
let mut resp = (status, body).into_response();
// Both retryable 503s advertise when to retry: Overloaded (capacity shed) and
// SearchIncomplete (a bounded CID search cut short by a cap — retry may complete
// it). They ride the shared tail above for body/status, so the header is attached
// here rather than in bespoke early returns, keeping each variant handled once.
if matches!(
self,
AppError::Overloaded(_) | AppError::SearchIncomplete(_)
) {
resp.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static("1"),
);
}
resp
}
}
@@ -182,4 +217,14 @@ mod tests {
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn overloaded_maps_to_503_with_retry_after() {
let resp = AppError::Overloaded("x".into()).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
}
+238 -96
View File
@@ -29,9 +29,9 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use anyhow::Result;
/// Env var that forces the push path to always full-scan, bypassing the delta
/// optimization (KTD7 kill-switch). Reuses the already-tested fallback branch
@@ -64,11 +64,21 @@ pub enum PinCandidates {
/// Fail-closed: any condition where the introduced set cannot be safely
/// determined returns [`PinCandidates::FullScanRequired`] rather than a partial
/// set, so the caller full-scans instead of silently under-pinning.
pub fn resolve_push_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> PinCandidates {
// KTD7 kill-switch: force the (already-tested) full-scan fallback. The env
// read is split out from the pure logic so the resolver stays unit-testable
// without touching process-global state.
resolve_push_delta_inner(repo_path, new_tips, old_tips, force_full_scan())
pub fn resolve_push_delta(
repo_path: &Path,
new_tips: &[&str],
old_tips: &[&str],
git_bin: &str,
deadline: Instant,
) -> PinCandidates {
resolve_push_delta_inner(
repo_path,
new_tips,
old_tips,
force_full_scan(),
git_bin,
deadline,
)
}
/// Whether the force-full-scan kill-switch env var is set.
@@ -83,6 +93,8 @@ fn resolve_push_delta_inner(
new_tips: &[&str],
old_tips: &[&str],
force_full_scan: bool,
git_bin: &str,
deadline: Instant,
) -> PinCandidates {
if force_full_scan {
tracing::debug!("{FORCE_FULL_SCAN_ENV} set — forcing full scan");
@@ -102,7 +114,7 @@ fn resolve_push_delta_inner(
// commit). Bare `cat-file -t` returns `tag` for an annotated tag, and
// `for-each-ref %(*objecttype)` peels only one level — neither is correct.
for tip in new_tips {
match peeled_object_type(repo_path, tip) {
match peeled_object_type(repo_path, tip, git_bin, deadline) {
Some(t) if t == "commit" => {}
other => {
tracing::debug!(
@@ -115,7 +127,7 @@ fn resolve_push_delta_inner(
}
}
match rev_list_delta(repo_path, new_tips, old_tips) {
match rev_list_delta(repo_path, new_tips, old_tips, git_bin, deadline) {
Ok(oids) => PinCandidates::Delta(oids),
Err(e) => {
tracing::debug!(err = %e, "push-delta rev-list failed — forcing full scan");
@@ -126,41 +138,43 @@ fn resolve_push_delta_inner(
/// Return the fully-peeled object type of `sha` (e.g. `commit`, `tree`,
/// `blob`), or `None` if the object is missing/unpeelable or git errored.
fn peeled_object_type(repo_path: &Path, sha: &str) -> Option<String> {
let output = Command::new("git")
.args(["cat-file", "-t", &format!("{sha}^{{}}")])
.current_dir(repo_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
fn peeled_object_type(
repo_path: &Path,
sha: &str,
git_bin: &str,
deadline: Instant,
) -> Option<String> {
let peel = format!("{sha}^{{}}");
let out = crate::git::visibility_pack::run_bounded_git(
git_bin,
&["cat-file", "-t", &peel],
repo_path,
b"",
deadline,
)
.ok()?;
Some(String::from_utf8_lossy(&out).trim().to_string())
}
/// Run `git rev-list --objects --no-object-names <new> --not <old>` and return
/// the bare OID set. Decides on `status.success()` *before* parsing stdout, so
/// a walk that prints a valid prefix then errors mid-walk is discarded.
fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Result<Vec<String>> {
fn rev_list_delta(
repo_path: &Path,
new_tips: &[&str],
old_tips: &[&str],
git_bin: &str,
deadline: Instant,
) -> Result<Vec<String>> {
let mut args: Vec<&str> = vec!["rev-list", "--objects", "--no-object-names"];
args.extend_from_slice(new_tips);
if !old_tips.is_empty() {
args.push("--not");
args.extend_from_slice(old_tips);
}
let output = Command::new("git")
.args(&args)
.current_dir(repo_path)
.output()
.context("failed to run git rev-list for push delta")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git rev-list failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let out =
crate::git::visibility_pack::run_bounded_git(git_bin, &args, repo_path, b"", deadline)?;
let stdout = String::from_utf8_lossy(&out);
Ok(stdout
.lines()
.map(|l| l.trim().to_string())
@@ -175,23 +189,19 @@ fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Res
/// reconciliation sweep relies on. It returns *all* objects (including
/// unreachable/dangling ones), which is what the sweep needs to catch
/// stragglers — do not swap it for a reachability walk.
pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
let output = Command::new("git")
.args([
pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<Vec<String>> {
let out = crate::git::visibility_pack::run_bounded_git(
git_bin,
&[
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname)",
])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git cat-file failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
],
repo_path,
b"",
deadline,
)?;
let stdout = String::from_utf8_lossy(&out);
Ok(stdout
.lines()
.map(|l| l.trim().to_string())
@@ -203,23 +213,23 @@ pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
/// `--batch-check='%(objectname) %(objecttype)'`. The pin path's fail-closed
/// filter needs to tell blobs (content, withholdable) from commits/trees
/// (structural, never withheld) without typing the candidate list itself.
pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, String)>> {
let output = Command::new("git")
.args([
pub fn list_all_objects_with_type(
repo_path: &Path,
git_bin: &str,
deadline: Instant,
) -> Result<Vec<(String, String)>> {
let out = crate::git::visibility_pack::run_bounded_git(
git_bin,
&[
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname) %(objecttype)",
])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git cat-file failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
],
repo_path,
b"",
deadline,
)?;
let stdout = String::from_utf8_lossy(&out);
Ok(stdout
.lines()
.filter_map(|l| {
@@ -235,8 +245,12 @@ pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, Strin
/// fail-closed pin filter drops any candidate blob absent from the reachable,
/// visibility-allowed set; a dangling private blob is in this set but not the
/// allowed set, so it never replicates (#99).
pub fn all_blob_oids(repo_path: &Path) -> Result<HashSet<String>> {
Ok(list_all_objects_with_type(repo_path)?
pub fn all_blob_oids(
repo_path: &Path,
git_bin: &str,
deadline: Instant,
) -> Result<HashSet<String>> {
Ok(list_all_objects_with_type(repo_path, git_bin, deadline)?
.into_iter()
.filter(|(_, ty)| ty == "blob")
.map(|(oid, _)| oid)
@@ -272,18 +286,22 @@ pub async fn resolve_candidates_for_push(
repo_path: PathBuf,
new_tips: Vec<String>,
old_tips: Vec<String>,
git_bin: String,
timeout: Duration,
) -> PinCandidateSet {
tokio::task::spawn_blocking(move || {
// ONE shared deadline for the whole scan, per jatmn ("the same deadline").
let deadline = Instant::now() + timeout;
let new_refs: Vec<&str> = new_tips.iter().map(String::as_str).collect();
let old_refs: Vec<&str> = old_tips.iter().map(String::as_str).collect();
match resolve_push_delta(&repo_path, &new_refs, &old_refs) {
match resolve_push_delta(&repo_path, &new_refs, &old_refs, &git_bin, deadline) {
PinCandidates::Delta(objs) => {
tracing::info!(delta = objs.len(), repo = %repo_path.display(), "pin candidate set from push delta");
PinCandidateSet { candidates: objs, full_scan: false }
}
PinCandidates::FullScanRequired => {
tracing::warn!(repo = %repo_path.display(), "pin delta unavailable (non-commit tip, git error, or force-full-scan) — full-scan fallback");
match list_all_objects(&repo_path) {
match list_all_objects(&repo_path, &git_bin, deadline) {
Ok(objs) => PinCandidateSet { candidates: objs, full_scan: true },
Err(e) => {
tracing::warn!(repo = %repo_path.display(), err = %e, "full-scan fallback failed; pinning nothing this push (reconciliation sweep backstops)");
@@ -304,8 +322,13 @@ pub async fn resolve_candidates_for_push(
mod tests {
use super::*;
use std::collections::HashSet;
use std::process::Command;
use tempfile::TempDir;
fn td() -> std::time::Instant {
std::time::Instant::now() + std::time::Duration::from_secs(600)
}
/// Minimal git helper for building test repos.
struct Repo {
_td: TempDir,
@@ -363,9 +386,10 @@ mod tests {
let repo = Repo::new();
let c1 = repo.commit_file("a.txt", "one\n");
let c2 = repo.commit_file("b.txt", "two\n");
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c2], &[&c1]))
.into_iter()
.collect();
let got: HashSet<String> =
delta(resolve_push_delta(&repo.path, &[&c2], &[&c1], "git", td()))
.into_iter()
.collect();
// The new blob b.txt and commit c2 are in the delta; the old blob a.txt
// and commit c1 are not.
let new_blob = repo.rev("HEAD:b.txt");
@@ -383,7 +407,7 @@ mod tests {
// genuinely new objects, never fewer.
let repo = Repo::new();
let c1 = repo.commit_file("a.txt", "one\n");
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[]))
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[], "git", td()))
.into_iter()
.collect();
assert!(got.contains(&c1));
@@ -399,9 +423,15 @@ mod tests {
// Rewrite history: reset to base, commit a different file.
repo.git(&["reset", "-q", "--hard", &base]);
let new_tip = repo.commit_file("c.txt", "three\n");
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&new_tip], &[&old_tip]))
.into_iter()
.collect();
let got: HashSet<String> = delta(resolve_push_delta(
&repo.path,
&[&new_tip],
&[&old_tip],
"git",
td(),
))
.into_iter()
.collect();
assert!(got.contains(&new_tip), "new tip in delta");
assert!(got.contains(&repo.rev(&format!("{new_tip}:c.txt"))));
// No error; force-push computes new-minus-old cleanly.
@@ -413,7 +443,7 @@ mod tests {
repo.commit_file("a.txt", "one\n");
// All updates were deletions => new_tips empty after the caller strips zeros.
assert_eq!(
resolve_push_delta(&repo.path, &[], &[ZERO]),
resolve_push_delta(&repo.path, &[], &[ZERO], "git", td()),
PinCandidates::Delta(Vec::new())
);
}
@@ -424,7 +454,7 @@ mod tests {
repo.commit_file("a.txt", "one\n");
let blob = repo.rev("HEAD:a.txt");
assert_eq!(
resolve_push_delta(&repo.path, &[&blob], &[]),
resolve_push_delta(&repo.path, &[&blob], &[], "git", td()),
PinCandidates::FullScanRequired,
"a blob tip must force full scan (rev-list would exit 0 and walk it)"
);
@@ -436,7 +466,7 @@ mod tests {
repo.commit_file("a.txt", "one\n");
let tree = repo.rev("HEAD^{tree}");
assert_eq!(
resolve_push_delta(&repo.path, &[&tree], &[]),
resolve_push_delta(&repo.path, &[&tree], &[], "git", td()),
PinCandidates::FullScanRequired
);
}
@@ -449,7 +479,7 @@ mod tests {
repo.git(&["tag", "-a", "treetag", "-m", "x", &tree]);
let tag = repo.rev("treetag");
assert_eq!(
resolve_push_delta(&repo.path, &[&tag], &[]),
resolve_push_delta(&repo.path, &[&tag], &[], "git", td()),
PinCandidates::FullScanRequired,
"annotated tag peeling to a tree must force full scan"
);
@@ -465,7 +495,7 @@ mod tests {
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
let t2 = repo.rev("t2");
assert_eq!(
resolve_push_delta(&repo.path, &[&t2], &[]),
resolve_push_delta(&repo.path, &[&t2], &[], "git", td()),
PinCandidates::FullScanRequired
);
}
@@ -480,7 +510,7 @@ mod tests {
let c1 = repo.commit_file("a.txt", "one\n");
repo.git(&["tag", "-a", "rel", "-m", "release", &c1]);
let tag = repo.rev("rel");
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[]))
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[], "git", td()))
.into_iter()
.collect();
assert!(
@@ -501,7 +531,7 @@ mod tests {
let t1 = repo.rev("t1");
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
let t2 = repo.rev("t2");
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[]))
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[], "git", td()))
.into_iter()
.collect();
assert!(
@@ -516,9 +546,14 @@ mod tests {
let repo = Repo::new();
let c1 = repo.commit_file("a.txt", "one\n");
let c2 = repo.commit_file("b.txt", "two\n");
let set =
resolve_candidates_for_push(repo.path.clone(), vec![c2.clone()], vec![c1.clone()])
.await;
let set = resolve_candidates_for_push(
repo.path.clone(),
vec![c2.clone()],
vec![c1.clone()],
"git".to_string(),
std::time::Duration::from_secs(600),
)
.await;
assert!(!set.full_scan, "happy-path delta is not a full scan");
let got: HashSet<String> = set.candidates.into_iter().collect();
let new_blob = repo.rev("HEAD:b.txt");
@@ -536,8 +571,18 @@ mod tests {
let repo = Repo::new();
repo.commit_file("a.txt", "one\n");
let blob = repo.rev("HEAD:a.txt");
let all: HashSet<String> = list_all_objects(&repo.path).unwrap().into_iter().collect();
let set = resolve_candidates_for_push(repo.path.clone(), vec![blob], vec![]).await;
let all: HashSet<String> = list_all_objects(&repo.path, "git", td())
.unwrap()
.into_iter()
.collect();
let set = resolve_candidates_for_push(
repo.path.clone(),
vec![blob],
vec![],
"git".to_string(),
std::time::Duration::from_secs(600),
)
.await;
assert!(set.full_scan, "non-commit tip is signalled as a full scan");
let got: HashSet<String> = set.candidates.into_iter().collect();
assert_eq!(got, all, "non-commit tip falls back to full repo scan");
@@ -549,7 +594,7 @@ mod tests {
repo.commit_file("a.txt", "one\n");
let bogus = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
assert_eq!(
resolve_push_delta(&repo.path, &[bogus], &[]),
resolve_push_delta(&repo.path, &[bogus], &[], "git", td()),
PinCandidates::FullScanRequired,
"a missing/corrupt tip OID must force full scan"
);
@@ -564,7 +609,7 @@ mod tests {
let c1 = repo.commit_file("a.txt", "one\n");
let c2 = repo.commit_file("b.txt", "two\n");
let old_tree = repo.rev(&format!("{c1}^{{tree}}"));
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree]);
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree], "git", td());
match result {
PinCandidates::FullScanRequired => {} // safe: caller full-scans
PinCandidates::Delta(objs) => {
@@ -583,10 +628,15 @@ mod tests {
// branch2 from base advances independently
repo.git(&["checkout", "-q", "-b", "branch2", &base]);
let b2 = repo.commit_file("c.txt", "three\n");
let got: HashSet<String> =
delta(resolve_push_delta(&repo.path, &[&b1, &b2], &[&base, &base]))
.into_iter()
.collect();
let got: HashSet<String> = delta(resolve_push_delta(
&repo.path,
&[&b1, &b2],
&[&base, &base],
"git",
td(),
))
.into_iter()
.collect();
assert!(got.contains(&b1), "branch1 new commit");
assert!(got.contains(&b2), "branch2 new commit");
}
@@ -595,7 +645,7 @@ mod tests {
fn empty_repo_no_tips_yields_empty_delta() {
let repo = Repo::new();
assert_eq!(
resolve_push_delta(&repo.path, &[], &[]),
resolve_push_delta(&repo.path, &[], &[], "git", td()),
PinCandidates::Delta(Vec::new())
);
}
@@ -609,12 +659,12 @@ mod tests {
let repo = Repo::new();
let c1 = repo.commit_file("a.txt", "one\n");
assert_eq!(
resolve_push_delta_inner(&repo.path, &[&c1], &[], true),
resolve_push_delta_inner(&repo.path, &[&c1], &[], true, "git", td()),
PinCandidates::FullScanRequired
);
// And with the flag off, the same push yields a Delta.
assert!(matches!(
resolve_push_delta_inner(&repo.path, &[&c1], &[], false),
resolve_push_delta_inner(&repo.path, &[&c1], &[], false, "git", td()),
PinCandidates::Delta(_)
));
}
@@ -624,7 +674,7 @@ mod tests {
let repo = Repo::new();
repo.commit_file("a.txt", "one\n");
repo.commit_file("b.txt", "two\n");
let all = list_all_objects(&repo.path).unwrap();
let all = list_all_objects(&repo.path, "git", td()).unwrap();
// 2 commits + 2 trees + 2 blobs = 6 objects.
assert_eq!(all.len(), 6, "got: {all:?}");
}
@@ -642,7 +692,7 @@ mod tests {
std::fs::write(repo.path.join("orphan.bin"), b"dangling\n").unwrap();
let dangling = repo.git(&["hash-object", "-w", "orphan.bin"]);
let blobs = all_blob_oids(&repo.path).unwrap();
let blobs = all_blob_oids(&repo.path, "git", td()).unwrap();
assert!(blobs.contains(&reachable_blob), "reachable blob present");
assert!(
blobs.contains(&dangling),
@@ -652,7 +702,7 @@ mod tests {
assert!(!blobs.contains(&tree), "tree is not a blob");
// The typed lister tags each object; spot-check the dangling blob's type.
let typed = list_all_objects_with_type(&repo.path).unwrap();
let typed = list_all_objects_with_type(&repo.path, "git", td()).unwrap();
assert!(
typed
.iter()
@@ -660,4 +710,96 @@ mod tests {
"dangling object is typed as a blob"
);
}
#[cfg(unix)]
#[test]
fn list_all_objects_times_out_on_a_hung_git_instead_of_blocking() {
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
let dir = tempfile::TempDir::new().unwrap();
// Fake git: hang on cat-file (bounded to 30s so a broken test can't wedge).
let fake = dir.path().join("fakegit");
std::fs::write(
&fake,
"#!/bin/sh\ncase \"$1\" in\n cat-file) i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n *) : ;;\nesac\n",
)
.unwrap();
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&fake, perm).unwrap();
let git_bin = fake.to_str().unwrap().to_string();
let (tx, rx) = std::sync::mpsc::channel();
let path = dir.path().to_path_buf();
std::thread::spawn(move || {
let _ = tx.send(list_all_objects(
&path,
&git_bin,
Instant::now() + Duration::from_millis(150),
));
});
let res = rx.recv_timeout(Duration::from_secs(10)).expect(
"list_all_objects must return within the watchdog budget, not block on a hung git",
);
assert!(
res.is_err(),
"a hung git must make list_all_objects error out, not hang"
);
}
/// #174 finding 2: the DELTA-path children — `peeled_object_type` (cat-file),
/// `rev_list_delta` (rev-list) — and `list_all_objects_with_type` (cat-file) are
/// the remaining candidate-discovery execs (the common push path). Each must
/// return within the watchdog budget on a hung git rather than block and pin the
/// write permit. Revert any one to a bare `Command::output()` and its arm blocks
/// past the recv budget (RED).
#[cfg(unix)]
#[test]
fn delta_path_exec_fns_time_out_on_a_hung_git() {
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
let dir = tempfile::TempDir::new().unwrap();
// Fake git hangs on BOTH cat-file and rev-list (bounded 30s so a broken
// test can't leak a permanent orphan or wedge the suite).
let fake = dir.path().join("fakegit");
std::fs::write(
&fake,
"#!/bin/sh\ncase \"$1\" in\n cat-file|rev-list) i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n *) : ;;\nesac\n",
)
.unwrap();
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&fake, perm).unwrap();
let git_bin = fake.to_str().unwrap().to_string();
let path = dir.path().to_path_buf();
// Run `f` on a thread and require it to RETURN (not block) within 10s.
fn returns_within<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(Duration::from_secs(10)).expect(
"a delta-path exec fn must return within the watchdog budget, not block on a hung git",
)
}
let dl = || Instant::now() + Duration::from_millis(150);
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
assert!(
returns_within(move || peeled_object_type(&p, sha, &g, d)).is_none(),
"peeled_object_type must time out to None on a hung cat-file"
);
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
assert!(
returns_within(move || rev_list_delta(&p, &[sha], &[], &g, d)).is_err(),
"rev_list_delta must error out on a hung rev-list"
);
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
assert!(
returns_within(move || list_all_objects_with_type(&p, &g, d)).is_err(),
"list_all_objects_with_type must error out on a hung cat-file"
);
}
}
+734 -52
View File
@@ -11,9 +11,12 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use sqlx::PgPool;
use sqlx::pool::PoolConnection;
use sqlx::postgres::PgPoolOptions;
use sqlx::{PgPool, Postgres};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
@@ -25,30 +28,79 @@ use super::tigris::TigrisClient;
pub struct RepoStore {
repos_dir: PathBuf,
tigris: Option<TigrisClient>,
/// Shared Postgres pool for advisory locks.
pool: PgPool,
/// Dedicated Postgres pool for repo write advisory locks, built by
/// `build_lock_pool` (see there for why it is separate and why it carries an
/// `after_release` hook). Never use this for ordinary queries.
lock_pool: PgPool,
/// Tracks repos already confirmed to exist in Tigris — avoids redundant
/// HEAD checks and background uploads for repos we've already migrated.
migrated: Arc<Mutex<HashSet<String>>>,
/// Test-only stall injected at the head of `acquire_write`'s Tigris phase,
/// i.e. AFTER the advisory lock is taken and BEFORE the guard exists. That
/// window is exactly where the outer `tokio::time::timeout` in
/// `api/repos.rs` can drop the future (#173). `TigrisClient` takes its
/// endpoint from process-wide AWS env vars and has no injectable seam, so
/// this flag is the smallest way to hold a real `acquire_write` open in that
/// window and cancel it there.
#[cfg(test)]
tigris_stall: Option<std::time::Duration>,
/// Test-only counter of how many times a write guard from this store REACHED the
/// Tigris upload site in `release` (the point past the `success` check, where a
/// configured client would be uploaded to). It counts the decision, not a network
/// call: `TigrisClient` takes its endpoint from process-wide AWS env vars and has no
/// injectable seam, so every test runs with `tigris: None` and a counter inside the
/// `Some` arm could never move. Reaching the site is the property under test anyway:
/// an interrupted push must not publish a half-applied repo, and the disconnect path
/// must therefore never get here (#173 F2).
///
/// Per store rather than a process global, so cases running in parallel do not see
/// each other's uploads, and an `Arc` rather than a `thread_local` because the guard
/// is released from a detached task on another worker thread. Same test-only counter
/// idiom as `ipfs_pin::note_legacy_repair_read`.
#[cfg(test)]
upload_site_reached: Arc<std::sync::atomic::AtomicUsize>,
}
impl RepoStore {
/// Derives its own lock pool from `pool`, so callers that only have the main
/// pool (tests, `for_testing` sites in other modules) still get the
/// `after_release` semantics `acquire_write` depends on.
#[cfg(test)]
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
Self {
Self::new(
repos_dir,
tigris: None,
pool,
migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
}
None,
build_lock_pool(&pool, 8, Duration::from_secs(5)),
)
}
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, pool: PgPool) -> Self {
/// Test-only: see `tigris_stall`.
#[cfg(test)]
pub fn with_tigris_stall(mut self, stall: Duration) -> Self {
self.tigris_stall = Some(stall);
self
}
/// Test-only: how many write guards from this store have reached the Tigris upload
/// site. See [`RepoStore::upload_site_reached`].
#[cfg(test)]
pub fn tigris_upload_site_reached(&self) -> usize {
self.upload_site_reached
.load(std::sync::atomic::Ordering::SeqCst)
}
/// `lock_pool` must come from `build_lock_pool`; a plain pool leaks advisory
/// locks on cancellation.
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, lock_pool: PgPool) -> Self {
Self {
repos_dir,
tigris,
pool,
lock_pool,
migrated: Arc::new(Mutex::new(HashSet::new())),
#[cfg(test)]
tigris_stall: None,
#[cfg(test)]
upload_site_reached: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
@@ -157,25 +209,64 @@ impl RepoStore {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
let lock_key = advisory_lock_key(&owner_slug, repo_name);
// Acquire Postgres advisory lock with retry using pg_try_advisory_lock
// to avoid blocking indefinitely on stale locks from crashed connections.
let mut acquired = false;
// Acquire the Postgres advisory lock with retry, using pg_try_advisory_lock so a
// stale lock from a crashed connection can't block us indefinitely.
//
// The connection is checked out INSIDE the loop and RETURNED before each sleep.
// Only the connection that actually took the lock is retained. Two constraints
// pull in opposite directions here, and this is what satisfies both:
//
// * Session ownership. A session-level advisory lock belongs to the CONNECTION
// that took it, so the lock and its `pg_advisory_unlock` must run on the same
// one. Running them through the pool (`fetch_one(&self.pool)`) lets them land
// on different connections: the unlock silently returns false and the lock
// leaks, while a competing acquire that happens to draw the holding
// connection re-enters the lock and two pushes to one repo run concurrently.
// Hence: keep the connection that WON.
// * Occupancy. Holding a connection across the ~60 one-second sleeps would let
// one spinning acquire park a lock-pool connection for a minute. That is not
// just a push-path concern: `api/issues.rs` and `api/pulls.rs` reach
// acquire_write holding no concurrency permit at all, so a caller could park
// the whole pool and starve authenticated pushes on every repo (#173 F1).
// Hence: return the connection when we LOSE, before sleeping.
//
// Returning a losing connection is safe with respect to the cancellation design:
// `after_release` runs `pg_advisory_unlock_all()`, a no-op on a connection that
// took nothing, so it cannot disturb a lock held by any other connection
// (proven by `returning_an_unlocked_connection_does_not_clear_another_connections_lock`).
//
// Cancellation safety is unchanged: the future can only be dropped while a
// connection is checked out, and dropping it runs the same `after_release` hook,
// which clears whatever lock it had just taken (#173 U1).
let mut lock_conn = None;
for attempt in 0..60 {
let mut conn = self.lock_pool.acquire().await.map_err(|e| {
anyhow::Error::new(LockPoolBusy)
.context(format!("checking out a lock-pool connection: {e}"))
})?;
let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
.bind(lock_key)
.fetch_one(&self.pool)
.fetch_one(&mut *conn)
.await
.context("trying advisory lock")?;
if row.0 {
acquired = true;
lock_conn = Some(conn);
break;
}
// Lost the race: give the connection back so a spinning acquire occupies
// nothing while it waits.
drop(conn);
if attempt < 59 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
if !acquired {
let Some(lock_conn) = lock_conn else {
anyhow::bail!("could not acquire advisory lock after 60s — possible stale lock for {owner_slug}/{repo_name}");
};
#[cfg(test)]
if let Some(stall) = self.tigris_stall {
tokio::time::sleep(stall).await;
}
// Always download the latest from Tigris before writing.
@@ -202,8 +293,10 @@ impl RepoStore {
repo_name: repo_name.to_string(),
local_path,
lock_key,
pool: self.pool.clone(),
lock_conn,
tigris: self.tigris.clone(),
#[cfg(test)]
upload_site_reached: Arc::clone(&self.upload_site_reached),
})
}
@@ -257,42 +350,63 @@ impl RepoStore {
/// segment is rejected. This is the CodeQL-recognised barrier
/// pattern for `rust/path-injection`.
fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> {
validate_path_components(owner_did, repo_name)?;
let owner_slug = owner_did.replace([':', '/'], "_");
let local_path = self
.repos_dir
.join(&owner_slug)
.join(format!("{repo_name}.git"));
if !local_path.starts_with(&self.repos_dir) {
anyhow::bail!(
"computed repo path escaped repos_dir: {}",
local_path.display()
);
}
// Explicit component walk — sanitisation barrier that static analysers
// (CodeQL `rust/path-injection`) recognise. The path must be composed
// entirely of Normal segments after the root prefix; any ParentDir or
// CurDir component is a traversal attempt.
for component in local_path.components() {
use std::path::Component;
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
Component::ParentDir => {
anyhow::bail!("path contains parent-directory component");
}
Component::CurDir => {
anyhow::bail!("path contains current-directory component");
}
}
}
let local_path = validated_repo_disk_path(&self.repos_dir, owner_did, repo_name)?;
Ok((owner_slug, local_path))
}
}
/// The three-layer validated form of `store::repo_disk_path`, with NO Tigris fetch and
/// no `RepoStore` (#173 round 11, F3). Extracted from `RepoStore::local_path` so a
/// second caller that must not pull a cold repo, the U4 legacy provider-CID sweep, gets
/// the same barrier instead of the raw join. `local_path` is now a thin wrapper over
/// this, so the two cannot drift.
///
/// Three-layer defence against path traversal:
/// 1. Strict allowlist on `owner_did` and `repo_name` (no `..`, slashes,
/// null bytes, leading dots; length-bounded).
/// 2. The joined path must remain rooted at `repos_dir`.
/// 3. Every component of the joined path must be `Component::Normal`
/// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir`
/// segment is rejected. This is the CodeQL-recognised barrier
/// pattern for `rust/path-injection`.
pub(crate) fn validated_repo_disk_path(
repos_dir: &Path,
owner_did: &str,
repo_name: &str,
) -> Result<PathBuf> {
validate_path_components(owner_did, repo_name)?;
let owner_slug = owner_did.replace([':', '/'], "_");
let local_path = repos_dir.join(&owner_slug).join(format!("{repo_name}.git"));
if !local_path.starts_with(repos_dir) {
anyhow::bail!(
"computed repo path escaped repos_dir: {}",
local_path.display()
);
}
// Explicit component walk — sanitisation barrier that static analysers
// (CodeQL `rust/path-injection`) recognise. The path must be composed
// entirely of Normal segments after the root prefix; any ParentDir or
// CurDir component is a traversal attempt.
for component in local_path.components() {
use std::path::Component;
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
Component::ParentDir => {
anyhow::bail!("path contains parent-directory component");
}
Component::CurDir => {
anyhow::bail!("path contains current-directory component");
}
}
}
Ok(local_path)
}
/// Strict allowlist validator for `owner_did` and `repo_name`.
///
/// Rejects any character that isn't explicitly safe, plus length and
@@ -347,6 +461,19 @@ fn validate_repo_name(repo_name: &str) -> Result<()> {
Ok(())
}
/// Error marker for "no lock-pool connection was available in time".
///
/// Carried through the `anyhow` chain (like [`smart_http::GitServiceTimeout`]) so the
/// HTTP handler can `downcast_ref` it and shed a 503 + Retry-After instead of the
/// generic 500 a git error maps to: an exhausted lock pool is a CAPACITY signal, and
/// telling the client to retry shortly is the same shed semantics the surrounding
/// admission code already uses (#173 F1).
///
/// [`smart_http::GitServiceTimeout`]: crate::git::smart_http::GitServiceTimeout
#[derive(Debug, thiserror::Error)]
#[error("no lock-pool connection available")]
pub struct LockPoolBusy;
/// Guard returned by `acquire_write()`. Holds the Postgres advisory lock and
/// uploads to Tigris + releases the lock on `release()`.
pub struct RepoWriteGuard {
@@ -354,8 +481,17 @@ pub struct RepoWriteGuard {
repo_name: String,
pub local_path: PathBuf,
lock_key: i64,
pool: PgPool,
/// The lock-pool connection that TOOK the advisory lock. It must be the one
/// that releases it (session locks are owned by their connection), and
/// holding it here is also what makes a guard dropped without `release`
/// safe: the drop returns the connection through `after_release`, which
/// clears the lock.
lock_conn: PoolConnection<Postgres>,
tigris: Option<TigrisClient>,
/// Shared with the store that handed this guard out; see
/// [`RepoStore::upload_site_reached`].
#[cfg(test)]
upload_site_reached: Arc<std::sync::atomic::AtomicUsize>,
}
impl RepoWriteGuard {
@@ -369,9 +505,16 @@ impl RepoWriteGuard {
/// half-applied or otherwise inconsistent repo would propagate corruption to
/// Tigris (and to every node that later downloads it). The lock is always
/// released regardless, to avoid stale locks blocking future writes.
pub async fn release(self, success: bool) {
pub async fn release(mut self, success: bool) {
// Upload to Tigris only on success.
if success {
// The upload site, recorded for tests before the client is consulted: with
// no injectable seam on `TigrisClient` a counter inside the arm below could
// never move, and it is reaching this point at all that an interrupted push
// must not do (#173 F2).
#[cfg(test)]
self.upload_site_reached
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if let Some(ref tigris) = self.tigris {
if let Err(e) = tigris
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
@@ -384,14 +527,56 @@ impl RepoWriteGuard {
warn!(repo = %self.repo_name, "write failed — skipping tigris upload to avoid propagating an inconsistent repo");
}
// Release advisory lock
// Release the advisory lock on the connection that took it. Anything else
// (a fresh `&pool` checkout) is a no-op that returns false: Postgres
// scopes a session lock to its owning connection.
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(self.lock_key)
.execute(&self.pool)
.execute(&mut *self.lock_conn)
.await;
// Dropping `self` returns the connection to the lock pool, where
// `after_release` sweeps anything the unlock above missed.
}
}
/// Build the dedicated advisory-lock pool a `RepoStore` runs its write locks on.
/// Connect options are cloned off an existing pool so callers need not re-parse
/// the database URL; the pool is lazy, so no connection is opened here.
///
/// Two properties, both load-bearing:
///
/// * The `after_release` hook runs `pg_advisory_unlock_all()` before a
/// connection goes back into the pool. sqlx's `PoolConnection::drop` spawns
/// `return_to_pool()`, which invokes this hook, so a connection dropped by
/// CANCELLATION still clears its locks. That is what keeps an `acquire_write`
/// killed mid-Tigris by the caller's `tokio::time::timeout` from leaking a
/// lock and wedging every later push to that repo (#173). Note the hook runs
/// from that spawned task, so the unlock is asynchronous with respect to the
/// drop: the lock clears shortly after the connection goes away, not
/// synchronously with it.
/// * It is a SEPARATE pool from the main query pool, not a slice of it. A push
/// holds its lock connection for the whole receive-pack, and
/// `db_max_connections` (default 20) is well below
/// `max_concurrent_git_pushes` (default 32), so drawing these from the main
/// pool would starve every other query during a push burst.
///
/// `acquire_timeout` bounds the wait when every lock-pool connection is busy, so
/// exhaustion surfaces as a clean error rather than an unbounded hang.
pub fn build_lock_pool(source: &PgPool, max_connections: u32, acquire_timeout: Duration) -> PgPool {
PgPoolOptions::new()
.max_connections(max_connections)
.acquire_timeout(acquire_timeout)
.after_release(|conn, _meta| {
Box::pin(async move {
sqlx::query("SELECT pg_advisory_unlock_all()")
.execute(&mut *conn)
.await?;
Ok(true)
})
})
.connect_lazy_with((*source.connect_options()).clone())
}
/// Compute a stable i64 hash for a Postgres advisory lock key.
fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
use std::hash::{Hash, Hasher};
@@ -404,6 +589,503 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
// ── advisory-lock test helpers (#173 U1) ───────────────────────────────
/// Postgres advisory locks live in a CLUSTER-wide space, not a per-database
/// one, so two `#[sqlx::test]` cases running against their own temporary
/// databases still share the key space. Every lock test therefore mints its
/// own key instead of reusing a fixed constant.
fn unique_lock_key() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static NEXT: AtomicI64 = AtomicI64::new(0);
let n = NEXT.fetch_add(1, Ordering::Relaxed);
((std::process::id() as i64) << 24) | (n & 0xff_ffff)
}
/// A plain pool (no `after_release` hook, no idle timeout) at the same
/// database as the `#[sqlx::test]` pool. Two separate reasons these tests
/// cannot just use the pool the harness hands them:
///
/// 1. Observing lock state has to happen from a session that is definitely
/// not the one under test. Session advisory locks are re-entrant, so
/// `pg_try_advisory_lock` on the very connection that already holds the key
/// returns true, and a same-pool probe silently reports a leaked lock free.
/// 2. The harness pool sets `idle_timeout(1s)`, so a connection returned to it
/// is closed about a second later and Postgres drops every lock that
/// session held. That would mask exactly the leak these tests exist to
/// catch, so the store under test runs on one of these too.
fn sibling_pool(pool: &PgPool, max_connections: u32) -> PgPool {
sqlx::postgres::PgPoolOptions::new()
.max_connections(max_connections)
.connect_lazy_with((*pool.connect_options()).clone())
}
/// Probe the lock from a connection that is NOT the one under test. Session
/// advisory locks are re-entrant within their own session, so a check from the
/// holding connection would pass vacuously and prove nothing.
async fn lock_is_free_elsewhere(pool: &PgPool, key: i64) -> bool {
let mut probe = pool.acquire().await.expect("probe connection");
let taken: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
.bind(key)
.fetch_one(&mut *probe)
.await
.expect("probe try-lock");
if taken.0 {
sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(key)
.execute(&mut *probe)
.await
.expect("probe unlock");
}
taken.0
}
/// `after_release` runs from the task sqlx spawns in `PoolConnection::drop`,
/// so the unlock is ASYNCHRONOUS with respect to the drop. Callers must poll
/// rather than assume the lock is gone the instant the connection goes away.
async fn wait_until_free(pool: &PgPool, key: i64, within: Duration) -> bool {
let deadline = std::time::Instant::now() + within;
loop {
if lock_is_free_elsewhere(pool, key).await {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
// ── DESIGN GATE ────────────────────────────────────────────────────────
// The whole cancellation-safety design rests on one sqlx behaviour:
// `PoolConnection::drop` spawns `return_to_pool()`, which invokes the pool's
// `after_release` hook before the connection is reused. If that holds, a
// connection dropped by cancellation still runs `pg_advisory_unlock_all()`
// and the lock cannot leak. This test proves it by execution, through the
// production `build_lock_pool` so that stripping the hook there turns it red.
#[sqlx::test]
async fn dropped_pool_connection_runs_after_release_and_clears_locks(pool: PgPool) {
let key = unique_lock_key();
let lock_pool = build_lock_pool(&pool, 4, Duration::from_secs(5));
{
let mut conn = lock_pool.acquire().await.expect("lock-pool connection");
let taken: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
.bind(key)
.fetch_one(&mut *conn)
.await
.expect("try-lock");
assert!(taken.0, "first try-lock must succeed");
assert!(
!lock_is_free_elsewhere(&pool, key).await,
"lock must be observably HELD from another session while the connection lives"
);
// Drop WITHOUT calling pg_advisory_unlock: this models cancellation.
}
assert!(
wait_until_free(&pool, key, Duration::from_secs(5)).await,
"after_release must clear the advisory lock of a dropped connection"
);
}
// ── acquire_write cancellation safety (#173 U1) ────────────────────────
/// The reviewer's named regression. `api/repos.rs` wraps `acquire_write` in a
/// `tokio::time::timeout`; when that fires during the Tigris phase the future
/// is dropped after the advisory lock was taken and before `RepoWriteGuard`
/// (the only thing that unlocks) exists. The lock then leaks and every later
/// push to the same repo spins the 60-attempt / 60s ceiling and fails.
#[sqlx::test]
async fn cancelled_acquire_write_mid_tigris_does_not_leak_the_lock(pool: PgPool) {
let repos_dir = PathBuf::from("/tmp/gitlawb-test-repos");
let owner = "did:key:z6MkCancelMidTigris";
let repo = "cancel-mid-tigris";
let store_pool = sibling_pool(&pool, 8);
let stalling = RepoStore::for_testing(repos_dir.clone(), store_pool.clone())
.with_tigris_stall(Duration::from_secs(30));
let cancelled = tokio::time::timeout(
Duration::from_millis(500),
stalling.acquire_write(owner, repo),
)
.await;
assert!(
cancelled.is_err(),
"the acquire must still be inside the Tigris phase when the timeout fires"
);
// Observed from an independent session, so the check cannot be satisfied
// by re-entrancy on whichever pooled connection happens to be handed back.
let probe = sibling_pool(&pool, 2);
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
assert!(
wait_until_free(&probe, key, Duration::from_secs(5)).await,
"a cancelled acquire_write must leave no advisory lock held"
);
// A subsequent acquire for the SAME repo must succeed promptly. Before the
// fix it blocks on the leaked lock until the 60-attempt ceiling.
let store = RepoStore::for_testing(repos_dir, store_pool);
let guard = tokio::time::timeout(Duration::from_secs(5), store.acquire_write(owner, repo))
.await
.expect("second acquire_write must not block on a leaked lock")
.expect("second acquire_write must succeed");
guard.release(false).await;
}
/// Cancellation BEFORE the lock is taken must leave nothing behind: no lock,
/// and no lock-pool connection stranded. The lock pool here holds exactly one
/// connection, so a stranded one would make the follow-up acquire time out
/// waiting for a checkout.
#[sqlx::test]
async fn cancelled_acquire_write_before_the_lock_leaves_nothing_held(pool: PgPool) {
let probe = sibling_pool(&pool, 2);
let owner = "did:key:z6MkCancelEarly";
let repo = "cancel-early";
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
let store = RepoStore::new(
PathBuf::from("/tmp/gitlawb-test-repos"),
None,
build_lock_pool(&pool, 1, Duration::from_secs(3)),
);
// A zero deadline polls the future once, which gets it no further than the
// first await (the pool checkout / the first try-lock round trip), so it is
// cancelled before any lock can be taken.
let cancelled =
tokio::time::timeout(Duration::ZERO, store.acquire_write(owner, repo)).await;
assert!(cancelled.is_err(), "the acquire must be cancelled");
assert!(
lock_is_free_elsewhere(&probe, key).await,
"no lock may be held when the acquire never got that far"
);
// The single lock-pool connection must be back: if cancellation stranded
// it, this checkout blocks until the 3s acquire timeout and fails.
let guard = tokio::time::timeout(Duration::from_secs(2), store.acquire_write(owner, repo))
.await
.expect("the lock-pool connection must have been returned")
.expect("acquire after cancellation");
guard.release(false).await;
}
/// Lock-pool exhaustion is a bounded wait and a clean error, never a panic and
/// never an unbounded hang.
#[sqlx::test]
async fn lock_pool_exhaustion_is_a_bounded_error(pool: PgPool) {
let owner = "did:key:z6MkExhaustion";
let store = RepoStore::new(
PathBuf::from("/tmp/gitlawb-test-repos"),
None,
build_lock_pool(&pool, 1, Duration::from_secs(2)),
);
let held = store
.acquire_write(owner, "exhaust-a")
.await
.expect("first acquire");
// Different repo, so this is not the advisory lock queueing: the only
// connection in the lock pool is checked out by `held`.
let started = std::time::Instant::now();
let err = tokio::time::timeout(
Duration::from_secs(10),
store.acquire_write(owner, "exhaust-b"),
)
.await
.expect("the wait must be bounded by the pool acquire timeout");
let err = match err {
Ok(_) => panic!("an exhausted lock pool must surface an error, not a guard"),
Err(e) => e,
};
assert!(
started.elapsed() < Duration::from_secs(6),
"the error must arrive on the acquire timeout, not after a long hang"
);
assert!(
err.to_string().contains("lock-pool connection"),
"the error must name the lock-pool checkout, got: {err}"
);
held.release(false).await;
}
/// #173 F1 (RED-before/GREEN-after). A contended `acquire_write` spins for up to
/// 60 one-second attempts. It must not OCCUPY a lock-pool connection for that whole
/// spin: `acquire_write` has non-push callers (`api/issues.rs`, `api/pulls.rs`) that
/// hold no concurrency permit, so any self-minted did:key could otherwise park a
/// connection per call and starve authenticated pushes on EVERY repo.
///
/// Lock pool of exactly 2, two spinners. Pre-fix (checkout hoisted above the retry
/// loop) they pin both connections for the full spin and an UNCONTENDED acquire on a
/// third repo dies on the pool acquire timeout. Post-fix each spinner returns its
/// connection before sleeping, so it occupies ~0 and the uncontended acquire sails
/// through.
#[sqlx::test]
async fn a_spinning_acquire_write_does_not_occupy_a_lock_pool_connection(pool: PgPool) {
let owner = "did:key:z6MkSpinOccupancy";
let owner_slug = owner.replace([':', '/'], "_");
let store = RepoStore::new(
PathBuf::from("/tmp/gitlawb-test-repos"),
None,
build_lock_pool(&pool, 2, Duration::from_secs(2)),
);
// An independent session holds both contended keys, so the spinners' try-locks
// return false on every iteration and they stay in the retry loop.
let holder = sibling_pool(&pool, 2);
let mut held_conn = holder.acquire().await.expect("holder connection");
for repo in ["spin-a", "spin-b"] {
let taken: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
.bind(advisory_lock_key(&owner_slug, repo))
.fetch_one(&mut *held_conn)
.await
.expect("holder try-lock");
assert!(taken.0, "the holder must own {repo}'s key");
}
let mut spinners = Vec::new();
for repo in ["spin-a", "spin-b"] {
let store = store.clone();
spinners.push(tokio::spawn(async move {
store.acquire_write(owner, repo).await
}));
}
// Let both reach the spin (each has done at least one failed try-lock by now).
tokio::time::sleep(Duration::from_millis(500)).await;
let started = std::time::Instant::now();
let uncontended = tokio::time::timeout(
Duration::from_secs(10),
store.acquire_write(owner, "spin-free"),
)
.await
.expect("the uncontended acquire must return, not hang");
let elapsed = started.elapsed();
let free_guard = uncontended.unwrap_or_else(|e| {
panic!(
"an UNCONTENDED acquire_write on a DIFFERENT repo must not be starved by \
spinners holding the lock pool; got: {e}"
)
});
assert!(
elapsed < Duration::from_secs(2),
"the uncontended acquire must not queue behind the spinners for the pool \
acquire timeout; took {elapsed:?}"
);
free_guard.release(false).await;
// The drop-and-retake cycle must still END in a real, exclusive lock: free
// spin-a's key and the spinner that was cycling connections must take it.
sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(advisory_lock_key(&owner_slug, "spin-a"))
.execute(&mut *held_conn)
.await
.expect("release spin-a");
let winner = tokio::time::timeout(Duration::from_secs(15), spinners.remove(0))
.await
.expect("the spinner must finish once its key frees")
.expect("spinner task")
.expect("the spinner must acquire once the key frees");
let probe = sibling_pool(&pool, 2);
assert!(
!lock_is_free_elsewhere(&probe, advisory_lock_key(&owner_slug, "spin-a")).await,
"the lock a spinner finally took must be observably held from another session"
);
winner.release(false).await;
for s in spinners {
s.abort();
}
sqlx::query("SELECT pg_advisory_unlock_all()")
.execute(&mut *held_conn)
.await
.expect("release the remaining holder lock");
}
/// #173 F1, the property the fix rests on: returning a lock-pool connection that
/// holds NOTHING runs `after_release`'s `pg_advisory_unlock_all()`, which is a no-op
/// and must not disturb a lock held on a DIFFERENT connection of the same pool.
/// Session advisory locks are per connection, so this is by construction, but the
/// spin fix depends on it, so it is proven by execution rather than assumed.
#[sqlx::test]
async fn returning_an_unlocked_connection_does_not_clear_another_connections_lock(
pool: PgPool,
) {
let owner = "did:key:z6MkNoOpUnlockAll";
let repo = "noop-unlock";
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
let probe = sibling_pool(&pool, 2);
let lock_pool = build_lock_pool(&pool, 4, Duration::from_secs(5));
let store = RepoStore::new(
PathBuf::from("/tmp/gitlawb-test-repos"),
None,
lock_pool.clone(),
);
let guard = store.acquire_write(owner, repo).await.expect("acquire");
// Churn the pool: check out and drop connections that hold no lock, exactly what
// a spinning acquire now does between attempts. Each return fires
// pg_advisory_unlock_all() on that connection.
for _ in 0..10 {
let mut conn = lock_pool.acquire().await.expect("churn checkout");
let _: (i32,) = sqlx::query_as("SELECT 1")
.fetch_one(&mut *conn)
.await
.expect("churn query");
drop(conn);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(
!lock_is_free_elsewhere(&probe, key).await,
"a held write lock must survive other lock-pool connections being returned"
);
guard.release(true).await;
assert!(
lock_is_free_elsewhere(&probe, key).await,
"release must still free the lock after the churn"
);
}
/// #173 F1: lock-pool exhaustion is a DISTINCT error the handler can shed as a 503,
/// not a generic git 500. Both directions: an exhausted pool downcasts to
/// [`LockPoolBusy`], and an unrelated failure (a rejected repo name) does not.
#[sqlx::test]
async fn lock_pool_exhaustion_is_a_distinct_downcastable_error(pool: PgPool) {
let owner = "did:key:z6MkBusyDowncast";
let store = RepoStore::new(
PathBuf::from("/tmp/gitlawb-test-repos"),
None,
build_lock_pool(&pool, 1, Duration::from_secs(1)),
);
let held = store
.acquire_write(owner, "busy-a")
.await
.expect("first acquire");
let err = match store.acquire_write(owner, "busy-b").await {
Ok(_) => panic!("an exhausted lock pool must error, not hand back a guard"),
Err(e) => e,
};
assert!(
err.downcast_ref::<LockPoolBusy>().is_some(),
"lock-pool exhaustion must be downcastable so the handler sheds 503, got: {err}"
);
// MUST-NOT: an ordinary rejection is not a capacity signal.
let other = match store.acquire_write(owner, "../escape").await {
Ok(_) => panic!("a traversal repo name must be rejected"),
Err(e) => e,
};
assert!(
other.downcast_ref::<LockPoolBusy>().is_none(),
"a validation failure must not masquerade as lock-pool capacity, got: {other}"
);
held.release(false).await;
}
/// Round trip: the lock is observably HELD between acquire and release, and
/// observably FREE after. Both checks run from an independent session; from
/// the holding session they would pass vacuously (session locks are
/// re-entrant) and would not notice an unlock that landed on the wrong
/// connection.
#[sqlx::test]
async fn acquire_write_holds_the_lock_until_release(pool: PgPool) {
let probe = sibling_pool(&pool, 2);
let owner = "did:key:z6MkRoundTrip";
let repo = "round-trip";
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
let store = RepoStore::for_testing(PathBuf::from("/tmp/gitlawb-test-repos"), pool.clone());
let guard = store.acquire_write(owner, repo).await.expect("acquire");
assert!(
!lock_is_free_elsewhere(&probe, key).await,
"the lock must be held while the guard is alive"
);
// No polling here, deliberately. `release` must free the lock SYNCHRONOUSLY,
// which it can only do by unlocking on the connection that took it; a
// `pg_advisory_unlock` sent through the pool would land on some other
// session and return false. The `after_release` hook is a net for the
// cancellation path and fires from a spawned task well after this point, so
// it must not be what makes this assertion pass.
guard.release(true).await;
assert!(
lock_is_free_elsewhere(&probe, key).await,
"release must free the lock as seen from another session"
);
}
/// `release(false)` skips the Tigris upload but must still free the lock; a
/// failed write that kept the lock would wedge the repo.
#[sqlx::test]
async fn release_after_failed_write_still_frees_the_lock(pool: PgPool) {
let probe = sibling_pool(&pool, 2);
let owner = "did:key:z6MkFailedWrite";
let repo = "failed-write";
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
let store = RepoStore::for_testing(PathBuf::from("/tmp/gitlawb-test-repos"), pool.clone());
let guard = store.acquire_write(owner, repo).await.expect("acquire");
guard.release(false).await;
assert!(
wait_until_free(&probe, key, Duration::from_secs(5)).await,
"release(success = false) must still free the lock"
);
}
/// The lock is per repo: a second acquire for the SAME repo waits for the
/// first to release, while a different repo proceeds straight through.
#[sqlx::test]
async fn same_repo_acquires_serialize_and_different_repos_do_not(pool: PgPool) {
let repos_dir = PathBuf::from("/tmp/gitlawb-test-repos");
let owner = "did:key:z6MkSerialize";
let store = RepoStore::for_testing(repos_dir, pool.clone());
let first = store
.acquire_write(owner, "serialize-a")
.await
.expect("first acquire");
// Different repo: unaffected by the held lock.
let other = tokio::time::timeout(
Duration::from_secs(2),
store.acquire_write(owner, "serialize-b"),
)
.await
.expect("a different repo must not wait on this lock")
.expect("acquire other repo");
other.release(false).await;
// Same repo: must not acquire while `first` is alive.
let contender = tokio::spawn({
let store = store.clone();
async move { store.acquire_write(owner, "serialize-a").await }
});
tokio::time::sleep(Duration::from_millis(1500)).await;
assert!(
!contender.is_finished(),
"a second acquire for the same repo must block while the first guard lives"
);
first.release(false).await;
let second = tokio::time::timeout(Duration::from_secs(10), contender)
.await
.expect("contender must finish once the lock is free")
.expect("contender task")
.expect("contender acquire");
second.release(false).await;
}
// ── repo_name validation ───────────────────────────────────────────────
File diff suppressed because it is too large. Load diff
+200
View File
@@ -306,6 +306,90 @@ pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -
Ok(content_output.stdout)
}
/// Bounded twin of [`object_type`] for the `GET /ipfs/{cid}` serve path (#173
/// round-10, R1/KTD2). Runs `git cat-file -t` under
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so the child runs
/// in its own process group and a watchdog reaps it (SIGTERM -> grace -> SIGKILL) at
/// `timeout`. The bare [`object_type`] is a `spawn_blocking` `Command::output` that an
/// async timeout cannot cancel, so a wedged `cat-file` there pins the caller's held
/// /ipfs walk admission for the whole hang; this twin cannot. Semantics mirror
/// [`object_type`]: `Ok(Some(t))` for an existing object, `Ok(None)` when git exits
/// non-zero without timing out (the object is absent), and
/// `Err(`[`GitServiceTimeout`](crate::git::smart_http::GitServiceTimeout)`)` on the
/// deadline so the handler can mark the search truncated (retryable 503) rather than a
/// false not-found. Callers off the /ipfs path keep the bare helper.
pub fn object_type_bounded(
git_bin: &str,
repo_path: &Path,
sha256_hex: &str,
timeout: std::time::Duration,
) -> Result<Option<String>> {
let deadline = std::time::Instant::now() + timeout;
match crate::git::visibility_pack::run_bounded_git(
git_bin,
&["cat-file", "-t", sha256_hex],
repo_path,
b"",
deadline,
) {
Ok(out) => Ok(Some(String::from_utf8_lossy(&out).trim().to_string())),
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => Err(e),
// A non-timeout failure is git reporting no such object (a non-zero exit),
// matching `object_type`'s `Ok(None)`.
Err(_) => Ok(None),
}
}
/// Bounded `git cat-file -s` size read for the `GET /ipfs/{cid}` serve path (#173
/// round-10, R1/KTD2): reads the object size WITHOUT its content (so an oversized object
/// is rejected before it is buffered, #173 F6), under
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so a wedged size
/// read is reaped at `timeout` instead of pinning the held /ipfs walk admission.
/// `Ok(Some(n))` on success, `Ok(None)` when the object is absent (a non-timeout
/// non-zero exit), `Err(GitServiceTimeout)` on the deadline.
pub fn object_size_bounded(
git_bin: &str,
repo_path: &Path,
sha256_hex: &str,
timeout: std::time::Duration,
) -> Result<Option<u64>> {
let deadline = std::time::Instant::now() + timeout;
match crate::git::visibility_pack::run_bounded_git(
git_bin,
&["cat-file", "-s", sha256_hex],
repo_path,
b"",
deadline,
) {
Ok(out) => Ok(String::from_utf8_lossy(&out).trim().parse::<u64>().ok()),
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => Err(e),
Err(_) => Ok(None),
}
}
/// Bounded twin of [`read_object_content`] for the `GET /ipfs/{cid}` serve path (#173
/// round-10, R1/KTD2): `git cat-file <type>` under
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so a wedged content
/// read is reaped at `timeout` instead of pinning the held /ipfs walk admission. Returns
/// the raw object bytes on success and an error (including `GitServiceTimeout` on the
/// deadline) otherwise, mirroring [`read_object_content`].
pub fn read_object_content_bounded(
git_bin: &str,
repo_path: &Path,
sha256_hex: &str,
obj_type: &str,
timeout: std::time::Duration,
) -> Result<Vec<u8>> {
let deadline = std::time::Instant::now() + timeout;
crate::git::visibility_pack::run_bounded_git(
git_bin,
&["cat-file", obj_type, sha256_hex],
repo_path,
b"",
deadline,
)
}
/// Read a git object by its SHA-256 hex object ID.
///
/// Returns `(object_type, content_bytes)` where `content_bytes` is the raw
@@ -323,6 +407,42 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
Ok(Some((obj_type, content)))
}
/// Bounded twin of [`read_object`] for write-side callers that must not hang on a
/// wedged `git cat-file` (the post-push pin path, #173): composes
/// [`object_type_bounded`] and [`read_object_content_bounded`] under ONE shared
/// deadline (mirroring `get_by_cid`'s read stage and `build_filtered_pack`), so a
/// single object read holds for at most `timeout` total, not one full timeout per
/// stage. `Ok(Some((type, bytes)))` on success, `Ok(None)` when the object is absent,
/// and `Err(`[`GitServiceTimeout`](crate::git::smart_http::GitServiceTimeout)`)` when
/// either stage overruns the deadline. The bare [`read_object`] is an unbounded
/// `Command::output` an async timeout cannot cancel, so a wedged `cat-file` there pins
/// the post-push coalescing key until process death; this twin cannot.
pub fn read_object_bounded(
git_bin: &str,
repo_path: &Path,
sha256_hex: &str,
timeout: std::time::Duration,
) -> Result<Option<(String, Vec<u8>)>> {
let deadline = std::time::Instant::now() + timeout;
let obj_type = match object_type_bounded(
git_bin,
repo_path,
sha256_hex,
deadline.saturating_duration_since(std::time::Instant::now()),
)? {
Some(t) => t,
None => return Ok(None),
};
let content = read_object_content_bounded(
git_bin,
repo_path,
sha256_hex,
&obj_type,
deadline.saturating_duration_since(std::time::Instant::now()),
)?;
Ok(Some((obj_type, content)))
}
/// Get the diff between two branches: changes on source_branch not in target_branch.
pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -> Result<String> {
let output = Command::new("git")
@@ -501,4 +621,84 @@ mod tests {
"unchanged file must not appear: {names:?}"
);
}
/// #173 round-10 (KTD2): `object_type_bounded` reaps a wedged `cat-file` child at its
/// deadline instead of blocking on it to natural exit, so a hung probe cannot pin the
/// /ipfs walk admission the owning task holds. A fake `git` records its pid and sleeps
/// far past the 1s deadline; the `run_bounded_git` watchdog (SIGTERM -> grace ->
/// SIGKILL of the process group) must kill it well before that natural exit, and the
/// call must surface `GitServiceTimeout`. REVERT PROOF (RED): swap the twin's
/// `run_bounded_git` for the bare `Command::output()` and the wedged child stays alive
/// past the deadline — the mid-flight liveness poll below reads it still running.
#[cfg(unix)]
#[test]
fn object_type_bounded_reaps_wedged_child_at_deadline() {
use std::time::Duration;
let tmp = tempfile::TempDir::new().unwrap();
let pidfile = tmp.path().join("catfile.pid");
// `cat-file` records its own pid then sleeps 8s (>> the 1s deadline) so the probe
// is genuinely wedged; the watchdog is what must end it.
let body = format!(
"#!/bin/sh\n\
case \"$1\" in\n\
cat-file) echo $$ > \"{}\"; sleep 8 ;;\n\
*) : ;;\n\
esac\n\
exit 0\n",
pidfile.display()
);
let git_path = tmp.path().join("fakegit");
std::fs::write(&git_path, &body).unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&git_path).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&git_path, perm).unwrap();
}
let repo = tmp.path().to_path_buf();
let git = git_path.to_str().unwrap().to_string();
let alive = |pid: i32| unsafe { libc::kill(pid, 0) == 0 };
// The bounded probe blocks until the watchdog tears the child down, so run it on
// a worker thread and poll for the reap from here.
let handle = std::thread::spawn(move || {
super::object_type_bounded(&git, &repo, "deadbeef", Duration::from_secs(1))
});
let mut pid = None;
for _ in 0..500 {
if let Some(p) = std::fs::read_to_string(&pidfile)
.ok()
.and_then(|s| s.trim().parse::<i32>().ok())
{
pid = Some(p);
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let pid = pid.expect("the fake cat-file must have spawned and recorded its pid");
// Past the 1s deadline + SIGTERM grace but well before the 8s natural exit: the
// watchdog must already have reaped the wedged group. A bare, unbounded read would
// leave it running here — the load-bearing RED.
std::thread::sleep(Duration::from_secs(3));
let reaped = !alive(pid);
// Defensive reap so a RED run leaks no orphan.
unsafe {
libc::kill(pid, libc::SIGKILL);
}
assert!(
reaped,
"object_type_bounded must reap the wedged cat-file child at the deadline, \
not leave it running to its natural exit"
);
let res = handle.join().expect("probe thread joins");
let err = res.expect_err("a deadline overrun must be an error, not a value");
assert!(
err.is::<crate::git::smart_http::GitServiceTimeout>(),
"a deadline overrun must surface GitServiceTimeout, got: {err:?}"
);
}
}
File diff suppressed because it is too large. Load diff
+544 -13
View File
@@ -9,6 +9,372 @@
use anyhow::Result;
use gitlawb_core::cid::Cid;
use std::time::Duration;
/// Attempts (including the first) for a transient DB-record retry.
const PIN_RECORD_ATTEMPTS: u32 = 3;
/// Backoff between DB-record retry attempts.
const PIN_RECORD_BACKOFF: Duration = Duration::from_millis(50);
/// Run an idempotent DB-record operation with a bounded retry so a sub-second
/// transient error does not silently leave the pin-source set permanently
/// incomplete. The resolver treats a nonempty below-cap source set as complete,
/// so a dropped `record_pin_source`/`record_pinned_cid` makes `GET /ipfs/{cid}`
/// 404 a valid public copy. Every wrapped insert is idempotent (`ON CONFLICT DO
/// NOTHING` / provenance-preserving upsert), so re-running is safe. On exhausted
/// attempts the last error is returned and the caller records the durable
/// `pin_sources_incomplete` marker (U3, #173), which is what keeps the resolver's
/// bounded scan fallback available for that object instead of 404ing a public copy.
/// Shared with the `pinata.rs` twin so both pin paths retry identically. Runs
/// inside the already-detached post-push task, so the backoff adds no push latency.
pub(crate) async fn retry_db_record<F, Fut>(mut op: F) -> Result<()>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let mut attempt = 1;
loop {
match op().await {
Ok(()) => return Ok(()),
Err(e) => {
if attempt >= PIN_RECORD_ATTEMPTS {
return Err(e);
}
tokio::time::sleep(PIN_RECORD_BACKOFF).await;
attempt += 1;
}
}
}
}
/// Opportunistically repair a legacy provider-CID row on the already-pinned skip
/// path (#173 R8, KTD8). Releases before this branch stored the PROVIDER CID
/// (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`; the `/ipfs` resolver
/// recomputes the raw CID from object bytes and 404s any row whose key does not
/// match, yet `list_pinned_cids` still advertises the stored key — so a client
/// gets a CID the resolver deliberately withholds. When a re-push carries the
/// object again, rewrite the key to the raw CID and stash the old provider value
/// in `legacy_provider_cid`.
///
/// COST GATE: candidacy is decided from the stored key's codec alone — a
/// CIDv1/raw key is already the resolver key and reads NO bytes, keeping the
/// steady-state skip cost DB-only. Only a legacy-codec row reads the object to
/// recompute. A row whose bytes are gone stays withheld (no destructive rewrite).
async fn repair_legacy_provider_cid(
repo_path: &std::path::Path,
git_bin: &str,
git_timeout: Duration,
sha: &str,
db: &crate::db::Db,
) -> Result<RepairOutcome> {
let stored = match db.cid_for_oid(sha).await? {
Some(c) => c,
None => return Ok(RepairOutcome::Settled),
};
// Cost gate: a canonical raw CIDv1 key is already correct — never read bytes.
if gitlawb_core::cid::is_raw_cidv1(&stored) {
return Ok(RepairOutcome::Settled);
}
// Legacy-codec row: read the object to recompute. Counted so a test can prove
// the gate above spares non-legacy rows this read.
#[cfg(test)]
note_legacy_repair_read();
// `read_object_bounded` is SYNCHRONOUS `git cat-file`, and its budget is
// `git_service_timeout_secs` (600 by default), so running it inline parks a tokio
// worker for as long as git takes: one wedged read on the sweep's first pass at boot
// holds a worker for ten minutes, per legacy row. Push it to the blocking pool, the
// same shape `replication_withheld_set` uses in api/repos.rs (#173 round 11, F4).
// Both callers of this function are async, so neither changes shape. The read-counter
// increment above stays on THIS thread so the thread_local cost-gate assertion holds.
let read = {
let repo_path = repo_path.to_path_buf();
let git_bin = git_bin.to_string();
let sha = sha.to_string();
tokio::task::spawn_blocking(move || {
crate::git::store::read_object_bounded(&git_bin, &repo_path, &sha, git_timeout)
})
.await
};
let data = match read {
Ok(Ok(Some((_ty, bytes)))) => bytes,
// Bytes gone: the row stays withheld, never destructively rewritten. Nothing a
// later pass changes, so this is a TERMINAL outcome for the sweep's re-walk gate.
Ok(Ok(None)) => return Ok(RepairOutcome::Settled),
// A wedged/D-state `git cat-file` (timeout/infra): the repair is opportunistic
// and best-effort, so skip it and return Ok so the pin task PROCEEDS to
// requeue_or_release rather than hanging the coalescing key until process death
// (grok F2, #173). A later re-push or the deferred sweep retries the repair.
Ok(Err(e)) => {
tracing::warn!(sha = %sha, err = %e, "skipping legacy provider-CID repair: bounded object read failed");
return Ok(RepairOutcome::Retryable);
}
// The blocking task panicked or was cancelled: same best-effort treatment, and
// worth another walk because it says nothing about the row itself.
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "skipping legacy provider-CID repair: object read task failed");
return Ok(RepairOutcome::Retryable);
}
};
let raw = Cid::from_git_object_bytes(&data).to_string();
if raw == stored {
return Ok(RepairOutcome::Settled);
}
db.repair_legacy_provider_cid(sha, &raw, &stored).await?;
Ok(RepairOutcome::Repaired)
}
/// What one opportunistic repair did with a row, so the sweep can tell a skip a later
/// run could fix from one nothing will (U4 re-walk, #173 round 11). The push skip path
/// ignores the value: it repairs whatever the push happens to carry and a failure there
/// is already warn-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RepairOutcome {
/// Nothing to do, or nothing a re-walk would change: the stored key is already the
/// raw resolver key, the recomputed key matches it, or the object's bytes are gone.
Settled,
/// The bounded object read failed (a wedged `git cat-file`, an unreadable repo).
/// The bytes may be readable later, so the row is worth walking again.
Retryable,
/// The row's key was rewritten to the raw-content CID.
Repaired,
}
/// What one sweep pass (or a whole sweep run) did. `scanned` counts `pinned_cids`
/// rows READ, which is the quantity the batch size bounds; `repaired` counts rows
/// whose key was actually rewritten to the raw CID.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SweepStats {
pub scanned: usize,
pub repaired: usize,
pub passes: usize,
/// Rows left unrepaired for a reason a LATER run could fix (the source repo is not
/// on this node's local disk, a DB read failed, a bounded object read failed). A
/// nonzero count is what makes the run rewind its cursor instead of parking it at
/// the end of the table forever. Rows that are unrepairable in principle (no
/// provenance, the repo row is gone, the bytes are gone) are NOT counted here.
pub retryable_skips: usize,
}
/// One bounded pass of the U4 sweep: read at most `batch` `pinned_cids` rows after
/// the persisted cursor, repair the legacy ones, and persist the new cursor.
///
/// The batch is what bounds the pass. It caps rows READ, not rows repaired, because
/// the legacy predicate is a codec decode SQL cannot express; a table of raw rows
/// therefore costs one indexed range scan per pass and nothing else.
///
/// The cursor advances to the LAST row read whatever happened to each row, including
/// rows that were skipped as unrepairable. A cursor that only advanced on success
/// would re-read the same unrepairable row on every pass and the sweep would never
/// reach the rows behind it.
async fn sweep_pass(
repos_dir: &std::path::Path,
git_bin: &str,
git_timeout: Duration,
batch: i64,
db: &crate::db::Db,
) -> Result<SweepStats> {
let cursor = db.pin_repair_cursor().await?;
let rows = db.pinned_cids_after(&cursor, batch).await?;
let scanned = rows.len();
let mut repaired = 0usize;
let mut retryable_skips = 0usize;
let mut last = cursor;
for (sha, stored) in rows {
// Advance FIRST: every path below this line may skip the row, and none of them
// may wedge the walk (scenario 7).
last = sha.clone();
// Same cost gate as the skip-path repair: a canonical raw CIDv1 key is already
// the resolver key, so it reads no bytes and resolves no repo.
if gitlawb_core::cid::is_raw_cidv1(&stored) {
continue;
}
// Resolve the row's repo from its recorded provenance (first-pinner plus the
// bounded additional source set). An empty set is a pin recorded before
// provenance existed: nothing to read the bytes from, so skip it.
let sources = match db.pin_sources_for_oid(&sha).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "sweep: failed to read pin sources");
// A DB read error says nothing about the row, so a later run retries it.
retryable_skips += 1;
continue;
}
};
// Whether this row ended the source walk repaired, and whether anything it hit
// along the way was a transient obstacle rather than a permanent one.
let mut row_repaired = false;
let mut row_retryable = false;
for repo_id in sources {
let repo = match db.get_repo_by_id(&repo_id).await {
Ok(Some(r)) => r,
// The repo row is gone: a later source may still hold the bytes. A
// deleted repo does not come back, so this is not a retryable skip.
Ok(None) => continue,
Err(e) => {
tracing::warn!(repo_id = %repo_id, err = %e, "sweep: failed to read repo");
row_retryable = true;
continue;
}
};
// Derive the LOCAL disk path rather than going through `repo_store.acquire`.
// The sweep is opportunistic background maintenance over every pinned row on
// the node, so it must never pull a cold repo back from remote storage: that
// would turn a repair pass into a bulk restore. A repo that is not on local
// disk simply reads no bytes here and stays withheld, but it IS a retryable
// skip: on a Tigris-backed node the repo is cold now and warm later, and
// without the re-walk that row would never be repaired by anything.
// The path goes through the repo store's VALIDATED resolver (allowlisted
// components, rooted at repos_dir, no ParentDir/CurDir segment), not the raw
// join: the sweep is a second caller of that path logic and gets the same
// barrier the acquire path has (#173 round 11, F3). It is the non-fetching
// variant, so the no-cold-pull property above is untouched.
let repo_path = match crate::git::repo_store::validated_repo_disk_path(
repos_dir,
&repo.owner_did,
&repo.name,
) {
Ok(p) => p,
// An unsafe name is not something a later run fixes, so it is terminal.
Err(e) => {
tracing::warn!(repo_id = %repo_id, err = %e, "sweep: rejected unsafe repo path");
continue;
}
};
if !repo_path.is_dir() {
row_retryable = true;
continue;
}
match repair_legacy_provider_cid(&repo_path, git_bin, git_timeout, &sha, db).await {
Ok(RepairOutcome::Repaired) => {
repaired += 1;
row_repaired = true;
break;
}
// The bytes could not be read from this source right now: try the next
// source, and if none of them works, walk the row again on a later run.
Ok(RepairOutcome::Retryable) => row_retryable = true,
// Nothing to repair from this source and nothing a re-walk changes.
Ok(RepairOutcome::Settled) => {}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "sweep: legacy provider-CID repair failed");
row_retryable = true;
}
}
}
if !row_repaired && row_retryable {
retryable_skips += 1;
}
}
db.set_pin_repair_cursor(&last).await?;
Ok(SweepStats {
scanned,
repaired,
passes: 1,
retryable_skips,
})
}
/// Test seam for a single bounded pass (scenarios 4 and 5 drive passes by hand to
/// observe the batch bound and the restart-resumes-from-cursor behavior).
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) async fn sweep_legacy_provider_cids_once(
repos_dir: &std::path::Path,
git_bin: &str,
git_timeout: Duration,
batch: i64,
db: &crate::db::Db,
) -> Result<SweepStats> {
sweep_pass(repos_dir, git_bin, git_timeout, batch, db).await
}
/// U4 (#173): the one-shot legacy provider-CID migration sweep.
///
/// Releases before this branch stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0)
/// in `pinned_cids.cid`. This branch's `/ipfs/{cid}` resolver recomputes the raw
/// content CID and withholds any row whose key does not match, so those rows are
/// unresolvable. The opportunistic repair on the already-pinned skip path only fires
/// when a later push re-carries the object, and normal git negotiation omits objects
/// the node already has, so on an upgraded node that push generally never comes. This
/// walks the table instead.
///
/// Runs until a pass comes back short of a full batch, which is the end of the table.
/// Sleeps `delay` between full batches so it cannot monopolize the DB, and persists
/// its cursor every pass so a restart continues instead of rewinding. Errors reading
/// or repairing an individual row are warn-and-skip; only a failure of the batch query
/// or the cursor write ends the run, and a later run picks up from the stored cursor.
///
/// A run that skipped at least one RETRYABLE row rewinds the cursor to the start of the
/// table on its way out (#173 round 11). Without that the cursor parked at the maximum
/// `sha256_hex` for good: every later boot read zero rows, so a row skipped for a
/// transient reason (its repo cold on a Tigris-backed node, a DB or object read error)
/// was skipped permanently, unadvertised and unresolvable with nothing left to fix it.
/// The rewind is a per-RUN decision made after the walk has already finished, never
/// mid-walk, so it cannot spin: the cost is one extra ordered scan on the next run, and
/// a row that is unrepairable in principle (bytes gone, provenance gone) does not count
/// as retryable, so a node holding one does not re-walk on every boot forever.
pub(crate) async fn sweep_legacy_provider_cids(
repos_dir: &std::path::Path,
git_bin: &str,
git_timeout: Duration,
batch: i64,
delay: Duration,
db: &crate::db::Db,
) -> SweepStats {
let mut totals = SweepStats::default();
loop {
let pass = match sweep_pass(repos_dir, git_bin, git_timeout, batch, db).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(err = %e, "legacy provider-CID sweep pass failed; stopping");
break;
}
};
totals.scanned += pass.scanned;
totals.repaired += pass.repaired;
totals.retryable_skips += pass.retryable_skips;
totals.passes += 1;
// A short batch means the ordered walk reached the end of the table. Stop here
// rather than after an extra empty pass, and do NOT sleep on the way out.
if (pass.scanned as i64) < batch {
break;
}
tokio::time::sleep(delay).await;
}
if totals.retryable_skips > 0 {
if let Err(e) = db.set_pin_repair_cursor("").await {
tracing::warn!(err = %e, "failed to rewind the legacy provider-CID sweep cursor");
}
}
totals
}
// Test-only cost-gate counter (R8, U7): how many times the opportunistic repair
// read an object's bytes on the skip path. The codec gate must spare a CIDv1/raw
// row this read; the counter is the both-ways guard (removing the gate reads the
// raw row and increments it). Same thread_local discipline as the serve-path
// oversize counter — the pin tests await `pin_new_objects` on a current-thread
// runtime, so the increment and the assertion share one thread.
#[cfg(test)]
thread_local! {
static LEGACY_REPAIR_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn reset_legacy_repair_reads() {
LEGACY_REPAIR_READS.with(|c| c.set(0));
}
#[cfg(test)]
pub(crate) fn legacy_repair_reads() -> usize {
LEGACY_REPAIR_READS.with(|c| c.get())
}
#[cfg(test)]
fn note_legacy_repair_read() {
LEGACY_REPAIR_READS.with(|c| c.set(c.get() + 1));
}
/// Pin a single git object to the local IPFS/Kubo node.
///
@@ -91,14 +457,18 @@ pub async fn cat(ipfs_api: &str, cid: &str) -> Result<Vec<u8>> {
/// `..._fail_closed` filter on the full-scan path before calling, so this
/// function never sees a withheld blob. `repo_path` is still needed to read each
/// object's bytes. The twin in `pinata.rs` mirrors this shape — change both in
/// lockstep.
/// lockstep. `repo_id` records the pin's provenance so `GET /ipfs/{cid}` resolves
/// straight to this repo instead of scanning every repo (#173).
///
/// Returns a list of `(sha256_hex, cid)` pairs for objects pinned this call.
pub async fn pin_new_objects(
ipfs_api: &str,
repo_path: &std::path::Path,
git_bin: &str,
git_timeout: Duration,
object_list: Vec<String>,
db: &crate::db::Db,
repo_id: &str,
) -> Vec<(String, String)> {
if ipfs_api.is_empty() {
return vec![];
@@ -107,9 +477,54 @@ pub async fn pin_new_objects(
let mut pinned = Vec::new();
for sha in object_list {
// Skip if already pinned
// Skip if already pinned — but first backfill provenance if the existing
// pin has none. A legacy pin (recorded before repo_id existed, #173, jatmn)
// is skipped here before record_pinned_cid ever runs, so its NULL provenance
// would never resolve to one repo and known CIDs keep hitting the scan. The
// backfill only sets repo_id (AND repo_id IS NULL guard preserves
// first-pinner-owns) and never re-pins the bytes — the object is already on IPFS.
match db.is_pinned(&sha).await {
Ok(true) => continue,
Ok(true) => {
match db.provenance_for_oid(&sha).await {
Ok(None) => {
if let Err(e) = db.backfill_pin_provenance(&sha, repo_id).await {
tracing::warn!(sha = %sha, err = %e, "failed to backfill pin provenance");
}
}
Ok(Some(_)) => {}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "DB error reading pin provenance");
}
}
// F1 (#173 round 8): record this repo as an ADDITIONAL source for the
// already-pinned object. This is the load-bearing skip-branch insert —
// a later repo pushing a shared object hits this path (already pinned),
// and without it `GET /ipfs/{cid}` only ever knows the first pinner, so a
// shared object first pinned from a private/quarantined repo 404s even
// when this repo would serve it. Bounded per object (MAX_PIN_SOURCES).
if let Err(e) = retry_db_record(|| db.record_pin_source(&sha, repo_id)).await {
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
// U3 (#173): the retries are spent and this repo is NOT in the source
// set, so the set is known incomplete. Persist that, or the resolver
// reads a non-empty below-cap set as COMPLETE and 404s an object this
// repo would serve. Warn-only in turn: if the marker write also fails
// the object degrades to the pre-U3 behavior, never worse.
if let Err(e) = db.mark_pin_sources_incomplete(&sha).await {
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
}
}
// R8 (#173 round 10): opportunistically repair a legacy provider-CID
// row (Kubo dag-pb / Pinata) to the raw-content resolver key on this
// re-push. Cost-gated on the stored key's codec — a non-legacy row
// reads no bytes. Warn-only: a failure leaves the row as-is for a
// later re-push or the deferred one-shot sweep.
if let Err(e) =
repair_legacy_provider_cid(repo_path, git_bin, git_timeout, &sha, db).await
{
tracing::warn!(sha = %sha, err = %e, "failed to repair legacy provider CID");
}
continue;
}
Ok(false) => {}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "DB error checking pinned status");
@@ -117,22 +532,49 @@ pub async fn pin_new_objects(
}
}
// Read raw object content
let data = match crate::git::store::read_object(repo_path, &sha) {
Ok(Some((_obj_type, bytes))) => bytes,
Ok(None) => continue,
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "failed to read git object for pinning");
continue;
}
};
// Read raw object content under a bounded read so a wedged/D-state `git
// cat-file` (stuck NFS/Tigris backend) is reaped at `git_timeout` instead of
// hanging pin_new_objects forever — which would pin the post-push coalescing
// key until process death (grok F2, #173). On Err the object is simply not
// pinned this pass; a later pass/push retries.
let data =
match crate::git::store::read_object_bounded(git_bin, repo_path, &sha, git_timeout) {
Ok(Some((_obj_type, bytes))) => bytes,
Ok(None) => continue,
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "failed to read git object for pinning");
continue;
}
};
// Pin to IPFS
match pin_git_object(ipfs_api, &sha, &data).await {
Ok(cid) if !cid.is_empty() => {
if let Err(e) = db.record_pinned_cid(&sha, &cid).await {
// The resolver key (`pinned_cids.cid`) must be the locally-computed
// raw-content CID, never the provider Hash: Kubo returns a dag-pb/UnixFS
// root for objects above its block size, which does not hash the raw
// content, so `GET /ipfs/{provider_cid}` would resolve then fail the F2
// integrity check (list-then-404). The serve path reads bytes from git and
// verifies them against the requested CID, so the raw CID is the correct
// key. Mirrors the pinata twin, which already records the raw CID.
let raw_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&data).to_string();
// F1 (#173 round 8): the first pinner is recorded in pin_repo_sources too,
// so every source (first and subsequent) is tried uniformly by the
// resolver. U3 (#173): the pin and its source go down in ONE transaction.
// As two independent best-effort calls this path could land the pin while
// dropping its own source, producing a source set silently missing its
// first pinner; atomically there is no such window, and a total failure
// leaves the object unpinned so the next push retries the whole thing.
if let Err(e) =
retry_db_record(|| db.record_pinned_cid_with_source(&sha, &raw_cid, repo_id))
.await
{
tracing::warn!(sha = %sha, err = %e, "failed to record pinned CID in DB");
}
// Return the provider Hash (not the resolver key), mirroring the pinata
// twin's contract: the DB `cid` is the raw resolver key (recorded above),
// the returned value is the provider CID. Here the return is consumed only
// for logging, but keeping the twins structurally identical avoids drift.
pinned.push((sha, cid));
}
Ok(_) => {}
@@ -144,3 +586,92 @@ pub async fn pin_new_objects(
pinned
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
// The retry helper is the load-bearing unit: it converts a sub-second
// transient DB error at the three warn-only record sites into a landed row,
// instead of a permanently incomplete pin-source set. These drive the helper
// directly against a controlled closure (the record sites take a concrete
// `&Db` over a `PgPool`, so a failing-first wrapper cannot slot in without
// changing signatures — see U6 seam note).
#[tokio::test]
async fn retry_lands_after_transient_failures() {
let calls = Cell::new(0u32);
let result = retry_db_record(|| {
let n = calls.get() + 1;
calls.set(n);
async move {
if n < PIN_RECORD_ATTEMPTS {
Err(anyhow::anyhow!("transient failure on attempt {n}"))
} else {
Ok(())
}
}
})
.await;
assert!(
result.is_ok(),
"retry lands the row after transient failures"
);
assert_eq!(
calls.get(),
PIN_RECORD_ATTEMPTS,
"op is retried until it succeeds"
);
}
#[tokio::test]
async fn retry_returns_last_err_after_exhaustion() {
let calls = Cell::new(0u32);
let result = retry_db_record(|| {
let n = calls.get() + 1;
calls.set(n);
async move { Err::<(), _>(anyhow::anyhow!("attempt {n} failed")) }
})
.await;
let err = result.expect_err("all attempts fail so the last error surfaces");
assert_eq!(
calls.get(),
PIN_RECORD_ATTEMPTS,
"attempts are bounded to the cap"
);
assert_eq!(
err.to_string(),
"attempt 3 failed",
"the LAST error is returned, not the first"
);
}
// Happy path against a real DB: a single-attempt success lands the row, and a
// redundant call is idempotent (`ON CONFLICT DO NOTHING`), so the source set
// holds exactly one row.
#[sqlx::test]
async fn retry_records_pin_source_once(pool: sqlx::PgPool) {
let db = crate::db::Db::for_testing(pool);
db.run_migrations().await.unwrap();
let sha = "a".repeat(64);
let repo_id = "repo-retry-1";
retry_db_record(|| db.record_pin_source(&sha, repo_id))
.await
.expect("happy-path record succeeds in one attempt");
retry_db_record(|| db.record_pin_source(&sha, repo_id))
.await
.expect("a redundant record is idempotent");
let sources = db.pin_sources_for_oid(&sha).await.unwrap();
assert_eq!(
sources,
vec![repo_id.to_string()],
"exactly one source row lands under ON CONFLICT DO NOTHING"
);
}
}
+292 -12
View File
@@ -58,6 +58,32 @@ struct DbStartupStatus {
next_retry_secs: AtomicU64,
}
/// Hard ceiling on the advisory-lock pool's `max_connections`.
///
/// `max_concurrent_git_pushes` is validated all the way up to 1_048_576, and the lock
/// pool used to derive its size straight from that knob, so raising the push cap
/// silently raised the node's Postgres connection ceiling with no CLI error and no
/// relation to the server's own `max_connections` (#173 F4). The node's total budget is
/// now bounded: `db_max_connections` (default 20) + at most this.
const LOCK_POOL_MAX_CONNECTIONS: u32 = 64;
/// Connections the lock pool keeps above the push cap. Covers the three non-push
/// `acquire_write` callers (`api/issues.rs` x2, `api/pulls.rs`), which hold no
/// concurrency permit, so a push never queues here for a connection where it did not
/// before.
const LOCK_POOL_PUSH_HEADROOM: u8 = 8;
/// Size the advisory-lock pool for a given push cap: the cap plus
/// [`LOCK_POOL_PUSH_HEADROOM`], clamped to [`LOCK_POOL_MAX_CONNECTIONS`]. Past the
/// clamp a push may wait for a lock-pool connection, which is a bounded wait that sheds
/// a clean 503 (see `LockPoolBusy`), not an unbounded hang.
fn lock_pool_size(max_concurrent_git_pushes: usize) -> u32 {
u32::try_from(max_concurrent_git_pushes)
.unwrap_or(u32::MAX)
.saturating_add(u32::from(LOCK_POOL_PUSH_HEADROOM))
.min(LOCK_POOL_MAX_CONNECTIONS)
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
@@ -279,8 +305,17 @@ async fn main() -> Result<()> {
None
};
let repo_store =
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
// Repo write locks run on their own pool, never the main query pool: each
// push holds its connection for the whole receive-pack, and
// db_max_connections (20) is below max_concurrent_git_pushes (32), so sharing
// would starve every other query under a push burst. See build_lock_pool for
// the cancellation semantics (#173).
let lock_pool = git::repo_store::build_lock_pool(
db.pool(),
lock_pool_size(config.max_concurrent_git_pushes),
std::time::Duration::from_secs(config.db_acquire_timeout_secs),
);
let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, lock_pool);
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
// DID (attacker-varied), so bound its key set to cap memory.
@@ -374,11 +409,83 @@ async fn main() -> Result<()> {
rate_limiter,
create_ip_rate_limiter,
push_rate_limiter,
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
// The legacy-probe budget is operator-tunable via GITLAWB_IPFS_MAX_REPOS_WALKED
// (R5); the history-walk ceiling above stays constant (a smaller value false-503s
// a provenanced request). Default 256 preserves the shipped behaviour.
ipfs_max_legacy_probes: AppState::ipfs_legacy_probe_budget(&config),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust,
sync_trigger_rate_limiter,
peer_write_rate_limiter,
shutdown_tx: shutdown_tx.clone(),
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_git_ops)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(
config.max_concurrent_git_pushes,
)),
// Anon receive-pack advertisements get their OWN pool, same size as the
// write pool but disjoint, so filling it (which takes many source IPs, each
// capped by git_push_advert_per_caller) never occupies a permit the
// authenticated POST needs (#174).
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(
config.max_concurrent_git_pushes,
)),
// Bounds concurrent detached post-push encryption walks, sized from the push
// pool (no separate knob — Q1): completed pushes cannot outnumber active
// encryption walks past this (#174 P1-e).
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(
config.max_concurrent_git_pushes,
)),
// Coalesces the DETACHED post-push encryption tasks per repo so a rapid pusher
// cannot grow the outstanding parked-waiter set past one task per repo (#174
// P2-2). No knob: it is a natural cap (one entry per distinct repo), not a
// sized pool.
encrypt_inflight: crate::state::EncryptInflight::new(),
git_read_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
config.max_concurrent_reads_per_caller,
),
// Per-source cap on the receive-pack advertisement, sized to an eighth of the
// write pool (min 1): a single source can hold at most this many write-pool
// slots via the anon advertisement, so saturating the pool takes ~8 distinct
// source IPs, each also rate-limited (#174).
git_push_advert_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
(config.max_concurrent_git_pushes / 8).max(1),
),
// Per-source cap on the authenticated receive-pack POST, sized like the advert
// cap: one source IP can hold at most this many write-pool slots, so
// monopolizing the pool takes ~8 distinct source IPs, each also rate-limited
// (#174 P1-d).
git_write_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
(config.max_concurrent_git_pushes / 8).max(1),
),
// Bounds concurrent /ipfs visibility walks — a distinct public cost center, so
// its own pool + per-source sub-cap + per-IP rate limiter, never a git pool
// (#174 P1-3). The per-source map is bounded (reject-before-insert, INV-15).
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(
config.max_concurrent_ipfs_walks,
)),
git_ipfs_walk_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
config.ipfs_walk_per_source,
),
ipfs_rate_limiter: rate_limit::RateLimiter::new_bounded(
config.ipfs_rate_limit,
std::time::Duration::from_secs(3600),
200_000,
),
// Separate WORK-budget bucket for the resolver's per-probe/per-walk charges (R6).
// Its capacity is DERIVED from the route limit (no new knob) and floored at the
// legacy-probe budget, so one full default-config legacy scan never self-throttles
// mid-request while the route brake above stays the pure once-per-request cap.
ipfs_work_rate_limiter: rate_limit::RateLimiter::new_bounded(
AppState::ipfs_work_budget(&config),
std::time::Duration::from_secs(3600),
200_000,
),
git_bin: "git".to_string(),
};
if config.ipfs_rate_limit == 0 {
tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled");
}
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
// we still set the gauge to 0 so dashboards don't show "no data".
@@ -408,22 +515,16 @@ async fn main() -> Result<()> {
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
{
let rl = state.rate_limiter.clone();
let create_ip_rl = state.create_ip_rate_limiter.clone();
let push_rl = state.push_rate_limiter.clone();
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
let peer_write_rl = state.peer_write_rate_limiter.clone();
let cleanup_state = state.clone();
let db = state.db.clone();
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
rl.cleanup().await;
create_ip_rl.cleanup().await;
push_rl.cleanup().await;
sync_trigger_rl.cleanup().await;
peer_write_rl.cleanup().await;
// Sweep every per-IP/DID limiter (incl. the ipfs walk brake)
// so bounded maps shed stale keys instead of sitting at cap.
cleanup_state.sweep_rate_limiters().await;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -442,6 +543,8 @@ async fn main() -> Result<()> {
});
}
let _legacy_cid_sweep = spawn_legacy_cid_sweep(&state, &config);
let router = server::build_router(state.clone());
// Re-register the socket bound at startup — same fd, so there was never a
// moment with the port closed between the degraded and full servers.
@@ -564,6 +667,49 @@ async fn main() -> Result<()> {
Ok(())
}
/// U4 (#173): spawn the one-shot legacy provider-CID repair sweep. Releases before this
/// version stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`,
/// and this version's `/ipfs/{cid}` resolver withholds any row whose stored key is not
/// the raw-content CID. The opportunistic repair on the pin path only fires when a push
/// re-carries the object, which normal git negotiation makes it not do, so those rows
/// need a walk. DETACHED, never on the boot path: the caller keeps serving while this
/// runs, and the sweep's own batch bound plus inter-batch delay keep it off the DB's
/// critical path. Its cursor is durable, so a restart mid-walk resumes instead of
/// rewinding.
///
/// A named function rather than an inline block in `main` so the WIRING has a seam a
/// test can call: that the task is spawned at all, that it reads its batch and delay
/// from the config knobs rather than some other field, that the caller is not blocked
/// on it, and that the shutdown watcher actually ends it mid-walk. The sweep's own
/// behavior is covered elsewhere; this is the boot-path half.
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
let db = state.db.clone();
let repos_dir = config.repos_dir.clone();
let git_bin = state.git_bin.clone();
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
let batch = config.pin_repair_sweep_batch;
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
tokio::select! {
stats = ipfs_pin::sweep_legacy_provider_cids(
&repos_dir, &git_bin, git_timeout, batch, delay, &db,
) => {
if stats.repaired > 0 {
tracing::info!(
scanned = stats.scanned,
repaired = stats.repaired,
"legacy provider-CID sweep finished"
);
}
}
// Shutdown mid-walk simply drops the run; the persisted cursor means the
// next boot picks up where this one stopped.
_ = shutdown_rx.changed() => {}
}
})
}
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
tokio::spawn(async move {
#[cfg(unix)]
@@ -1024,6 +1170,140 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
}
}
#[cfg(test)]
mod legacy_cid_sweep_wiring_tests {
use super::spawn_legacy_cid_sweep;
use sqlx::PgPool;
use std::time::Duration;
/// Seed `count` `pinned_cids` rows whose keys are already canonical raw CIDv1, in a
/// known `sha256_hex` order. The sweep's own cost gate skips a raw-CIDv1 row without
/// reading bytes or resolving a repo, so each row is SCANNED (it advances the cursor)
/// and nothing else. That is what makes the cursor a clean readout of how far the
/// walk got, with no dependency on repos on disk.
async fn seed_scannable_rows(pool: &PgPool, count: usize) -> Vec<String> {
let mut shas = Vec::new();
for i in 1..=count {
let sha = format!("wire{i:02}");
let cid = gitlawb_core::cid::Cid::from_git_object_bytes(sha.as_bytes()).to_string();
assert!(
gitlawb_core::cid::is_raw_cidv1(&cid),
"the seeded key must hit the sweep's raw-CIDv1 skip, not a repair attempt"
);
sqlx::query("INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) VALUES ($1, $2, $3)")
.bind(&sha)
.bind(&cid)
.bind("2020-01-01T00:00:00Z")
.execute(pool)
.await
.unwrap();
shas.push(sha);
}
shas
}
/// Poll the persisted sweep cursor until it reaches `want`, or give up.
async fn cursor_reaches(db: &crate::db::Db, want: &str, within: Duration) -> String {
let deadline = std::time::Instant::now() + within;
loop {
let c = db.pin_repair_cursor().await.unwrap();
if c == want || std::time::Instant::now() >= deadline {
return c;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// #173 U4, the BOOT-PATH half. The sweep's own logic (batching, cursor resumption,
/// terminal vs retryable skips) is covered in `test_support`; what this covers is the
/// wiring `main` performs, which nothing else executes: the task is spawned at all,
/// it takes its batch and delay from the two `pin_repair_sweep_*` knobs rather than
/// some other config field, the caller is not blocked on the walk, and the shutdown
/// watcher ends the run mid-walk.
///
/// Six scannable rows, batch 2, delay 30s. One pass must land the cursor on exactly
/// the second row and the task must then still be alive in its inter-batch sleep,
/// which pins both knobs at once: a different batch stops at a different row, and a
/// delay that did not come from the knob either finishes the table or leaves the task
/// gone. Shutdown must then end it while four rows are still unwalked.
#[sqlx::test]
async fn the_boot_path_spawns_the_sweep_detached_with_its_configured_knobs(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let shas = seed_scannable_rows(&pool, 6).await;
let repos_dir = tempfile::TempDir::new().unwrap();
let mut config = (*state.config).clone();
config.repos_dir = repos_dir.path().to_path_buf();
config.pin_repair_sweep_batch = 2;
// Far longer than this test runs, so a task still alive after the first pass can
// only be one that is honoring the configured inter-batch delay.
config.pin_repair_sweep_delay_secs = 30;
let started = std::time::Instant::now();
let handle = spawn_legacy_cid_sweep(&state, &config);
let spawn_cost = started.elapsed();
let cursor = cursor_reaches(&state.db, &shas[1], Duration::from_secs(10)).await;
assert_eq!(
cursor, shas[1],
"the spawned sweep must run and stop its first pass at the CONFIGURED batch \
bound (2), leaving the cursor on the second row"
);
assert!(
spawn_cost < Duration::from_secs(1),
"the sweep must be detached, not awaited on the boot path; the spawn took \
{spawn_cost:?}"
);
assert!(
!handle.is_finished(),
"with a 30s inter-batch delay the task must still be sleeping between passes, \
not finished: a finished task means the delay was not the configured one"
);
state.shutdown();
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("the shutdown watcher must end the sweep, and not after its 30s delay")
.expect("the sweep task must not panic");
assert_eq!(
state.db.pin_repair_cursor().await.unwrap(),
shas[1],
"shutdown must have ended the run MID-walk, with the remaining rows unwalked"
);
}
}
#[cfg(test)]
mod lock_pool_sizing_tests {
use super::{lock_pool_size, LOCK_POOL_MAX_CONNECTIONS, LOCK_POOL_PUSH_HEADROOM};
/// The default push cap gets its cap plus headroom, so no push ever queues for a
/// lock-pool connection where it did not before.
#[test]
fn default_push_cap_gets_headroom_over_the_cap() {
assert_eq!(lock_pool_size(32), 32 + u32::from(LOCK_POOL_PUSH_HEADROOM));
assert_eq!(lock_pool_size(1), 1 + u32::from(LOCK_POOL_PUSH_HEADROOM));
}
/// #173 F4: `max_concurrent_git_pushes` is validated all the way to 1_048_576, so an
/// operator raising it used to raise the node's Postgres connection ceiling with it,
/// silently and without bound. The lock pool is CLAMPED instead.
#[test]
fn an_oversized_push_cap_is_clamped_not_propagated() {
assert_eq!(lock_pool_size(1_048_576), LOCK_POOL_MAX_CONNECTIONS);
assert_eq!(lock_pool_size(usize::MAX), LOCK_POOL_MAX_CONNECTIONS);
// The largest cap that still fits under the clamp keeps its full headroom.
let widest = (LOCK_POOL_MAX_CONNECTIONS - u32::from(LOCK_POOL_PUSH_HEADROOM)) as usize;
assert_eq!(lock_pool_size(widest), LOCK_POOL_MAX_CONNECTIONS);
assert_eq!(
lock_pool_size(widest - 1),
LOCK_POOL_MAX_CONNECTIONS - 1,
"values below the clamp must not be rounded up to it"
);
}
}
#[cfg(test)]
mod gossip_ssrf_tests {
use super::ping_peer_health;
+62 -4
View File
@@ -74,8 +74,10 @@ pub async fn pin_object(
/// `..._fail_closed` filter on the full-scan path before calling. `repo_path` is
/// still needed to read each object's bytes. The twin in `ipfs_pin.rs` mirrors
/// this shape — change both in lockstep. Objects already recorded with a
/// `pinata_cid` are skipped. Returns `(sha_hex, cid)` pairs for each newly
/// pinned object.
/// `pinata_cid` are skipped. `repo_id` records the pin's provenance (#173).
/// Returns `(sha_hex, provider_cid)` pairs for each newly pinned object: the
/// provider CID is the Pinata gateway CID (used for branch→CID recording and
/// ref-update gossip), NOT the raw resolver-key CID stored in `pinned_cids.cid`.
pub async fn pin_new_objects(
client: &reqwest::Client,
upload_url: &str,
@@ -83,6 +85,7 @@ pub async fn pin_new_objects(
repo_path: &std::path::Path,
object_list: Vec<String>,
db: &crate::db::Db,
repo_id: &str,
) -> Vec<(String, String)> {
if jwt.is_empty() {
return vec![];
@@ -92,7 +95,39 @@ pub async fn pin_new_objects(
for sha in object_list {
match db.has_pinata_cid(&sha).await {
Ok(true) => continue,
Ok(true) => {
// Backfill NULL first-pinner provenance from a known source, in lockstep
// with the ipfs_pin skip branch: a pinata-only node otherwise leaves
// pre-provenance rows' `pinned_cids.repo_id` NULL forever (grok P2-D). The
// resolver still finds the object via the pin_repo_sources union below, so
// this is a consistency backfill, not a correctness fix.
match db.provenance_for_oid(&sha).await {
Ok(None) => {
if let Err(e) = db.backfill_pin_provenance(&sha, repo_id).await {
tracing::warn!(sha = %sha, err = %e, "failed to backfill pin provenance");
}
}
Ok(Some(_)) => {}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "DB error reading pin provenance");
}
}
// F1 (#173 round 8): record this repo as an additional source for the
// already-pinned object (mirrors the ipfs_pin skip-branch insert) so the
// resolver can serve a shared object from any pin-path source. U3 (#173):
// retried through the SHARED helper (this was a bare call, so a single
// transient error dropped the source outright) and, on exhaustion, marked
// durably so the resolver keeps the bounded scan fallback for the object.
if let Err(e) =
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)).await
{
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
if let Err(e) = db.mark_pin_sources_incomplete(&sha).await {
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
}
}
continue;
}
Ok(false) => {}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "DB error checking pinata_cid");
@@ -111,9 +146,32 @@ pub async fn pin_new_objects(
match pin_object(client, upload_url, jwt, &sha, &data).await {
Ok(cid) if !cid.is_empty() => {
if let Err(e) = db.record_pinata_cid(&sha, &cid).await {
// The resolver key (`pinned_cids.cid`) must be the locally-computed
// raw-content CID, never the provider CID: Pinata wraps the bytes in
// dag-pb/UnixFS, so its returned CID does not hash the raw content and
// must not become an alias `/ipfs/{cid}` serves raw git bytes for (#173).
let raw_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&data).to_string();
// U3 (#173): both records go through the shared retry helper, at parity
// with the ipfs_pin twin. These were bare calls, so one transient DB error
// permanently dropped a pin source.
if let Err(e) = crate::ipfs_pin::retry_db_record(|| {
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id))
})
.await
{
tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB");
}
// F1 (#173 round 8): also record the first pinner in pin_repo_sources.
// U3: an exhausted retry marks the set incomplete so the resolver keeps
// the scan fallback rather than 404ing a copy it could serve.
if let Err(e) =
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)).await
{
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
if let Err(e) = db.mark_pin_sources_incomplete(&sha).await {
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
}
}
pinned.push((sha, cid));
}
Ok(_) => {}
+176
View File
@@ -121,6 +121,28 @@ impl RateLimiter {
true
}
/// Non-consuming check: is this key ALREADY at its limit for the current window?
/// Unlike [`check`], it records nothing and never inserts a new key — used to shed
/// expensive preparatory work (e.g. the `/ipfs/{cid}` legacy scan's O(repos) DB
/// preload) BEFORE it runs, without perturbing the per-unit budget the consuming
/// `check` maintains (#173, F3). An unknown key or a disabled limiter is not
/// throttled. Prunes the key's expired timestamps as a side effect (keeps state
/// tidy) but adds none, so it cannot itself fill or grow the map.
pub(crate) async fn is_throttled(&self, key: &str) -> bool {
if self.max_requests == 0 {
return false;
}
let now = Instant::now();
let mut state = self.state.lock().await;
if let Some(window) = state.get_mut(key) {
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
return window.timestamps.len() >= self.max_requests;
}
false
}
pub async fn cleanup(&self) {
let now = Instant::now();
let mut state = self.state.lock().await;
@@ -130,6 +152,103 @@ impl RateLimiter {
!w.timestamps.is_empty()
});
}
/// Number of distinct keys currently tracked. Test-only introspection so a
/// cross-module test can assert that a sweep actually evicted expired entries.
#[cfg(test)]
pub(crate) async fn tracked_keys(&self) -> usize {
self.state.lock().await.len()
}
}
/// A bounded per-caller CONCURRENCY limiter — distinct from [`RateLimiter`], which
/// caps request RATE. Each caller key may hold at most `per_caller` in-flight
/// permits at once; beyond that [`try_acquire`](Self::try_acquire) returns `None`
/// and the caller sheds. Used to stop one caller (a single anonymous source-IP or
/// DID) monopolizing the served-git read pool (#174).
///
/// The key map is self-bounding: a key is removed the instant its in-flight count
/// reaches zero, so it never holds more keys than there are concurrently-active
/// callers (itself bounded by the read semaphore). A `max_keys` reject-before-insert
/// backstop guarantees a key farm can never grow the map even if that invariant
/// weakened — a NEW key at the cap is rejected WITHOUT allocating an entry (INV-15).
///
/// Uses a `std::sync::Mutex` (not the file's `tokio::sync::Mutex`) because the
/// permit's `Drop` must release synchronously; the critical section holds no await.
#[derive(Clone)]
pub struct PerCallerConcurrency {
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
per_caller: usize,
max_keys: usize,
}
/// RAII permit from [`PerCallerConcurrency::try_acquire`]. On drop it decrements
/// the caller's in-flight count and removes the key when it reaches zero.
pub struct PerCallerPermit {
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
key: String,
}
impl PerCallerConcurrency {
pub fn new(per_caller: usize, max_keys: usize) -> Self {
Self {
state: Arc::new(std::sync::Mutex::new(HashMap::new())),
per_caller: per_caller.max(1),
max_keys: max_keys.max(1),
}
}
/// Convenience constructor with the default key bound.
pub fn with_default_max_keys(per_caller: usize) -> Self {
Self::new(per_caller, DEFAULT_MAX_KEYS)
}
/// `Some(permit)` when the caller is under its cap and the map has room;
/// `None` (shed) otherwise. Reject-before-insert: a new key at `max_keys` is
/// rejected without allocating.
pub fn try_acquire(&self, key: &str) -> Option<PerCallerPermit> {
// Recover from a poisoned lock rather than panicking: the critical section
// is pure counter arithmetic and cannot itself panic, so a poisoned mutex
// would only ever come from an unrelated abort, and a slightly-off count
// self-heals as permits drop. A panic here would instead brick the limiter
// for every caller (each subsequent lock re-panics).
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
match map.get_mut(key) {
Some(count) => {
if *count >= self.per_caller {
return None;
}
*count += 1;
}
None => {
if map.len() >= self.max_keys {
return None;
}
map.insert(key.to_string(), 1);
}
}
Some(PerCallerPermit {
state: self.state.clone(),
key: key.to_string(),
})
}
#[cfg(test)]
pub fn tracked_keys(&self) -> usize {
self.state.lock().unwrap().len()
}
}
impl Drop for PerCallerPermit {
fn drop(&mut self) {
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(count) = map.get_mut(&self.key) {
*count -= 1;
if *count == 0 {
map.remove(&self.key);
}
}
}
}
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
@@ -288,6 +407,63 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
mod tests {
use super::*;
#[test]
fn per_caller_concurrency_caps_one_caller_and_frees_on_drop() {
let lim = PerCallerConcurrency::new(2, 100);
let p1 = lim.try_acquire("did:key:zA").expect("first under cap");
let p2 = lim.try_acquire("did:key:zA").expect("second under cap");
assert!(
lim.try_acquire("did:key:zA").is_none(),
"a third in-flight op for the same caller sheds (over the per-caller cap)"
);
// A DIFFERENT caller is unaffected — the cap is per-caller, not global.
let _other = lim
.try_acquire("did:key:zB")
.expect("a different caller has its own budget");
drop(p1);
assert!(
lim.try_acquire("did:key:zA").is_some(),
"freeing one in-flight slot lets the same caller back in"
);
drop(p2);
}
#[test]
fn per_caller_concurrency_map_is_self_bounding_and_reject_before_insert() {
// Self-bounding: acquire+drop many distinct keys — the map never grows
// because a key is removed the instant its in-flight count hits zero.
let lim = PerCallerConcurrency::new(4, 3);
for i in 0..50 {
let _p = lim.try_acquire(&format!("k{i}"));
}
assert_eq!(
lim.tracked_keys(),
0,
"keys with zero in-flight ops are removed, so an acquire+drop flood leaves the map empty"
);
// Reject-before-insert: HOLD max_keys distinct keys, then a new key sheds
// WITHOUT growing the map past the cap (INV-15 — a rejected request never
// allocates an entry).
let held: Vec<_> = (0..3)
.map(|i| lim.try_acquire(&format!("h{i}")).unwrap())
.collect();
assert_eq!(
lim.tracked_keys(),
3,
"three distinct callers held concurrently"
);
assert!(
lim.try_acquire("h3").is_none(),
"a new key at max_keys is rejected"
);
assert_eq!(
lim.tracked_keys(),
3,
"the rejected new key did not allocate an entry (reject-before-insert)"
);
drop(held);
}
#[tokio::test]
async fn allows_within_limit() {
let limiter = RateLimiter::new(3, Duration::from_secs(60));
+11
View File
@@ -214,9 +214,20 @@ pub fn build_router(state: AppState) -> Router {
// identity and can apply per-repo visibility (#110); anonymous callers stay
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
// stays unsigned — gating the pin index is tracked separately (#121).
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
// request can drive a full-history git walk, so the per-IP rate limiter is the
// outermost layer (rejects a flood before the walk-admission work), mirroring the
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
let ipfs_limiter = rate_limit::IpRateLimiter {
limiter: state.ipfs_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let ipfs_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.layer(middleware::from_fn(auth::optional_signature))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(ipfs_limiter))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
// ── Arweave permanent anchors ──────────────────────────────────────────
+311
View File
@@ -65,6 +65,45 @@ pub struct AppState {
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
/// push path throttles on the resolved client IP instead.
pub push_rate_limiter: RateLimiter,
/// Per-client-IP ROUTE brake for `GET /ipfs/{cid}`: charged ONCE per request by the
/// `rate_limit_by_ip` middleware (server.rs), never inside the handler. It bounds
/// request RATE (the "requests per hour" contract of `GITLAWB_IPFS_RATE_LIMIT`) on
/// the non-farmable source IP, so an anonymous flood of the public route is capped.
/// The per-probe/per-walk WORK accounting the resolver does WITHIN a request draws
/// from the SEPARATE `ipfs_work_rate_limiter` below — the two cannot share one bucket
/// or a single request that spends a route token and then its own probe token off the
/// same bucket is admitted at the route and falsely shed mid-request (#173 round-10,
/// R6). Keyed by `push_limiter_trust`.
pub ipfs_rate_limiter: RateLimiter,
/// Per-client-IP WORK-budget limiter for the `GET /ipfs/{cid}` resolver's internal
/// fan-out: charged per legacy (NULL-provenance) PROBE (`acquire` + `cat-file`) and
/// per provenance-path WALK, and peeked non-consuming before the O(repos) legacy
/// preload. A legacy CID from the public pins index otherwise lets one request drive
/// O(repos) subprocess spawns and cold Tigris fetches, and repeat requests amplify
/// that across requests with zero limiter contact (INV-10, F3). Charging the work to
/// the non-farmable source IP bounds it. A bucket DISTINCT from the route brake above:
/// one request legitimately spends many work tokens (a full legacy scan is up to
/// `ipfs_max_legacy_probes` probes), so it must not double as the once-per-request
/// route bucket. Capacity is DERIVED from the route limit (`AppState::ipfs_work_budget`,
/// no separate operator knob), floored at the legacy-probe budget so a single default-
/// config deep search never self-throttles mid-scan. Keyed by `push_limiter_trust`.
pub ipfs_work_rate_limiter: RateLimiter,
/// Per-request ceiling on full-history reachability walks the CID resolver
/// may spawn (default `api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST`). A field,
/// not a bare const, so tests can shrink it to exercise the cap cheaply;
/// production keeps the const default.
pub ipfs_max_history_walks: u32,
/// Per-request ceiling on legacy (NULL-provenance) repo probes in the CID
/// resolver's scan fallback (default `api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST`).
/// Bounds the anonymous `acquire` + `cat-file` fan-out across the node (#173,
/// INV-10); a field for the same test-seam reason as `ipfs_max_history_walks`.
pub ipfs_max_legacy_probes: u32,
/// Hard ceiling on the byte size of an object `GET /ipfs/{cid}` will buffer and
/// serve (default `api::ipfs::MAX_SERVED_OBJECT_BYTES`). The serve reads via a
/// blocking `git cat-file` and buffers the whole object; without a bound a large
/// public blob could exhaust memory or block a runtime worker (#173, F6, INV-10).
/// A field for the same test-seam reason as the sibling caps.
pub ipfs_max_served_object_bytes: u64,
/// Which forwarded header (if any) the edge is trusted to set, for
/// resolving the push limiter's client-IP key. See `GITLAWB_TRUSTED_PROXY`.
/// Node-wide; also keys the two peer-sync limiters below.
@@ -88,6 +127,94 @@ pub struct AppState {
/// * the libp2p swarm task
/// * the gossip, sync, operator heartbeat, and rate-limit cleanup loops
pub shutdown_tx: tokio::sync::watch::Sender<bool>,
/// Bounds concurrent served git READ operations (upload-pack + both info/refs
/// advertisements). A read handler acquires a permit before spawning git and
/// holds it for the op; when none are free the request is shed with a 503.
/// Writes draw from `git_write_semaphore` so a read flood cannot shed an
/// authenticated push at admission (#174).
pub git_read_semaphore: Arc<tokio::sync::Semaphore>,
/// Bounds concurrent `git-receive-pack` (push) operations, a pool separate
/// from `git_read_semaphore` so an anonymous READ flood can never shed an
/// authenticated push (#174). Sized by `max_concurrent_git_pushes`. Drawn from
/// by the `git-receive-pack` POST (owner-gated) ONLY. The anon-reachable
/// receive-pack `info/refs` advertisement draws from the SEPARATE
/// `git_push_advert_semaphore` below, never this pool, so a multi-source flood
/// of push-handshake advertisements can never occupy a permit an authenticated
/// POST needs at admission (#174).
pub git_write_semaphore: Arc<tokio::sync::Semaphore>,
/// Bounds concurrent anon-reachable `git-receive-pack` `info/refs`
/// advertisements — a pool SEPARATE from `git_write_semaphore` so adverts (which
/// hold a permit across `acquire_fresh` + `info/refs`) can never consume a slot
/// the authenticated POST relies on. A per-source flood can at worst exhaust this
/// advert pool (each source also capped by `git_push_advert_per_caller` and the
/// per-IP push rate limiter), and the reserved POST pool is untouched (#174).
pub git_push_advert_semaphore: Arc<tokio::sync::Semaphore>,
/// Bounds concurrent post-push encrypt-then-pin history walks. Each successful
/// path-scoped push releases its handler write permit and then runs a DETACHED
/// full-history walk (`withheld_blob_recipients_bounded`) to seal withheld blobs;
/// without a cap, N fast pushes spawn N concurrent full-history git walks past
/// `max_concurrent_git_pushes` (which only bounds the in-handler phase). The walk
/// acquires a permit here and DEFERS (blocks) when the pool is full rather than
/// shedding — the work is background and dropping it would lose the recovery copy
/// (#174 P1-e). A pool of its own, not `git_write_semaphore`: a long background
/// walk must not hold a foreground write slot, and a handler already holding a
/// write permit that needed a second would self-deadlock at pool size 1.
pub git_encrypt_semaphore: Arc<tokio::sync::Semaphore>,
/// Bounds the OUTSTANDING post-push encryption-task set by per-repo coalescing
/// (#174 P2-2). `git_encrypt_semaphore` caps *active* walks; this caps how many
/// detached tasks *spawn and park* on that semaphore's `acquire_owned().await`.
/// Before spawning a per-push encryption task, the receive-pack handler consults
/// this set: if the repo already has a task in flight it coalesces (skips the
/// duplicate spawn) rather than parking a new unbounded waiter. Coalescing only
/// delays a duplicate walk — it never drops the withheld-blob recovery copy, which
/// `2a54c15` deliberately kept fail-closed (there is no reconciliation sweep to
/// re-derive a dropped copy). See [`EncryptInflight`].
pub encrypt_inflight: EncryptInflight,
/// Per-caller concurrency sub-cap on the read pool: each caller (keyed on the
/// resolved source IP, #174 U1) may hold at most `max_concurrent_reads_per_caller`
/// in-flight read ops, so one caller cannot monopolize `git_read_semaphore`
/// (#174). Applied by `git_upload_pack` and the upload-pack `info/refs`
/// advertisement.
pub git_read_per_caller: crate::rate_limit::PerCallerConcurrency,
/// Per-source concurrency sub-cap on the anon-reachable receive-pack `info/refs`
/// advertisement: each source IP may hold at most a small share of the write
/// pool, so a multi-source flood of push-handshake advertisements cannot
/// saturate `git_write_semaphore` and shed authenticated pushes (#174). Sized as
/// a fraction of `max_concurrent_git_pushes`, so filling the write pool takes many
/// distinct source IPs (each also braked by the per-IP push rate limiter).
pub git_push_advert_per_caller: crate::rate_limit::PerCallerConcurrency,
/// Per-source concurrency sub-cap on the authenticated `git-receive-pack` POST:
/// each source IP may hold at most a small share of `git_write_semaphore`, so one
/// host minting disposable `did:key` identities cannot open enough slow pushes to
/// monopolize the write pool and 503 every other source's push (#174 P1-d). Keyed
/// on the resolved source IP (never the DID — a DID farm defeats a DID key). Sized
/// like `git_push_advert_per_caller`, a fraction of `max_concurrent_git_pushes`.
pub git_write_per_caller: crate::rate_limit::PerCallerConcurrency,
/// Bounds concurrent `GET /ipfs/{cid}` visibility-walk requests. The public
/// `/ipfs/{cid}` route runs `allowed_blob_set_for_caller_bounded` in
/// `spawn_blocking` (a full-history git walk) with NO served-git admission of its
/// own; without this a permissionless caller fans out concurrent walks past every
/// git pool, exhausting the blocking pool + PIDs (#174 P1-3). A request acquires a
/// permit before the repo loop and holds it for the whole request (across every
/// `spawn_blocking` walk), so the slot reflects real thread occupancy — a tokio
/// walk-timeout cannot free it while the blocking work still runs. A pool of its
/// own (`max_concurrent_ipfs_walks`), NOT a git pool: distinct cost center + public
/// surface, so anonymous /ipfs traffic can never shed an authenticated git op.
pub git_ipfs_walk_semaphore: Arc<tokio::sync::Semaphore>,
/// Per-source concurrency sub-cap on the `/ipfs/{cid}` walk pool: each source
/// (keyed on the resolved source IP, never the DID — `/ipfs` admits any `did:key`
/// unthrottled, so a DID key would be free to mint around) may hold at most
/// `ipfs_walk_per_source` in-flight walk slots, so one source cannot monopolize
/// `git_ipfs_walk_semaphore` (#174 P1-3). A request with no resolvable key is
/// bounded by the global pool only, never this sub-cap. The key map is bounded
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
/// it (INV-15).
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
/// The `git` executable the served-git withheld-blob walk spawns. Production is
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
/// process-group teardown in handler tests without mutating the process-global
/// PATH (#174).
pub git_bin: String,
}
impl AppState {
@@ -97,6 +224,21 @@ impl AppState {
self.shutdown_tx.subscribe()
}
/// Sweep expired entries from every per-IP/DID rate limiter. Driven by the
/// periodic cleanup task so a bounded limiter's key map sheds stale entries
/// instead of sitting near its cap until an inline capacity sweep reclaims
/// them. Every limiter on the state is swept here; adding a new limiter means
/// adding it to this list.
pub(crate) async fn sweep_rate_limiters(&self) {
self.rate_limiter.cleanup().await;
self.create_ip_rate_limiter.cleanup().await;
self.push_rate_limiter.cleanup().await;
self.ipfs_rate_limiter.cleanup().await;
self.ipfs_work_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
self.peer_write_rate_limiter.cleanup().await;
}
/// Trigger graceful shutdown. Idempotent — calling more than once
/// has no effect. Returns `true` if this call was the one that
/// flipped the signal.
@@ -117,4 +259,173 @@ impl AppState {
pub fn is_shutting_down(&self) -> bool {
*self.shutdown_tx.borrow()
}
/// Legacy-probe budget wired from the `GITLAWB_IPFS_MAX_REPOS_WALKED` operator
/// knob (R5, KTD5). The knob seeds `ipfs_max_legacy_probes` at construction so it
/// controls the per-request legacy (NULL-provenance) probe fan-out it advertises.
/// It deliberately does NOT feed `ipfs_max_history_walks`: that ceiling must stay
/// at `MAX_HISTORY_WALKS_PER_REQUEST` (`MAX_PIN_SOURCES + 1`) or a provenanced
/// request with a full source set is truncated into a false 503, and the knob's
/// range starts at 1. The knob is `usize`, the field `u32`; the range cap
/// (1_048_576) keeps the cast lossless.
pub(crate) fn ipfs_legacy_probe_budget(config: &crate::config::Config) -> u32 {
config.ipfs_max_repos_walked as u32
}
/// Work-budget capacity for [`ipfs_work_rate_limiter`](Self#structfield.ipfs_work_rate_limiter)
/// (R6, KTD6), DERIVED from the route limit rather than a new operator knob. The route
/// limiter (`ipfs_rate_limiter`) charges once per request; this separate bucket absorbs
/// the resolver's per-probe/per-walk work charges so both the route "requests per hour"
/// contract and the amplification bound hold. Floor: at least one full legacy search per
/// window — the effective `ipfs_max_legacy_probes` (the `GITLAWB_IPFS_MAX_REPOS_WALKED`
/// knob, U4) — so a single default-config deep search cannot self-throttle mid-scan and
/// recreate the F6 admit-then-429 for a legitimate caller. `GITLAWB_IPFS_RATE_LIMIT=0`
/// disables the route brake and this derived bucket alike (a 0-capacity limiter admits
/// everything).
pub(crate) fn ipfs_work_budget(config: &crate::config::Config) -> usize {
if config.ipfs_rate_limit == 0 {
return 0;
}
config.ipfs_rate_limit.max(config.ipfs_max_repos_walked)
}
}
/// Bounds the OUTSTANDING post-push encryption-task set by per-repo coalescing
/// (#174 P2-2). Each successful path-scoped push `tokio::spawn`s a DETACHED task that
/// parks on `git_encrypt_semaphore.acquire_owned().await` (which DEFERS when the pool
/// is full rather than shedding — `2a54c15` kept it fail-closed so the withheld-blob
/// recovery copy is never dropped). The semaphore caps *active* walks, but nothing
/// capped how many detached tasks *spawn and park* on that await: N rapid pushes to a
/// repo spawn N parked tasks, each holding cloned object lists/rules/paths/keys — an
/// unbounded outstanding set.
///
/// This tracks the repo keys with an in-flight encryption task, each carrying a
/// DIRTY flag. Before spawning, the handler calls [`try_begin`](Self::try_begin): if
/// a task for the repo is already in-flight it MARKS THE REPO DIRTY and returns `None`
/// so the handler SKIPS spawning a duplicate (coalesce). The in-flight task consults
/// the flag at its tail via [`requeue_or_release`](EncryptInflightGuard::requeue_or_release):
/// a set flag makes it run ONE MORE pass (re-reading repo state) before it exits, so a
/// push coalesced during the in-flight window is REQUEUED, never dropped. This bounds
/// the outstanding set to <=1 task per repo without losing work: there is no
/// reconciliation sweep, so a *dropped* job would be lost forever.
///
/// The returned [`EncryptInflightGuard`] is moved into the detached task. The normal
/// exit path is `requeue_or_release`, which removes the repo key ATOMICALLY with the
/// "clean" decision — no push can land in the gap between "checked clean" and "task
/// exits". `Drop` is a panic backstop only: if the task panics (or returns without
/// calling `requeue_or_release`) it still removes the key so a crashed walk can never
/// permanently lock a repo out of future recovery copies.
#[derive(Clone, Default)]
pub struct EncryptInflight {
// std::sync::Mutex: only ever held for O(1) HashMap ops in a sync context (before
// `tokio::spawn`, at the task tail, and in Drop) — never across an await, so a std
// Mutex is correct and cheaper than a tokio one. The value is the DIRTY flag: a
// coalesced push flips it true so the in-flight task requeues one more pass.
repos: Arc<std::sync::Mutex<std::collections::HashMap<String, bool>>>,
}
impl EncryptInflight {
pub fn new() -> Self {
Self::default()
}
/// Try to begin an encryption task for `repo_id`. Returns `Some(guard)` if no task
/// for the repo was in-flight (the caller should spawn). If one already is, MARKS
/// the repo dirty and returns `None` (the caller COALESCES — skips the duplicate
/// spawn; the in-flight task requeues one more pass to cover this push).
pub fn try_begin(&self, repo_id: &str) -> Option<EncryptInflightGuard> {
let mut map = self.repos.lock().expect("encrypt_inflight mutex poisoned");
match map.get_mut(repo_id) {
Some(dirty) => {
*dirty = true;
None
}
None => {
map.insert(repo_id.to_string(), false);
Some(EncryptInflightGuard {
repos: Arc::clone(&self.repos),
repo_id: repo_id.to_string(),
released: false,
})
}
}
}
/// Number of repos with an in-flight encryption task. Test/metrics observability;
/// the bound under saturation is `len() <= number of distinct repos`, i.e. at most
/// one task per repo.
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.repos
.lock()
.expect("encrypt_inflight mutex poisoned")
.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Test-only: read the dirty flag for `repo_id` (`None` if no task is in-flight).
#[cfg(test)]
pub fn dirty(&self, repo_id: &str) -> Option<bool> {
self.repos
.lock()
.expect("encrypt_inflight mutex poisoned")
.get(repo_id)
.copied()
}
}
/// RAII guard for one in-flight encryption task's repo key. The task drives it at its
/// tail with [`requeue_or_release`](Self::requeue_or_release); `Drop` is a panic
/// backstop. Move-only — cloning would double-release.
pub struct EncryptInflightGuard {
repos: Arc<std::sync::Mutex<std::collections::HashMap<String, bool>>>,
repo_id: String,
released: bool,
}
impl EncryptInflightGuard {
/// TASK-TAIL check-and-clear, atomic with the release decision. If a push
/// coalesced since the last pass (dirty), clear the flag and return `true` (the
/// task loops one more pass). Otherwise remove the key and return `false` (the task
/// exits). Atomic under the mutex: a concurrent push either sets the flag BEFORE
/// this reads it (-> requeue covers it) or arrives AFTER the key is removed (-> a
/// fresh `try_begin` spawns a new task) — there is no window where the key is
/// present-but-clean while the task exits.
pub fn requeue_or_release(&mut self) -> bool {
let mut map = self.repos.lock().expect("encrypt_inflight mutex poisoned");
match map.get_mut(&self.repo_id) {
Some(dirty) if *dirty => {
*dirty = false;
true
}
_ => {
map.remove(&self.repo_id);
self.released = true;
false
}
}
}
}
impl Drop for EncryptInflightGuard {
fn drop(&mut self) {
// Panic backstop ONLY. The normal exit is `requeue_or_release` (which removed
// the key atomically and set `released`), so this does nothing then. If the
// task panicked or returned without releasing, remove the key so one crashed
// walk never permanently locks the repo out.
//
// Accepted residual: a panic with the dirty flag still set drops the requeued
// work — Drop cannot loop — the same loss class as the on-panic behavior before
// this change. No reconciliation sweep re-derives it.
if self.released {
return;
}
if let Ok(mut map) = self.repos.lock() {
map.remove(&self.repo_id);
}
}
}
File diff suppressed because it is too large. Load diff
+25
View File
@@ -437,6 +437,31 @@ mod tests {
);
}
// #135 T1: a Mode-B rule on `/secret/**` must DENY the withheld directory's
// OWN path `/secret` (the `path == prefix` arm), not just strict descendants —
// otherwise get_by_cid's tree gate would serve the /secret tree object and leak
// its children. Pins parity with get_tree, which denies the /secret path.
#[test]
fn subtree_rule_denies_the_withheld_directory_itself() {
let reader = "did:key:z6MkReader";
let rules = [rule("/secret/**", VisibilityMode::B, &[reader])];
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/secret"),
Decision::Deny,
"anon denied at the withheld directory's OWN path /secret"
);
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/public"),
Decision::Allow,
"anon allowed at a sibling path outside the withheld subtree"
);
assert_eq!(
visibility_check(&rules, true, OWNER, Some(reader), "/secret"),
Decision::Allow,
"listed reader allowed at the withheld directory (caller-aware)"
);
}
// #153 regression: cross-method DID must still be denied even when the
// trailing segment collides with a bare owner key.
#[test]
+139
View File
@@ -0,0 +1,139 @@
//! #174 U6 — INV-22 completeness guard (rung-raising).
//!
//! INV-22: a permit held per op recovers only if every path that holds it is also
//! duration-bounded and reaps the process group before releasing admission, and every
//! detached git/blocking task carries its own admission. PR #174 fixed five paths
//! (U1-U5) that violated this. Each fix has a per-unit RED/GREEN regression; together
//! those form the five-revert matrix. This guard adds the missing piece: a source-scan
//! tripwire that fails when a NEW site reintroduces the class, or when one of the five
//! gates is removed.
//!
//! It lives in `tests/` (a separate crate) on purpose: a guard that scanned the same
//! file it lives in would match its own identifier literals and pass vacuously. Here
//! the scanned `src/` files never contain this file's literals, so each check is
//! load-bearing — reverting the named gate turns the assertion red.
//!
//! These are deliberately coarse structural checks, not a parser. They cannot prove a
//! gate is *correct* (the per-unit tests do that); they prove a gate is *present and
//! not bypassed*, which is what stops the class from silently regressing.
use std::path::Path;
fn src(rel: &str) -> String {
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(rel);
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
}
#[test]
fn inv22_concurrency_gates_present_and_not_bypassed() {
let repos = src("api/repos.rs");
let smart_http = src("git/smart_http.rs");
let vis = src("git/visibility_pack.rs");
let ipfs = src("api/ipfs.rs");
// U1 / P1-a: run_bounded_git stands the watchdog down only after confirming the
// child actually terminated (WNOWAIT), not on the raw stdout-drain EOF — otherwise
// a child that closes stdout then hangs pins the permit past the deadline. The
// probe is defined and called, so >= 2 occurrences; reverting the fix removes both.
assert!(
vis.matches("child_terminated_without_reaping").count() >= 2,
"U1/P1-a gate missing: run_bounded_git must confirm child exit via \
child_terminated_without_reaping before signalling the watchdog"
);
// U2 / P1-c: on client disconnect KillGroupOnDrop must launch a detached reaper
// that runs the full TERM/grace/KILL/reap, not a lone SIGTERM. The reaper is spawned
// via a runtime handle in Drop; `Handle::try_current` is unique to that launch (the
// timeout path already has an async context and never calls it).
assert!(
smart_http.contains("Handle::try_current"),
"U2/P1-c gate missing: KillGroupOnDrop::drop must launch the full \
TERM/grace/KILL reaper on disconnect (Handle::try_current), not a lone SIGTERM"
);
// U4 / P1-d: git_receive_pack must acquire the per-source write sub-cap before the
// global write permit. The acquire reads `state.git_write_per_caller`; comments name
// the field without the `state.` prefix, so this targets the real acquire site.
assert!(
repos.contains("state.git_write_per_caller"),
"U4/P1-d gate missing: git_receive_pack must acquire the per-source write cap \
(state.git_write_per_caller) before the global write permit"
);
// U5 / P1-e: the detached post-push encryption walk must run through the
// admission-gated helper, which is wired to the shared encrypt pool.
assert!(
repos.contains("fn withheld_recipients_gated")
&& repos.contains("state.git_encrypt_semaphore"),
"U5/P1-e gate missing: the encryption walk must run through \
withheld_recipients_gated, which acquires git_encrypt_semaphore"
);
// U1 / R2 (#173 round-10): the path-scoped filtered-pack serve must thread the
// caller's AdmissionGuard through BOTH git stages so read + per-caller admission is
// held until the pack-objects group is reaped on disconnect, closing the cap bypass
// the plain upload_pack path already fixed. Two load-bearing markers: rev-list hands
// the disarmed guard back (its tuple return type), and build_filtered_pack forwards
// that guard into the pack-objects stage (the `admission` arg after the
// "pack-objects" label). Reverting either — dropping the guard between stages, or
// passing `None` to pack-objects — trips this.
assert!(
smart_http.contains("Result<(Vec<String>, Option<AdmissionGuard>)>")
&& smart_http.contains("\"pack-objects\",\n admission,"),
"U1/R2 gate missing: build_filtered_pack must thread the AdmissionGuard through \
rev-list -> pack-objects so the permits are held until the pack-objects group \
is reaped on disconnect (the path-scoped half of #174 P1-a)"
);
// U2 / R1 (#173 round-10): the `GET /ipfs/{cid}` serve pipeline must run in a
// DETACHED tokio task that OWNS the AdmissionGuard built from BOTH walk permits, so a
// cancelled or timed-out request releases admission only after the spawned (bounded)
// work completes — not the instant the handler future drops (the /ipfs half of #174
// P1-a). Two load-bearing markers: the guard is constructed from the two named
// permits, and the whole pipeline is moved into a `tokio::spawn`. Reverting to
// handler-local permits (dropping the guard/spawn) trips this.
assert!(
ipfs.contains("AdmissionGuard::new(ipfs_walk_permit, ipfs_caller_permit)")
&& ipfs.contains(
"let serve: tokio::task::JoinHandle<Result<Response>> = tokio::spawn(async move {"
),
"U2/R1 gate missing: get_by_cid must move both /ipfs admission permits into an \
AdmissionGuard owned by a detached tokio::spawn task so admission is released \
only after the spawned serve work completes (the /ipfs half of #174 P1-a)"
);
// U2 / KTD2 (#173 round-10): the probe/read children on the /ipfs path must be the
// duration-bounded twins (process-group teardown via run_bounded_git), not the bare
// `store::object_type` / `read_object_content` (or an unbounded `cat-file -s`) a tokio
// timeout cannot cancel — otherwise a wedged cat-file lingers and pins the held
// admission past the deadline. Reverting any twin call site back to a bare read trips
// this.
assert!(
ipfs.contains("object_type_bounded(")
&& ipfs.contains("object_size_bounded(")
&& ipfs.contains("read_object_content_bounded("),
"U2/KTD2 gate missing: the /ipfs probe+read must call the run_bounded_git-backed \
*_bounded twins so a wedged cat-file is reaped at the deadline, not left to pin \
the held /ipfs walk admission"
);
// P1-e non-bypass tripwire: the bounded recipients walk is spawn_blocking'd nowhere
// but inside withheld_recipients_gated. A second call site (count > 1) is a new
// detached git walk that skips the admission gate — exactly the class U5 closed.
assert_eq!(
repos.matches("withheld_blob_recipients_bounded").count(),
1,
"P1-e bypass: the bounded recipients walk must be invoked only inside \
withheld_recipients_gated; a new call site bypasses the encrypt-walk admission cap"
);
// U4 / P2-2: the detached post-push encryption task must be gated by the per-repo
// coalescing set (`encrypt_inflight.try_begin`) so the OUTSTANDING parked-task set is
// bounded to <=1 per repo. Removing the gate lets N rapid pushes spawn N parked
// waiters (the unbounded set U4 closed); the semaphore only caps active walks.
assert!(
repos.contains("state.encrypt_inflight.try_begin"),
"U4/P2-2 gate missing: the detached post-push encryption spawn must consult \
encrypt_inflight.try_begin to coalesce per repo (bound the outstanding-task set)"
);
}
+197 -5
View File
@@ -33,13 +33,16 @@ pub enum IpfsCmd {
cid: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
}
pub async fn run(args: IpfsArgs) -> Result<()> {
match args.cmd {
IpfsCmd::List { node, dir } => cmd_list(node, dir).await,
IpfsCmd::Get { cid, node } => cmd_get(cid, node).await,
IpfsCmd::Get { cid, node, dir } => cmd_get(cid, node, dir).await,
}
}
@@ -86,11 +89,32 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
Ok(())
}
async fn cmd_get(cid: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/ipfs/{cid}");
async fn cmd_get(cid: String, node: String, dir: Option<PathBuf>) -> Result<()> {
// #173 (F5): the resolver now serves path-scoped objects to authorized readers,
// so sign with an available identity like `gl ipfs list` — otherwise an owner or
// listed reader gets the opaque anonymous 404 for content they can read.
// `get_authed` signs when a keypair is present and falls back to unsigned.
//
// An explicit `--dir` is a request to use THAT identity: propagate a
// missing/corrupt-keystore error (like `list`) instead of silently sending an
// anonymous request the authorized reader would see as the node's opaque 404
// (#173 review). Only the default (no `--dir`) keeps the best-effort unsigned
// fallback, so `get` stays usable for genuinely public content.
let keypair = match dir.as_deref() {
Some(dir) => Some(crate::identity::load_keypair_from_dir(Some(dir))?),
None => crate::identity::load_keypair_from_dir(None).ok(),
};
let client = NodeClient::new(&node, keypair);
// #173 review (F1): the node now accepts equivalent multibase spellings,
// including base64 CIDs (prefix 'm'), whose alphabet contains '/', '+', '='.
// Interpolating the CID raw would make the client request (and sign)
// `/ipfs/<prefix>/<suffix>`, which neither matches the single-segment Axum
// route nor points at the intended target. Percent-encode the CID as exactly
// one path segment so the signed and sent target agree and the server's
// `Path` extractor decodes it back to the original CID.
let path = format!("/ipfs/{}", encode_cid_segment(&cid));
let resp = client
.get(&path)
.get_authed(&path)
.await
.with_context(|| format!("failed to fetch CID {cid} from {node}"))?;
@@ -119,6 +143,16 @@ async fn cmd_get(cid: String, node: String) -> Result<()> {
Ok(())
}
/// Percent-encode a CID so it occupies exactly one path segment of `/ipfs/<cid>`.
/// `urlencoding::encode` escapes every byte outside the RFC 3986 unreserved set
/// (ALPHA / DIGIT / `-._~`), so the base64-CID characters that would otherwise
/// break the single-segment route — `/`, `+`, `=` — are all escaped, and the
/// server's `Path` extractor decodes the result back to the original CID (#173
/// review, F1).
fn encode_cid_segment(cid: &str) -> String {
urlencoding::encode(cid).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -235,4 +269,162 @@ mod tests {
m.assert_async().await;
}
/// #173 (F5): `gl ipfs get` must SIGN with an available identity, like
/// `gl ipfs list`, so an owner/reader can retrieve a path-scoped object the node
/// now resolves by CID. RED before the fix: cmd_get ignores the identity dir and
/// sends an unsigned request, so the signature-matching mock is never hit
/// (cmd_get errors on the unmatched 501, and m.assert fails). GREEN after: the
/// signed request carries the RFC 9421 headers and is served 200.
#[tokio::test]
async fn test_cmd_get_signs_when_identity_present() {
let mut server = mockito::Server::new_async().await;
let keystore = seed_keystore();
let m = server
.mock("GET", "/ipfs/bafkreitestcid")
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_header("x-git-hash", "abc123")
.with_body("object bytes")
.create_async()
.await;
cmd_get(
"bafkreitestcid".to_string(),
server.url(),
Some(keystore.path().to_path_buf()),
)
.await
.expect("signed get of a resolvable object should succeed");
m.assert_async().await;
}
/// #173 (F5) must-not: a genuine anonymous denial must surface as an error, not
/// be masked as success. With no identity dir the request is unsigned; a 404
/// from the node must produce an Err mentioning the status.
#[tokio::test]
async fn test_cmd_get_anonymous_denial_is_error() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("GET", "/ipfs/bafkreidenied")
.with_status(404)
.with_header("content-type", "text/plain")
.with_body("no git object found")
.create_async()
.await;
let err = cmd_get("bafkreidenied".to_string(), server.url(), None)
.await
.expect_err("a 404 denial must be an error, not masked success");
assert!(
err.to_string().contains("404"),
"error should mention the status, got: {err}"
);
m.assert_async().await;
}
/// #173 (INV-8) must-not: the node's new 503 "search incomplete" (the legacy CID
/// scan hit its bound and could not prove absence) must surface as an actionable
/// Err naming the status, NOT be rendered as an empty/"not found" success — a
/// retryable outcome the caller has to see. Mirrors the 404 denial case for the
/// bounded-search response the resolver now emits.
#[tokio::test]
async fn test_cmd_get_search_incomplete_503_is_error() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("GET", "/ipfs/bafkreiincomplete")
.with_status(503)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"search_incomplete","message":"CID search incomplete — retry"}"#)
.create_async()
.await;
let err = cmd_get("bafkreiincomplete".to_string(), server.url(), None)
.await
.expect_err("a 503 incomplete-search must be an error, not masked as not-found");
assert!(
err.to_string().contains("503"),
"error should mention the status, got: {err}"
);
m.assert_async().await;
}
/// #173 review (F1): a base64 CID (multibase prefix 'm') can contain '/', '+',
/// and '='. The client must percent-encode it into ONE path segment before
/// building and signing `/ipfs/<cid>`; otherwise the '/' splits the target so
/// it misses the single-segment Axum route and the signature covers the wrong
/// path. Assert the encoded segment carries no raw '/', '+', or '=', and that
/// it decodes back to the original CID (the server's `Path` extractor performs
/// that same decode). RED with the old raw `format!("/ipfs/{cid}")`: the
/// segment still contains '/'.
#[test]
fn test_encode_cid_segment_escapes_base64_alphabet() {
let cid = "mFoo/Bar+baz==";
let encoded = encode_cid_segment(cid);
assert!(
!encoded.contains('/'),
"encoded CID must be a single path segment (no raw '/'), got: {encoded}"
);
assert!(
!encoded.contains('+'),
"encoded CID must escape '+', got: {encoded}"
);
assert!(
!encoded.contains('='),
"encoded CID must escape '=', got: {encoded}"
);
let decoded = urlencoding::decode(&encoded).expect("encoded CID must decode");
assert_eq!(
decoded, cid,
"encoding must round-trip back to the original CID"
);
}
/// #173 review: `gl ipfs get --dir <path>` must PROPAGATE a missing/corrupt
/// identity-load error like `gl ipfs list`, not silently fall back to an anonymous
/// request — otherwise an authorized reader pointing `--dir` at a broken keystore
/// gets the node's opaque 404 instead of the actionable key-load error. The
/// unsigned fallback is preserved only when NO `--dir` is given (covered by
/// `test_cmd_get_anonymous_denial_is_error`). RED before the fix (`.ok()` swallows
/// the error, an anonymous request is sent, and the `.expect(0)` mock is hit),
/// GREEN after.
#[tokio::test]
async fn test_cmd_get_explicit_dir_no_identity_errors_without_request() {
let mut server = mockito::Server::new_async().await;
// Empty keystore dir passed explicitly via --dir: no identity.pem present.
let empty = tempfile::TempDir::new().unwrap();
// The endpoint must never be hit when an explicit --dir fails to load.
let m = server
.mock("GET", "/ipfs/bafkreitestcid")
.expect(0)
.create_async()
.await;
let err = cmd_get(
"bafkreitestcid".to_string(),
server.url(),
Some(empty.path().to_path_buf()),
)
.await
.expect_err("an explicit --dir that fails to load must be an error");
assert!(
err.to_string().contains("gl identity new")
|| err.to_string().contains("no identity found")
|| err.to_string().contains("failed to load keypair"),
"error should name the key-load failure, got: {err}"
);
m.assert_async().await;
}
}