Compare commits

...
Author SHA1 Message Date
beardthelion 3a82ccb8a1 fix(node): satisfy clippy on stale-local download test path join 2026-08-30 16:31:25 -05:00
beardthelion c306933e07 style(node): rustfmt advisory-lock gap-closure commits 2026-08-30 16:26:21 -05:00
beardthelion 22da2d6b96 fix(node): refuse stale under-lock refresh and gate tail durability on release
Under-lock GET failures now always shed as RepoUnavailable, even when a local
copy exists, so a transient download error cannot publish over a newer stored
generation. PublishDurabilitySlot only synthesizes UploadUnknowable after
release starts, and receive-pack marks that boundary before spawning the tail.

Adds regression tests for both paths; inv22 F4 gate accepts the release wrapper.
2026-08-30 16:14:25 -05:00
beardthelion 5cd6703aad fix(node): compensate under the write lock and harden tail durability handoff
Run create_issue rollback through release_compensating so issue refs are
deleted only while the advisory lock is still held and only on definite
publish refusals, not UploadUnknowable. Replace the tail durability slot's
async try_lock drop with a std mutex so cancellation during polling cannot
skip installing UploadUnknowable.
2026-08-30 15:30:34 -05:00
beardthelion 85c6f48bc4 fix(node): verify adversarial findings on advisory-lock PR
Fail closed when publish durability never records, bound pg_advisory_unlock
with the same transfer budget as upload, and cap close_issue pre-lock snapshots
at git_acquire_timeout_secs instead of the write-lock transfer bound.
2026-08-30 13:25:06 -05:00
beardthelion 9d02139dad fix(node): close three jatmn findings on advisory-lock PR
Retry fork create confirmation before compensating or returning, schedule
background recovery when the lookup stays unavailable, install publish
durability outcomes before the recorded flag, and size the lock pool for
non-push mutations (default 40).
2026-08-30 13:04:54 -05:00
beardthelion 487687c5fd fix(node): add ValidatedRepoDiskPath for CodeQL and reconcile tail durability
Introduce a validated-path newtype with the same inline join and component
walk main uses, so path-injection sinks only accept sanitised repo paths.
Reconcile the publish-durability gate with the disconnect-safe tail spawn via
PublishDurabilitySlot and explicit UploadUnknowable handling.
2026-08-29 15:29:01 -05:00
beardthelion 2ad2f12860 fix(node): gate replication tail on publish durability and split close_issue limits
Wait for a confirmed release outcome before post_receive_replication_tail runs
any walks or coalescing work. Give close_issue its own per-IP rate bucket so
it cannot drain receive-pack quota. Add regression tests for both paths.
2026-08-29 12:29:36 -05:00
beardthelion 5d3a4ea462 fix(node): close write-attempt ownership gaps on advisory-lock release
Make publish swaps claim-exclusive via CAS, invalidate the local read cache
on definite publish refusals, harden fork create compensation and clone
cleanup, and keep snapshot temp dirs owned through the async handoff.
2026-08-29 12:01:06 -05:00
beardthelion 1911291533 fix(node): harden issue close/create and fork publish paths on #285
Move close_issue rate limiting after the read gate so denied readers see 404
not 429, roll back local issue refs when publish refuses, re-check swap
authority under publish_lock, propagate fork upload failures before DB insert,
and update lock-pool config help text.
2026-08-28 22:53:17 -05:00
beardthelion 8e66e77c57 fix(node): close jatmn round on publish authority and durability gating
Revoke under-lock refresh swaps when the advisory lock ends, fail closed on
unrecorded release outcomes before Pinata/gossip, compensate fork archives when
create_repo fails, recompute acquire backoff from the live deadline, and route
publish swaps through validated_repo_disk_path for CodeQL.
2026-08-28 22:10:38 -05:00
beardthelion d1ce2e3291 fix(node): harden release-upload outcomes and replication tail gating
Treat timed-out or failed archive uploads as non-durable in into_result,
gate pinata/gossip on confirmed publish durability while keeping inv22 spawn
order, split lock-pool vs main DB pool validation, map LockPoolBusy through
From<anyhow::Error>, and attach Retry-After on repo write contention errors.
2026-08-28 16:02:06 -05:00
beardthelion 4eb0c4479c fix(node): route fork disk paths through validated_repo_disk_path
CodeQL flagged remove_dir_all on a user-derived path in fork_repo. Use the
same three-layer barrier as RepoStore::local_path and reject names the
validator refuses before clone.
2026-08-28 15:17:52 -05:00
beardthelion 4f1c1bbd09 fix(node): drop unused logcap helper after drain_faults seam 2026-08-28 14:45:25 -05:00
beardthelion 98fe670c11 fix(node): carry transient acquire failures through every write handler
Extend acquire_write_app_error so RepoBusy and RepoUnavailable map to
fixed-body 503s on issue and pull paths, matching receive-pack. Fix the
flaky u2 drain exhaustion test via a drain_faults seam and add handler
regressions for contention and storage unavailability on create_issue.
2026-08-28 13:56:42 -05:00
beardthelion 3df71c9aef fix: unblock pre-push after main merge
Read CappedBody.text in gl whoami (#381 shape), restore legacy CID sweep
wiring dropped in the merge, route rate-limiter cleanup through AppState,
and allow build_lock_pool for test-only callers.
2026-08-25 23:29:00 -05:00
beardthelion 7ba0fefd75 fix(node): mark close_issue mirror visibility gate for push hook
Annotate list_visibility_rules with mirror-rows-handled so the pre-push
surface detector accepts the synced-row read gate.
2026-08-25 23:09:33 -05:00
beardthelion 3c201486cc fix(node): cap close_issue snapshot work on the read pool
Take a git_read_semaphore permit before the non-owner author snapshot so
parallel close attempts cannot each drive a full archive extraction while still
under the hourly rate bucket. Format owner-DID test helpers.
2026-08-25 23:03:07 -05:00
beardthelion b35cb31944 fix(node): address jatmn review on merged advisory-lock head
Align .env.example db pool with validate floor, move snapshot cleanup into
the blocking extract task, remove fork clones on PreconditionLost, restore
upload_site_reached test plumbing, and use owner DIDs in receive-pack tests
after #330 owner-push default.
2026-08-25 22:30:07 -05:00
beardthelion 4cee7e0935 merge origin/main into fix/279-advisory-lock-session-affinity
Reconcile the advisory-lock series with main after #173, #330, and related
landings. Conflict resolution in repo_store, repos, issues, pulls, ipfs, and
main.
2026-08-25 22:30:00 -05:00
Kevin Codex 73fd7473af Merge pull request #330 from Gitlawb/fix/default-enforce-owner-push
fix(node)!: enforce owner-only push by default
2026-08-25 22:06:12 +08:00
Kevin Codex 21c74c65d9 Merge pull request #223 from Gravirei/fix/issue-220-whoami-registered-false-trust-lookup
fix(gl): only report registered: false on 404 in whoami (#220)
2026-08-25 22:04:56 +08:00
Kevin Codex 21f8cb6135 Merge pull request #173 from Gitlawb/fix/issue-135-ipfs-cid-tree-gate
fix(node): gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak (#135)
2026-08-25 22:04:14 +08:00
Gravirei 861cced8f8 chore: update h2 0.4.13 → 0.4.18 to resolve RUSTSEC-2026-0258 2026-08-24 15:10:32 +06:00
Gravirei 39ce655904 fix(gl): sanitize full transport error chain in whoami, remove raw error attachment (#220) 2026-08-23 22:12:19 +06:00
Gravirei d25cd0c6e6 merge: resolve sync.rs conflict — move read_body_capped/sanitize_node_msg to http.rs (#220) 2026-08-23 22:07:46 +06:00
beardthelion 9dd71e999c fix(node): record why the close-issue read gate cannot carry a synced row
The gate added for the pre-lock snapshot reads a repo row's own visibility
rules and public flag. A row synced from a peer is stored public and carries
none of the owner's rules, so for that class the gate can only return allow.

Refusing instead would deny the repo's real owner and the issue's real author
on any node whose only copy is a synced one, and it would not buy the
protection it appears to, because a synced row's recorded owner comes from the
peer that sent it. Every other read gate in the API reaches the same verdict
for such a row, so this is left as is and stated at the call site rather than
special-cased here.

The test that covered the gate seeds a locally created repo and cannot observe
this. Adds one that pins the property directly: the gate allows an arbitrary
caller on a synced row, and the handler refuses that caller anyway, because the
owner-or-author check is what decides this route.
2026-08-18 00:43:06 -05:00
beardthelion 9f35dca002 fix(node): address the 2026-08-10 review round on the advisory-lock series
- P1: serve the receive-pack advertisement from a non-mutating snapshot
  instead of acquire_fresh, so the unlocked advertisement cannot delete or
  swap the live repo directory under a concurrent guarded write (the stale-ETag
  success ordering). acquire_fresh loses its only production caller and is
  removed.
- P1: read-gate, rate-limit, bound, and cancellation-clean the close_issue
  pre-lock snapshot, so a signed non-owner cannot drive unbounded Tigris
  downloads and blocking extractions, and an abandoned extraction no longer
  leaks its temp dir.
- P1: classify raw 409/412 responses as a lost conditional write via
  SdkError::raw_response() in both upload and the publish supersede-retry, so
  an unparsable error body cannot acknowledge a write that was never published.
- P2: wrap the cold-cache under-lock download failure in RepoUnavailable so it
  sheds as a retryable 503 like the HEAD arm, not a permanent 500.
- P2: surface a refused create-only fork upload as PreconditionLost and refuse
  the fork, so an orphan archive cannot shadow a fork's DB record.

Refs #279
2026-08-18 00:04:50 -05:00
beardthelion 2a92a1f73a fix(node): reconcile F-series tests and test seams with the rebased guard shape
The rebase onto main carried main's #174 F-series guard tests forward. The
branch's guard replaces main's locked/released/test_pre_unlock_gate Drop
mechanics with close-on-drop (runtime) and leak (off-runtime), so two tests
that asserted the replaced mechanics are reconciled: the off-runtime disposal
test now asserts the leak observable (the slot never returns to idle) and the
detached-unlock-returns-connection test is dropped, its invariant covered by
the U-series drop-frees-the-lock gates. The pre-unlock gate seam is restored
(test-only) so the mid-unlock cancellation tests keep their deterministic
park. ipfs.rs tests are updated to the branch's sync test-client constructor
and 4-arg RepoStore::new.

Refs #279
2026-08-17 23:23:32 -05:00
beardthelion 3d803ff217 test(node): drive the abandoned-upload race end to end, and probe the real backend
The headline test is the one that had to exist. Writer A's release is parked past
its transfer bound and returns with the outcome unknowable, B acquires and
publishes, and A's captured PUT is then replayed: the store answers 412 and B's
archive survives. The create-only variant covers the arm whose real-world failure
is silent rather than loud.

The control is what makes those attributable. With no interleaved B, an
abandoned-then-replayed PUT whose generation still matches lands. Without it the
headline would only show that replays get rejected, not that staleness is what
rejects them.

Header assertions come last in all three, so a lost fence reds on the outcome it
is about rather than on a wire-format check.

A mock cannot prove Tigris honors any of this, and the vendor requires a
Single-region or Multi-region bucket for conditional operations, so against a
Global or Dual-region bucket the fence is a silent no-op. The credentials-gated
probe checks both arms against the real endpoint and cleans up unconditionally,
including when an assertion fails, which is the case it exists to catch. It
accepts 412 or 409 on the create-only arm because both mean the precondition was
enforced and both are already classified as a loss.
2026-08-17 23:00:06 -05:00
beardthelion 317841ee24 fix(node): fence the release publish so an abandoned upload cannot win
acquire_write now reads the archive's ETag under the advisory lock and the guard
carries it, so release publishes conditionally on the generation it actually
refreshed from. An upload abandoned by an earlier writer no longer overwrites a
successor's archive: the store rejects it, because the ETag it was written
against is gone.

A refused precondition gets exactly one supersede-retry, never a loop. The
distinction that makes this sound is that a 412 is a definite answer, unlike the
timeout arm where nothing is knowable, and the retrying writer still holds the
lock, so whatever landed underneath was written without one and its tree is not
the authority. Two losses in a row refuse instead of escalating.

That retry is what keeps ordinary pushes working now that init publishes
create-only: a first push racing init's upload of the empty repo loses once and
then wins, rather than surfacing a 503 on the most common operation there is.

release returns a must-use outcome and the four publishing handlers propagate it
before any trust bump, webhook, or success body, so a publish the store refused
reads as a retryable 503 instead of a 201. The three release(false) sites
deliberately do not map it: they publish nothing, and a 503 there would shadow
the 403 or 404 the route means to return.

The download-failure fallback also publishes fenced now. That arm knows the
stored generation (its HEAD succeeded, only the GET failed), so publishing
unconditionally from it would reintroduce the same overwrite.
2026-08-17 23:00:06 -05:00
beardthelion 3eabf794a5 feat(node): let the storage client express and detect a conditional write
upload takes an UploadPrecondition (IfMatch, IfAbsent, Unconditional) rather
than an Option<String>, so the absent case and the deliberate no-fence case are
distinguishable at the type level and a caller cannot lose the fence by passing
None. head_etag reads the current ETag alongside exists, which keeps exists and
its other callers untouched.

A failed precondition has to be classified off the raw HTTP status: PutObjectError
models no PreconditionFailed variant, so a 412 arrives as Unhandled with nothing
useful on it. 412 is always a lost precondition and 409 is one under IfAbsent.
404 deliberately is not: no archive delete exists on this line, so a 404 on a
conditional PUT means a wrong bucket or endpoint, and reporting that as retryable
would send clients into a loop against a permanent fault.

The three background uploads outside the write guard now publish with IfAbsent.
They fire only where the archive is expected absent, and leaving them
unconditional would defeat the fence from the side: init uploads an empty bare
repo, so a push landing just before it could have its archive replaced by that
empty one. A refusal there means someone else already published the key, which is
logged as the correct outcome rather than a failure.
2026-08-17 22:59:52 -05:00
beardthelion 31f90c4e05 fix(node): stop pretending a held lock fences a late upload
The timeout arm claimed that keeping the advisory lock protected a successor
from an abandoned PUT. It does not. release takes mut self, so the guard drops
the moment it returns and Drop closes the session; measured on this branch, a
successor took the same repo's lock 5ms after release returned while the PUT
was still in flight.

The comment and warn now say what is actually true: the outcome is unknowable,
the PUT may still land, the lock releases normally, and a conditional upload is
what keeps a late publish from overwriting a successor's archive.

Two tests replace the claim. Session disposition is the observable that
separates the two shapes, so the unlock is pinned to run and be confirmed on the
guard's own session with the connection returned to the pool, checked by backend
pid. Successor admission is pinned too, but noted as not what proves the point,
since the lock frees within milliseconds either way.
2026-08-17 22:59:52 -05:00
beardthelion 60d70c7fc7 test(node): add an S3 mock that really enforces conditional writes
The fence work landing next is only as good as what the tests can observe, and
a mock that answers 200 to every PUT would make the whole suite vacuous. This
one holds the object and its ETag, refuses a mismatched If-Match and an
If-None-Match "*" over an existing object with 412, and mints a fresh ETag per
successful PUT so two byte-identical archives never share a token.

Capture-then-replay is deliberate rather than parking a handler and hoping it
resumes: when tokio drops an SDK future the client can tear the connection down
and cancel the server task with it. Replaying what arrived models the arm that
matters (body fully transmitted, commit decided later) with no timing in it.

Six tests pin the semantics in both directions so a hollowed mock cannot hide.
2026-08-17 22:59:52 -05:00
beardthelion 9f313b9bf7 fix(node): bound every await in the lock-acquire loop by the deadline
The remaining budget was checked before the pool checkout but bounded neither
the checkout nor the pg_try_advisory_lock query that follows it. A checkout
starting just under the deadline could wait out the pool's own acquire timeout,
and a slow query could be accepted after the budget was spent, so the advertised
wall-clock cap held only on the fast path.

Both awaits now run under the remaining budget and shed as RepoBusy when it runs
out. The probe's Drop closes its session, which cannot hold a lock it never
confirmed taking, so the query-timeout arm is a plain shed.
2026-08-17 22:59:52 -05:00
beardthelion c4dbd8a797 fix(node): read the close_issue author pre-check from a non-mutating snapshot
The non-owner author fallback refreshed through acquire_fresh, which publishes
into the live repo directory: its extract step removes the existing directory
and renames the new one into place. That runs with no write lock held, so any
signed non-owner could trigger a directory swap underneath an in-flight
guarded write on the same path.

read_snapshot downloads to a throwaway temp dir and hands back a RepoSnapshot
that removes it on drop, so the pre-check still sees fresh data and the live
path is never touched. download_to grows a publish flag to serve both shapes
from one path.

The wedge invariant still holds: a stranger is refused without waiting on the
write lock.
2026-08-17 22:59:52 -05:00
beardthelion f81ebbe614 test(node): assert the second writer sheds as RepoBusy instead of timing out
two_writers_on_the_same_repo_are_not_both_admitted wrapped the second
acquire_write in an 8-second outer timeout and only checked the future had not
finished. That passes for any stall, including lock-pool saturation or a slow
CI box, so it could not tell a working shed from an unrelated hang, and it cost
8 seconds on every suite run.

Use with_lock_acquire_deadline and assert the typed RepoBusy downcast while the
first guard is still held, matching what contended_acquire_sheds_as_repo_busy_
not_internal_error already does. It now also fails loudly if a second writer is
admitted, which the timeout version could not distinguish.
2026-08-17 22:59:52 -05:00
beardthelion 5fdb902096 fix(node): raise a failed fresh download with no local copy as RepoUnavailable
acquire_fresh already refused a failed Tigris HEAD as RepoUnavailable, so the
handler layer mapped it to a retryable 503. A failed GET on the same path still
returned a bare anyhow error, which the info/refs map_err closure stringified to
AppError::Git and answered 500. One endpoint therefore told the client a
transient object-storage blip was permanent or retryable depending on which call
failed, and close_issue's pre-check inherited the same split through its bare ?.

Raise it at the source instead of at each consumer: From<anyhow::Error> for
AppError already downcasts RepoUnavailable out of the context chain, so both
callers pick up the retryable mapping without touching either.

The new test drives HEAD 200 with GET 500, which is the exact state the refusal
is for: archive present per HEAD, GET failed, no local copy to fall back on.
2026-08-17 22:59:52 -05:00
beardthelion 999ee72f3c test(node): poll for the closed session instead of sleeping a fixed 300ms
The release-invariant test slept 300ms for a Drop-spawned close before
comparing backend pids, which is a coin flip on a loaded CI runner. It now
polls pg_stat_activity for the captured pid on a standalone connection, the
same discipline poll_until_free documents: a pooled observer would be handed
the session under measurement and hide the effect.

The conversion was checked against the failure it exists to catch rather than
assumed. Pooling the session on an unlock that returned false makes the test
fail after the poll deadline, not hang, and no_reap_pool disables idle timeout
and max lifetime so nothing but the close under test can retire that backend.
A generous deadline would have been the same defect as the sleep.
2026-08-17 22:59:52 -05:00
beardthelion 259d5873ec fix(node): log the read failure the close_issue pre-check folds into a denial
The authorship pre-check treated a get_issue I/O error and a genuinely absent
issue as the same None, with no log line. The client answer is deliberately
identical, since a caller who cannot write must not learn whether the issue
exists, but the two are not the same event and an operator had no way to tell
a real authorization denial from a filesystem or parse failure behind it.

Splitting the arm leaves the 403 exactly where it was and makes the read
failure visible in the log.
2026-08-17 22:59:52 -05:00
beardthelion 10230ac50e fix(node): log expected transient acquire failures at warn, not error
Both acquire call sites logged every failure at error, including RepoBusy,
which the raise site already logs at warn. Ordinary write contention paged.
The previous commit widened the problem: info_refs now raises RepoUnavailable
on a storage blip, so that site would have started paging on the condition
this series just classified as transient and retryable.

A classifier over the two typed refusals picks the level at both sites,
mirroring the startup path's permanent-vs-transient split. Anything it cannot
classify still logs at error, so an unknown failure keeps paging.
2026-08-17 22:59:52 -05:00
beardthelion 63a9ef8b1b fix(node): refuse a fresh-copy write when the archive HEAD cannot be read
acquire_fresh collapsed a failed HEAD into "no archive exists" via
unwrap_or(false) and served the local copy, while the sibling path under the
lock already refused on the same condition. A push that hit a storage blip got
an advertisement built from a possibly-stale tree, uploaded a whole pack, and
was then refused by acquire_write for the reason the advertisement had already
swallowed. The authorship pre-check on close_issue read the same way, which is
an infrastructure failure resolving toward a denial.

A failed HEAD now raises RepoUnavailable, so both callers surface it as the
retryable 503 rather than a stale success. The download-failure fallback is
unchanged: a present-but-unreadable archive is still self-healed from local.

info_refs keeps its map_err for every other failure so the read path's error
vocabulary does not move; only the typed error takes the From chain.

A test-only TigrisClient constructor pointed at a closed port makes both
refusals executable, so this is no longer verified by reading. It also proves
the under-lock arm end to end, which the earlier draft had recorded as an
untestable gap.
2026-08-17 22:58:55 -05:00
beardthelion e5558fbb27 fix(node): shed an unrefreshable write as a retryable 503 with a fixed body
The two refusal arms added for the under-lock transfer bound returned bare
anyhow errors, so AppError's From impl (which downcasts only sqlx::Error and
RepoBusy) landed them in Internal: a 500 internal_error whose body was the
error string. Two defects in one. A transient object-storage failure told the
client not to retry, and the timeout arm's message interpolated the owner slug
and repo name straight into the response body, contradicting the fixed-body
policy the RepoBusy arm sets six lines above it.

RepoUnavailable follows RepoBusy exactly: a fieldless type raised with the
operator detail in a context string, downcast to its own rung, mapped to a 503
whose body interpolates nothing. The detail stays in the log at the raise site.

The timeout arm logs at error! where its sibling logs at warn!, deliberately: a
300s stall pinned a lock-pool slot for five minutes and is the condition the
transfer bound exists to surface, so it must keep paging when the handler
classifier demotes the ordinary blip case.
2026-08-17 22:58:26 -05:00
beardthelion af5948b204 style(node): rustfmt the new contention test
Whitespace only; cargo fmt --check gates the push.
2026-08-17 22:58:26 -05:00
beardthelion 8cf6b7ae46 fix(node): re-authorize close_issue under the guard instead of only re-checking existence
The guarded re-read matched `Ok(Some(_))` and discarded the blob, so the
authorization decision rested entirely on the pre-lock read. A comment above
claimed the opposite. That is not a narrow window: `acquire_write` re-downloads
the archive after locking, so the tree that gets mutated is routinely not the one
the author was read from. With owner-push enforcement defaulting to false and
branch protection covering only `refs/heads/*`, `refs/gitlawb/issues/*` is
pushable, so a forged author blob landing between the two reads was honored.

The blob is already in hand under the guard, so re-asserting owner-or-author
costs a deserialize. A non-owner whose issue has vanished gets 403 rather than
404, matching the pre-check's existing refusal to reveal existence.

`owner_can_still_close_after_the_reorder` seeded the owner as their own issue's
author, which made it unable to fail: with the owner check disabled the author
fallback granted the close and the test stayed green. It now seeds a third party,
so only the owner arm can grant. RED with `is_owner = false`.

Also drops the claim that the author twin covers the acquire-vs-acquire_fresh
distinction. `RepoStore::for_testing` hardcodes `tigris: None`, so the two calls
are identical in every test here and reverting that line leaves the twin green.
Separating them needs an object-storage seam. Stating the gap beats asserting
coverage that does not exist.
2026-08-17 22:58:26 -05:00
beardthelion 82143ad692 fix(node): refuse a write when object storage is unknowable, and shed contention as 503
Four defects a seven-reviewer pass found in the previous two commits, three of
them cases where one arm of a match contradicted its neighbour.

The under-lock refresh read a failed Tigris HEAD as "no archive"
(`exists().await.unwrap_or(false)`), skipped the refresh, and wrote against a
possibly-stale tree, then re-uploaded over it. That is the outcome the timeout
arm twenty lines below explicitly refuses. A failed HEAD and a failed download
leave us knowing different things, so they no longer share a branch: only a
download failure after a successful HEAD establishes that the local copy is a
sound thing to fall back to and re-upload. A HEAD failure now refuses the write.

Lock contention that ran out the acquire deadline surfaced as a 500 carrying the
owner slug and repo name in the client-visible body, while pool exhaustion
fifteen lines up returned a deliberate 503. Contention is transient and ordinary,
so it now maps through a typed `RepoBusy` to a retryable 503 with a fixed body;
the detail stays in the log at the raise site.

`lock_not_taken` was assigned after the try-lock answered, so an error left a
previous `true`-derived value standing and could return a lock-holding session to
the pool. It is now cleared before the statement is sent.

`leak()`'s no-panic property in the guard's no-runtime Drop branch depended on
`min_connections == 0` without saying so; a future tuning change would have
silently re-armed a panic inside Drop. Made explicit with the reason.

Two tests that did not bind:

  - `waiter_on_one_repo_does_not_block_another` proved only that two lock keys do
    not collide. It now samples the pool counters across more than two backoff
    cycles. RED at 0/50 samples with `drop(probe)` moved after the sleep, the
    exact defect it names, which the previous version survived.
  - the exhaustion test's `Ok(Err(_)) | Err(_)` was satisfied by its own outer
    timeout. It now requires `PoolTimedOut` and asserts the slots are accounted
    for. RED with the pool sized N+1.

`contended_acquire_sheds_as_repo_busy_not_internal_error` is new and drives the
deadline path for real; the deadline became a field so it does not wait 90s. RED
at 500-vs-503 with the downcast removed.

The acquire deadline's docstring claimed a total under the 120s proxy idle
timeout, which the 300s under-lock transfer bound in the same function
contradicts. It bounds the wait only, and now says so. The backoff is clamped to
the remaining budget.
2026-08-17 22:58:26 -05:00
beardthelion d346bde532 fix(node): log lock-pool saturation in the request path, not via readiness
Completes the shed-in-path half of the availability story. F7 already made a
pool timeout a retryable 503 by letting the sqlx downcast through; this adds
the operator-facing half, with the pool's own size/idle counters so an
incident can distinguish "the pool is full" from "the database is gone"
without reproducing it.

Deliberately NOT a readiness probe. /ready gates Fly routing with no
fail-open, so failing it on a saturated pool would pull this node's READS
out of service too and push its write load onto peers carrying the same
load. That is the downward spiral AWS's health-check guidance names and the
SRE Book documents independently. A lock-pool readiness probe would also add
nothing on the reachability axis, because both pools are built from the same
database_url, so the existing app-pool ping already answers it.

The error is still returned via .context() rather than replaced, because
anyhow preserves downcastability through context layers and the 503 mapping
depends on it.

Cost is bounded by construction: one line per failed acquire, and a failed
acquire has already paid a multi-second pool timeout.

Refs #279
2026-08-17 22:58:14 -05:00
beardthelion 5d254fb336 fix(node): address the review findings on the advisory-lock series
Seven fixes from a seven-reviewer pass. Eight of the nine substantive
findings were defects introduced by this branch, and two contradicted
claims made in its own earlier commit messages.

P0, the worst of them. The under-lock download's timeout was folded into
the same Err as a corrupt archive, so it fell through to the
use-the-local-copy fallback and the write proceeded. Two ways that
destroys data: we do not know whether we hold the latest tree, so
re-uploading can overwrite another node's newer archive; and the abandoned
download's extraction runs in an uncancellable spawn_blocking that ends in
remove_dir_all + rename over the same path, so git would have been running
against a directory a background task was about to delete. Now the
Option is branched rather than collapsed: a genuine fetch error keeps the
local-copy fallback, a timeout refuses the acquire and lets the guard's
Drop free the lock.

LockProbe closed its connection on ordinary contention, so a 60-attempt
spinner tore down 60 backends -- the exact behavior an earlier commit
claimed it avoided. The test written to guarantee that asserted the
HOLDER's lock count, which cannot observe the probe's own connection, so it
passed throughout. The correct predicate turned out to be narrower than the
first attempt at this fix: not "we saw an answer" but "we positively know
nothing was acquired," because a true answer means the lock IS held and
dropping without handoff leaks it exactly as a cancellation would. The
first attempt reopened U1's leak and U1's gate test caught it.

tigris.exists() ran unbounded inside the lock-held span, so the knob's
promise was not kept and the docs were wrong about the shape. The HEAD and
the download now share one budget, so worst-case occupancy is one budget
per span rather than two.

close_issue's pre-lock author read used acquire(), whose fast path returns
on a cached directory and never contacts object storage, so on a multi-node
deploy an issue author would be refused their own issue. Now acquire_fresh,
which refreshes without taking the lock -- the property the comment
already claimed. That comment also asserted authorship is immutable, which
a reviewer disproved by force-pushing a forged blob at refs/gitlawb/**;
reworded to the real justification (a pre-check whose mutation re-reads
under the guard). The underlying pushability is pre-existing and larger
than this branch.

The retry loop had no wall-clock cap, reaching ~360s against a 120s proxy
idle timeout that was itself lowered from 600s after held connection slots
caused a production outage. Capped at 90s, with the bail message naming the
real bound.

All four acquire_write call sites stringified their error, destroying the
anyhow downcast that error.rs documents explicitly ("without this, every
database outage surfaces as a 500 instead of a 503"). PoolTimedOut is in
that 503 set, so a saturated lock pool told clients not to retry something
transient. Now propagated.

Tests: six that the plan required and the first pass never wrote. The R4
pool-isolation test (named in the plan as proving the pool split, entirely
absent), close_issue's owner and non-owner-author twins (INV-21c, a
two-principal gate with only its deny arm covered), a waiter-does-not-block-
an-unrelated-repo case, and a config test for the transfer knob. The
runtime-teardown test no longer returns green when DATABASE_URL is unset.

Drop's no-runtime branch now detaches via leak() instead of mem::forget:
PgConnection has no Drop impl, so the socket closes synchronously and the
lock frees immediately rather than at process exit.

Evidence. The probe predicate is proven load-bearing in BOTH directions:
forcing always-close reddens the churn regression, forcing never-close
reddens the cancellation gate, and only the correct predicate satisfies
both. The author twin reddens when the fallback is removed.

One honest limit: the author twin does NOT redden when acquire_fresh is
reverted to acquire. With tigris disabled in tests the two calls are
identical, so that fix is correct by reading, not by execution -- the same
seam that leaves the transfer bound's wiring untested.

Full crate suite 529 passed, clippy -D warnings clean.

Refs #279
2026-08-17 22:58:14 -05:00
beardthelion 9da0939312 fix(node): authorize before taking the write lock in close_issue
close_issue acquired the per-repo advisory lock, then ran the
owner-or-author check, then returned 403 with the lock still held. That was
harmless only because the lock excluded nothing. Making it work, as the
preceding commits do, turns this ordering into a denial primitive: any
caller with repo read access could take the write lock on demand and be
refused the write, while a legitimate writer burned its 60-attempt retry
budget against a lock held by someone with no write authorization. On a
public repo that is every permissionless identity. This series creates the
exposure, so it closes it in the same series.

The owner check is cheap and moves above the lock outright. The author
fallback needs the issue's git-JSON blob, since there is no author column,
so it now reads through acquire() rather than acquire_write(): an issue's
author is set at creation and never changes, so reading it outside the lock
races nothing, and only the mutation needs exclusion.

Two deliberate behavior choices. A non-owner whose authorship cannot be
established gets 403 rather than 404, so this route does not tell an
unauthorized caller whether an issue exists. And the owner's existing
404-for-a-missing-issue path is preserved by re-reading under the guard,
which the mutation wants anyway.

Swept the other three acquire_write sites: merge_pr owner-gates at :200
before acquiring at :214, the repo write path checks did_matches at :916
before :933, and create_issue's read gate is legitimate because that caller
IS authorized for the action it performs. close_issue was the only one with
the wrong order.

RED before: with an independent session holding the repo's lock, a
stranger's request sat in the retry loop until the 3s deadline fired
(Elapsed), which is the wedge itself. GREEN after, refused immediately.
Proven load-bearing: disabling the pre-lock refusal restores the Elapsed.

Full crate suite 523 passed.

Refs #279
2026-08-17 22:57:57 -05:00
beardthelion 9321d3b993 fix(node): bound the object-storage transfers that run under the write lock
Two transfers happen while the per-repo advisory lock is held: the archive
download inside acquire_write, which runs after the lock is taken and
before the guard exists, and the upload inside release. Both were free
before the guard pinned a lock-pool connection, because the lock's
connection went back to the pool immediately. Now an unbounded stall holds
a lock-pool slot for its whole duration, so enough concurrent stalls deny
every write on the node with no reaping path. Add
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS (default 300) and route both
through it.

A timed-out upload is UNKNOWABLE rather than failed, so the timeout arm
takes no compensating action: the PUT may well have landed. The lock is
released either way, which trades a narrow last-writer-wins window for not
wedging the repo behind a stalled transfer. Deliberate, and stated here
rather than discovered later.

Note what is NOT bounded by this knob: acquire_fresh's download. It runs
before any lock is taken, so it holds nothing. An earlier revision of this
change put the bound there by mistake, because both functions contain an
identical download call and the first match won.

Two disclosures.

Coverage: the committed tests cover the bound mechanism, not the wiring.
Driving a genuinely stalled transfer through acquire_write needs either the
object-store abstraction (out of scope) or a process-global
AWS_ENDPOINT_URL_S3 mutation, which would make the suite order-dependent
under the concurrent runner. That a stalled transfer is bounded is
therefore verified by reading, not by execution.

Behavior: on timeout with a local copy present, the download falls into the
pre-existing self-healing fallback and the write proceeds against that
local copy. This widens the conditions reaching that path from
corrupt-or-unreachable to include merely-slow, so a stale tree could now be
written and re-uploaded on a slow link. Kept consistent with the existing
failed-download behavior rather than inventing new semantics here; changing
it is its own decision.

Full crate suite 522 passed.

Refs #279
2026-08-17 22:57:57 -05:00
beardthelion 98f2daf804 fix(node): read the advisory unlock's result instead of discarding it
pg_advisory_unlock reports "you did not hold this lock" as a false RETURN
VALUE plus a server WARNING, never an error, so let _ = execute(...) could
not distinguish a real release from a no-op. Three blindnesses stacked in
those four lines: execute discards the row, the boolean lives in the row,
and let _ discarded the Result too.

Read it through fetch_one into (bool,). A false means this session's lock
state is not what we believe it is, so the connection stays in the guard
for Drop to close rather than being handed back to the pool as clean. Only
a confirmed unlock returns it. A query error gets the same treatment, since
the lock must not outlive a session we can no longer reason about.

Note this is the only unlock site: the pre-write download's error path
returns through the guard, so Drop covers it and there is no second place
to keep in sync.

RED before: the connection came back on the same backend pid after an
unlock that returned false. GREEN after, and proven load-bearing by
treating false as success, which turns it RED again. The must-not case (a
normal release still reuses its backend) passes in both states, so this
does not over-close the happy path.

Full crate suite 520 passed.

Refs #279
2026-08-17 22:57:43 -05:00
beardthelion c49d3cdb57 fix(node): free the advisory lock when a write guard dies without releasing
A guard can exit without reaching release(): an early ? on the pre-write
download, a panic, or an axum handler future cancelled when the client
disconnects. Its session still holds the lock, so returning that
connection to the pool would block every future write to the repo until
sqlx recycles it, which on the 0.8.6 defaults is ten minutes idle or
thirty minutes lifetime. Close the session instead and let Postgres free
the lock at session end.

One hazard found by writing the teardown test rather than by reasoning:
PoolConnection::drop spawns onto the runtime for both closing and
returning, and panics outright when no runtime handle exists. That panic
would fire inside a Drop and abort the process during unwind. It is not
introduced by this commit, it comes with owning a PoolConnection at all,
but this is where it becomes reachable. So Drop checks for a runtime
first and, with none, leaks the handle deliberately rather than panicking:
the process is already exiting and socket teardown ends the session, which
is what frees the lock at exit anyway.

Observed RED before the fix, with the lock still held for the full 10s
poll window against a standalone observer on a no-reap pool, and the
teardown case panicking in sqlx-core connection.rs:208. Proven
load-bearing after: neutering the close_on_drop call turns the drop test
RED again. The must-not case (a released guard reuses its backend pid
across four writes on a pool sized 1) passes in both states, so the
signal is specific to the abandoned-guard path.

Full crate suite 519 passed.

Refs #279
2026-08-17 22:57:43 -05:00
beardthelion 5ce3c7f413 fix(node): hold the lock-owning connection in RepoWriteGuard (#279)
Postgres advisory locks are session-scoped: only the backend that took one
can release it. acquire_write took the lock through fetch_one(&pool) and
release unlocked through execute(&pool), two independent checkouts, so the
unlock usually landed on a session that held nothing and returned false.
Measured on main: two writers on one node and the same repo BOTH acquired,
50 of 50 sequential cycles leaked, and 100 writes left 100 orphaned
advisory locks on the server.

The guard now owns the PoolConnection that took the lock, drawn from the
dedicated lock pool, and releases on that same session. The retry loop
probes through LockProbe so a cancellation mid-acquire cannot strand the
lock, and hands the connection back before each backoff so a spinner on a
contended repo does not pin a slot while idle.

Pool exhaustion is deliberately not retried. It is a different condition
from lock contention, and retrying it would spend all 60 attempts on a
capacity problem unrelated to this repo while reporting it as someone else
holding the lock.

Both #279 acceptance tests were observed RED first: the exclusion test
admitted the second writer, and the leak test reported 1 lock held where 0
was required. Both GREEN after. Full crate suite 516 passed.

Db::pool() is removed because this change was its only caller.

Refs #279
2026-08-17 22:57:43 -05:00
beardthelion edcf0f2ccf fix(node): add a dedicated, lazily-connected advisory-lock pool
Pinning a connection for the lock's lifetime is only safe if those
connections come from somewhere other than the pool serving ordinary
request handlers, otherwise a push burst starves every other query. Add
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS (default 32) and a Db::lock_pool
builder, with the sizing tradeoff documented on the field and in
.env.example: every in-flight write pins one connection here, so the
value is a hard ceiling on simultaneous writes node-wide.

The pool connects lazily on purpose. The main pool must connect eagerly
because it runs migrations, which is why it needs connect_db_with_retry's
backoff and degraded-server handoff; that function is not a generic retry
helper and the lock pool is built well after the db-ready handoff has
already resolved. A lazy pool has no startup work, so it adds no new way
for the process to fail to boot and needs no second copy of that
machinery. If Postgres is unreachable when the first write arrives, that
write fails on the pool's own acquire timeout, like any other
database-backed request.

Pure configuration, so no proof-first cycle: the knob is covered by a
parse/default/reject-zero test. Db::lock_pool has no caller until the
guard wiring lands, hence the temporary dead_code attribute.

Refs #279
2026-08-17 22:57:07 -05:00
beardthelion 7af306701d fix(node): close the advisory-lock probe's session if it is dropped mid-acquire
A cancelled .await does not cancel an already-sent SQL statement, so a
pg_try_advisory_lock whose future is dropped still takes the lock
server-side while the caller abandons the result, leaving nothing to
release it. The connection then returns to the pool holding the lock and
wedges that repo until sqlx recycles the session.

Introduce LockProbe, which owns the connection across the in-flight
try-lock and closes it in its own Drop if it is still held. close_on_drop
is a one-way setter, so the arming lives in Drop rather than being set up
front and cleared on success; disarming is Option::take, which is what
into_conn does once an acquire is actually observed. This is now the only
place that issues pg_try_advisory_lock.

The committed gate drops a probe without taking its connection, which is
the state a cancellation leaves behind, and polls a standalone observer
until the lock frees. Deterministic on purpose: the timing sweep that
found this window leaks roughly 1 in 600, which is not something a CI
gate can rest on. Observed RED before this change with the lock still
held for the full 10s window.

Refs #279
2026-08-17 22:56:50 -05:00
Kevin Codex e4c7458fb3 Merge pull request #332 from anandh8x/agent/docs-correct-security-status
docs: correct security and workspace status
2026-08-16 21:40:30 +08:00
beardthelion 76f630008f fix(node): never hand back a continuation that made no progress
On a resumed request whose visit budget was already spent by the provenance
phase, the scan's top-of-loop visit arm sealed pager.cursor, which at that
moment is the position the caller just sent. The node returned the caller's own
token, verbatim, rung after rung. Three rungs were observed returning an
identical position.

A token looks like progress, so the client keeps going: gl retries to its
resume cap, and every one of those requests re-runs the full provenance phase,
up to seventeen repo acquires and cat-file subprocesses, advancing nothing
before it errors. That is roughly nine anonymous requests worth of work for
none, and it is worse than shedding nothing, because a caller who is told the
ladder is over stops immediately.

A seal now has to be strictly ahead of where the request itself started: a
different candidate is ahead by construction, since only the gated advance can
name one, and the same candidate needs a row past the start row. A request that
started at the front is before everything, so its seals pass untouched.

When the proposer settled at least one row this rung, the existing ceiling arm
already seals that row, and it is strictly ahead because a resumed scan only
walks rows past its cursor. Only a rung that settled nothing sheds without a
token, and that is honest: the spender is the provenance phase, which runs the
same way every rung, so no retry can do better.

The filter sits at the single mint site, where a future call site cannot bypass
it, and it logs the drop as a boolean. record_scan_truncation has already
logged that a position was sealed by then, and a 503 carrying no token next to
that line is the confusion that log exists to prevent.
2026-08-16 02:20:05 -05:00
Vasanthdev2004 888fab6028 fix(infra): serialize the upgrade and write compose through a unique temp path
The compose reinstall added last round is not safe under overlap, and `main`
never wrote compose from this document, so the race arrives with that commit.

Two executions — a double run, a retry while the first is still pulling, two
operators — shared the fixed `/opt/gitlawb/compose.yaml.new`. The second
truncates the file the first is about to install; and once the first renames,
the second's open descriptor keeps writing into the live `compose.yaml` inode.
Both outcomes stay valid YAML, so the `up -d --remove-orphans` on the next line
accepts a blended or truncated service set and deletes whatever fell off — on a
live node, the local postgres or the TLS terminator. Both reviewers reproduced
it independently; one measured a short file in 12 of 12 trials.

The whole step now runs under `flock -n` on /opt/gitlawb/.upgrade.lock and
exits non-zero if the lock is held, so a second run cannot restart against a
file the first is mid-install. The temp file comes from `mktemp`, so concurrent
writers cannot truncate each other, with a trap to clean it up on failure.

The compose body is base64-encoded rather than written through a nested
heredoc. The rendering carries `$${VAR}` passthroughs that must reach the file
unexpanded, which needs a quoted delimiter, and a shell heredoc nested inside an
indented Terraform heredoc depends on the dedent landing that delimiter at
column 0. Decoding one line has neither failure mode and no delimiter can
collide with the content.

Not validated locally: terraform is not installed on this machine, so this
change has had no `fmt`, `validate`, or `plan` run against it.
2026-08-16 11:00:21 +05:30
beardthelion d0635d95b7 fix(node): resume the /ipfs scan per candidate so a starved one is not skipped
A CID that maps to several oids shared one resume slot across every candidate,
and the slot kept the maximum position. An earlier candidate could spend the
probe budget walking past a repo that holds the object for a later one, seal a
position beyond it, and the next rung would resume past a row that candidate
never examined, wrap, and shed tokenless. The client reads an absent token as
the ladder being over, so the object became permanently unretrievable, at stock
config, deterministically on every retry.

The token now names which candidate it is resuming, and the rules that keep
that sound are narrower than they first look:

Only one candidate per request may seal, and which one depends on where the
REQUEST started. On a resumed request it is the resumed candidate alone, since
the shared pager holds only the table suffix from the caller's cursor, so a
later candidate walked a suffix and never saw the front. On a front-started
request it is the first unfinished candidate, since there every candidate walks
from the front and a later candidate's stop is honest coverage. Silencing later
candidates unconditionally would remove the only thing that mints rung 1 when
the first candidate wraps untruncated.

A candidate is finished when its row loop walked every fetched row, or when it
owed no scan at all. Both matter: a properly provenanced candidate never wraps,
so without the second arm the ladder dies every rung. The wrap is witnessed per
candidate at the row loop's own two exits, never by reading the shared pager
flag at the tail, which any short page sets and which would let a candidate that
truncated mid-page look finished and strand the rows it refused.

Finishing a non-final candidate advances the seal to the next one at a front
sentinel and taints, because the tail emits a continuation only when something
tainted; sealing without tainting would suppress the taint and return a
definitive 404 while discarding the token it had just minted.

The keep-the-maximum comparison is gone. With one proposer per request the slot
is written at most once, so an assertion states that directly instead.

The pager stays shared per request. A per-candidate pager would restore the
fan-out the paging exists to remove.
2026-08-15 23:09:36 -05:00
beardthelion 2656718916 feat(core): carry the resumed candidate's identity in the scan token
A CID can map to several git oids, and the ladder needs to name which one it is
resuming. The sealed position gains the candidate's oid hex so a rung resumes
that candidate rather than a position in a list: oids_for_cid is a sorted set,
so an ordinal silently repoints at a different candidate when a pin that sorts
earlier arrives between rungs, while an identity degrades safely to "not found,
restart at the front".

The field is length-prefixed and padded to 64, matching the framing the row
fields already use, because production oids are 40 hex, not 64: repos are
created with --object-format=sha1 and only the test fixtures are sha256. A
fixed 64-byte field would fail every seal on a real deployment and shed a
tokenless 503, which the client reads as the ladder being over. Both widths are
exercised, and a zero-length candidate is rejected at decode so it cannot be
confused with the front-of-table sentinel, which is empty row fields with a
real candidate.

VERSION goes to 3 and the plaintext to 527 bytes, so a token minted under the
old layout opens to None and the caller restarts at the front. Nothing has
minted one outside tests.

The slot carrying the position is a struct rather than a widened tuple on
purpose: a 3-tuple would have pulled the hex into the existing keep-the-maximum
comparison, changing behavior this commit is meant to leave alone.

Token length stays invariant across both oid widths, since length would
otherwise be a side channel for the withheld row it names. That is asserted on
a real seal in gitlawb-core, not on the gl fixtures: nothing in gl seals or
opens a token, so its width constant cannot detect a wrong layout.
2026-08-15 22:20:34 -05:00
beardthelion 366b599331 fix(node): order the CID candidate list so it cannot depend on heap order
oids_for_cid ran a bare SELECT with no ORDER BY, so Postgres was free to return
the candidates in physical heap order. get_by_cid walks those candidates under
one shared probe budget, visit budget and pager, so whichever comes back first
is the one that spends the request's budget: two nodes holding identical data,
or one node before and after an unrelated write, could resolve the same CID
differently and one could shed a 503 where the other serves.

The instability is not hypothetical. An unpin and re-pin of a single object,
which is an ordinary production sequence, moves that row to the end of the heap
and rotates the list.

The sibling pin_sources_for_oid already orders its union for exactly this
reason, and the handler comment next to it leans on that determinism.
2026-08-15 21:56:07 -05:00
beardthelion 1bdefb18c2 chore(node): log every /ipfs scan truncation and correct the helper's docs
Code review of the previous commit turned up three things worth fixing in place.

record_scan_truncation centralized the taint and the seal but logged nothing, so
centralizing actually made a truncation less visible than the scattered inline
taints it replaced: only the visit ceiling logged, and it logs from inside the
gate, before the caller decides whether a position gets sealed. Two identical log
lines could therefore mean "the ladder continues" or "the caller is stranded".
One debug line now carries the reason and whether a position was sealed. It logs
only whether one exists, never its value, since the position names a withheld
row's created_at and id.

The doc comment claimed to be "the one site that records a scan truncation" while
eight other taint sites bypass it. The distinction is real but it is not the one
the comment drew: a ceiling stops the scan and owes the caller a position, while
a transient skip refuses one row and the rows behind it are still walked. Says
that now.

The forward-only rule's stated justification was wrong. A later candidate's
position is still ahead of the token the caller echoed, so letting it win would
not move the ladder backwards; it would shrink each rung toward a single row.
Also records that the comparison is Rust byte order while the pager's keyset runs
under the database collation, which can disagree on a non-C collation, and why
that costs a replay rather than a skipped row.
2026-08-15 19:45:08 -05:00
beardthelion 42e6aabc10 Merge origin/main into fix/issue-135-ipfs-cid-tree-gate
Carries #326 (distinct-signer certificate threshold) onto this head.
2026-08-15 18:38:15 -05:00
beardthelion 69cfbd4b3c fix(node): mint a continuation when a ceiling stops the /ipfs scan mid-page
The probe and visit ceilings taint inside gate_and_serve and returned Skip, so
the row they refused and every row behind it were walked past without a verdict
while the resume position was sealed from pager.cursor, the end of the fetched
page. Two ways that stranded content:

- On the final page, `pager.exhausted` breaks ahead of every mint arm, so the
  shed carried no continuation at all. A tokenless search_incomplete is the
  wrapped-scan answer, which tells `gl ipfs get` its ladder is over, so a holder
  on that page was unreachable on every retry.
- Mid-page, the sealed cursor sat past the refused rows, so the resume skipped
  them. The shipped ladder tests all set page_rows == ceiling, which puts the
  break exactly on a page boundary and hides both.

The ceiling now returns GateOutcome::CeilingStop rather than tainting on its way
out, and the scan loop stops there and seals the row in front of the one that was
refused. record_scan_truncation is the single site that records a truncation:
it taints and seals together, and only ever moves the position forward, so a
later oid candidate re-walking the same rows on a spent budget cannot hand back a
token the caller already echoed. The wrapped-scan tail no longer clears a seal,
since a ceiling can stop a resumed scan part way through the last page.
2026-08-15 18:38:09 -05:00
Vasanthdev2004 579eee07bc docs(infra): correct the ignore_changes comment for the new upgrade path
The lifecycle comment still listed image_tag among the values needing a deliberate instance replacement. It is rendered into compose.yaml, which the upgrade command now reinstalls, so apply plus upgrade rolls it out. Only the values user-data writes into .env at first boot stay replacement-only.
2026-08-15 14:12:14 +05:30
Vasanthdev2004 de4d932609 docs(infra,macos): make the opt-out reachable and stop the AWS guide contradicting itself
All four are in the deployment half added last round; none is in the Rust.

The AWS guide told operators two different things
--------------------------------------------------
"Changing configuration" still said editing `image_tag` does not affect a running
instance and routed to `-replace`, while the upgrade section thirty lines above
now reinstalls the rendered compose, which carries `image_tag`. The same
paragraph told operators to edit `.env` and restart — which the new "Why the
upgrade rewrites the compose file" section explains is silently ineffective for
any key the installed compose does not already name.

Split by which file carries the change rather than deleted, because the
distinction is real: values rendered into `compose.yaml` (`image_tag`, ports,
`domain_name`, `db_host`, `icaptcha_*`) roll out with `apply` + `upgrade_command`;
values user-data writes into `.env` at first boot (`public_url`,
`bootstrap_peers`, integrations) do not propagate from tfvars at all and need an
edit on the instance or a replacement. The third case is the one that bites: a
setting the installed compose does not yet name is read from `.env` and dropped,
so it needs the upgrade first.

`--remove-orphans` stopped being inert
---------------------------------------
The flag is not new, but until the upgrade rewrote `compose.yaml` it reconciled
against the instance's own file, so the only orphans were hand-started
containers. It now reconciles against a fresh rendering whose service set is
conditional — `postgres` only when `db_host` is empty, `caddy` only when
`domain_name` is set. Setting `use_rds` or clearing `domain_name` and then
running what the README calls a pull-and-restart stops and removes the local
postgres or the TLS terminator. That is what those variables mean, but it happens
on the upgrade rather than on apply, so it is now stated next to the
Terraform-owned warning. Data survives either way; both bind-mount under
/mnt/data.

The macOS setting had no writer
--------------------------------
`enforceOwnerPush` was persisted, loaded, emitted into `.env`, and forwarded by
the generated compose — and nothing set it. `SettingsWindow` had no state and no
assignment, so the only way to reach the opt-out on that launch path was hand
editing config.json in Application Support, which no document mentions. Editing
`.env` does not work there either: `writeEnvFile()` rebuilds it from `Config` on
every start. It now has a Toggle beside "Sync repos from peers", following the
pattern already in that view.

Three lines the change made untrue
-----------------------------------
The handler test asserted "with no configuration at all" while setting
`enforce_owner_push` explicitly — the right thing for the test, the wrong claim
for the message. The cross-tenant docstring still described three distinct pusher
DIDs after A and B were given `n1`'s owner. And OSS-READINESS-AUDIT listed
unprotected branches not enforcing owner checks as a live blocker; owner checks
now cover every branch, and only the UCAN half of that sentence still stands.

Also dropped the `#[allow(dead_code)]` on `test_state_with`, redundant since the
same commit added its call site.
2026-08-15 11:47:36 +05:30
anandh8x 0b47e1fc08 docs: clarify visibility limits 2026-08-15 10:32:24 +05:30
anandh8x 505c452378 docs: correct security and workspace status 2026-08-15 10:30:51 +05:30
Kevin Codex 50d3cbbe97 Merge pull request #326 from euxaristia/fix/cert-threshold-duplicate-signatures
fix(core): Count distinct signer DIDs in certificate threshold check
2026-08-15 10:49:11 +08:00
beardthelion a7e8e35030 fix(review): make the coupled sites findable and pin two more properties
Code review found the doc comments carrying claims the code does not
support and two coupling points documented from one side only.

The floor and the resolver's walk_cap are not textual twins: the floor
reads the constant, the resolver reads the AppState seam, and they agree
only because every construction seeds one from the other. Say that, and
give walk_cap the back-reference it lacked. The lifted node_verifies
helper no longer named the middleware it mirrors, so an edit to
require_signature would not find either copy.

The request-target clause pins @path, not @method or content-digest: a
301, 302 or 303 still rewrites a signed POST to a bodyless GET while the
signature headers ride along. Record that rather than implying the hop is
safe. Add the query-removed and fragment-only matrix rows, and a mutant
pinning that scan-phase walks are not charged to the work bucket, which
nothing covered.
2026-08-14 19:21:44 -05:00
beardthelion dc5daa14b4 fix(node): size the ipfs work floor for provenance walks plus one full search
The floor reserved one complete legacy search per window, probes plus page
tolls, but the provenance visibility walk debits the same bucket before the
fallback runs and its cap is charged per phase. With a route limit set below
the floor the provenance phase spent from the budget the search was promised,
the fallback 429d short of its configured reach, and the retry re-paid the
same walk charges, so a readable holder past that point stayed unreachable.

Add the walk term, min(MAX_HISTORY_WALKS_PER_REQUEST, ipfs_max_repos_walked),
which is textually the resolver's own walk_cap so the two move together. The
ladder fixture pins repos-walked to 1 to keep the page toll, not the new term,
as the thing binding it.
2026-08-14 17:43:48 -05:00
beardthelion 1c86ee22d6 test(node): pin require_signature to the request-target it received
The verifier already rebuilds @path from the URI it was sent, which is why
the client-side redirect bug surfaced as a 401 rather than as a bypass.
Nothing pinned that, so a change to the reconstruction could drop the query
half or collapse it to a constant and only the clients would notice.

Drive the production router fixture with a signature made over one path and
a request on another, and again with a query mismatch, plus an identically
signed control. No pre-fix RED is obtainable here by construction, so each
case is proven load-bearing by injecting the defect it names.
2026-08-14 15:48:46 -05:00
beardthelion 92bb55441d test(clients): prove the refused redirect never reaches the node verifier
The two same-origin follow tests drove exactly the path-changing hop the
predicate now refuses, and their mocks only proved a target was reached,
never that the signature verified there.

Rewrite both into refusal tests and run the real gitlawb-core verification
over the request the target actually received, so the recorded verdict is
what the assertion speaks about. Each refusal is paired with a positive
control that verifies, so an empty verdict slot is attributable to the
refusal rather than to a dead harness.
2026-08-14 15:10:52 -05:00
beardthelion 5c0d6f3252 fix(core): refuse a redirect that rewrites the signed request-target
may_follow compared host, port and scheme only, so a same-origin hop that
normalized a trailing slash or a query was followed. The signature binds
@path as the client sent it and the node rebuilds it from the URI it
received, so that hop left the signature covering a target nobody asked
for and the read 401d.

Add the request-target clause and repoint the seven matrix rows that rode
on a path change, so each keeps pinning the host or port property it
exists for rather than going false on the path alone.
2026-08-14 15:10:44 -05:00
Vasanthdev2004 1f0d9ff368 fix(node,infra,macos): make the owner-push opt-out reach every launch path
The previous round forwarded GITLAWB_ENFORCE_OWNER_PUSH in three compose
templates. Two of those are not the file their runtime actually reads, so the
documented rolling-upgrade opt-out did not work where it mattered.

Existing AWS instances could not opt out
-----------------------------------------
`aws_instance` ignores user_data drift, so an instance keeps the compose file it
was created with, and the published SSM upgrade only ran `docker compose pull &&
up -d`. Compose passes only the variables named in a service's `environment:`
block, so a key added to the template never reached a running instance: an
operator following our own instructions wrote GITLAWB_ENFORCE_OWNER_PUSH=false
to /opt/gitlawb/.env, upgraded, and got the new default anyway — silently, with
their CI pushers refused.

The upgrade document now reinstalls the rendered compose file before restarting.
It is idempotent (identical bytes on an already-current instance) and it makes
the upgrade a migration rather than a restart. compose.yaml is Terraform-owned
and overwritten; per-instance settings belong in .env, which is untouched.
infra/aws/README.md documents that `terraform apply` must precede the upgrade,
since the SSM document embeds the rendering.

The packaged macOS app read a different compose file
-----------------------------------------------------
The bundled Resources/docker-compose.yml is not what a packaged app runs: with
no source checkout selected, `generateComposeFile()` writes its own template,
whose environment block did not carry the key. `Config.writeEnvFile()` also
regenerates .env on every start, so a hand-added value was discarded. Between
them, a packaged-app user had no way to opt out at all.

`enforceOwnerPush` is now a persisted app setting, emitted into .env on every
start, and `generateComposeFile()` forwards it with the same interpolation as
the bundled resource — one definition, three consistent sites.

A test inherited the environment it was supposed to assert about
-----------------------------------------------------------------
`default_config_refuses_a_non_owner_push` built state through `test_state`, which
parses `Config` from the process environment — 47 fields come from GITLAWB_*
variables. On a host exporting GITLAWB_ENFORCE_OWNER_PUSH=false, which is exactly
what the rolling-upgrade guidance tells operators to set, it constructed a
disabled state, let the push through to git, and failed for a reason unrelated to
the code under test.

Renamed to `enforced_owner_push_refuses_a_signed_non_owner` and given an explicit
config through a new `test_state_with`, so it states the configuration it is
about. Verified by running it both with and without the variable exported. The
shipped default keeps its own env-independent proof off the parser declaration,
in config::tests — a parser question and an authorization question should not
share a failure mode. `test_state` now carries a warning about the hazard, since
any of those 47 fields can skew a test the same way.
2026-08-14 23:35:50 +05:30
Vasanthdev2004 2f0174cc04 fix(node): close review findings on the owner-push default
Every item below was verified against the code before changing it; two of them
were confirmed by running the probe the reviewer described.

A test the fixture sweep missed, and that could not fail
--------------------------------------------------------
`f1_write_cap_is_inert_without_a_resolvable_source_key` seeded `z6f1none` and
pushed as `z6MkF1NoKeyPusher…`, so the owner gate rejected it before it reached
the per-caller cap the test exists to measure. Its assertion was
`!matches!(r, Err(Overloaded(_)))`, which a `Forbidden` satisfies, so it reported
ok while measuring nothing — and it is not `#[cfg(unix)]`, so it did that inside
every green run. Confirmed by inverting the assertion to expect `Forbidden`: it
passed, proving the request stopped at the gate.

The pusher is now the owner, and the assertion is positive: reaching git on the
missing on-disk repo (`Err(Git(_))`) is proof the request cleared both the gate
and the cap. The sibling `f1_write_cap_key_is_per_source_not_per_repo` carried
the same weak negative in its second assertion and is tightened the same way.

The default was barely tested, and what tested it read the environment
---------------------------------------------------------------------
`Config::parse_from` consults the process environment, so on a host exporting
`GITLAWB_ENFORCE_OWNER_PUSH=false` the old `enforce_owner_push_defaults_to_true`
reported the operator's setting rather than what this crate declares — it failed
outright, which would also break any contributor who sets the variable. It is
replaced by two tests that are env-independent and behavioural:

  * `enforce_owner_push_is_declared_true_independent_of_the_environment` reads
    the declared default off `Config::command()`.
  * `default_config_refuses_a_non_owner_push` drives the handler on an untouched
    `test_state` config and asserts 403 — authenticated by a real RFC 9421
    signature, so 403 rather than 401 is what makes it a statement about
    authorization.

Both were checked by reverting `default_value_t` to `false`: the handler test
reports 500 where it wants 403, and the declared test reports `["false"]`.

The cross-tenant test lost its second tenant
--------------------------------------------
The sweep collapsed `u1_parked_push_does_not_shed_another_pusher_behind_the_same
_ip` onto one identity, leaving the test named for cross-tenant behaviour with a
single tenant and two docstrings describing a scenario it no longer ran. `n2` is
now owned by `z6u1nat2` and its push signs as that owner, so two distinct tenants
share one edge IP again — which is the property under test, since the per-source
key is the resolved peer address and never the DID.

Four comments outlived their premise
-------------------------------------
`repos.rs` (3 sites) and `.env.example` still said owner enforcement defaults
off. The IP-keying rationale they support is unchanged and still correct — a DID
farm defeats a DID key — so only the dead premise is rewritten: the cap is
load-bearing because the rate limiter bounds arrival rate rather than occupancy,
because the gate can be turned off for a rolling upgrade, and because one owner
can open many concurrent pushes to their own repo.

Operators on the container path got none of this
-------------------------------------------------
`GITLAWB_ENFORCE_OWNER_PUSH` is now forwarded in `docker-compose.yml`, the macOS
app's bundled compose, and `infra/aws/compose.yaml.tftpl`, defaulting to `true`
to match the node. Without it the rolling-upgrade opt-out this PR documents in
three places was unreachable for anyone running compose.

A cutover this project never promised
--------------------------------------
The README paragraph announcing a 15 September 2026 flip of
`GITLAWB_REQUIRE_SIGNED_PEER_WRITES` is removed. That flag is not changed here,
the date appears nowhere on main, and a PR about a different flag is not where
that commitment would be made. The table row for `GITLAWB_ENFORCE_OWNER_PUSH`
carries the part that belongs.

A docstring that credited the wrong thing
------------------------------------------
The field and test docs claimed the value-taking action is what makes
`GITLAWB_ENFORCE_OWNER_PUSH=false` resolve to `false`. It is not: the env form
resolved under the presence-only declaration too. What the change buys is
`--enforce-owner-push false` parsing at all, rather than erroring as an
unexpected argument. Both docstrings now say so.
2026-08-14 20:43:43 +05:30
Vasanthdev2004 7de6f7cf5d test(node): derive the P2 pusher from the repo it targets
`p2_push` signed every push as one shared `P2_PUSHER` constant, but its two
callers own different repos — `z6p2tail` and `z6p2fail`. Pinning the constant to
either owner leaves the other rejected by the owner-push gate, so the identity
now follows the `owner` argument the helper already takes, the same way the
burst test derives it.

The rejection surfaced as `async fn resumed after completion` rather than a 403:
a denied push completes on the first poll, `p2_logged` never observes
`receive-pack`, and the polling loop then polls the finished future again.
2026-08-14 18:31:00 +05:30
Vasanthdev2004 586896d8b2 test(node): push as the repo owner in the remaining lease and scan fixtures
Fourteen concurrency tests seed a repo through `f4_state_with_repo` or
`p2_parked_release_state` and then push under an unrelated DID, which the
owner-push gate now rejects before the machinery each test asserts on is ever
reached. They are transport and lease tests, not authorization tests, so each
now signs as the owner of the repo it targets.

Two needed more than a swapped constant:

- `receive_pack_burst_scans_serialized_and_both_pushes_succeed` pushes to two
  repos with different owners, so the identity is derived per push from the
  owner already threaded through the closure.
- `u1_parked_push_does_not_shed_another_pusher_behind_the_same_ip` used three
  distinct pusher DIDs, which owner-only push makes impossible. The distinction
  was narrative: the per-source key is the resolved peer IP and never the DID,
  so three concurrent pushes from one edge IP contend exactly as before. The
  comment now says so rather than leaving the reader to infer it.

The gate keeps its own coverage in `enforced_rejects_non_owner_with_forbidden`
and `enforced_rejects_missing_did_with_forbidden`, so nothing here removes the
assertion that a non-owner push is refused.

These tests are `#[cfg(unix)]`, so a Windows checkout cannot run them; they were
missed in the previous round because the Windows suite runs 717 of the 825 tests
Linux runs.
2026-08-14 18:00:36 +05:30
Vasanthdev2004 cdbda19c29 fix(node)!: enforce owner-only push by default
A `did:key` signature is authentication, not authorization: the method is
self-certifying, so any party can generate a keypair, derive its DID and sign.
With `GITLAWB_ENFORCE_OWNER_PUSH` defaulting to `false`, `owner_push_rejection`
short-circuited and every `git-receive-pack` carrying any valid signature was
accepted — including pushes to a repository the signer does not own, and
including private ones, on every branch not explicitly protected.

The gate itself already worked when switched on. What was missing is that no
reachable default configuration switched it on, so a node started with nothing
configured accepted a push from anyone. That default is what this changes.

The argument now takes a value so there is a way back: `--enforce-owner-push
false` and `GITLAWB_ENFORCE_OWNER_PUSH=false` both disable it, while the bare
`--enforce-owner-push` form still means `true`. Without this the flag would be
presence-only (`ArgAction::SetTrue`), which with a `true` default would leave
operators no way to opt out during a rolling upgrade.

Three concurrency tests pushed as non-owners in order to reach the shedding
logic they actually assert on. They now push as the repo owner, so they keep
exercising the shipped default rather than opting out of the gate.

Docs updated in README.md, .env.example and docs/RUN-A-NODE.md, including the
delegated-key caveat below. README also carries a dated cutover for
`GITLAWB_REQUIRE_SIGNED_PEER_WRITES`, which stays `false` for now so live peers
can finish upgrading.

BREAKING CHANGE: `git-receive-pack` now rejects a push whose authenticated DID
is not the repo owner, returning 403 before any ref update is applied.
Delegated and CI keys count as non-owners: a UCAN `git/push` capability is
verified but not yet honored for authorization, so an agent pushing under its
own DID cannot push while this is on. Set `GITLAWB_ENFORCE_OWNER_PUSH=false`
during a rolling upgrade, or have automation push as the repo owner, until
scoped collaborator / UCAN-delegated push rights land.
2026-08-14 17:22:23 +05:30
Vasanthdev2004 5a7a77b8dc fix(node): gate two Unix-only tests so the crate compiles on Windows
Two tests build a fake git as a `/bin/sh` script, mark it executable through
`std::os::unix::fs::PermissionsExt`, and reap the hung `rev-list` with
`libc::kill(SIGKILL)`. None of that exists on Windows, and neither test was
cfg-gated, so `cargo test -p gitlawb-node` failed to compile there with six
errors before running anything. A Windows checkout could not run a single test
in the crate, including the ones that are platform-independent.

Gate both with `#[cfg(unix)]`. The attribute is a no-op on Linux, so CI keeps
running them exactly as before; only the Windows build changes, from "does not
compile" to "runs the platform-independent tests".

Refs #228
2026-08-14 17:22:23 +05:30
beardthelion c82a70ab21 fix(core): put the redirect predicate behind a feature so core stays lean
The dependency allowlist caught this: sharing the predicate pulled url, and
behind it idna and the icu crates, into the closure of the one crate that is
supposed to stay embeddable. Both clients that need the predicate already parse
URLs, so they opt in and nothing else pays for it.

The test dependency is not optional, so the origin matrix still runs under a
bare cargo test -p gitlawb-core. Gating the module on the feature alone would
have left those tests silently unbuilt, which is the failure the module is
there to prevent.
2026-08-13 18:55:58 -05:00
beardthelion ba44a018ec fix(core): share the origin-scoped redirect rule with the git helper
The previous commit stopped the CLI carrying request signatures to a host the
node redirected it to, and left the other signing client alone. That one is the
git remote helper, which is what actually runs on clone, fetch and push, and it
had no redirect policy at all. A push signs from its first request, so a node
answering 302 handed out a working credential, and on a 307 or 308 the pack
body went with it. The rule now lives in core and both clients use it, so the
two cannot drift apart again.

Three guards were not holding what they claimed. Bounding the retry wait by the
remaining deadline could be deleted with every test still green, which the
timing shows was four real seconds of overshoot past the bound the docs
advertise; the assertion was loose enough to pass either way and is now tight
enough to fail. The refusing-gateway warning was asserted through a test-only
mirror rather than the write the user sees, so deleting the write kept the test
green. And both no-hint exceptions, the definitive not-found and the wrapped
scan, were only ever reached without a token in hand, which is the one state
where they do nothing.

A response whose body failed partway through the read still replayed the held
token for every remaining attempt, spending the whole ladder on one position and
then reporting that the object might lie further on. A body cut by the read cap
was already terminal for that reason; a body cut by a broken connection is the
same unreadable state and is terminal now too.

A successful fetch no longer buffers the whole object before writing a byte of
it, so a node cannot make the client hold an arbitrary amount of memory inside a
time limit that was never a memory limit. The remaining silent skips in the
recovery loops now say what went wrong, including the manifest fetch and the
body reads, so a failure there is reported rather than looking like an absent
blob.

Also: the redirect chain bound is off by one against reqwest's own, has a test,
and its comment no longer claims to prevent something the request timeout
already prevents.
2026-08-13 17:34:14 -05:00
beardthelion de3f230940 fix(gl): stop carrying request signatures to a host the node redirected us to
The node client followed redirects with no policy, and reqwest strips only the
classic credential headers when a redirect crosses origins. RFC 9421 signature
headers are not on that list, so a node answering 302 could hand a caller's
signature to another host, which then served whatever bytes it liked for a
content address that never hashed to them. Following continuations made it
worse by turning one signed request per invocation into as many as nine. The
signature covers the method, the path and the body digest but not the
authority, and the node accepts any signature inside a five minute skew with no
nonce ledger, so one harvested header reads a caller's private paths at any
node in the fleet.

Redirects are now scoped to the origin that issued them. Same-origin ones still
follow, so an http to https upgrade or a trailing-slash normalization keeps
working, and a cross-origin one stops rather than errors, so the caller sees
the 3xx through the status path it already has. A downgrade back to http on the
same host is refused too: the origin matches, but the signature would leave in
cleartext, which is the same leak by a slower route. A custom policy replaces
reqwest's built-in chain limit, so the limit is restated rather than lost.

The recovery path in clone had the same silence the CLI just lost: it skipped a
blob on any non-success without saying why, so a truncated scan, a permission
denial and a dead gateway were one indistinguishable outcome. It now names the
status or the transport error, capped and stripped of control characters since
the text comes from a caller-chosen gateway, and continues as before.

Reading a response body no longer conflates three endings. The cap cutting the
body, the body finishing, and the read failing partway were all one silent
break, so a failed read surfaced as an error message that stopped after the
colon. Each is now reported, and a body that could not be read says so.

Coverage for three paths that were previously argued rather than run: a
transport failure mid-ladder still hands back the resume token, a scan that
ends before any ceiling still answers a definitive not-found, and a response
whose body stalls after its headers is still ended by the client's own
deadline.
2026-08-13 16:10:04 -05:00
beardthelion 8c39cfdcb3 fix(review): keep the resume token on every terminal that still holds one
Review of the scan-continuation work found the ladder surfacing its token on
three terminals and dropping it on three others: a mid-ladder 5xx that is not
an overload, a truncation whose offered continuation fails validation while a
good token is still held, and a transport error. In each case the user is told
to start over, and a bare re-run restarts at the first row and re-spends the
node's per-IP budget, which is the failure the token exists to prevent. It is
surfaced on those paths now, still never on a definitive 404 or on the node's
deliberate scan-wrapped signal, and a rejected token is still never echoed
back.

A body cut at the read cap used to change the answer. Truncated mid-JSON it
failed to parse, lost its error code, and fell to the transient-overload arm,
which retried the old position for every remaining attempt and then reported
that the object might lie beyond the rows scanned when nothing past the first
page had been. The cap now reports that it cut, and a cut body is terminal.

Three guards were doing nothing. Mutation testing showed the Retry-After clamp
could be deleted with every test still green, so a node advertising a day-long
wait would have parked the client inside a single sleep that neither the
attempt cap nor the deadline can interrupt. The rejection of a malformed
caller-supplied token was equally untested, though the same property is proven
on the node-offered side. The withheld-blob ladder asserted only that each rung
was not a success, which any early exit satisfies without the rule ever being
evaluated; it now witnesses that the scan reached the end of the table.

The inter-attempt wait is bounded by the time left on the deadline, so a run
can no longer overshoot the bound it advertises. That drops the wait out of the
worst case entirely rather than shrinking it: the last attempt starts before
the deadline and the client's total timeout covers that request through the end
of its body, so a run is over by the deadline plus that timeout. The docs said
about 95 seconds and now say about 90.

They also said a large download already streaming is never cut off part way.
That was wrong. The scan deadline does not cover the download, but the client's
timeout is a total request deadline running until the body finishes, so a slow
transfer is cut off. Both prose sites now say what the code does.

Also: stdout is flushed before the command returns, so a closed pipe fails
loudly instead of yielding a truncated object with a success status.
2026-08-13 15:09:45 -05:00
beardthelion eac4d43c67 docs(gl): describe how gl ipfs get resumes a truncated scan, and its limits
Document the behaviour rather than the mechanism: that an object pinned before
the node recorded its repo is found by scanning the inventory, that the scan
stops at the node's ceilings, and that the command follows the resume token the
node hands back instead of reporting a false not-found.

State the bounds in values, not constant names, and state honestly what each
one covers. The deadline bounds the search: every attempt gets the time left on
it to produce response headers, and it deliberately does not cover downloading
an object once found, so a large blob already streaming is never cut off. The
error-body read runs outside it under the client's own timeout, which is why
the longest a single run can take is about 95 seconds rather than the flat
deadline, and the in-code comment now derives that instead of asserting it.

Say what is not promised, too. A rate limit ends the ladder because its window
is an hour and its Retry-After cannot be honored inside one invocation, and the
node's per-IP fanout brake can end a ladder well short of the cap, so automatic
resumption is not a guarantee that the object will be reached.

Where a bound stops the ladder, the printed invocation is the only thing that
makes progress: a bare re-run restarts at the first row, reproduces the same
truncation and spends the per-IP budget again.
2026-08-13 14:11:59 -05:00
beardthelion 181f6178a0 fix(gl): accept the resume token the incomplete-scan message tells you to pass
The bounded terminals printed "gl ipfs get <cid> --scan <token>", but the
command took no --scan argument, so following that advice failed on an
unexpected argument and the resume input existed only behind a private seam.
The flag now exists and forwards to the same validation a node-supplied token
gets, which is what makes surfacing the token worth anything: without it a
stopped ladder restarts from the front on every rerun.

Three test fixes ride along, all found by running the mutation matrix rather
than by reading.

The deadline scenario asserted its message "must name the deadline" while its
fixture CID was bafkreideadline, so any message echoing the CID satisfied it.
Under a mutant that removed the deadline the ladder ran to the cap and that
assertion still passed. The CID is renamed so the assertion can only pass when
the message genuinely names the deadline, and the mutant now reddens on it
instead of on the call count.

The default classification arm was certified only for the token-less case: no
fixture reached it with a token already held, so degrading it to retry was an
equivalent mutation against the whole suite. A scenario now drives an
unrecognized code mid-ladder with a token in hand and asserts it is terminal.

The wired path from the argument to the resume input had no coverage; only the
seam was tested. A scenario now goes through the function clap dispatches to,
so dropping the argument turns it red.
2026-08-13 14:02:34 -05:00
beardthelion 9a8d190492 fix(gl): follow scan continuations in gl ipfs get, bounded three ways
A truncated legacy CID scan answers 503 with a sealed continuation token, and
the command turned that into a hard error, so content past a scan ceiling was
unreachable from the first-party client. It now follows the continuation
automatically.

Three bounds, each protecting a different resource. The attempt cap bounds the
work asked of the node. A wall-clock deadline bounds the caller's own time and
is passed into each request as that request's timeout, so a slow-but-alive node
cannot outlast it. The per-wait clamp bounds a single Retry-After, so a hostile
value cannot stall the client. There is deliberately no non-advancing-token
check: tokens are nonce-randomized ciphertext, so two seals of one position are
never byte-equal and a client cannot detect a wedged ladder by comparing them.

Classification happens at one site with no wildcard to retry. Status is checked
before the body is read. A rate limit is terminal on status alone, since its
window is an hour and its Retry-After cannot be honored inside one invocation.
An overload mid-ladder is retried on the still-held token, because that shed
releases its permit when the request ends and the node advertises a retry. Any
other status or code is terminal through the default arm, including an overload
on the first request, where no token is held and there is nothing to resume.

Every terminal message is capped and sanitized before it reaches the terminal,
and a rejected token is never echoed back; the bound is named instead. Tokens
are accepted only as non-empty base64url within a generous ceiling, so a future
change to the sealed layout cannot silently disable resume.

Where a bound stops the ladder, the still-held token is printed with the
invocation that resumes from it, rather than wording that implies a bare re-run
makes progress.

gl suite 332 green, fmt and clippy clean.
2026-08-13 13:47:41 -05:00
beardthelion 1d37cb1ce9 test(gl): cover the scan-continuation resume ladder in gl ipfs get
The node answers a truncated legacy CID scan with a 503 carrying a sealed
continuation token, and gl ipfs get turns every non-success into a hard error,
so content reachable only past a scan ceiling cannot be fetched with the
first-party client at all. The old test asserted that terminal 503 as the
desired behaviour, and its fixture body predates the token, so it is replaced
rather than adjusted.

Thirteen scenarios pin the contract: a resume that completes, the attempt cap,
a wedged server that keeps minting fresh tokens for the same position, a
missing or malformed token, a first-request overload, the signed resume, the
wall-clock deadline, a mid-ladder rate limit that is terminal, a mid-ladder
overload that is retried, the token length boundary pair, the token surfaced at
every bounded terminal, and a caller-supplied continuation that starts from the
token rather than the front.

Hostile-input scenarios assert the surfaced message is capped and stripped of
control and bidi characters and that a rejected token is never echoed back.
Ladder fixtures answer Retry-After: 0 so the clamped waits are zero and the
whole gl suite still runs in under a second in real time.

The seam signatures land here so the tests compile and fail on their
assertions: cmd_get delegates to cmd_get_inner, which threads a deadline, an
attempt cap, and an optional starting continuation. Its body is unchanged and
still bails on any non-success, so all fourteen new tests fail on the property
each one names.
2026-08-13 13:13:29 -05:00
beardthelion 21e6873683 fix(node): bound the legacy scan's query by the remaining row budget
GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS accepts any value from one upward, but the
pager always asked the database for a full page and the ceiling was only
checked afterwards, in the scan loop. With the ceiling below the page size an
anonymous request still selected and rule-loaded a whole page while holding
walk admission, and any ceiling that is not a page multiple overshot by up to
a page. Compute the remaining budget first and ask for min(page, remaining),
so the page size stays a batching detail rather than an implicit floor under an
operator ceiling.

The exhaustion inference moves with it. A short page used to prove the table
was exhausted; once the limit can be shortened by the budget it proves nothing,
and marking the pager exhausted breaks at the top-of-loop arm that sits ahead
of every ceiling arm, taints nothing, and mints no continuation. That path ends
in a definitive 404 for content that exists, so the predicate now compares the
returned rows against the limit actually sent.

The budget computation sits above the timeout opener so the deadline-wrapping
guard's lookback window is unaffected, and the limit is read from the AppState
ceiling the row-ceiling arm reads, not the config field, so the arm and the
query cannot disagree.

Full suite 1059 green, fmt, clippy, and --locked clean.
2026-08-13 12:39:05 -05:00
beardthelion a60026bbfa test(node): pin the legacy scan's DB-facing row selection to the configured ceiling
The row ceiling is enforced in the scan loop, after a full page has already
been fetched and rule-loaded, so a cap below the page size is ignored for the
first fetch and any cap that is not a page multiple overshoots by up to a page.

The existing coverage could not catch that. Both row-ceiling tests asserted
rows <= ceiling + one page, which passes whether or not the query is bounded,
and rows-returned cannot distinguish a bounded query from a full fetch that is
trimmed afterwards. Add a scan_limit seam recording the limit actually sent to
the database, tighten both tolerant assertions to exact counts, and add two
tests that pin the limit: a ceiling below the page size and a ceiling that is
not a page multiple.

Also add the fail-open fixture. A repo evaluated with a partial rule set is
served as if it had no rules, so the boundary repo is seeded public at root
with a path-scoped rule and a real blob on disk, pinned with NULL provenance so
the scan path's rule map is the one consulted, and asserted never served across
its own ladder. Its positive control serves the same blob with the rule absent,
so a failure there is attributable to the rules rather than to a probe or a
budget skip.

Both new tests fail against current code on the scan_limit assertion.
2026-08-13 11:54:41 -05:00
beardthelion 75c0e282af test(node): probe the production advisory-lock key, not a stale copy
Both write-lock tests hand-copied advisory_lock_key's derivation. The copy
still used DefaultHasher, so once the key moved to SHA-256 (#210) the probe
checked a key nobody held. The disconnect test then failed asserting the
lock was held, and the success test passed asserting a release it never
actually observed.

Import the production function, as the acquire-deadline test already does,
so the two cannot diverge again.
2026-08-13 09:55:24 -05:00
beardthelion f4f0f4bff0 Merge remote-tracking branch 'origin/main' into fix/issue-135-ipfs-cid-tree-gate 2026-08-13 09:44:35 -05:00
beardthelion e6cfb588c2 fix(review): bound token key, rule bytes, and the discovery load
An external review found three costs that outlive the request or the boot
that paid for them.

The scan token was sealed with a key minted per boot, so a restart or a
rolling deploy made every outstanding continuation undecryptable and the
handler reads an unopenable token as absent. A caller walking a large
inventory could be sent back to the front indefinitely. The key is now
derived from the node's Ed25519 seed with HKDF, so it is the same after a
reload, under a distinct salt and info string so it is not the signing
key and cannot be turned against signatures. Rotation is a version bump in
the info string. An earlier comment rejected derivation on the grounds
that surviving restarts bought nothing; that is exactly what it buys, and
the comment now says so.

The rule byte ceiling was summed after the page had already been fetched,
so it truncated the request without bounding the work: the allocation and
the transfer had happened. The budget now bounds the query itself. The cut
lands on a repo boundary, which is not cosmetic: a mid-repo cut leaves a
partial rule set, and a partial rule set at the gate is indistinguishable
from a repo with no rules, so it would fail open and serve an object those
rules withheld. Only fully loaded repos are gated. The query always admits
the first rule-carrying repo of a page, so the bound is the budget plus
one repo rather than the budget; without that a single oversized repo
would put the cut at the cursor and every retry would reproduce the same
page.

The old check carried a not-exhausted condition so a short final page
could not turn a complete scan into a permanent shed. That condition is
gone because the taint no longer keys on page length; it keys on the query
having left repos unloaded. A short final page that fits produces no cut
and still 404s, and one that is cut is honestly incomplete and resumable.

Discovery paged the whole repo table and stat'd every warm repo to pick a
sixteen candidate window. Its comment called that acceptable because the
cost was paid once, which was true when the sweep ran once per boot. This
round made the sweep periodic, so an upgraded node with one unrepairable
row paid a full table scan on every run, forever. The load now stops once
the window is full. It collects one candidate past the cap so that running
out still distinguishes a warm set that fits from one that does not, which
is what the reset arm needs now that the total count is no longer known.
Ordering is unchanged, so an attacker who mints repos still cannot move
the boundary.

Verified by mutation: a per-boot key strands a continuation across a
restart, using the signing seed directly defeats the domain separation,
summing bytes after the fetch materializes the whole page, paging to
exhaustion buys pages the window cannot use, and reading the fit under cap
from the window length loses the reset.
2026-08-12 23:38:15 -05:00
beardthelion 342dd48d24 fix(review): mint a continuation at every ceiling and widen the token
Code review found two ways the scan ladder still dead-ends, both the same
shape the round has been fixing: a truncation that sheds without a token,
which this design reads as "the ladder is over", so the caller restarts at
the front forever and everything past that point is unreachable.

The continuation was wired into the row and rules ceilings but not the
probe and visit ceilings, and those two bind first on any inventory with
root-readable repos, long before the row ceiling. Every fixture seeded
root-denying repos, so no probe was ever charged and no test could reach
the arms that were missing it. All four breaks stop at the same cursor, so
all four now carry it.

The token's field width was 128 bytes against a repo id the write path
admits at 356 (255 owner, a slash, 100 name); a plain full DID with a long
name already passes 128. A repo over the width at a truncation boundary
failed the seal and shed tokenless, and repo names are peer-controlled, so
the boundary was steerable rather than unlucky. Fields are now sized
separately, 64 for the timestamp key and 384 for the id, and the version
byte moves so older tokens fail open to absent instead of mis-parsing.
Tokens stay constant length at 668 characters.

The sweep no longer exits for good on a failed pass. Making it periodic
was the point of the round, and returning on the first error meant one
deadlock disabled repair until the next boot, silently. It re-arms on a
longer interval, escalates the log once failures are consecutive, and
backs off again when a run repairs nothing so a table that will never
repair stops costing 64 object reads every five minutes.

The rule ceiling counts retained bytes instead of rule rows and is checked
where the rules are actually fetched, since a page can carry unboundedly
many rules and rule count is the wrong unit for a memory bound. It skips a
short final page: without that, a complete scan of an absent object turns
into a permanent 503.

Two docs contradicted the code and are corrected: the walk cap is charged
per phase now, so a request can spend twice the knob, and the README's
retry bound holds when the row ceiling binds.

Verified by mutation: reverting either ceiling's continuation strands the
holder, narrowing the token width fails the seal at the boundary,
restoring the terminal return stops the sweep, counting rules instead of
bytes lets a page overshoot, and removing the idle backoff keeps a
fruitless table under the base interval.
2026-08-12 19:12:24 -05:00
Kevin Codex 96d8123e0f Merge pull request #320 from euxaristia/fix/like-prefix-escaping
fix(node): Escape LIKE wildcards in certificate prefix search
2026-08-13 06:22:26 +08:00
Kevin Codex c380f9cee5 Merge pull request #215 from Gravirei/fix/issue-210-stable-advisory-lock-key
fix(repo_store): use SHA-256 for stable advisory-lock key (#210)
2026-08-13 06:21:50 +08:00
beardthelion 0d7711c089 fix(node): bound the pre-walk CID resolve on its own short budget
get_by_cid takes the global walk permit and the per-source permit before
resolving the CID, and that lookup was clamped only by the 600s request
budget. A well-formed CID with no pinned row does no probe and no walk,
but against a stalled pool it held a scarce walk slot for the full
window, so requests from enough distinct sources could reject every real
retrieval at admission while nothing was walking.

The lookup now runs under its own budget, defaulting to ten seconds and
clamped by the request budget, so a larger value degrades rather than
extends. Admission still comes first. Resolving before admission was the
other way to fix this and it trades one amplification for another: taking
the read out from behind the gate lets unadmitted anonymous callers stack
concurrent queries, and shedding a flood cheaply before it reaches the
database is what admission-first is for.

Only that one await moves. Everything after it stays on the request
budget because it runs interleaved with work the caller has already been
admitted for: the per-oid source lookup runs after real probing from the
second candidate on, the marker pair only on a provenance miss, and the
pager fetches between pages that cost probes. A short clock anchored at
admission would be long spent by then, so widening it would shed
requests that are making progress. The region comment now carries that
assignment per await.

The shed names the knob an operator can actually turn, separately from
the request-budget shed, so the two are distinguishable in the body and
the log.

Verified by mutation: reverting the clamp overruns the short budget, and
anchoring one short clock across the whole permit-held region sheds a
slow but progressing walk. The second leaves the first test green, which
is what makes it a boundary case rather than a second removal.
2026-08-12 16:59:02 -05:00
beardthelion ae96ffa467 fix(node): budget provenance and fallback walks separately
MAX_HISTORY_WALKS_PER_REQUEST is MAX_PIN_SOURCES + 1, which is exactly
enough for the first pinner plus every recorded additional source. Those
seventeen sources each spend a walk reaching their deny when they are
root-readable but path-scoped, so the walk budget is gone by the time the
at-cap fallback reaches a public source that record_pin_source dropped at
its sixteen-row cap. If that source needs a walk too it is skipped at the
ceiling and the request returns 503 search_incomplete on every retry: a
public object nobody can ever fetch.

The counter is now per phase. gate_and_serve already knows which phase it
is in, so provenance and fallback each get their own budget against the
same cap, and the fallback's capacity no longer depends on what the
provenance phase spent. Total walk work per request stays bounded at
twice the cap, a constant, so the amplification story holds.

Raising the shared ceiling was the obvious alternative and does not work:
the adversary picks how many provenance slots exist, since they are
grindable repo ids filling pin_repo_sources, so cap + N denials recreate
the exhaustion for any constant N.

The test uses real repos with real path-scoped rules that each spend a
real walk, proven by the walk log. The existing buried-source test seeds
repos that do not exist, which never enter the walk block at all, and
that is why it could not see this.

The must-not assertion needed changing to mean anything. A CID is
content-addressed, so every holder of it has byte-identical content and
comparing the served bytes against the public repo's object cannot show
which repo served. The gate is pinned instead by a second object held
only by a path-denying repo, with the fallback armed for it, asserted to
404 for an anonymous caller.

Verified by mutation: re-merging the counters restores the deterministic
503; capping the fallback at what the provenance phase left over is a
reserve that still couples them and fails the same way; and dropping the
fallback cap entirely unbounds the total. Each reddens exactly one of the
two tests, so neither is standing in for the other.
2026-08-12 16:27:23 -05:00
beardthelion 22f4db94b2 fix(node): bound the legacy CID scan and let the caller carry its position
A denial-only inventory paged through the whole repo table. gate_and_serve
returns Skip on quarantine and on the root visibility deny before either
walk.probes or walk.visits increments, and those two counters were the
pager's only stop conditions, so an anonymous caller holding a CID from
the public pins index could read every row and its rules while the global
IPFS walk admission stayed held for the request budget.

Three parts, and the shape of each is set by a way an earlier attempt was
wrong.

A row ceiling, with a rules companion. Rows scanned bounds the DB reads
and the pager's row retention; it does not bound rules, since the rules
query has no LIMIT and an owner picks how many rules their repos carry, so
that gets its own ceiling. Both taint into the existing retryable 503 and
never a 404.

A ceiling alone would stop there and strand content. The scan starts from
a NULL cursor over (created_at, id) ASC, so a bare ceiling cuts the same
oldest prefix on every request and a holder past it is unservable for
good, which is the defect this PR already carries a finding about. So the
503 hands back the position. Server-side alternatives were tried and
dropped: a node-global cursor lets concurrent callers skip each other's
windows, and a per-caller map lets a key farm evict an honest caller
mid-ladder. State the caller carries has neither problem.

The token is sealed, not encoded. It names a row the caller may not be
allowed to see, and id derives from the owner DID, so plaintext would leak
a withheld repo's existence, creation time and owner. XChaCha20-Poly1305
with a fresh nonce per seal, the canonical CID as associated data, an
expiry inside the sealed bytes, and a fixed-width plaintext so token
length cannot vary with the row it names. Undecryptable, expired,
malformed and foreign-CID tokens are one indistinguishable case: absent,
start at the front.

The wrap taint keys on the pager reaching exhaustion while resumed, not
on any break site, so a token pointing at the last row still sheds a
retryable 503 instead of falling through to the 404 tail.

Pages are charged to the per-IP work bucket. Without that the ceiling
bounds one request and nothing across requests, and the denial path pays
nothing, which is the per-request-free-allowance shape already rejected
once on this branch. The derived work-budget floor grows by the same page
term so a deep scan cannot self-throttle mid-ladder.

A toll shed renders 429 rather than 503, which reverses the tail's
documented precedence for this one case. The tail comment names the
exception: the caller's own bucket stopped the scan, not the node's
search, and a 503 would invite an immediate retry into an empty bucket.

Four existing tests hand-size the work limiter to their exact probe count
and now also pay for a page; each gained one token and a comment showing
the arithmetic. Production is unaffected because the floor grew by the
same term.

Verified by mutation: removing the ceiling unbounds the scan; truncating
without tainting turns it into a 404; emitting a token but never resuming
never reaches the holder; dropping the toll makes pages free; encoding
the token as base64 plaintext leaks the withheld fields; dropping the CID
from the associated data honors a cross-CID resume; a variable-width
encoding makes token length vary with the row; and a constant nonce makes
two seals identical.
2026-08-12 15:49:51 -05:00
euxaristia 3993fd1f5d fix(core): count distinct signer DIDs in certificate threshold check
satisfies_threshold counted raw signature entries against the maintainer
list, so a certificate carrying the same maintainer's signature duplicated
across two entries satisfied a 2-of-N threshold with only one real
signer. Dedupe by collecting valid signers into a set before comparing
against the threshold, so each maintainer counts once regardless of how
many times their signature appears in the certificate.
2026-08-12 15:44:06 -04:00
beardthelion 9babb5ece0 fix(node): record a discovered source and its marker in one commit
Discovery recorded the source and armed the fallback in two independent
best-effort writes. If the insert landed and the marker did not, the row
was left raw CIDv1 with a nonempty, below-cap, unmarked source set. Later
sweeps skip such a row, and get_by_cid reads that set as complete and
suppresses the fallback scan, so once the transient error cleared, a
public duplicate of an object whose recorded holder is private stayed
unreachable for good.

record_discovered_pin_source does both writes in one transaction: the
capped source insert, the gated failure-row delete, and the sentinel that
arms the fallback. The sentinel is unconditional because one holder found
by a bounded warm-only probe never proves the set complete.

The method ends in an explicit commit, so an elapsed deadline is a
definite non-write rather than an unknown. A failed record still leaves
an empty source set, which the resolver's own empty-set signal routes to
the fallback, so the degradation posture is unchanged and now has a test.

The fault injection is a BEFORE INSERT trigger on pin_source_failures,
not a dropped table. Dropping it would fail the delete inside
record_pin_source's own transaction, roll that back, and produce the same
empty set as the fix, so the test would pass without ever reaching the
half-state.

Verified by mutation: splitting the transaction back into two writes
404s the public copy, and dropping the sentinel leaves the fallback
unarmed.
2026-08-12 14:13:17 -05:00
beardthelion 4fdfa1cedb fix(node): rotate discovery candidates across traversals
Discovery probed the first sixteen warm candidates of a stable
(created_at, id) ordering on every pass, so an object held only by the
seventeenth repo was never repaired and every boot repeated the same
work. The probe cap bounds expensive reads, which is right; it was also
silently bounding which candidates are ever considered.

A persisted continuation now rotates the window. Four choices carry it,
each forced by a way an earlier draft was wrong:

Keyset, not offset. An offset indexes into a list whose length anyone can
change, so candidates entering below the boundary shift the window off the
holder. A key names a position that survives insertions. created_at is
server-stamped, so minted repos only ever append.

Per traversal, not per pass. The row cursor also advances per pass, so
per-pass rotation gives row R the window index (t*m + j) mod W and a row
sees only W/gcd(m, W) of the W windows. Sharing one window per traversal
makes coverage a per-row property.

Advanced only over probes that started with a live deadline. A probe
against a spent deadline is charged a read but never opens the repo, so
counting it would step the window over candidates nobody examined.

Persisted from inside sweep_pass, next to the row cursor. The run-end site
never executes: spawn_legacy_cid_sweep drops the future on SIGTERM.
Traversal state is owned by the wrapper because the dead-read cap can pause
a run mid-traversal and a later run ends it.

The sweep re-arms on a timer instead of running once per boot, so coverage
is wall-clock rather than a reboot count, and returns only on a failing
pass query. The unconditional sleep is what keeps a repaired table from
spinning.

Verified by mutation: no rotation strands the holder; an offset
continuation shifts the window; advancing to the window end both skips an
unread holder and burns an unprobed window; per-pass advancement drifts
one row's window from another's; no re-arm leaves the holder unreached; a
run-scoped accumulator applies the wrong arm after a cap pause; and
deleting the v26 entry fails the upgrade path.
2026-08-12 13:46:03 -05:00
beardthelion 22560b0872 fix(node): give each discovery row its own slice of the pass budget
DiscoveryCtx held one deadline for a whole sweep pass, so a single hung
candidate on the first source-less row spent it for every row behind that
one. Those rows then called repair_legacy_provider_cid with an expired
deadline, came back retryable without a meaningful probe, and were charged
reads for work they could not do. CID order is stable, so the same first
row consumed the budget on every boot and the rows behind it starved
permanently.

Each row now probes under min(now + git_timeout / 4, pass_deadline), so at
least four rows per pass get a live deadline and no row can take more than
a quarter. A row that starts with the pass deadline already spent returns
PassBudgetSpent: no probe, zero reads charged, retryable, and surfaced on
SweepStats so a run can say it happened. Charging those reads would burn
MAX_DEAD_ROW_READS_PER_RUN on probes that cannot succeed.

The field is renamed to pass_deadline because that is what it is:
load_discovery_ctx runs once per pass and a run loops passes. Naming it
for the run would have stated a bound the code does not hold.

The divisor is not a bound on a single probe. A row's slice is still
shared by up to MAX_LEGACY_DISCOVERY_PROBES candidates, and the doc says
so rather than implying a guarantee.

Verified by mutation: collapsing the row deadline back to the pass
deadline starves the second row, and folding the spent-budget arm into
ordinary retryable accounting charges reads that must stay free.
2026-08-12 12:20:19 -05:00
beardthelion 650460acc0 test(node): cover the pre-provenance upgrade row end to end
The sweep's source-less discovery had no fixture built from a real
pre-migration row. Every existing test seeded its pin through the modern
path, which cannot reproduce the shape an upgraded node actually carries:
a row written when pinned_cids.repo_id and pin_repo_sources did not exist
yet, so its source set is empty with no other signal.

The fixture un-applies migrations 19 and 20, inserts the provider-keyed
pin naming only (sha256_hex, cid, pinned_at), then migrates forward. That
ordering is what makes it real; seed_legacy_pin binds repo_id, the column
being proven absent, so the insert is inline.

It brackets the repair rather than asserting an end state: the anonymous
GET is driven before the sweep and asserted not to serve, then after the
sweep asserted to return the object's bytes. The marker assertion queries
pin_source_failures directly for the "" sentinel instead of reading the
pin_sources_incomplete bool, which cannot distinguish the sentinel from a
repo id that record_pin_source would later clear.

Verified by mutation: short-circuiting the sources.is_empty() discovery
arm restores the pre-fold fall-through and the test goes red on the
repair assertion.
2026-08-12 11:56:56 -05:00
Gravirei b01d17b7f3 docs(repo_store): pin colon-free owner_slug contract on advisory_lock_key
The domain separation owner_slug + ':' + repo_name has no length prefix, so
it is injective only while owner_slug cannot contain a colon. State that on
the pub(crate) function and guard it with a debug_assert, since a raw DID
would collide (('did:key:abc','x') and ('did','key:abc:x') hash the same).
2026-08-12 13:07:50 +06:00
beardthelion 4c665fbca8 fix(node): port discovery's candidate load onto the paged repo query
#173 removed Db::list_all_repos in favour of a keyset-paged
list_repos_page_for_scan, so this branch stopped compiling on rebase. One call
site, load_discovery_ctx, ported with its reviewed semantics intact.

The quarantine drop gets simpler rather than different: each page row carries its
own quarantined flag, so the separate list_quarantined_repos query is gone and
the filter runs over rows the pass already read. Private non-quarantined repos
still stay in the list. Warm-only via validated_repo_disk_path inside
spawn_blocking is untouched, so nothing here reaches repo_store.acquire and a
repair pass still cannot become a bulk restore, and an unsafe path is still
dropped with the same warn and stays terminal.

Oldest-first by (created_at, id) now comes from the query's ORDER BY (index
backed by migration v25) instead of a client-side sort, and pages concatenate in
that order, so the explicit sort_by is redundant and removed. The anti-grinding
reason for that order is unchanged and the doc comment keeps it: repo_id derives
from a grindable owner DID, so an id sort would let someone bury the true holder
past the probe cap. The probe cap still bounds expensive reads rather than
candidates considered, and no cap on candidates was added.

This pager runs to exhaustion, unlike the resolver's, which drives the same query
and stops when its probe or visit budget is spent. The comment says why: the
resolver is an anonymously reachable route holding scarce walk admission, while
this is background maintenance on a timer with no caller and no permit
contention that needs the whole warm candidate set to settle a row. Paging is per
pass, matching load_discovery_ctx's existing once-per-pass lifetime.

Refs Gitlawb/node#321.
2026-08-11 22:46:29 -05:00
beardthelion bc202368e1 fix(node): charge discovery's probes against the per-run read budget
The two halves of round 12 left a hole between them. The cursor rewind became
unconditional on reaching the end of the table, so every completed run re-walks
every row, and MAX_DEAD_ROW_READS_PER_RUN is what stops that from costing
O(rows) object reads on every boot. But row_read_attempted was only ever set in
the provenance loop, so a source-less row, the one shape discovery exists for,
spent up to MAX_LEGACY_DISCOVERY_PROBES reads and contributed nothing. The
per-row cap bounded one row; nothing bounded the run.

discover_legacy_row now reports the probes it spent and the caller charges them
whenever the row was not repaired.

Per probe rather than per row, because at 16 probes a row the budget would
otherwise admit 16 times the reads it names.

Including the retryable arm, which is where discovery differs from the
provenance loop. There a retryable read is against a repo the row names as a
holder and is expected to succeed once that repo warms. Discovery probes repos
the row does not name and re-derives its candidate list from scratch each run,
so an unrepaired row costs the same reads again on the next boot whatever its
outcome was. The cap-reached arm is retryable by design, to stop a grindable
repo id burying the true holder, and leaving it uncharged would leave exactly
the steerable path uncharged.
2026-08-11 22:08:10 -05:00
beardthelion bd0bef1476 fix(node): carry discovery onto the per-(oid, repo) incompleteness marker
Rebasing onto the #173 head that made the marker per (object, repo) broke
three assumptions this branch was written against.

Discovery re-marked the set incomplete after recording a discovered source,
because a successful record_pin_source used to clear the whole per-object
boolean. It no longer does, so that call went from compensating for a clear to
being the only thing arming the resolver's fallback. It now marks against the
unknown-repo sentinel rather than the repo it just recorded, which would claim
that repo failed when it is the one that succeeded. The sentinel is what the
v24 migration carries pre-upgrade markers under and means the same thing here:
the bounded warm-only probe may have missed a holder and nobody knows which.

Two tests asserted the superseded behavior and are inverted rather than
deleted, which is what their own comments asked for. GAP 2 documented the
per-object marker being cleared by any later record; that residual is closed,
so the test now proves discovery's marker survives one. The cold-candidate test
proved the cursor was not rewound; the rewind is unconditional on completion
now, so it asserts the cost that still matters, that re-walking the row reads
no object bytes and restores nothing.
2026-08-11 22:08:10 -05:00
beardthelion 25cab14024 test(node): pin the per-object marker's third-repo residual
record_pin_source's doc already names this residual and accepts it: the
pin_sources_incomplete marker is one boolean per object rather than per
(object, repo), so a genuine record from a third repo clears a marker that a
different repo's failed record set. Its two neighbouring cases are tested,
the same repo re-recording and a no-op insert that must not clear, and this
one sat between them described in prose and guarded by nothing.

The test drives the real path rather than writing rows. Repo A first-pins the
object. Repo B genuinely holds it and pushes, its skip-branch record fails on
the definite-error arm, and the code marks the set incomplete. An anonymous
read is then SERVED from B, which is the assertion that matters: it proves the
unrecorded holder is reachable while the marker stands. Repo C pushes, its
record succeeds, and the clear fires inside C's transaction. The identical
read now 404s, with the preload counter proving the fallback scan never ran.

Nothing here changes behavior. It converts an accepted tradeoff from a comment
into a guard, so widening it takes a deliberate edit rather than going
unnoticed. The doc comment says the residual fails closed, and that holds:
the provenance loop and the fallback scan both serve through the same
gate_and_serve, so clearing the marker only ever removes a gated search and
can never serve an object the caller could not already read. The window is
exactly one to fifteen sources, because at cap the count-guarded insert is a
no-op and the marker survives.

If someone implements the per-(oid, repo) marker the doc proposes, this test
is expected to go red. It should be updated to the new behavior, not deleted.

Proven load-bearing: removing the clear from record_pin_source leaves the
second read at 200 and turns the test red on the marker assertion.
2026-08-11 22:08:10 -05:00
beardthelion d88c0c726c fix(node): repair source-less legacy pins by bounded additive discovery
The boot sweep resolved each legacy row's bytes only through
pin_sources_for_oid. A row predating provenance has repo_id NULL and no
pin_repo_sources entry, so the source set is empty, the `for repo_id in
sources` body never runs, and the cursor advances past it. list_pinned_cids
also filters those rows out on is_raw_cidv1, so they were both unadvertised
and unrepairable: nothing on the node would ever fix them. The sweep exists
precisely for pre-provenance rows, and that was the shape it could not
handle.

For a source-less row, discovery now probes a bounded set of warm local repos
for the object and, on a hit, repairs the key through the existing
repair_legacy_provider_cid.

What it records is the part worth reading closely. It calls record_pin_source
additively and then mark_pin_sources_incomplete. It does NOT call
backfill_pin_provenance, and repo_id stays NULL. Reading identical bytes out
of a repo proves that repo HOLDS the object, not that it is the source: forks,
a vendored file, a shared LICENSE blob and the empty tree all collide across
repos. Since backfill_pin_provenance guards on AND repo_id IS NULL, the first
claim written is permanent, so an exclusive claim would let probe order decide
which repo's visibility rules gate a public read surface, with no correction
path. It would also disable the resolver's own fallback: needs_scan is
sources.is_empty() || at_cap || incomplete, so a non-empty below-cap unmarked
set reads as complete and stops the scan that would have found a servable
copy. The additive record plus the incomplete marker keeps that fallback
alive and makes nothing permanent.

Candidates are quarantined-filtered. list_all_repos is a bare SELECT over
repos with no quarantine or visibility filter, while the resolver's own legacy
scan loads list_quarantined_repos and gates on it, so discovery loads the same
set and drops those ids. A quarantined repo is hidden from every reader, so it
must not become a recorded source. Private non-quarantined repos stay in the
list: an additive source record does not widen what the resolver will serve,
since every source is gated independently.

The candidate list and the warm filter load once per pass, lazily, on the
first source-less row, and the is_dir sweep runs under spawn_blocking rather
than inline on a worker. Nothing goes through repo_store.acquire, so a cold
repo is never pulled back from remote storage; a repair pass must not become
a bulk restore.

Two classification rules matter more than they look. A cold or quarantined
candidate does NOT mark the row retryable. The candidate list is every repo on
the node rather than the row's holders, and sweep_legacy_provider_cids rewinds
the cursor whenever any retryable skip occurred, so counting a cold candidate
would make every source-less row retryable on every run on a cold-storage
node: the cursor would rewind forever, the sweep would never drain, and each
pass would pay full discovery cost. Only a read error on a warm candidate is
evidence about the row. Conversely, cap exhaustion IS retryable rather than
terminal, and candidates are ordered oldest-first by (created_at, id) rather
than by id: repo_id derives from the owner DID, which anyone can grind, so a
first-N-wins cap over an id-sorted list would let someone bury the true holder
permanently behind cheap registrations.

MAX_LEGACY_DISCOVERY_PROBES bounds the expensive unit, a bounded object read
from a warm repo. A candidate dropped cold, quarantined, or unsafe costs no
probe. Its doc states the contract it encodes rather than its resemblance to
the resolver's own cap, which happens to share the value under a different
contract.

The source record is best-effort and says so at the site rather than claiming
a later pass heals it. Once the key is rewritten the row is raw CIDv1, so the
cost gate skips it free and nothing revisits it; the healing path is the
resolver's needs_scan fallback, which the incomplete marker keeps available.

The no-remote-fetch property is asserted from both sides. On the effect side,
a cold candidate's disk path still does not exist after two full runs, with a
control proving the sweep declined an available copy rather than finding
nothing to take. On the call side, a source scan of this module (comments and
string literals stripped, four anti-vacuity assertions) rejects the
fetch-capable call shapes, and a mutation planting a real acquire in the probe
loop turns it red. What that pair does not cover, and the test says so: a
fetch reached transitively through git::store or db, and git's own promisor
lazy-fetch on a partial clone. Neither shape exists in the tree today.

Concurrency around the marker is executed rather than assumed. Discovery
records then marks, in that order, because a successful record_pin_source
clears the marker. Under a proven lock-wait interleaving, a concurrent record
committing before discovery's mark leaves the row marked, and two concurrent
passes over one row converge on a single source with the marker set. Both the
swap and the drop of that pair are red-checked.

One residual is pinned by a test rather than papered over, and it is not
introduced here. pin_sources_incomplete is one boolean per object, not per
(object, repo), so a genuine pusher whose record_pin_source commits strictly
AFTER discovery's mark clears it, leaving a non-empty below-cap unmarked set
that tells needs_scan to stop scanning. No ordering inside this sweep can
prevent that, and record_pin_source's own doc already states the same residual
for its existing call sites; closing it needs a per-(oid, repo) marker, which
is a change to the marker's shape. It is bounded in practice: the clearing
writer really holds the bytes, so every source in the set is servable, and for
these rows it cannot be a regression, since before discovery ran they were
keyed on a provider CID and were neither served nor advertised.

Tests: fifteen regressions driving the sweep entry points. The repair, cheap
second pass, probe cap and multi-holder cases were observed RED first. The
must-not cases carry mutations instead, since a guard against something
happening has no honest pre-fix red: quarantined holder never serves, a cold
candidate is neither fetched nor materialized and does not rewind the cursor,
an unsafe candidate name is dropped without aborting the pass while a later
safe candidate still repairs, a raw-CIDv1 row never enters discovery, and a
provenanced row still resolves through the source loop alone. Every guard
carries a red-check mutation with an attributed message, including three on
the design itself: one adds backfill_pin_provenance beside the additive record
and must redden the no-exclusive-claim assertion, one neutralizes the
incomplete marker, and one swaps the record and mark order.
2026-08-11 22:08:10 -05:00
beardthelion 4985e38af2 fix(ipfs): page the legacy CID scan instead of materializing every repo
GITLAWB_IPFS_MAX_LEGACY_PROBES bounded the probe fan-out, but nothing bounded
the load that ran before the first probe. On a legacy (NULL-provenance) or
capped/incomplete provenance set, get_by_cid called list_all_repos, cloned every
id, fetched visibility rules for the whole set, and fetched the node's entire
quarantine list, then retained all three for the request. /ipfs/{cid} is
anon-reachable and CIDs are enumerable from the unsigned pins index, and an
object pinned from MAX_PIN_SOURCES repos is permanently at cap, so every later
anonymous GET for it paid that whole preload with the scarce walk permits held,
even at a probe budget of 1.

The selection is now keyset-paged on (created_at, id) ASC, with the quarantine
flag returned per row so the separate whole-node quarantine query is gone. Both
key columns are immutable, so paging is exact: a repo touched mid-scan cannot
cross a page boundary and go unvisited, and updated_at is attacker-bumpable,
which would have let a caller sort their own repos ahead of the true holder.
The pager lives outside the oid loop, so its cursor and its accounting are per
request rather than per candidate. Before fetching another page the scan checks
that a row on it could still reach a verdict; a spent probe or visit budget
stops the read and TAINTS, so a truncated search still sheds the retryable 503
and never a false 404. A page of pure denials is deliberately not a stop
condition, since a cheap denial costs no probe and a public object buried behind
private repos must still be reached. Both page queries stay clamped to the
remaining request budget, and the rules query still fails closed.

A new v25 migration adds idx_repos_created_at_id. Bounding what the handler
materializes is only half the property: repos carried no index in the new keyset
order, so Postgres seq-scanned the table and top-N sorted it to return every
page while the walk admission was held, which moves the same O(rows) cost onto
the database rather than removing it. Measured on 50k rows: 951 shared buffers
and ~44ms per page without the index, versus an Index Only Scan at 7 buffers and
~0.09ms with it, and the keyset predicate pushed down as an Index Cond instead
of filtering after a scan. Nothing names the index in any query text, so its
entry says why it exists and a test asserts its presence structurally.

Two order-dependent tests were reseeded rather than left green: the buried-row
and acquire-taint cases encoded their ordering through updated_at, which no
longer determines iteration. Both were re-confirmed RED under their documented
mutations after the reseed. Three regressions land with the fix. One drives a
one-probe, one-row-per-page request against three candidate repos and asserts it
materializes one row and runs one query, which a query counter alone could not
have caught. One seeds two distinct source-less oids under a single CID and pins
the per-request property directly: the second candidate re-reads the pages the
first paid for, so the whole request costs one pass rather than one per
candidate. The third covers the index, including the INV-7 upgrade path.

Refs Gitlawb/node#173.
2026-08-11 21:40:19 -05:00
euxaristia 1d3987bba6 fix(review): make the LIKE escape independent of the SQL parser mode
Spelling the escape character as `ESCAPE '\'` puts a backslash inside an
ordinary SQL string literal. Under the legacy `standard_conforming_strings=off`
setting that backslash escapes the closing quote, so the literal runs on and the
statement fails to parse. The failure happens before the bound parameters are
considered, so every prefix lookup breaks, including prefixes that contain no
metacharacter at all, and short certificate IDs stop resolving entirely.
Connection settings come from outside the process, so that mode can arrive from
a database- or role-level setting.

Use `!` as the LIKE escape character instead, escaping `%`, `_`, and `!` in the
bound pattern. The statement then holds no backslash and parses the same either
way, without needing a second layer of backslash escaping. A backslash in the
prefix becomes an ordinary character, which is the literal match the search
wants anyway.

Cover it with a test that runs the production query through a second pool that
requests the legacy mode at connection startup, so a future return to a
parser-mode-dependent literal is caught rather than only wildcard handling under
the default configuration.

Refs #319
2026-08-11 14:09:13 -04:00
beardthelion 9758bc7303 docs(node): name the knob that actually sets the legacy-probe budget
The comment beside ipfs_max_legacy_probes told operators to tune
GITLAWB_IPFS_MAX_REPOS_WALKED, but AppState::ipfs_legacy_probe_budget reads
GITLAWB_IPFS_MAX_LEGACY_PROBES. The knob it named is the separate cap on
expensive visibility walks, so anyone following the guidance next to this line
adjusted the walk cap and left the legacy scan fan-out at its default.
2026-08-11 12:23:08 -05:00
beardthelion 5575da4b65 fix(node): make the pin-source incompleteness marker per (object, repo)
pinned_cids.pin_sources_incomplete was one boolean per object, so any
successful source record cleared it, including a genuine record from a repo
unrelated to the failure. The resolver reads a cleared marker as "every source
is recorded", drops the scan fallback, and 404s an anonymous caller whose only
servable copy is the one whose record failed. The missing source is a property
of an (object, repo) pair, so v24 stores it as one and only a later record from
that same repo clears it.

Pre-upgrade markers carry over with an empty sentinel repo id that no real
record matches, so they keep their fallback instead of being cleared by the
next unrelated record. pinned_cids.pin_sources_incomplete is left in place,
unread, so a rollback to the previous release still finds its markers.

Also adds the handler-level coverage the size-probe commit could not: a test
seam drops the object between the type probe and the size probe, and the
request tails to a retryable 503 rather than a definitive 404. The seam is
keyed on (repo path, oid) because fixture oids are content-derived and shared
across tests, and an oid-only key deleted objects out from under two unrelated
tests running in parallel.
2026-08-11 12:20:39 -05:00
beardthelion ed6355ed2c fix(node): stop the /ipfs size probe reporting a fault as a missing object
object_size_bounded mapped every non-timeout failure to Ok(None), which
gate_and_serve reads as a verified absence and does not taint the search for.
A corrupt object, an unreadable pack, or a failed spawn therefore ended the
search cleanly and handed an authorized caller a definitive 404 instead of the
retryable 503 tail, even though the type probe had already reported the object
present. Confirmed by execution against the old code: a corrupt loose object
returned Ok(None) where the healthy read returned Ok(Some(n)).

The probe now returns Result<u64, ProbeError>, the vocabulary the type and
content stages either side of it already use: a reaped child is Transient, and
everything else is classified by store readability, Transient when the store
cannot be read and Deterministic when it can. There is no absence value left,
so the collapse is unrepresentable rather than fixed at one call site, and the
gc-between-stages case is classified like any other fault.

ServedRead::Gone lost its only producer and is removed with it.

Covered by two store tests, each asserting a healthy read first so the fault
case cannot pass on a broken fixture. Not covered: the handler path that turns
the fault into a 503, because that call site hardcodes "git" rather than
state.git_bin, so no shim can be injected there.
2026-08-11 11:38:59 -05:00
beardthelion e4aaae8c52 fix(node): bound the fruitless reads one sweep run will spend
Rewinding on every completed walk means a later run re-attempts the object
read for every row whose bytes are permanently gone, so a node that collected
dead pins from a deleted repo or a force-pushed history paid O(dead rows) git
invocations on every boot with no decay. A run now stops after 64 such reads
and keeps its cursor, so the next boot resumes past what it already walked and
the table is still covered across boots.

Also corrects two pieces of prose that described the superseded round 11
behavior: the SweepStats::retryable_skips field doc still claimed that field
drives the rewind, and the new below-cursor test's comments still said a clean
run parks its cursor.

The pass-failure test waited on a fixed 100ms sleep for the first pass to
finish before renaming the table out from under the walk. It now waits on the
cursor that pass writes, so a runner slow enough to push the first pass past
the sleep no longer fails the assertion for the wrong reason.
2026-08-11 11:11:34 -05:00
Gravirei aa68d97a8d refactor(repo_store): expose advisory_lock_key pub(crate) and drop test copy
The deadline test hand-copied the advisory-lock key derivation, and that
copy drifted once the key moved to SHA-256, silently breaking the lock
collision the test is built around. Import the production function
instead so the key cannot diverge again.
2026-08-11 17:59:33 +06:00
beardthelion 1b44b12b46 fix(node): rewind the pin repair sweep cursor on every completed walk
A clean sweep run parked its cursor at the table's maximum sha256_hex and
later runs read only rows above it, so a provider-CID row written below that
point by another node during a rolling upgrade was never swept. The resolver
withholds such a row's advertised key, leaving the object unretrievable with
nothing left to repair it.

The round 11 rewind could not cover this: it fired only when a run skipped a
retryable row, and the run that parks the cursor is by definition clean. The
rewind is now unconditional on reaching the end of the table. A run that stops
on a pass error keeps its position instead, so a node whose DB fails part-way
through does not restart the walk from the beginning on every boot.

The cost is one ordered scan per run, plus one repair attempt per run for a
row that is unrepairable in principle. Rows already carrying the canonical raw
key cost a codec decode and no object read, so a node that has finished
repairing pays the scan and nothing more. The terminal-skip test now asserts
that bounded cost rather than the row going unread.
2026-08-11 04:33:47 -05:00
euxaristiaandcairn-code eeafa1fa53 fix(node): Escape LIKE metacharacters in certificate prefix search
Short-ID resolution passes a caller-provided prefix into a LIKE pattern. Any '%', '_' or '\' in that prefix is interpreted as a LIKE wildcard rather than a literal character, so lookups can match certificates outside the requested prefix.

Escape the three metacharacters in the prefix and add an explicit ESCAPE '\' clause so prefix matching is always literal.

Add a regression test covering literal '%', '_' and '\' prefixes.

Co-Authored-By: cairn-code <[email protected]>
2026-08-10 23:38:05 -04:00
Gravirei c68e1b07f8 fix(test): match SHA-256 advisory-lock key in receive-pack deadline test
The test reproduces acquire_write's advisory-lock key derivation locally so
a held session lock can stall the handler. repo_store's advisory_lock_key
now uses SHA-256, so the DefaultHasher copy no longer collided and the
stall never happened. Update the local copy to the SHA-256 derivation.
2026-08-10 22:09:10 +06:00
Gravirei 9f340eac5e Merge upstream/main into fix/issue-210-stable-advisory-lock-key
Resolve conflicts: keep the SHA-256 advisory-lock key change alongside
upstream's connection-affine RepoWriteGuard rework and its F1/F3b/F4/U8
test suite; RUN-A-NODE.md keeps both the updated Public URL row and the
rolling-upgrade advisory-lock row.
2026-08-10 21:31:38 +06:00
Gravirei a27a267871 fix(gl): require non-empty error message and use ephemeral port in whoami tests (#220) 2026-08-06 13:18:29 +06:00
Gravirei b4007a5e00 fix(gl): assert whoami 404 verdict and preserve transport error cause (#220) 2026-08-06 11:58:11 +06:00
Gravirei a329174175 fix(gl): assert 404 verdict and harden transport error handling in whoami (#220) 2026-08-06 11:57:23 +06:00
Gravirei fd78cfa3ad fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:15 +06:00
Gravirei 3cc15cf724 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei 16cc3b37e8 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei 3f41df5868 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei b0e5cc0ee0 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei 12bc759ea6 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei 10e3492825 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei c10f2ca255 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:39:07 +06:00
Gravirei 75b0df8f1c fix(whoami): update registered status handling on 404 response 2026-08-06 11:39:07 +06:00
Gravirei 3add5356e3 fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:38:47 +06:00
Gravirei fdd11030dc fix(gl): only report registered: false on 404 in whoami (#220) 2026-08-06 11:38:47 +06:00
Gravirei 89e10d0ff1 fix(repo_store): use SHA-256 for stable advisory-lock key (#210)
DefaultHasher is not frozen by the Rust standard and has shifted across
toolchain versions, so the same (owner_slug, repo_name) produced a
different i64 advisory-lock key on different builds and broke
cross-machine write-exclusion for the same repo.

Hash with SHA-256 instead and pin the result with a golden-value test,
plus a one-axis-at-a-time differential test so a regression that drops
either parameter is caught. Document the SHA-256 re-keying caveat on
acquire_write and in the RUN-A-NODE.md operator checklist (drain writes
or cut over single-node during a rolling upgrade).
2026-08-05 19:26:44 +06:00
49 changed files with 24143 additions and 2161 deletions

No files matched your search

+49 -9
View File
@@ -24,11 +24,26 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
# ── Database pool & startup resilience ────────────────────────────────────
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
# connections open lazily. Size against the DB server's max_connections,
# remembering admin tooling opens its own pool. Each concurrent write pins one
# connection for its whole duration (the connection-affine advisory lock), so the
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
# headroom — keep this comfortably above that (default 48 for pushes 32).
# remembering admin tooling opens its own pool.
GITLAWB_DB_MAX_CONNECTIONS=48
# Maximum connections in the DEDICATED advisory-lock pool, separate from the
# pool above. Every in-flight repo write pins one connection here for its whole
# duration, so this is a hard ceiling on simultaneous writes node-wide: size it
# to expected peak concurrent writers, not small. Keeping it separate is what
# stops a push burst from starving ordinary request handlers. Budget
# (GITLAWB_DB_MAX_CONNECTIONS + this) per node against the server's
# max_connections, times node count, plus admin tooling.
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=40
# Upper bound, in seconds, on any object-storage transfer that runs while a
# per-repo write lock is HELD (the archive download inside acquire_write and the
# upload inside release). These were free before the lock's connection was
# pinned to the guard; now an unbounded stall holds a lock-pool slot, and enough
# stalls deny every write on the node. The bound applies PER SPAN and there are
# two (the acquire-side refresh, which covers the existence check and download
# together, and the release-side upload), so worst-case slot occupancy is about
# twice this value plus the git work between them. Read it together with the pool
# size above and with GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS=300
# Seconds a request waits for a pool connection before failing with 503.
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
@@ -94,9 +109,14 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# Require the authenticated pusher to be the repo owner on git-receive-pack.
# A valid did:key signature is authentication, not authorization: anyone can
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
# Keep false until the repo owner is ready for owner-only writes.
GITLAWB_ENFORCE_OWNER_PUSH=false
# mint a key, derive its DID and sign, so with this off every signed caller may
# push to every repository, private ones included. On by default.
#
# Set to false only for a rolling upgrade whose pushers are not yet the repo
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
# verified but not yet honored for authorization, so they cannot push while this
# is on. See docs/RUN-A-NODE.md.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Comma-separated libp2p multiaddrs.
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
@@ -191,8 +211,9 @@ GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
# many WRITE-pool slots. This one is load-bearing for push availability, since
# it is acquired before the global write permit: without it, one host minting
# disposable did:key identities could open enough slow pushes to monopolize the
# write pool and 503 every other source (owner enforcement defaults off, and
# the push rate limiter caps arrival rate, not in-flight concurrency).
# write pool and 503 every other source (the push rate limiter caps arrival
# rate, not in-flight concurrency, and owner enforcement can be turned off for
# a rolling upgrade).
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
@@ -217,6 +238,16 @@ GITLAWB_IPFS_WALK_PER_SOURCE=4
# 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_LEGACY_PROBES=256
# Max repo ROWS one /ipfs request's legacy scan may read from the database. The probe
# ceiling above only counts once a probe runs, and quarantined or private repos are
# denied before that, so without this an all-denying inventory paged the whole repo
# table for one anonymous request. A truncated scan sheds a retryable 503 carrying a
# sealed `continuation` token; echo it as ?scan= to resume, so a holder buried past the
# ceiling is reachable in ceil(repos / ceiling) + 1 requests. Raising this also raises
# the per-caller /ipfs work allowance, since each page is charged to it. Lowering it
# sharpens a coarse oracle: laddering to the end reveals the node's total repo count to
# within one ceiling. Default 2048.
GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS=2048
# Max EXPENSIVE path-scope visibility walks per single /ipfs request (only a
# blob in a path-scoped repo costs a full-history walk). Over-cap repos are
# skipped without a verdict and the scan continues; if the object is then found
@@ -243,6 +274,15 @@ GITLAWB_IPFS_MAX_REPO_VISITS=1024
# Must be 1..=3153600000 (100 years): the node derives an Instant deadline from
# this value, and a larger one cannot be represented. Default 600.
GITLAWB_IPFS_REQUEST_BUDGET_SECS=600
# Shorter budget (seconds) for the pre-walk CID resolve: the lookup that maps a
# requested CID to its git oid(s), which runs while the scarce walk admission is
# already held. A well-formed CID with no pin row does no probe and no walk work,
# so without this a stalled lookup could hold a walk slot for the whole request
# budget while nothing walked. The effective deadline is the lesser of this and
# the remaining request budget; walk and probe work stay on the request budget,
# so a slow but progressing scan is never shed by it.
# Must be 1..=3153600000 (100 years). Default 10.
GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
# 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
+4 -2
View File
@@ -31,7 +31,9 @@ crates/
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN, ref certs)
├── gitlawb-node/ axum HTTP server, git smart HTTP, P2P, GraphQL
├── gl/ CLI — identity, repos, MCP server, Base L2 names
└── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
├── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
├── gitlawb-attest/ provenance attestations for ref-update certificates
└── icaptcha-client/ client for the iCaptcha proof-of-intelligence service
docs/ Operator guides
scripts/ Build helpers
```
@@ -106,7 +108,7 @@ cargo test -p gitlawb-node
- **TypeScript SDK** (`@gitlawb/sdk`) — client library for the HTTP API
- **Python SDK** (`gitlawb`) — for ML/agent pipeline integration
- **UCAN chain validation** — complete the auth middleware
- **UCAN authorization** — add trusted issuer anchoring, capability checks, and authorization-aware revocation
- **Filecoin storage tier** — wire up cold storage deals
- **Documentation** — guides, tutorials, API examples
- **Node operators** — run a public node and report issues
Generated
+10 -9
View File
@@ -2630,7 +2630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 2.0.117",
"syn 1.0.109",
]
[[package]]
@@ -3002,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3427,6 +3427,7 @@ dependencies = [
"sha2",
"thiserror 2.0.18",
"tokio",
"url",
"uuid",
"zeroize",
]
@@ -3542,9 +3543,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.13"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [
"atomic-waker",
"bytes",
@@ -3863,7 +3864,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -5431,7 +5432,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5468,7 +5469,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
]
@@ -5900,7 +5901,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6794,7 +6795,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+3
View File
@@ -53,6 +53,9 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
# http client
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
# take a parsed URL without pulling reqwest into gitlawb-core)
url = "2"
# HMAC
hmac = "0.12"
+64 -9
View File
@@ -19,7 +19,7 @@ The mission is simple: once code is pushed to the network, it should not disappe
## What is in this repository?
This is a Rust workspace with four crates:
This is a Rust workspace with six crates:
| Crate | Purpose |
|---|---|
@@ -27,6 +27,8 @@ This is a Rust workspace with four crates:
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
| `gitlawb-attest` | Pluggable external provenance attestations for ref-update certificates. |
| `icaptcha-client` | Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
---
@@ -54,6 +56,7 @@ Good today:
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
- Repository and path-scoped visibility enforcement for repository and Git content reads, with 404-shaped repository denials.
- DID identities.
- `gl` CLI workflows.
- libp2p peer discovery/gossip foundation.
@@ -63,11 +66,13 @@ Good today:
Known limitations:
- Private repository read enforcement is not wired yet. Treat public nodes as public infrastructure unless you restrict access at your proxy/firewall.
- UCAN chain validation and revocation are not complete.
- Repository write authorization is not capability-complete yet; HTTP signatures prove identity, not full authorization policy.
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
See:
@@ -224,6 +229,53 @@ Notes:
sends the bearer token **only** to your configured origin, never to a URL a
node advertises, so a hostile node can't capture the key or redirect the solve.
### Fetching an object by CID
```bash
gl ipfs list # CIDs this node has pinned
gl ipfs get bafkrei... > object.bin # object bytes on stdout
```
Objects that were pinned before the node started recording which repo they came
from are found by scanning its repo inventory, and that scan stops at the
per-request ceilings in the [Configuration](#configuration) table. A stopped scan
answers 503 with a resume token instead of a false "not found", and `gl ipfs get`
follows the token automatically: up to 8 resumes after the first request, so at
most 9 calls to the node, waiting between attempts for as long as the node's
`Retry-After` asks and never longer than 5 seconds.
The whole ladder runs under a 60 second wall-clock deadline. The deadline bounds
the search, not the download: each attempt gets the time left on it to produce
response headers, and once an object is found its bytes stream outside that
deadline. They are not unbounded, though. The client's own 30 second HTTP timeout
is a total request timeout, running from the start of a request until its body
has finished, so a transfer still going 30 seconds after its request began is cut
off. Waits between attempts never run past the deadline either, so a single run
spends at most around 90 seconds on the network: the deadline plus the 30 second
timeout covering the last attempt. Writing the object out sits outside both
bounds, so piping into a reader that stops reading can hold the command open
longer than that.
Two node-side brakes end a ladder early and are reported rather than retried
around. A 429 is terminal, because the node's rate-limit window is an hour and
that wait cannot be honored inside one invocation; a transient overload (a 503
carrying no incomplete-scan code) is retried on the token already held, under the
same cap, clamp and deadline. The per-IP fanout brake can also stop a ladder well
short of the 9 calls, so automatic resumption is not a guarantee of reaching the
object.
When a bound stops the ladder with a usable token in hand, the command prints the
token and the invocation that continues from it before exiting nonzero:
```txt
resume from where this stopped: gl ipfs get bafkrei... --scan <token>
```
Run that to carry on from where the scan stopped. Re-running without `--scan`
restarts at the first row, reproduces the same truncation and spends the node's
per-IP budget again, so the token is the only thing that makes progress. Tokens
are valid for an hour.
---
## Architecture
@@ -340,7 +392,8 @@ Important node settings:
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `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, 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. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
@@ -353,9 +406,11 @@ Important node settings:
| `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_LEGACY_PROBES` | 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_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks per `/ipfs/{cid}` request; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal per-request history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. Default 64. |
| `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` | Max repo rows one `/ipfs/{cid}` request's legacy scan may read from the database. The probe ceiling above only starts counting once a probe runs, and quarantined or private repos are denied before that, so this is what bounds a scan over an inventory that denies the caller everywhere. A truncated scan sheds a retryable 503 carrying an opaque `continuation` token; echoing it as `?scan=` resumes the scan where it stopped. Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one, so each request advances the ladder by at least the rows it read and a holder buried past a ceiling is reached in a bounded number of requests, `ceil(repos / ceiling) + 1` when this row ceiling is the one that binds. No ceiling ever produces a 404. Every page is charged to the caller's `/ipfs` work allowance, so raising this raises that allowance too. Lowering it sharpens an oracle: because a truncation emits a token and a completed wrap does not, laddering to the end reveals the node's total repo count (private and quarantined included) to within one ceiling. Default 2048. |
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks a `/ipfs/{cid}` request may run per phase; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. It is charged per phase: the provenance lookup and the legacy-scan fallback get separate equal budgets, so one request can run up to twice the cap in total. Default 64. |
| `GITLAWB_IPFS_MAX_REPO_VISITS` | Ceiling on repos one `/ipfs/{cid}` request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
| `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. |
@@ -469,8 +524,8 @@ Short-term priorities:
2. Add Docker and installer smoke tests.
3. Improve operator docs and `gl doctor` checks.
4. Harden peer writes and publish the signed-peer rollout plan.
5. Implement repo write authorization: owner checks, protected branches, and UCAN capability checks.
6. Implement private-read enforcement or remove private repo affordances until it exists.
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
Product direction:
+32 -22
View File
@@ -28,10 +28,11 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
- Every git object is content-addressed via CIDv1 (SHA-256)
- Tamper-evident by construction — a modified object changes its CID
**UCAN capability tokens**
- Bootstrap UCAN tokens issued at registration
- Capability-scoped: `git:push`, `git:fetch`, `issue:create`, `pr:open`
- JWT-format tokens with expiry
**UCAN token validation**
- Bootstrap UCAN tokens are issued at registration.
- A supplied token's signature, audience, expiry, and proof-chain attenuation are validated.
- Tokens use a signed JSON wire format with expiry.
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
**Smart contracts (Base Sepolia testnet)**
- `GitlawbDIDRegistry` — on-chain DID → document registry
@@ -48,21 +49,20 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
---
## Known Limitations (Planned for v0.2)
## Known Limitations
These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades.
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
### UCAN chain validation
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization.
- **Fix target:** v0.2
### Repository write authorization defaults
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
### UCAN revocation
- Issued UCAN tokens cannot be revoked before expiry.
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Until revocation/blocklisting is implemented, operators should remove compromised DIDs directly from their local database.
- **Fix target:** v0.2
### UCAN delegation and revocation
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
- Agent lifecycle revocation is not checked by HTTP Signature authorization. Removing or revoking an agent record does not itself block a compromised DID from authenticating.
- **Impact:** Do not use UCANs for collaborator permissions or rely on agent-record revocation as a key-compromise response.
- **Mitigation:** Keep sensitive deployments behind operational network controls and enable owner-only push enforcement where it fits the deployment until trusted delegation and authorization-aware revocation are implemented.
### git-receive-pack authentication
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
@@ -71,10 +71,20 @@ These are **documented, accepted limitations** for the current live release and
- **Fix target:** v0.2
### Private repository reads
- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release.
- **Impact:** Do not store private repositories or secrets on public nodes.
- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer.
- **Fix target:** v0.2
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
### Pull-request review enforcement
- Pull-request review comments are not yet threaded or line-anchored, and merges do not enforce required approvals.
- **Impact:** Teams must use their own review policy or external controls for merge approval requirements.
### GraphQL mutation coverage
- Existing GraphQL mutations require an authenticated signer, but a mutation-specific source-level guardrail has not yet been added for future mutations.
- **Impact:** A new mutation could accidentally omit its signer check without an explicit test fence.
### Peer route hardening rollout
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
@@ -101,7 +111,7 @@ These are **documented, accepted limitations** for the current live release and
| Key storage | PKCS#8 PEM, 0600 permissions |
| Content hashing | SHA-256 via CIDv1 |
| HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) |
| UCAN tokens | JWT (Ed25519 signatures) |
| UCAN tokens | Signed JSON object (Ed25519 signature) |
| On-chain | ECDSA secp256k1 (Base L2 / Ethereum) |
---
@@ -109,7 +119,7 @@ These are **documented, accepted limitations** for the current live release and
## Threat Model
gitlawb is designed to be secure against:
- **Unauthorized writes** — HTTP Signature auth on all write endpoints
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Tampered git objects** — CIDv1 content addressing detects modification
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
+1 -1
View File
@@ -11,7 +11,7 @@ name = "git-remote-gitlawb"
path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
anyhow = { workspace = true }
reqwest = { workspace = true }
tracing = { workspace = true }
+488 -42
View File
@@ -199,9 +199,7 @@ fn handle_connect<R: Read>(
other => bail!("unsupported git service: {other}"),
}
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()?;
let client = build_http_client()?;
// ── Phase 1: ref advertisement (GET /info/refs?service=<service>) ─────────
//
@@ -320,6 +318,58 @@ fn handle_connect<R: Read>(
)
}
// ── HTTP client ───────────────────────────────────────────────────────────────
/// Total request timeout. A pack transfer can be large and slow, so this is far
/// wider than the CLI's.
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// The one client both phases use, with the redirect policy the signing surfaces
/// need.
///
/// Every request this client sends can carry RFC 9421 `Signature` and
/// `Signature-Input` headers: a push signs from the first request, and a fetch of a
/// private repo signs on the retry. reqwest strips only `Authorization`, `Cookie`,
/// `Proxy-Authorization` and `WWW-Authenticate` across hosts, so under the default
/// `Policy::limited(10)` those signature headers rode a 302 to whatever origin the
/// node named, and on a 307/308 the pack body went with them. Scope the follow to the
/// origin that issued the redirect AND to an identical request-target, which is the
/// same predicate `gl` uses.
fn build_http_client() -> Result<reqwest::blocking::Client> {
Ok(reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
.build()?)
}
/// Refuse any redirect that leaves the issuing origin or rewrites the request-target,
/// and bound the chain.
///
/// Refusal is `stop`, not `error`: the 3xx comes back as an ordinary response and
/// the caller reports it through the status path it already has.
///
/// `Policy::custom` replaces reqwest's built-in limit, so the chain bound is
/// restated. It is not what makes the request finite (`HTTP_TIMEOUT` covers the whole
/// chain); it is what keeps a node that redirects to itself from costing a request
/// per round trip until that timeout. `>` and not `>=` because reqwest pushes the
/// redirecting URL onto `previous` before consulting the policy, which is how
/// `Policy::limited` reads the same counter.
fn same_origin_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
let Some(previous) = attempt.previous().last() else {
// Unreachable through reqwest, which pushes the redirecting URL first, but
// the safe reading of "cannot prove same-origin" is to refuse.
return attempt.stop();
};
if attempt.previous().len() > gitlawb_core::redirect::MAX_REDIRECTS {
return attempt.stop();
}
if gitlawb_core::redirect::may_follow(previous, attempt.url()) {
attempt.follow()
} else {
attempt.stop()
}
}
// ── Smart-protocol request builders ───────────────────────────────────────────
const USER_AGENT: &str = "git/2.0 git-remote-gitlawb/0.1.0";
@@ -858,6 +908,438 @@ mod tests {
String::from_utf8_lossy(&buf).into_owned()
}
/// The signed headers must not survive a redirect off the node's origin, on
/// EITHER phase.
///
/// This is the binary git runs for `clone`, `fetch` and `push`. It built its
/// client with a timeout and nothing else, so it ran reqwest's default
/// `Policy::limited(10)`, and reqwest's cross-host header stripping covers
/// `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` only.
/// `Signature` and `Signature-Input` came straight through. A push signs from the
/// first request, so a hostile node answering 302 was handed a working credential,
/// and a 307/308 would have taken the pack body along.
///
/// Two mockito servers are two ports on one host, which is the boundary this
/// policy draws. The second server answers everything and expects nothing, with a
/// second mock matching on the `signature` header also at zero, so a followed
/// redirect fails whether or not the signature came with it. Phase 1 is driven
/// with a 302 and Phase 2 with a 308, the status that would carry the body.
///
/// MUTATION (RED): drop the `.redirect(...)` line from `build_http_client`.
#[test]
fn signed_requests_do_not_follow_a_redirect_off_the_node_origin() {
let kp = Keypair::generate();
let client = build_http_client().unwrap();
let mut elsewhere = mockito::Server::new();
let never = elsewhere
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body("bytes from the redirect target")
.expect(0)
.create();
let never_post = elsewhere
.mock("POST", mockito::Matcher::Any)
.with_status(200)
.expect(0)
.create();
let signature_seen_get = elsewhere
.mock("GET", mockito::Matcher::Any)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.expect(0)
.create();
let signature_seen_post = elsewhere
.mock("POST", mockito::Matcher::Any)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.expect(0)
.create();
let mut node = mockito::Server::new();
let repo_base = format!("{}/zOwner/myrepo", node.url());
let bounce_get = node
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
.with_status(302)
.with_header("location", &format!("{}/info/refs", elsewhere.url()))
.expect(1)
.create();
let bounce_post = node
.mock(
"POST",
mockito::Matcher::Regex(r"/git-receive-pack$".to_string()),
)
.with_status(308)
.with_header("location", &format!("{}/git-receive-pack", elsewhere.url()))
.expect(1)
.create();
let refs_url = format!("{repo_base}/info/refs?service=git-receive-pack");
let advertisement = build_advertisement_request(&client, &refs_url, Some(&kp))
.send()
.unwrap();
let body = b"0009done\n".to_vec();
let post_url = format!("{repo_base}/git-receive-pack");
let pack_post =
build_pack_post_request(&client, &post_url, "git-receive-pack", &body, Some(&kp))
.body(body.clone())
.send()
.unwrap();
// The other origin first: it is the assertion that names the leak, and it must
// be the one that speaks when a followed redirect makes every one of these
// fail at once.
never.assert();
never_post.assert();
signature_seen_get.assert();
signature_seen_post.assert();
bounce_get.assert();
bounce_post.assert();
assert_eq!(
advertisement.status(),
302,
"a refused redirect stops rather than errors, so the caller sees the 3xx"
);
assert!(
!advertisement
.text()
.unwrap()
.contains("bytes from the redirect target"),
"the redirect target's bytes must never reach the caller"
);
assert_eq!(
pack_post.status(),
308,
"the pack POST stops at the redirect too"
);
}
/// A same-origin hop that REWRITES the request-target is refused, even though the
/// origin never changes.
///
/// The signature binds `@path` as the literal path-and-query the helper signed, and
/// the node rebuilds it from the URI it received. An `info/refs` to `info/refs/`
/// bounce therefore presents a signature over a target the node never saw, so a
/// clone or push behind such a proxy 401s. Refusing the hop hands the caller the
/// 3xx that names what happened instead.
///
/// The target mock expects zero hits and is asserted: mockito only checks
/// `.expect(N)` when `.assert()` runs, so an unbound or unasserted mock passes
/// vacuously.
#[test]
fn a_same_origin_path_changing_redirect_is_refused() {
let kp = Keypair::generate();
let client = build_http_client().unwrap();
let mut node = mockito::Server::new();
let bounce = node
.mock("GET", "/zOwner/myrepo/info/refs")
.with_status(301)
.with_header("location", "/zOwner/myrepo/info/refs/")
.expect(1)
.create();
let target = node
.mock("GET", "/zOwner/myrepo/info/refs/")
.with_status(200)
.with_body("bytes from the rewritten target")
.expect(0)
.create();
let refs_url = format!("{}/zOwner/myrepo/info/refs", node.url());
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
.send()
.unwrap();
assert_eq!(
resp.status(),
301,
"a refused redirect stops rather than errors, so the caller sees the 3xx"
);
assert!(
!resp
.text()
.unwrap()
.contains("bytes from the rewritten target"),
"the rewritten target's bytes must never reach the caller"
);
bounce.assert();
target.assert();
}
/// The other direction, and the only same-origin hop still followed: a redirect
/// that re-issues the IDENTICAL request-target.
///
/// Without this the policy could be tightened to `Policy::none()` and nothing in
/// this crate would notice. It is also the first executed coverage of the chain
/// bound in `build_http_client`'s policy closure: the route answers 301 pointing at
/// itself, so bounded it is hit once for the original request plus `MAX_REDIRECTS`
/// follows and the call returns the 301 (a refused hop stops rather than errors).
/// Unbounded it would spin until `HTTP_TIMEOUT`, costing the node a request per
/// round trip. Its twin is `a_self_redirect_stops_at_the_chain_bound` in
/// `crates/gl/src/http.rs`.
#[test]
fn an_identical_target_redirect_is_still_followed_up_to_the_chain_bound() {
let kp = Keypair::generate();
let client = build_http_client().unwrap();
let mut node = mockito::Server::new();
let loop_route = node
.mock("GET", "/zOwner/myrepo/info/refs")
.with_status(301)
.with_header("location", "/zOwner/myrepo/info/refs")
.expect(gitlawb_core::redirect::MAX_REDIRECTS + 1)
.create();
let refs_url = format!("{}/zOwner/myrepo/info/refs", node.url());
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
.send()
.expect("the bound must end the chain, not the timeout");
assert_eq!(
resp.status(),
301,
"the chain ends by refusing the next hop, so the last 3xx is what comes back"
);
loop_route.assert();
}
// ── the node's own verification, run over what the helper actually sent ──
/// What the verifying mock made of a request that reached it.
///
/// The empty slot (`None`) is its own state and means the mock was never hit at
/// all, which is what the refusal test asserts. It must stay distinguishable from
/// [`Verdict::WrongIdentity`], because "nobody verified anything" and "something
/// verified against the wrong key" are opposite findings.
///
/// The payloads are read through `Debug` in the assertion messages and nowhere
/// else, which the dead-code pass does not count; they carry the detail that makes
/// a failure legible, so they stay.
#[derive(Debug)]
#[allow(dead_code)]
enum Verdict {
/// The chain accepted the signature AND the key it resolved is the test's DID.
Accepted,
/// The chain refused it. Carries the error so a failure reads as the actual
/// rejection rather than a bare hit count.
Rejected(String),
/// The chain accepted a signature made by somebody else. A key resolved from
/// the parsed `key_id` is read out of the artifact under verification, so an
/// accept on it alone proves consistency, never authenticity.
WrongIdentity { expected: String, got: String },
}
/// The node's `require_signature` verification, over a request this crate did not
/// necessarily build: parse the headers, recompute the content-digest from the
/// body, rebuild the signing string over `@method`/`@path`/`content-digest`,
/// Ed25519-verify. Returns the DID the signature resolved to, so a caller can pin
/// the identity.
///
/// Its twin is the hand-copy in `crates/gl/src/http.rs`, which cannot import this
/// module (this is a binary crate's test module). Keep the two textually identical
/// apart from the mockito seam around them, so an edit to one is visibly an edit to
/// both.
///
/// The production verifier both copies mirror is `crate::auth::require_signature` in
/// `crates/gitlawb-node/src/auth/mod.rs`. This is a re-implementation, not a call, so
/// an edit to that middleware has to land here too: otherwise the copies drift and
/// this test keeps passing against a rule the node has stopped applying.
///
/// It asserts internally, which is deliberate but constrains its callers: inside
/// `with_body_from_request` those assertions fire on the server thread and reach
/// the client as a transport error, not as a recorded verdict. So the identity
/// check lives in the caller as a [`Verdict`] variant, never as an assert in here.
fn node_verifies(
method: &str,
path_and_query: &str,
body: &[u8],
sig_input: &str,
sig_header: &str,
content_digest: &str,
) -> anyhow::Result<String> {
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
use gitlawb_core::identity::verify;
use std::collections::HashMap;
let sig = HttpSignature::parse(sig_input, sig_header)?;
sig.check_created()?;
assert!(
sig.missing_components().is_empty(),
"signature must cover all required components"
);
assert_eq!(sig.alg, "ed25519");
assert_eq!(
content_digest,
compute_content_digest(body),
"content-digest must match the body"
);
let vk = sig.key_id.to_verifying_key()?;
let mut values = HashMap::new();
values.insert("@method".to_string(), method.to_uppercase());
values.insert("@path".to_string(), path_and_query.to_string());
values.insert("content-digest".to_string(), content_digest.to_string());
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
verify(&vk, signing_string.as_bytes(), &sig_array)?;
Ok(sig.key_id.to_string())
}
/// Pull a header value off a received mockito request, or explain which one the
/// helper failed to send.
fn received_header(req: &mockito::Request, name: &str) -> String {
req.header(name)
.first()
.unwrap_or_else(|| panic!("the helper sent no {name} header"))
.to_str()
.unwrap()
.to_string()
}
/// Run [`node_verifies`] over a GET that arrived at the mock and record what the
/// node would have made of it, pinned to `expected_did`.
fn record_get_verdict(
req: &mockito::Request,
expected_did: &str,
slot: &std::sync::Arc<std::sync::Mutex<Option<Verdict>>>,
) {
let verdict = match node_verifies(
"GET",
req.path_and_query(),
b"",
&received_header(req, "signature-input"),
&received_header(req, "signature"),
&received_header(req, "content-digest"),
) {
Ok(did) if did == expected_did => Verdict::Accepted,
Ok(did) => Verdict::WrongIdentity {
expected: expected_did.to_string(),
got: did,
},
Err(e) => Verdict::Rejected(e.to_string()),
};
*slot.lock().unwrap() = Some(verdict);
}
/// The finding's repro, now a guard: a rewritten same-origin target must never
/// receive the advertisement's signature, and the proof is the node's own
/// verification, not a hit count.
///
/// Post-fix the hop is refused, so the slot stays empty. Pre-fix the hop is
/// followed and the slot records the Ed25519 rejection of a signature made over
/// `.../info/refs?service=git-upload-pack` and presented at `.../info/refs/...`,
/// which is the 401 an operator behind such a proxy actually sees. The verdict is
/// asserted first, so a failure speaks about verification rather than about
/// reachability.
///
/// Its paired positive control is
/// `a_direct_signed_advertisement_verifies_under_the_node_verifier`: without it, an
/// empty slot would be satisfied just as well by a harness that can never record
/// anything.
#[test]
fn a_rewritten_target_never_receives_the_signature() {
let kp = Keypair::generate();
let expected_did = kp.did().to_string();
let client = build_http_client().unwrap();
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
let mut node = mockito::Server::new();
let bounce = node
.mock("GET", "/zOwner/myrepo/info/refs?service=git-upload-pack")
.with_status(301)
.with_header(
"location",
"/zOwner/myrepo/info/refs/?service=git-upload-pack",
)
.expect(1)
.create();
let recorder = slot.clone();
let did_for_target = expected_did.clone();
let target = node
.mock("GET", "/zOwner/myrepo/info/refs/?service=git-upload-pack")
.with_status(200)
.with_body_from_request(move |req| {
record_get_verdict(req, &did_for_target, &recorder);
Vec::new()
})
.expect(0)
.create();
let refs_url = format!(
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
node.url()
);
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
.send()
.unwrap();
let verdict = slot.lock().unwrap().take();
assert!(
verdict.is_none(),
"the node's own verifier must never see this request: the signature covers \
/zOwner/myrepo/info/refs?service=git-upload-pack and the rewritten target \
adds a trailing slash, so what arrives there is a stale request-target; \
recorded verdict: {verdict:?}"
);
assert_eq!(
resp.status(),
301,
"the caller sees the 3xx, not the rewritten target's answer"
);
bounce.assert();
target.assert();
}
/// The positive control for the test above, and the proof that the helper signs the
/// query it sends.
///
/// A direct signed advertisement GET, no redirect anywhere, through the same
/// verifying mock. The verdict must be `Accepted`, which is what makes the refusal
/// test's empty slot attributable to the refusal rather than to a harness that
/// cannot record. The advertisement URL carries `?service=`, so a helper that
/// signed the bare path would land here as `Rejected`.
#[test]
fn a_direct_signed_advertisement_verifies_under_the_node_verifier() {
let kp = Keypair::generate();
let expected_did = kp.did().to_string();
let client = build_http_client().unwrap();
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
let mut node = mockito::Server::new();
let recorder = slot.clone();
let did_for_target = expected_did.clone();
let route = node
.mock("GET", "/zOwner/myrepo/info/refs?service=git-upload-pack")
.with_status(200)
.with_body_from_request(move |req| {
record_get_verdict(req, &did_for_target, &recorder);
Vec::new()
})
.expect(1)
.create();
let refs_url = format!(
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
node.url()
);
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
.send()
.unwrap();
assert_eq!(resp.status(), 200);
let verdict = slot.lock().unwrap().take();
assert!(
matches!(verdict, Some(Verdict::Accepted)),
"a direct signed advertisement must verify under the node's own chain and \
resolve to {expected_did}, or the refusal test's empty slot proves \
nothing; recorded verdict: {verdict:?}"
);
route.assert();
}
/// The regression that round-1 missed: the Phase-2 `git-upload-pack` POST was
/// left unsigned, so an owner's fetch of a private repo cleared the (now signed)
/// advertisement and then 404'd on the pack POST. Drive BOTH request builders
@@ -1137,49 +1619,13 @@ mod tests {
/// to end (sign here, verify with the node's verifier), not reasoned.
#[test]
fn client_signature_verifies_under_node_verification_for_both_services() {
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
use gitlawb_core::identity::verify;
use std::collections::HashMap;
let kp = Keypair::generate();
let client = reqwest::blocking::Client::new();
let body = b"0009done\n".to_vec();
// Re-implements the node's require_signature verification (auth/mod.rs):
// parse headers, recompute content-digest from the body, rebuild the signing
// string over @method/@path/content-digest, Ed25519-verify. Ok iff the node
// would accept it.
let node_verifies = |method: &str,
path_and_query: &str,
body: &[u8],
sig_input: &str,
sig_header: &str,
content_digest: &str|
-> anyhow::Result<()> {
let sig = HttpSignature::parse(sig_input, sig_header)?;
sig.check_created()?;
assert!(
sig.missing_components().is_empty(),
"signature must cover all required components"
);
assert_eq!(sig.alg, "ed25519");
assert_eq!(
content_digest,
compute_content_digest(body),
"content-digest must match the body"
);
let vk = sig.key_id.to_verifying_key()?;
let mut values = HashMap::new();
values.insert("@method".to_string(), method.to_uppercase());
values.insert("@path".to_string(), path_and_query.to_string());
values.insert("content-digest".to_string(), content_digest.to_string());
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
verify(&vk, signing_string.as_bytes(), &sig_array)?;
Ok(())
};
// The verification chain is [`node_verifies`], the test-module helper the
// redirect verdict tests share. Same primitives the node's require_signature
// runs, over the request-target this crate transmits.
// @path exactly as the node reconstructs it from the request it receives.
let path_and_query = |req: &reqwest::blocking::Request| match req.url().query() {
Some(q) => format!("{}?{}", req.url().path(), q),
+7
View File
@@ -22,6 +22,7 @@ multihash-codetable = { workspace = true }
cid = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
url = { workspace = true, optional = true }
zeroize = { version = "1", features = ["derive"] }
pkcs8 = { version = "0.10", features = ["pem", "std"] }
curve25519-dalek = "4"
@@ -30,3 +31,9 @@ chacha20poly1305 = "0.10"
[dev-dependencies]
tokio = { workspace = true }
# Not optional here: the redirect matrix must run under a bare
# `cargo test -p gitlawb-core`, with no feature selected.
url = { workspace = true }
[features]
redirect = ["dep:url"]
+35 -2
View File
@@ -6,6 +6,8 @@
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
//! Nodes that receive a certificate with an unknown version MUST reject it.
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -135,10 +137,14 @@ impl RefUpdateCert {
/// Check if this certificate satisfies a threshold of valid signatures
/// from the provided set of authorized maintainer DIDs.
///
/// Counts distinct signer DIDs, not signature entries: a repeated
/// signature from the same maintainer counts once.
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
let valid = self.verify_all()?;
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
Ok(count >= threshold)
let distinct_signers: HashSet<&Did> =
valid.iter().filter(|d| maintainers.contains(d)).collect();
Ok(distinct_signers.len() >= threshold)
}
/// Validate the certificate structure (not signatures).
@@ -344,6 +350,33 @@ mod tests {
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
}
#[test]
fn satisfies_threshold_rejects_duplicated_signature() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let kp3 = Keypair::generate();
let repo_did = kp1.did();
let mut cert = RefUpdateCert::new(
repo_did,
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
// Copy-paste the only real signature onto the certificate a second
// time. Same signer, same valid signature, still one real signer.
let dup = cert.signatures[0].clone();
cert.signatures.push(dup);
let maintainers = vec![kp1.did(), kp2.did(), kp3.did()];
// Two signature entries but a single distinct signer must not
// satisfy a 2-of-3 threshold.
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
}
#[test]
fn threshold_zero_is_always_satisfied() {
let kp = Keypair::generate();
+9
View File
@@ -5,7 +5,16 @@ pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod identity;
// `url` is the one dependency here that drags a tail (idna, then the icu
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
// needs this predicate already parses URLs, so they opt in and nothing else
// pays. `test` is in the cfg so `cargo test -p gitlawb-core` still compiles and
// runs the matrix below with no feature selected; without it the tests would
// silently not run, which is the failure this module exists to prevent.
#[cfg(any(feature = "redirect", test))]
pub mod redirect;
pub mod sanitize;
pub mod scan_token;
pub mod ucan;
pub use error::Error;
+241
View File
@@ -0,0 +1,241 @@
//! Redirect policy shared by every gitlawb HTTP client that signs its requests.
//!
//! Both `gl` (async) and `git-remote-gitlawb` (blocking) attach RFC 9421
//! `Signature` and `Signature-Input` headers, and reqwest strips only
//! `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` when a
//! redirect crosses hosts. A signature would survive that hop, and it binds
//! `@method`, `@path` and `content-digest` with no authority component, so a node
//! answering 302 could hand a working credential to a host of its choosing and read
//! as the caller anywhere until the clock-skew window closes. On a 307/308 the
//! request body goes along with it, which for the remote helper is the pack.
//!
//! That same missing authority component is why the predicate also pins the
//! request-target: `@path` is signed as the client sent it and verified as the node
//! received it, so a same-origin hop that rewrites the path or the query (a
//! trailing-slash or query normalization) makes the signature cover a target the
//! node never saw and the read 401s. Only a hop that re-issues the identical target,
//! an http-to-https upgrade being the one that matters in practice, is followed.
//!
//! The decision lives here rather than in either client because the two used to
//! disagree: `gl` was scoped to the origin while the remote helper, the binary that
//! actually runs `git clone gitlawb://`, still ran reqwest's default and followed
//! anywhere. One predicate is what keeps a future third client from repeating that.
//!
//! The type is `url::Url`, which is what `reqwest::Url` re-exports, so both clients
//! pass their attempt URLs straight in.
/// Longest redirect chain followed. `reqwest::redirect::Policy::custom` replaces
/// reqwest's built-in limit, so the bound has to be restated by every client that
/// installs a custom policy; the value is reqwest's own default.
///
/// This counts FOLLOWS, matching `Policy::limited`: reqwest pushes the redirecting
/// URL onto `previous` before consulting the policy, and `Limit(max)` refuses once
/// `previous.len() > max`, so a caller comparing against this constant must use `>`
/// too or it permits one hop fewer than it says.
pub const MAX_REDIRECTS: usize = 10;
/// Follow a redirect only when it stays on the origin that issued it AND re-issues
/// the identical request-target.
///
/// `Policy::none()` would have been the simpler answer, but one same-origin redirect
/// shape is legitimate here: a node fronted by a proxy that upgrades http to https,
/// or otherwise re-issues the same path and query, so the policy is scoped rather
/// than switched off.
///
/// Path and query must match exactly, and that is the request-target clause rather
/// than an origin one. `@path` is signed as the client sent it and verified as the
/// node received it, so a hop that rewrites either half leaves a signature covering
/// a target nobody asked for and the node answers 401. Refusing the hop turns a
/// confusing 401 into the 3xx that names what actually happened. One policy covers
/// signed and unsigned callers alike, for the same reason the predicate is shared:
/// two rules would drift.
///
/// The clause pins `@path`, and only `@path`. A gitlawb signature also covers
/// `@method` and `content-digest`, and both of those are still open on a followed
/// hop: on a 301, 302 or 303, reqwest 0.12.28 delegates to tower-http's
/// `FollowRedirect`, which rewrites a POST to a GET and empties the body
/// (tower-http-0.6.8 `src/follow_redirect/mod.rs:273-285`), while its
/// `drop_payload_headers` removes only `Content-Type`, `Content-Length`,
/// `Content-Encoding` and `Transfer-Encoding`. So `Signature`, `Signature-Input` and
/// `Content-Digest` ride along on a request that no longer has the method or the body
/// they were computed over. Only 307 and 308 preserve both. This predicate returning
/// true therefore makes a GET-shaped hop safe to replay against the node's verifier
/// and says nothing about a bodied one: a signed write must not rely on it alone.
///
/// `Url::query` is `None` for `/a` and `Some("")` for `/a?`, and those are two
/// different request-targets on the node side too, so the comparison is strict and
/// needs no special case.
///
/// Host and port must match exactly. Port is compared as `Url::port`, which is
/// `None` for a scheme's default port, so http -> https on the same host compares
/// equal while http -> http on a different port does not. A downgrade from https to
/// http is refused as well: the target is the same host, but the signature would go
/// out in cleartext, which is the same credential leak by a slower route.
///
/// Host comparison rides on `url`'s parse-time normalization (lowercasing and IDN
/// -> punycode), so the spellings an attacker reaches for do not open a gap. That
/// is a property of the parsed `Url`, not of this function, which is why the test
/// matrix pins it: a move to raw string comparison would silently lose it.
pub fn may_follow(previous: &url::Url, next: &url::Url) -> bool {
let same_origin = next.host_str() == previous.host_str() && next.port() == previous.port();
let same_target = next.path() == previous.path() && next.query() == previous.query();
let downgraded = previous.scheme() == "https" && next.scheme() != "https";
same_origin && same_target && !downgraded
}
#[cfg(test)]
mod tests {
use super::*;
/// Every branch of the decision, both ways, plus the spellings that would slip
/// past a comparison less careful than `Url`'s own normalization.
#[test]
fn may_follow_covers_each_origin_branch() {
let url = |s: &str| url::Url::parse(s).unwrap();
let cases: &[(&str, &str, bool, &str)] = &[
(
"http://node.example/a",
"http://node.example/b",
false,
"same origin but the path changed: @path is signed as sent and verified as received",
),
(
"http://node.example/a",
"https://node.example/a",
true,
"http to https on one host with an identical request-target: both ports are \
the scheme default, so the proxy upgrade is still followed",
),
(
"https://node.example/a",
"https://node.example/a/",
false,
"trailing-slash normalization changes the request-target, so it is refused",
),
(
"https://node.example:8443/a",
"https://node.example:8443/a",
true,
"same explicit port",
),
(
"http://node.example/a",
"http://attacker.example/a",
false,
"different host",
),
(
"http://node.example/a",
"http://node.example:8080/a",
false,
"same host, different port",
),
(
"https://node.example/a",
"http://node.example/a",
false,
"https downgraded to cleartext on the same host",
),
(
"https://node.example/a",
"http://node.example:443/a",
false,
"a downgrade dressed up as the https port",
),
// The rows below pass today because `Url::parse` normalizes the host, not
// because anything here compares case-insensitively or decodes IDN. They
// are the variants an attacker reaches for, so they are pinned: swapping
// this predicate for a raw string comparison must break the suite.
// Each of these pairs an IDENTICAL path on both sides, deliberately. The
// request-target clause below would make every one of them false on the
// path alone, and a row that is false for two reasons has stopped pinning
// either. With the paths equal, the host or port comparison is the only
// thing left that can decide them.
(
"https://node.example/a",
"https://NODE.EXAMPLE/a",
true,
"same host in a different case: parse lowercases it",
),
(
"https://node.example/a",
"https://node.example./a",
false,
"a trailing dot is a different host to url, so the redirect is refused",
),
(
"https://exämple.test/a",
"https://xn--exmple-cua.test/a",
true,
"unicode host and its punycode spelling are one host after parse",
),
(
"https://node.example/a",
"https://user:[email protected]/a",
true,
"userinfo is not part of the origin: same host, still followed",
),
(
"https://node.example/a",
"https://[email protected]/a",
false,
"the node's name smuggled into userinfo: the host is the attacker's",
),
(
"https://node.example/a",
"https://attacker.example/a#node.example",
false,
"the node's name pushed into the fragment: the host is the attacker's",
),
// The request-target clause, both directions. `@path` is the only thing a
// gitlawb signature binds the request to, so a hop that rewrites it hands
// the node a signature over a target it never received.
(
"https://node.example/a?x=1",
"https://node.example/a?x=1",
true,
"identical request-target: the same-origin hop that is still followed",
),
(
"https://node.example/a?x=1",
"https://node.example/a?x=2",
false,
"same path but a different query: the request-target covers the query too",
),
(
"https://node.example/a",
"https://node.example/a?x=1",
false,
"a query added where there was none",
),
(
"http://node.example/a",
"http://node.example/a?",
false,
"an empty query added where there was none: a missing query and an empty \
one are different request-targets",
),
(
"https://node.example/a?x=1",
"https://node.example/a",
false,
"the query dropped where there was one: the comparison is symmetric, and \
nothing else in the matrix pins that direction",
),
(
"https://node.example/a#x",
"https://node.example/a#y",
true,
"a fragment-only difference is still followed: a fragment never reaches \
the wire, so it is no part of the request-target the node verifies",
),
];
for (previous, next, expected, why) in cases {
assert_eq!(
may_follow(&url(previous), &url(next)),
*expected,
"{previous} -> {next} ({why})"
);
}
}
}
+499
View File
@@ -0,0 +1,499 @@
//! Sealed continuation token for the node's bounded legacy CID scan (INV-13).
//!
//! The `/ipfs/{cid}` resolver's legacy scan stops at a row ceiling and sheds a
//! retryable 503. To let a holder buried past that ceiling still be reached, the
//! shed carries the scan position so the caller can echo it back and resume. The
//! whole point of the design is that the node keeps NO server-side scan state: the
//! position rides in the caller's token.
//!
//! That makes the token an EMITTED continuation derived from a FETCHED row, and on
//! a scan that served nothing every fetched row is by construction a private or
//! quarantined repo the caller may not read. The row's `created_at` leaks its
//! creation time and its `id` carries the owner's DID, so both halves are withheld
//! fields and the token must be CONFIDENTIAL, not merely tamper-evident:
//!
//! * AEAD-sealed (XChaCha20-Poly1305), never base64-of-plaintext and never
//! signed plaintext. Integrity is not confidentiality.
//! * A fresh `OsRng` nonce on EVERY seal. Under a stream cipher a repeated nonce
//! means repeated keystream, and an attacker who can force the node to seal a
//! position whose plaintext they know XORs two tokens and recovers a withheld
//! row's fields in full, strictly worse than emitting plaintext.
//! * FIXED-WIDTH plaintext. AEAD ciphertext is plaintext-length plus the tag, and
//! every field of a scan position varies in length, so a variable encoding would
//! make token LENGTH a side channel for the sealed row (a short name under a
//! short owner vs a long one) and for the candidate oid's width (40 hex on a
//! sha1 repo, 64 on a sha256 one). Every token this module mints is byte-identical
//! in length. Each field is padded to its own separate width, which is a per-field
//! constant and so still leaks nothing about a given row or candidate.
//! * The canonical CID as associated data, so a token minted while scanning for
//! one CID does not authenticate when replayed against another.
//!
//! Every failure to open (wrong key, tampered bytes, wrong CID, expired, malformed)
//! returns the same `None`. The caller treats that as "no token" and starts at the
//! front, so no failure class is distinguishable and the token is no oracle.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng, Payload},
XChaCha20Poly1305, XNonce,
};
use rand::RngCore;
/// Keyset position of the last row a truncated scan fetched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanPosition {
/// The row's raw stored `created_at` text, the first half of the keyset cursor.
pub created_at_key: String,
/// The row's `id`, the tiebreaking half of the keyset cursor.
pub id: String,
/// The oid hex of the CANDIDATE this position resumes. One CID can map to several
/// git oids, so a bare row cursor names a row without naming whose walk it belongs
/// to. The candidate is named by IDENTITY rather than by position in the candidate
/// list: that list is ordered by hex and mutates between rungs, so an index would
/// name a different candidate the moment anything is pinned or unpinned.
pub sha256_hex: String,
}
/// Plaintext version byte, so a future layout change is a clean open-failure
/// (treated as absent) rather than a misparse.
///
/// Bumped to 2 when the two halves stopped sharing one width (see [`ID_WIDTH`]), and
/// to 3 when the position gained the candidate oid it resumes (see [`OID_WIDTH`]).
/// A token minted under an earlier layout is a different length and a different
/// framing, so it opens to `None` and the caller restarts at the front, which is the
/// safe direction: a misparse would resume at a fabricated row and skip coverage.
const VERSION: u8 = 3;
/// Byte width the `created_at` half is padded to. Every value stored here is a
/// serialized timestamp, about 30 bytes, so 64 is roomy for the field's whole domain.
/// It is deliberately NOT widened to match [`ID_WIDTH`]: padding both halves to the id
/// width would nearly double every token for a field that can never use the space.
const CREATED_WIDTH: usize = 64;
/// Byte width the `id` half is padded to.
///
/// The bound is set by the WRITERS, not by what a typical id happens to look like.
/// `upsert_mirror_repo` builds `repos.id` as `{owner}/{name}`, and the slug validators
/// in the node's `repo_store` admit an owner of up to 255 bytes and a name of up to
/// 100, so 356 bytes is reachable through the ordinary write path and repo names are
/// peer-controllable. 384 clears that with margin.
///
/// Under-sizing this is not a cosmetic bug. A row at a truncation boundary whose id
/// exceeds the width fails the seal, the handler sheds a 503 with no continuation, and
/// a tokenless shed is byte-identical to the wrapped-scan response whose contract is
/// "the absence of a token means the ladder is over". The boundary row is deterministic
/// for a stable inventory, so every retry reproduces it and every row past it becomes
/// permanently unreachable. Anything past the width still fails loudly rather than
/// silently truncating a cursor into one that resumes at the wrong row.
const ID_WIDTH: usize = 384;
/// Byte width the candidate oid half is padded to.
///
/// Git mints exactly two oid widths and this field carries BOTH. A production repo is
/// created by `store::init_bare` with `git init --bare --object-format=sha1`, so its
/// oids are 40 hex; only the sha256 test fixtures mint 64. The field is therefore
/// length-prefixed like the two row halves rather than a bare fixed 64: a 64-only
/// field would fail every seal on a real deployment, and a failed seal sheds a
/// tokenless 503 that is byte-identical to "your ladder is over".
///
/// The padding to 64 is what keeps the WIDTH off the wire. Without it a 40-hex token
/// is 24 bytes shorter than a 64-hex one, and token length would say which object
/// format the holder's repo uses.
const OID_WIDTH: usize = 64;
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
const PLAINTEXT_LEN: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 2 + OID_WIDTH + 8;
/// Nonce width for XChaCha20-Poly1305.
const NONCE_LEN: usize = 24;
/// A fresh random 32-byte sealing key from the OS CSPRNG.
pub fn new_key() -> [u8; 32] {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
// The two halves below are deliberately separate and adjacent. The framing pair owns
// "every token is the same length"; the AEAD pair owns "the contents are confidential
// and CID-bound". Keeping them apart is what lets each property be exercised (and
// broken) without disturbing the other.
/// Encode a position into the FIXED-WIDTH plaintext:
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
///
/// The padding is the point. AEAD ciphertext is plaintext-length plus the tag, and every
/// field of a scan position varies in length, so a length-prefixed encoding with no
/// padding would make token LENGTH a side channel for the sealed row and for the
/// candidate's object format. Each field is padded to its OWN fixed width, which keeps
/// every minted token the same length while letting the id half carry the range the
/// write path actually admits.
fn encode_position(pos: &ScanPosition, expires_at_unix: i64) -> anyhow::Result<Vec<u8>> {
let mut out = vec![0u8; PLAINTEXT_LEN];
out[0] = VERSION;
let mut at = 1;
for (field, width) in [
(pos.created_at_key.as_bytes(), CREATED_WIDTH),
(pos.id.as_bytes(), ID_WIDTH),
(pos.sha256_hex.as_bytes(), OID_WIDTH),
] {
if field.len() > width {
// Loud rather than truncating: a clipped cursor resumes at the wrong row and
// silently skips coverage, which is the availability half of the bug this
// token exists to fix.
anyhow::bail!(
"scan token field is {} bytes, over the {width}-byte fixed width",
field.len()
);
}
out[at..at + 2].copy_from_slice(&(field.len() as u16).to_le_bytes());
at += 2;
out[at..at + field.len()].copy_from_slice(field);
at += width;
}
out[at..at + 8].copy_from_slice(&expires_at_unix.to_le_bytes());
Ok(out)
}
/// Decode what [`encode_position`] wrote. `None` on any structural mismatch.
fn decode_position(bytes: &[u8]) -> Option<(ScanPosition, i64)> {
if bytes.len() != PLAINTEXT_LEN || bytes[0] != VERSION {
return None;
}
let mut at = 1;
let mut fields = [const { String::new() }; 3];
for (slot, width) in fields.iter_mut().zip([CREATED_WIDTH, ID_WIDTH, OID_WIDTH]) {
let len = u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize;
at += 2;
if len > width {
return None;
}
*slot = String::from_utf8(bytes[at..at + len].to_vec()).ok()?;
at += width;
}
let expires_at = i64::from_le_bytes(bytes[at..at + 8].try_into().ok()?);
let [created_at_key, id, sha256_hex] = fields;
// A zero-length candidate is a third state the encoder never mints. The front-of-table
// sentinel is empty ROW halves with a real oid, so an empty oid would hand the resume
// path a candidate that names nothing; refuse it like every other malformed frame.
if sha256_hex.is_empty() {
return None;
}
Some((
ScanPosition {
created_at_key,
id,
sha256_hex,
},
expires_at,
))
}
/// AEAD-seal `plaintext` under `key`, bound to `cid`, framed as `nonce || ciphertext`.
fn seal_bytes(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("scan token key: {e}"))?;
// A FRESH nonce per seal, from the OS CSPRNG. Under a stream cipher a repeated nonce
// repeats the keystream, and two tokens sealed under one nonce XOR to the difference
// of their plaintexts, which recovers a withheld row in full when the attacker can
// force one of the two positions. This draw is the property the whole confidentiality
// claim rests on.
let mut nonce = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce);
let sealed = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: plaintext,
// The canonical CID as associated data: a token minted while scanning for
// one CID does not authenticate against another, so it cannot be replayed
// to seed a different scan.
aad: cid.as_bytes(),
},
)
.map_err(|e| anyhow::anyhow!("scan token seal: {e}"))?;
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
/// Open what [`seal_bytes`] framed. `None` on any failure, including a wrong `cid`.
fn open_bytes(key: &[u8; 32], cid: &str, raw: &[u8]) -> Option<Vec<u8>> {
if raw.len() <= NONCE_LEN + 16 {
return None;
}
let (nonce, sealed) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new_from_slice(key).ok()?;
cipher
.decrypt(
XNonce::from_slice(nonce),
Payload {
msg: sealed,
aad: cid.as_bytes(),
},
)
.ok()
}
/// Seal `pos` under `key`, bound to `cid`, expiring at `expires_at_unix`.
///
/// Returns the base64url (no pad) token. Errors only when a field exceeds
/// its own fixed width ([`CREATED_WIDTH`], [`ID_WIDTH`], [`OID_WIDTH`]) or the AEAD
/// itself fails, never silently truncates.
pub fn seal_scan_token(
key: &[u8; 32],
cid: &str,
pos: &ScanPosition,
expires_at_unix: i64,
) -> anyhow::Result<String> {
let plaintext = encode_position(pos, expires_at_unix)?;
Ok(B64URL.encode(seal_bytes(key, cid, &plaintext)?))
}
/// Open a token minted by [`seal_scan_token`] under the same key and CID.
///
/// `None` for every failure class alike (wrong key, tampered, foreign CID, expired,
/// malformed, wrong version), so the caller can treat all of them as "absent" without
/// leaking which one occurred.
pub fn open_scan_token(
key: &[u8; 32],
cid: &str,
token: &str,
now_unix: i64,
) -> Option<ScanPosition> {
let raw = B64URL.decode(token).ok()?;
let plaintext = open_bytes(key, cid, &raw)?;
let (pos, expires_at) = decode_position(&plaintext)?;
if now_unix >= expires_at {
return None;
}
Some(pos)
}
#[cfg(test)]
mod tests {
use super::*;
/// A production-shaped candidate: `git init --bare --object-format=sha1`, so 40 hex.
const OID_40: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
/// A test-fixture-shaped candidate: the sha256 repos the suite creates mint 64 hex.
const OID_64: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn pos(created: &str, id: &str) -> ScanPosition {
pos_for(created, id, OID_40)
}
fn pos_for(created: &str, id: &str, sha256_hex: &str) -> ScanPosition {
ScanPosition {
created_at_key: created.to_string(),
id: id.to_string(),
sha256_hex: sha256_hex.to_string(),
}
}
/// Seal a hand-built plaintext through the AEAD half, so a test can frame bytes the
/// encoder would never mint (an old version byte, a zero-length oid) and still exercise
/// the real open path.
fn seal_raw(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> String {
B64URL.encode(seal_bytes(key, cid, plaintext).unwrap())
}
/// Byte offset of the oid length prefix inside the plaintext, derived from the widths
/// rather than hardcoded so a width change moves it with the layout.
const OID_LEN_AT: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH;
/// Scenario 1: both of git's oid widths round trip. The 40-hex case is the PRODUCTION
/// shape (`git init --bare --object-format=sha1`), and an all-64 fixture suite would
/// never exercise it, which is exactly how a fixed-64 field would ship broken.
#[test]
fn round_trips_at_both_oid_widths() {
let key = new_key();
for hex in [OID_40, OID_64] {
let p = pos_for("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo", hex);
let t =
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("both oid widths must seal");
assert_eq!(
open_scan_token(&key, "bafkcid", &t, 0),
Some(p),
"a {}-hex candidate must open to the identical position",
hex.len()
);
}
}
/// Scenario 2: a token framed under the OLD version opens to `None`, never a misparse.
/// Both legs matter: the version-2 plaintext was a different LENGTH, and a future
/// same-length layout would only be caught by the version byte itself.
#[test]
fn a_prior_layout_version_opens_to_none() {
let key = new_key();
// The version-2 layout verbatim: no oid field, so 461 bytes.
let mut old = vec![0u8; 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 8];
old[0] = 2;
assert_eq!(old.len(), 461, "the version-2 plaintext was 461 bytes");
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &old), 0),
None,
"a version-2 token must open to None so the caller restarts at the front"
);
// Same length, stale version byte: only the version check can refuse this one.
let mut stamped = encode_position(
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
1 << 40,
)
.unwrap();
stamped[0] = VERSION - 1;
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &stamped), 0),
None,
"a stale version byte must open to None even at the current width"
);
}
/// Scenario 3: token length is invariant across candidate VALUE and candidate WIDTH.
/// The padding is what keeps the oid width off the wire: without it a 40-hex token is
/// 24 bytes shorter than a 64-hex one and the length says which repo format the
/// holder uses.
#[test]
fn token_length_is_invariant_across_oid_widths() {
let key = new_key();
let created = "2020-01-01T00:00:00+00:00";
let id = "z6MkOwner/private-repo";
let short =
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_40), 1 << 40).unwrap();
let long =
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_64), 1 << 40).unwrap();
assert_eq!(
short.len(),
long.len(),
"a 40-hex and a 64-hex candidate must mint tokens of identical length, or the \
oid width is a side channel"
);
// The absolute width, pinned by execution rather than by arithmetic on paper. The
// gl client's mock fixtures hardcode this number (`TOKEN_LEN` in
// crates/gl/src/ipfs_cmd.rs), and nothing in that crate seals a real token, so this
// assertion is the only executable check that the two agree.
assert_eq!(
short.len(),
756,
"24 nonce + {PLAINTEXT_LEN} plaintext + 16 tag, base64url no pad"
);
}
/// Scenario 4: the front-of-table sentinel. Empty row halves with a REAL candidate
/// round trip, which is what lets a seal say "this candidate, no row cursor yet".
#[test]
fn empty_row_fields_round_trip_with_a_real_candidate() {
let key = new_key();
let p = pos_for("", "", OID_40);
let t =
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("the front sentinel must seal");
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
/// Scenario 6, third leg: a zero-length oid is a distinguishable third state that the
/// encoder never mints, and accepting it would hand the sentinel machinery a candidate
/// naming nothing. The decode path refuses it.
#[test]
fn a_zero_length_candidate_opens_to_none() {
let key = new_key();
let mut plaintext = encode_position(
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
1 << 40,
)
.unwrap();
plaintext[OID_LEN_AT..OID_LEN_AT + 2].copy_from_slice(&0u16.to_le_bytes());
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &plaintext), 0),
None,
"a zero-length candidate must open to None, not to a position naming nothing"
);
}
/// Scenario 6, second leg: an oid past the 64-byte width fails the seal loudly rather
/// than being clipped into a hex that names a different candidate.
#[test]
fn an_oid_over_the_fixed_width_fails_loudly() {
let key = new_key();
let p = pos_for(
"2020-01-01T00:00:03+00:00",
"z6MkOwner/private-repo",
&"a".repeat(OID_WIDTH + 1),
);
assert!(
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
"an over-wide candidate must fail the seal, never be truncated into a hex that \
resumes the wrong candidate"
);
}
#[test]
fn round_trips_under_the_same_key_and_cid() {
let key = new_key();
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
#[test]
fn every_failure_class_opens_to_none() {
let key = new_key();
let other = new_key();
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
assert_eq!(open_scan_token(&other, "bafkcid", &t, 0), None, "wrong key");
assert_eq!(
open_scan_token(&key, "bafkOTHER", &t, 0),
None,
"foreign CID"
);
assert_eq!(
open_scan_token(&key, "bafkcid", &t, 1 << 41),
None,
"expired"
);
assert_eq!(open_scan_token(&key, "bafkcid", "!!not b64", 0), None);
assert_eq!(open_scan_token(&key, "bafkcid", "", 0), None);
let mut flipped: Vec<u8> = t.bytes().collect();
let last = flipped.len() - 1;
flipped[last] = if flipped[last] == b'A' { b'B' } else { b'A' };
assert_eq!(
open_scan_token(&key, "bafkcid", &String::from_utf8(flipped).unwrap(), 0),
None,
"tampered"
);
}
/// The id half must clear the LARGEST repo id the node's own write path admits,
/// not merely a typical one. `upsert_mirror_repo` builds `repos.id` as
/// `{owner}/{name}`, and the slug validators in `repo_store` admit 255 bytes of
/// owner and 100 of name, so 356 is reachable. A width under that turns the
/// boundary row into a seal failure, which sheds a tokenless 503 that is
/// byte-identical to "your ladder is over" and strands every row past it forever.
#[test]
fn round_trips_a_repo_id_at_the_write_paths_maximum() {
let key = new_key();
let id = format!("{}/{}", "o".repeat(255), "n".repeat(100));
assert_eq!(id.len(), 356, "255 owner + '/' + 100 name");
let p = pos("2020-01-01T00:00:03+00:00", &id);
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40)
.expect("a repo id the write path admits must seal, never fail the width");
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
#[test]
fn a_field_over_the_fixed_width_fails_loudly() {
let key = new_key();
let p = pos("2020-01-01T00:00:03+00:00", &"x".repeat(ID_WIDTH + 1));
assert!(
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
"an over-wide field must fail the seal, never be truncated into a cursor \
that resumes at the wrong row"
);
}
}
File diff suppressed because it is too large. Load diff
+831 -51
View File
@@ -61,20 +61,30 @@ 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| 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);
let create_result = git_issues::create_issue(guard.path(), &issue_id, &json_str);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(create_result.is_ok()).await;
let git_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
let deadline = std::time::Instant::now() + git_timeout;
let git_bin = state.git_bin.clone();
let issue_id_for_comp = issue_id.clone();
let release_result = if create_result.is_ok() {
guard
.release_compensating(true, move |path| {
git_issues::delete_issue_ref(&git_bin, path, &issue_id_for_comp, deadline)
})
.await
.into_result()
} else {
guard.release(false).await.into_result()
};
release_result?;
create_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -225,6 +235,8 @@ pub async fn close_issue(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
headers: axum::http::HeaderMap,
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
@@ -232,8 +244,161 @@ 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.
// READ-GATE before any snapshot work or rate charging. The author fallback below
// needs the issue blob, which needs the repo tree, so authorship cannot be established
// without a download; but a caller who cannot even READ the repo must be
// stopped here, cheaply, before any Tigris transfer or extraction happens.
// Without this, any signed non-owner could issue parallel close requests for
// arbitrary issue ids and drive unbounded downloads and blocking extraction
// (a disposable-identity DoS), because the route has no other pre-authorization.
//
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden repo
// from a missing one (INV-12 read-denial status contract).
//
// mirror-rows-handled: a repo row synced from a peer is stored public and
// carries none of the owner's visibility rules, so for such a row this check
// can only return allow. That is deliberate rather than overlooked, and it is
// the same verdict every other read gate in this API reaches for one. Refusing
// instead would deny the repo's real owner and the issue's real author on any
// node whose only copy of the repo is a synced one, which is an ordinary state
// here, and it would not buy the protection it appears to, because a synced
// row's recorded owner comes from the peer that sent it. The expensive work
// this check guards is bounded ahead of it by the per-IP limiter and the read
// pool slot taken below, and the authoritative owner-or-author decision still
// runs below and again under the write lock.
{
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.0.as_str();
if crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
Some(caller),
"/",
) == crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
}
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
// policy as the push advertisement. The pre-lock snapshot downloads the
// whole archive and runs a blocking extraction, so an unlimited route would
// let disposable identities drive unbounded transfer/CPU/disk with parallel
// close requests for arbitrary issue ids. Applied after the read gate so a
// denied reader still sees 404, not 429.
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
if !state.close_issue_rate_limiter.check(&key).await {
tracing::warn!(repo = %repo, key = %key, "close_issue rate limited");
return Err(AppError::TooManyRequests(
"rate limit exceeded — try again later".into(),
));
}
}
// AUTHORIZE BEFORE ACQUIRING. The per-repo advisory lock genuinely excludes
// now, so taking it first would hand any caller with read access a way to hold
// that lock on demand and be refused afterwards, while a legitimate writer
// burned its retry budget against it. On a public repo that is every
// permissionless identity. The lock must not be reachable by a caller who is
// about to be refused the write.
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
if !is_owner {
// Cap concurrent snapshot work on the shared read pool. The hourly rate
// bucket above is not a concurrent-work brake; without this, parallel close
// attempts from read-capable callers could each drive a full archive
// download and blocking extraction before the author denial below.
let _read_permit = state
.git_read_semaphore
.clone()
.try_acquire_owned()
.map_err(|_| {
tracing::warn!(
repo = %repo,
"close_issue snapshot refused — git read pool at capacity"
);
AppError::Overloaded("git service at capacity, retry shortly".into())
})?;
// Not the owner, so the author fallback decides it, and the author lives in
// the issue's git-JSON blob rather than a DB column.
//
// Read it WITHOUT the write lock, from a NON-MUTATING SNAPSHOT. The
// justification is NOT that authorship is immutable — it is not:
// `refs/gitlawb/**` is pushable, so a forged author blob can be pushed
// (tracked separately; it is what makes this fallback only as trustworthy
// as push authorization). The justification is that this read is only a
// PRE-CHECK, deciding whether to take the lock at all. It is NOT the
// authorization decision: `acquire_write` re-downloads the archive after
// locking, so the tree that gets mutated is routinely not this one, and the
// authoritative owner-or-author check runs again under the guard below.
// Refusing here early just keeps a caller who is already visibly
// unauthorized from reaching the lock.
//
// `read_snapshot`, not `acquire_fresh`: acquire's fast path returns as soon
// as the directory exists and never contacts object storage, so on a node
// with a stale copy the author's own issue would be invisible and the
// cannot-establish-authorship arm below would 403 a legitimate author.
// read_snapshot refreshes the same way, but unpacks into a throwaway temp
// dir instead of publishing into the live repo path — an unlocked
// pre-check must not delete or swap the directory under a concurrent
// guarded write on the same path.
// Pre-lock authorization only: bound with the read acquire budget, not the
// under-lock transfer timeout a guarded write may hold for minutes.
let snapshot_bound_secs = state.config.git_acquire_timeout_secs;
let snapshot = tokio::time::timeout(
std::time::Duration::from_secs(snapshot_bound_secs),
state
.repo_store
.read_snapshot(&record.owner_did, &record.name),
)
.await
.map_err(|_elapsed| {
tracing::warn!(
repo = %repo,
bound_secs = snapshot_bound_secs,
"close_issue snapshot exceeded the read acquire bound — shedding as a retryable refusal"
);
AppError::RepoUnavailable
})??;
let snapshot_path = snapshot.path().to_path_buf();
let author_did: Option<String> = match git_issues::get_issue(&snapshot_path, &issue_id) {
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
.ok()
.and_then(|i| i.author),
// Cannot establish authorship, so fail closed. Deliberately 403 rather
// than 404 for a non-owner: a caller who is not authorized to write
// should not learn from this route whether the issue exists. Both arms
// below return None; they are split only so a read failure is visible
// to operators, since a genuinely absent issue and an unreadable one
// are the same answer to the client but not the same event.
Ok(None) => None,
Err(e) => {
tracing::warn!(
repo = %repo,
issue = %issue_id,
err = %e,
"get_issue failed during close_issue authorship pre-check"
);
None
}
};
let is_author = author_did
.as_deref()
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
if !is_author {
return Err(AppError::Forbidden(
"only the repo owner or the issue author can close this issue".into(),
));
}
}
// Authorized. Only now is the lock taken.
// Propagate rather than stringify: AppError's From<anyhow::Error> downcasts to
// sqlx::Error so a pool timeout or a database outage surfaces as a retryable
// 503. Calling .to_string() first destroys that and reports both as a 500.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
@@ -241,37 +406,56 @@ pub async fn close_issue(
.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
// blob (not a DB column); a None author (legacy issues) falls back to
// owner-only. Read it under the write guard, before mutating.
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
.ok()
.and_then(|i| i.author),
// Re-read under the guard and RE-AUTHORIZE against what we read, rather than
// only confirming the issue still exists. The pre-lock read decided whether to
// take the lock; it cannot be the authorization decision, because acquire_write
// re-downloads the archive after locking, so this is frequently a different tree
// than the one the author was read from. Checking existence alone would leave the
// whole decision resting on the earlier read of a tree we are no longer looking
// at. The blob is already in hand here, so this costs a deserialize.
match git_issues::get_issue(&disk_path, &issue_id) {
Ok(Some(raw)) => {
let author_now: Option<String> = serde_json::from_str::<IssueRecord>(&raw)
.ok()
.and_then(|i| i.author);
let is_author_now = author_now
.as_deref()
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
if !is_owner && !is_author_now {
// Consumed, NOT propagated, and that is deliberate at all three
// `release(false)` sites below. These release without
// publishing, so there is nothing for the store to refuse, and
// mapping the outcome here would let a 503 shadow the
// authorization answer this route exists to give.
let _ = guard.release(false).await;
return Err(AppError::Forbidden(
"only the repo owner or the issue author can close this issue".into(),
));
}
}
Ok(None) => {
guard.release(false).await;
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
let _ = guard.release(false).await;
// The owner keeps the informative 404; a non-owner must not learn from
// this route whether the issue exists, matching the pre-check above.
return Err(if is_owner {
AppError::NotFound(format!("issue {issue_id} not found"))
} else {
AppError::Forbidden(
"only the repo owner or the issue author can close this issue".into(),
)
});
}
Err(e) => {
guard.release(false).await;
let _ = guard.release(false).await;
return Err(AppError::Git(e.to_string()));
}
};
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
let is_author = author_did
.as_deref()
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
if !is_owner && !is_author {
guard.release(false).await;
return Err(AppError::Forbidden(
"only the repo owner or the issue author can close this issue".into(),
));
}
let close_result = git_issues::close_issue(&disk_path, &issue_id);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(close_result.is_ok()).await;
// Same short-circuit as create_issue, and before the 200 body below.
guard.release(close_result.is_ok()).await.into_result()?;
let updated = close_result
.map_err(|e| AppError::Git(e.to_string()))?
@@ -285,12 +469,344 @@ 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 tests {
use super::*;
use sqlx::PgPool;
/// U7: once the advisory lock actually excludes, taking it BEFORE authorizing
/// turns close_issue into a wedge primitive. Any caller with repo read access
/// (on a public repo, any permissionless identity) could take the per-repo
/// write lock on demand and be refused the write afterwards, while the owner's
/// push burned its retry budget against a lock held by someone with no write
/// authorization.
///
/// The observable: hold the lock from an independent session, then call the
/// handler as a stranger. If it authorizes first it refuses immediately; if it
/// acquires first it sits in the 60-attempt retry loop and the deadline fires.
#[sqlx::test]
async fn stranger_is_refused_without_waiting_on_the_write_lock(pool: PgPool) {
use sqlx::Connection;
let opts = (*pool.connect_options()).clone();
let state = crate::test_support::test_state(pool.clone()).await;
let owner = "did:key:z6MkU7Owner";
state
.db
.upsert_mirror_repo("z6MkU7Owner", "u7repo", "/tmp/u7repo", None, true)
.await
.expect("seed repo");
let record = state
.db
.get_repo("z6MkU7Owner", "u7repo")
.await
.expect("get_repo")
.expect("repo exists");
// An independent session holds the repo's write lock for the whole call.
let key = crate::git::repo_store::advisory_lock_key_for_test(
&record.owner_did.replace([':', '/'], "_"),
&record.name,
);
let mut holder = sqlx::PgConnection::connect_with(&opts).await.unwrap();
let held: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
.bind(key)
.fetch_one(&mut holder)
.await
.unwrap();
assert!(
held.0,
"the test must hold the lock for this to mean anything"
);
let _ = owner;
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkU7Stranger".to_string());
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(3),
close_issue(
axum::extract::State(state.clone()),
axum::Extension(stranger),
axum::extract::Path((
"z6MkU7Owner".to_string(),
"u7repo".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.64:5000".parse().unwrap())),
),
)
.await;
let refused = outcome.expect(
"a caller with no write authorization must be refused WITHOUT waiting on the \
write lock; hitting this deadline means the handler tried to acquire first, \
which is the wedge primitive",
);
assert!(
matches!(refused, Err(AppError::Forbidden(_))),
"expected 403 Forbidden for a stranger, got {:?}",
refused.err().map(|e| format!("{e:?}"))
);
}
/// The read-gate's blind spot, pinned rather than left to be rediscovered.
///
/// A repo row synced from a peer is stored public and carries none of the
/// owner's visibility rules, so the gate's own inputs can only produce allow
/// for it. The gate above therefore does not carry this class of row, and the
/// test that does cover it (`non_reader_is_refused_before_the_snapshot`) seeds
/// a locally created repo, which cannot observe this: a passing test there is
/// not coverage here.
///
/// Two things are asserted, and the second is why the first is acceptable.
/// The gate's verdict for such a row is allow for an arbitrary caller, and the
/// handler still refuses that caller afterwards, because the decision that
/// matters is the owner-or-author check rather than this one. If a later change
/// makes the gate the load-bearing decision for this route, the first assertion
/// breaks and this comment is where to start.
#[sqlx::test]
async fn a_synced_row_is_not_gated_by_its_own_visibility(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
// Only a synced row exists for this repo, with no locally created twin.
state
.db
.upsert_mirror_repo("z6MkSyncOwner", "syncrepo", "/tmp/syncrepo", None, false)
.await
.expect("seed synced repo"); // false = not quarantined, the ordinary case
let record = state
.db
.get_repo("z6MkSyncOwner", "syncrepo")
.await
.expect("get_repo")
.expect("repo exists")
.clone();
assert!(
record.id.contains('/'),
"this test is only meaningful against a synced row; got id {}",
record.id
);
// The gate's two inputs, and what they force.
let rules = state
.db
.list_visibility_rules(&record.id)
.await
.expect("list rules");
assert!(rules.is_empty(), "a synced row carries no rules of its own");
assert!(record.is_public, "a synced row is stored public");
assert_eq!(
crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
Some("did:key:z6MkSyncStranger"),
"/",
),
crate::visibility::Decision::Allow,
"the gate can only allow for a synced row, which is the property the \
handler's mirror-rows-handled note records",
);
// So the refusal has to come from the decision that is actually load-bearing.
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkSyncStranger".to_string());
let outcome = close_issue(
axum::extract::State(state.clone()),
axum::Extension(stranger),
axum::extract::Path((
"z6MkSyncOwner".to_string(),
"syncrepo".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.99:5000".parse().unwrap())),
)
.await;
assert!(
outcome.is_err(),
"a stranger must still be refused on a synced row, gate or no gate",
);
let body = format!("{:?}", outcome.err().unwrap());
assert!(
!body.contains("syncrepo/") && !body.to_lowercase().contains("issue body"),
"the refusal must not leak repo contents: {body}",
);
}
/// The read-gate added for the pre-lock snapshot: a caller who cannot READ
/// the repo (private repo, no rule granting them access) must be refused with
/// a not-found BEFORE any snapshot download or extraction happens. The
/// observable is the refusal itself; the cheaper part (no Tigris work) is
/// structural (the gate precedes the snapshot call in the handler).
#[sqlx::test]
async fn non_reader_is_refused_before_the_snapshot(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let now = chrono::Utc::now();
state
.db
.create_repo(&crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: "priv-close".to_string(),
owner_did: "z6MkT3Owner".to_string(),
description: None,
is_public: false,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: "/tmp/priv-close".to_string(),
forked_from: None,
machine_id: None,
})
.await
.expect("seed private repo");
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkT3Stranger".to_string());
let res = close_issue(
axum::extract::State(state.clone()),
axum::Extension(stranger),
axum::extract::Path((
"z6MkT3Owner".to_string(),
"priv-close".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.67:5000".parse().unwrap())),
)
.await;
assert!(
matches!(res, Err(AppError::RepoNotFound(_))),
"a non-reader must be refused as not-found, got {:?}",
res.err().map(|e| format!("{e:?}"))
);
}
async fn seed_repo_with_issue(
state: &crate::state::AppState,
owner_slug: &str,
owner_did: &str,
repo: &str,
issue_id: &str,
author_did: &str,
) -> std::path::PathBuf {
state
.db
.upsert_mirror_repo(owner_slug, repo, "/unused", None, true)
.await
.expect("seed repo row");
// Seed at the path the HANDLER will resolve. upsert_mirror_repo stores the
// bare slug in owner_did, and close_issue resolves from record.owner_did, so
// seeding from the full did:key would create the repo in a different
// directory and the handler would find nothing.
let record = state
.db
.get_repo(owner_slug, repo)
.await
.expect("get_repo")
.expect("seeded repo exists");
let _ = owner_did;
let path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.expect("resolve disk path");
let _ = std::fs::remove_dir_all(&path);
crate::git::store::init_bare(&path).expect("init bare repo");
// Must deserialize as a real IssueRecord: `created_at` and `status` are
// required, and a parse failure would silently drop the author (the
// `.ok()` on from_str), which reads as a 403 rather than as a broken fixture.
let json = serde_json::to_string(&IssueRecord {
id: issue_id.to_string(),
title: "seeded".to_string(),
body: Some(String::new()),
author: Some(author_did.to_string()),
created_at: chrono::Utc::now().to_rfc3339(),
status: "open".to_string(),
signed_payload: None,
})
.expect("serialize seeded issue");
crate::git::issues::create_issue(&path, issue_id, &json).expect("seed issue blob");
path
}
/// INV-21(c) positive twin 1: the OWNER can still close. The reorder moved the
/// owner check above the lock, so this is the arm most likely to have broken,
/// and the deny test alone could not see it.
///
/// The issue is seeded with a THIRD party as its author, deliberately. Seeding
/// the owner as their own author made this test unable to fail: with the owner
/// check disabled, the author fallback granted the close anyway and the test
/// stayed green. Only the owner arm can grant here now.
#[sqlx::test]
async fn owner_can_still_close_after_the_reorder(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let owner_did = "did:key:z6MkT1Owner";
seed_repo_with_issue(
&state,
"z6MkT1Owner",
owner_did,
"t1repo",
"1",
"did:key:z6MkT1Stranger",
)
.await;
let res = close_issue(
axum::extract::State(state.clone()),
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
axum::extract::Path((
"z6MkT1Owner".to_string(),
"t1repo".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.65:5000".parse().unwrap())),
)
.await;
assert!(
res.is_ok(),
"the owner must still be able to close: {:?}",
res.err().map(|e| format!("{e:?}"))
);
}
/// INV-21(c) positive twin 2: the non-owner AUTHOR can still close, through both
/// the pre-lock check and the re-assertion under the guard.
///
/// It does NOT cover the acquire-vs-acquire_fresh distinction, despite that being
/// the reason the call changed. `RepoStore::for_testing` hardcodes `tigris: None`,
/// which makes `acquire` and `acquire_fresh` identical in every test here, so
/// reverting that line leaves this green. Separating them needs an object-storage
/// seam, which is out of scope for this change and tracked separately. Claiming
/// the coverage here would be worse than admitting the gap.
#[sqlx::test]
async fn issue_author_who_is_not_the_owner_can_still_close(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let owner_did = "did:key:z6MkT2Owner";
let author_did = "did:key:z6MkT2Author";
seed_repo_with_issue(&state, "z6MkT2Owner", owner_did, "t2repo", "1", author_did).await;
let res = close_issue(
axum::extract::State(state.clone()),
axum::Extension(crate::auth::AuthenticatedDid(author_did.to_string())),
axum::extract::Path((
"z6MkT2Owner".to_string(),
"t2repo".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.66:5000".parse().unwrap())),
)
.await;
assert!(
res.is_ok(),
"the issue author, who is NOT the repo owner, must still be able to close: {:?}",
res.err().map(|e| format!("{e:?}"))
);
}
}
/// #173 F1 follow-up: issue write paths reach `acquire_write` with no admission
/// permit, so an exhausted write-lock pool must shed 503 + Retry-After.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
@@ -314,22 +830,17 @@ mod lock_pool_shed_tests {
}
}
/// 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)),
std::time::Duration::from_secs(300),
);
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!(
@@ -346,10 +857,6 @@ mod lock_pool_shed_tests {
);
}
/// 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";
@@ -380,9 +887,7 @@ mod lock_pool_shed_tests {
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 _ = held.release(false).await;
let admitted = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
@@ -401,7 +906,38 @@ mod lock_pool_shed_tests {
);
}
/// RED-before/GREEN-after for `close_issue`, same two directions.
#[sqlx::test]
async fn close_issue_read_pool_exhaustion_sheds_before_snapshot(pool: PgPool) {
use std::sync::Arc;
let owner = "did:key:zISSUECLOSEREADPOOLBBBBBBBBBBBBBBBBBBBBB";
let mut state = crate::test_support::test_state(pool.clone()).await;
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
state
.db
.create_repo(&seed_repo(owner, "read-cap"))
.await
.expect("seed repo");
let shed = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(
"did:key:zISSUECLOSEREADSTRANGER".to_string(),
)),
Path((
owner.to_string(),
"read-cap".to_string(),
"deadbeef".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(None),
)
.await;
assert!(
matches!(shed, Err(AppError::Overloaded(_))),
"an exhausted read pool must shed before snapshot work; got {shed:?}"
);
}
#[sqlx::test]
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
@@ -426,12 +962,14 @@ mod lock_pool_shed_tests {
"lp-close".to_string(),
"deadbeef".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(None),
)
.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 _ = held.release(false).await;
let admitted = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
@@ -440,6 +978,8 @@ mod lock_pool_shed_tests {
"lp-close".to_string(),
"deadbeef".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(None),
)
.await;
assert!(
@@ -448,4 +988,244 @@ mod lock_pool_shed_tests {
admitted.err()
);
}
async fn assert_retryable_repo_acquire(err: AppError, what: &str, code: &str) {
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"{what}: a transient acquire refusal must shed 503, not a 500 git error"
);
let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body");
let body = String::from_utf8_lossy(&body);
assert!(
body.contains(code),
"{what}: the 503 must carry the {code} code, got {body}"
);
}
/// Advisory-lock contention on a non-push mutation must map through the shared
/// `acquire_write_app_error` classifier to `repo_busy`, not a 500 git_error.
#[sqlx::test]
async fn create_issue_contention_sheds_repo_busy_not_500(pool: PgPool) {
let owner = "did:key:zISSUECREATEBUSYAAAAAAAAAAAAAAAAAAAAA";
let repo_name = "busy-create";
let state = crate::test_support::test_state(pool.clone()).await;
state
.db
.create_repo(&seed_repo(owner, repo_name))
.await
.expect("seed repo");
let held = state
.repo_store
.acquire_write(owner, repo_name)
.await
.expect("the first writer takes the advisory lock");
let shed = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), repo_name.to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
let err = shed.expect_err("contention must refuse the second writer");
assert_retryable_repo_acquire(err, "create_issue", "repo_busy").await;
let _ = held.release(false).await;
}
/// A refused under-lock refresh on a non-push mutation must map to
/// `repo_unavailable`, not expose the storage error as a 500 git_error.
#[sqlx::test]
async fn create_issue_unavailable_sheds_repo_unavailable_not_500(pool: PgPool) {
use axum::response::IntoResponse;
let app = axum::Router::new().route(
"/{*key}",
axum::routing::any(|method: axum::http::Method| async move {
if method == axum::http::Method::HEAD {
let mut resp = axum::http::StatusCode::OK.into_response();
resp.headers_mut()
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
resp
} else {
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let owner = "did:key:zISSUEUNAVAILAAAAAAAAAAAAAAAAAAAAAAA";
let repo_name = "unavail-create";
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
std::path::PathBuf::from("/tmp/gitlawb-issue-unavail"),
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
);
state
.db
.create_repo(&seed_repo(owner, repo_name))
.await
.expect("seed repo");
let shed = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), repo_name.to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
let err = shed.expect_err("a failed archive download must refuse the write");
assert_retryable_repo_acquire(err, "create_issue", "repo_unavailable").await;
server.abort();
}
/// A denied reader on a private repo must see 404 even when the caller's rate
/// bucket is exhausted. Rate limiting runs after the read gate.
#[sqlx::test]
async fn close_issue_rate_limit_runs_after_the_read_gate(pool: PgPool) {
use std::net::SocketAddr;
use std::time::Duration;
let owner = "did:key:zCLOSERATEOWNERAAAAAAAAAAAAAAAAAAAAAAA";
let stranger = "did:key:zCLOSERATESTRANGERBBBBBBBBBBBBBBBBBB";
let mut state = crate::test_support::test_state(pool).await;
state.close_issue_rate_limiter =
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
let mut repo = seed_repo(owner, "priv-close");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
let peer: SocketAddr = "203.0.113.88:7000".parse().unwrap();
assert!(
state
.close_issue_rate_limiter
.check(&peer.ip().to_string())
.await,
"exhaust the close_issue bucket before close_issue"
);
let res = close_issue(
State(state),
Extension(AuthenticatedDid(stranger.to_string())),
Path((owner.to_string(), "priv-close".to_string(), "1".to_string())),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some(peer)),
)
.await;
assert!(
matches!(res, Err(AppError::RepoNotFound(_))),
"a non-reader must see 404, not 429, even when rate limited: {:?}",
res
);
}
/// close_issue and receive-pack must not share one per-IP bucket.
#[sqlx::test]
async fn close_issue_rate_limit_does_not_drain_push_bucket(pool: PgPool) {
use std::net::SocketAddr;
use std::time::Duration;
let mut state = crate::test_support::test_state(pool).await;
state.close_issue_rate_limiter =
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.push_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
let peer: SocketAddr = "203.0.113.99:7000".parse().unwrap();
let key = peer.ip().to_string();
assert!(
state.close_issue_rate_limiter.check(&key).await,
"exhaust only the close_issue bucket"
);
assert!(
state.push_rate_limiter.check(&key).await,
"push traffic must keep its own bucket after close_issue is exhausted"
);
}
/// A refused publish must roll back the local issue ref so a retry does not
/// mint a second id for the same logical filing attempt.
#[sqlx::test]
async fn create_issue_rolls_back_local_ref_when_publish_refuses(pool: PgPool) {
use axum::response::IntoResponse;
let app = axum::Router::new().route(
"/{*key}",
axum::routing::any(|method: axum::http::Method| async move {
if method == axum::http::Method::HEAD {
let mut resp = axum::http::StatusCode::OK.into_response();
resp.headers_mut()
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
resp
} else {
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let owner = "did:key:zISSUEROLLBACKAAAAAAAAAAAAAAAAAAAAAAA";
let repo_name = "rollback-create";
let mut state = crate::test_support::test_state(pool.clone()).await;
let disk = tempfile::TempDir::new().unwrap();
crate::git::store::init_bare(&disk.path().join("repo.git")).expect("bare repo");
let mut seeded = seed_repo(owner, repo_name);
seeded.disk_path = disk.path().join("repo.git").to_string_lossy().to_string();
state.db.create_repo(&seeded).await.expect("seed repo");
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
disk.path().to_path_buf(),
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
);
let shed = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), repo_name.to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
assert!(
shed.is_err(),
"a failed archive upload must refuse the write"
);
let issues = git_issues::list_issues(&disk.path().join("repo.git")).expect("list issues");
assert!(
issues.is_empty(),
"the local issue ref must be rolled back after a refused publish, got {issues:?}"
);
server.abort();
}
}
+8 -19
View File
@@ -209,9 +209,6 @@ 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)
@@ -228,7 +225,10 @@ pub async fn merge_pr(
);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(merge_result.is_ok()).await;
// Short-circuit on a refused publish before the PR is marked merged and
// before the webhook fires. Both are irreversible announcements of a merge
// commit that only exists on this node's disk.
guard.release(merge_result.is_ok()).await.into_result()?;
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -428,12 +428,8 @@ pub async fn list_comments(
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.
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
/// pool must shed 503 + Retry-After.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
@@ -457,20 +453,15 @@ mod lock_pool_shed_tests {
}
}
/// 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)),
std::time::Duration::from_secs(300),
);
let repo = seed_repo(owner, "lp-merge");
@@ -524,9 +515,7 @@ mod lock_pool_shed_tests {
"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 _ = held.release(false).await;
let admitted = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
File diff suppressed because it is too large. Load diff
+7
View File
@@ -490,6 +490,7 @@ mod tests {
use clap::Parser;
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let pool = sqlx::postgres::PgPoolOptions::new()
@@ -516,10 +517,16 @@ 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)),
close_issue_rate_limiter: RateLimiter::new(120, 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_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
ipfs_max_legacy_scan_rows: crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
ipfs_max_legacy_scan_rule_bytes:
crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
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)),
+389 -71
View File
@@ -1,13 +1,13 @@
use clap::Parser;
use std::path::PathBuf;
/// Upper bound on `git_service_timeout_secs` and `ipfs_request_budget_secs`, in seconds
/// (100 years).
/// Upper bound on `git_service_timeout_secs`, `ipfs_request_budget_secs`, and
/// `ipfs_resolve_budget_secs`, in seconds (100 years).
///
/// Two consumers now, so a future tightening moves both. `ipfs_request_budget_secs`
/// derives only the `Instant` addition in `get_by_cid`, not the lease-steal multiply
/// below, but it shares this ceiling because the defect class and the "set it very large
/// to disable" contract are the same.
/// Three consumers now, so a future tightening moves all of them. `ipfs_request_budget_secs`
/// and `ipfs_resolve_budget_secs` derive only the `Instant` addition in `get_by_cid`, not the
/// lease-steal multiply below, but they share this ceiling because the defect class and the
/// "set it very large to disable" contract are the same.
///
/// The knob is not just stored, it is arithmetic input: the write path derives the
/// per-repo lease steal bound from it (`* 2 + 60`), and #174 routed it into
@@ -78,10 +78,26 @@ pub struct Config {
/// Require the authenticated pusher to be the repo owner on `git-receive-pack`.
/// Authentication (a valid did:key signature) is not authorization on its own:
/// any party can sign as their own DID. When true, pushes whose authenticated
/// DID is not the repo owner are rejected. Keep false during rolling upgrades;
/// flip it on once owners are ready for owner-only writes.
#[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)]
/// any party can mint a did:key and sign as it, so with this off every signed
/// caller may push to every repository, private ones included. On by default.
///
/// Turn it off only for a rolling upgrade whose pushers are not yet the repo
/// owner. Both `GITLAWB_ENFORCE_OWNER_PUSH=false` and `--enforce-owner-push
/// false` disable it, and the bare `--enforce-owner-push` form still means
/// `true`.
///
/// The value-taking action is what makes the CLI form parse at all: as a
/// presence-only flag, `--enforce-owner-push false` is an "unexpected argument"
/// error. The env form resolved correctly either way, so it is the CLI escape
/// hatch this buys, not the environment one.
#[arg(
long,
env = "GITLAWB_ENFORCE_OWNER_PUSH",
action = clap::ArgAction::Set,
num_args = 0..=1,
default_value_t = true,
default_missing_value = "true"
)]
pub enforce_owner_push: bool,
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
@@ -236,11 +252,9 @@ pub struct Config {
/// 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. Each
/// concurrent write pins one pooled connection for its whole duration (the
/// advisory lock in `repo_store::acquire_write` is connection-affine), so this
/// must exceed `max_concurrent_git_pushes` by `DB_POOL_APP_HEADROOM` or slow
/// pushes starve every other DB path — enforced by `Config::validate`.
/// max_connections, remembering admin tooling opens its own pool. Writes pin
/// the dedicated lock pool, not this one; this pool still needs enough headroom
/// for ordinary request handlers and metadata paths (`DB_POOL_APP_HEADROOM`).
#[arg(
long,
env = "GITLAWB_DB_MAX_CONNECTIONS",
@@ -249,6 +263,46 @@ pub struct Config {
)]
pub db_max_connections: u32,
/// Maximum connections in the dedicated advisory-lock pool, which is separate
/// from the main pool above.
///
/// Size this against the expected peak number of concurrent distinct-repo
/// writers, NOT small. Every in-flight repo write pins one connection here for
/// its whole duration (the write, its metadata tail, and the bounded archive
/// upload), so this value is a hard ceiling on simultaneous writes node-wide.
/// Keeping it separate from GITLAWB_DB_MAX_CONNECTIONS is what stops a push
/// burst from starving ordinary request handlers; the cost is that
/// (main pool + lock pool) must fit inside the database server's
/// max_connections, times the number of nodes, plus admin tooling.
#[arg(
long,
env = "GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS",
default_value_t = 40,
value_parser = clap::value_parser!(u32).range(1..)
)]
pub db_lock_pool_max_connections: u32,
/// Upper bound, in seconds, on any single object-storage transfer that runs
/// while the per-repo advisory lock is HELD.
///
/// Two bounded spans exist, and the bound applies per span. The acquire-side
/// refresh in `acquire_write` covers the existence HEAD and the download
/// together under ONE budget (it runs after the lock is taken and before the
/// guard is constructed), and the archive upload in `release` gets its own.
/// Worst-case slot occupancy is therefore about twice this value plus the git
/// work between them, not one times this value. Both used to be free, because the lock's
/// connection was returned to the pool immediately; now that a write guard
/// pins a lock-pool connection for its whole lifetime, an unbounded transfer
/// holds that slot, and enough stalled transfers deny every write on the node.
/// This is the bound that keeps a stall from becoming an outage.
#[arg(
long,
env = "GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS",
default_value_t = 300,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub lock_held_transfer_timeout_secs: u64,
/// Maximum time a request waits for a pool connection before failing with
/// 503, in seconds. Bounds queueing when the database is slow or down.
#[arg(
@@ -343,15 +397,14 @@ pub struct Config {
/// 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 48) + the lock pool (default 40), i.e.
/// 88 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.
/// advisory-lock pool (`GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS`, default 40) for
/// the whole receive-pack. That pool must be at least this value plus
/// `DB_LOCK_POOL_NON_PUSH_HEADROOM` so admitted pushes and non-push mutations
/// can each pin a lock-pool connection for their whole duration. Size BOTH
/// pools against the database server's `max_connections`: the main pool
/// (`GITLAWB_DB_MAX_CONNECTIONS`, default 48) serves ordinary handlers, and the
/// lock pool serves writes. Raising this knob does not raise the lock pool;
/// set `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` explicitly.
#[arg(
long,
env = "GITLAWB_MAX_CONCURRENT_GIT_PUSHES",
@@ -480,6 +533,37 @@ pub struct Config {
)]
pub ipfs_max_legacy_probes: usize,
/// Per-request ceiling on how many repo ROWS the `/ipfs/{cid}` resolver's legacy
/// scan may fetch from the database. The probe ceiling above only starts counting
/// once a probe runs, and the two denial classes that dominate a hostile inventory
/// (quarantine, and a root-scope visibility deny) return before a probe or a visit
/// is spent, so without this an all-denying node paged its ENTIRE repo table for one
/// anonymous request while holding a scarce walk permit.
///
/// Reach bound: a holder buried past the ceiling is servable in
/// `ceil(repos / ceiling) + 1` token-echoing retries. A truncated scan sheds a
/// retryable 503 carrying a sealed continuation token; the caller echoes it as
/// `?scan=` and the scan resumes where it stopped. No server-side scan state.
///
/// Floor coupling: raising this knob raises every caller's per-window `/ipfs` work
/// allowance whenever the route limit sits below the derived floor, because the
/// floor must fit one full deep scan's page toll (see `AppState::ipfs_work_budget`).
///
/// Tuning DOWN trade: token presence is a coarse inventory-size oracle. A ceiling
/// truncation emits a token and a wrapped scan does not, so laddering to the
/// `scan-wrapped` taint tells an anonymous caller the node's total repo count,
/// private and quarantined included, to within one ceiling. Tolled and coarse at
/// the 2048 default; it sharpens as the ceiling is lowered.
///
/// Must be between 1 and 1_048_576. Default: 2048.
#[arg(
long,
env = "GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS",
default_value_t = crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
)]
pub ipfs_max_legacy_scan_rows: usize,
/// Upper bound on the number of EXPENSIVE visibility walks
/// (`allowed_blob_set_for_caller_bounded`, a full-history git walk in a
/// blocking thread) a single `/ipfs/{cid}` request may run. Only a blob in a
@@ -492,8 +576,8 @@ pub struct Config {
/// absent with a 404. The handler still short-circuits the moment it serves.
/// Must be between 1 and 1_048_576. Default: 64.
///
/// The effective per-request ceiling is the TIGHTER of this knob and the node's
/// internal per-request history-walk ceiling, `MAX_PIN_SOURCES + 1` = 17 (see
/// The effective ceiling is the TIGHTER of this knob and the node's internal
/// history-walk ceiling, `MAX_PIN_SOURCES + 1` = 17 (see
/// `api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST` and the `min()` that combines the
/// two in the resolver). Setting this above 17 changes nothing, because the
/// internal ceiling already binds. Setting it below 17 does lower the cap: the
@@ -501,6 +585,12 @@ pub struct Config {
/// before its whole bounded provenance source set has been tried, so an operator
/// who goes under it is choosing a tighter cap that can 503 a provenanced
/// request, which is allowed.
///
/// That combined cap is charged PER PHASE, not per request: the provenance phase and
/// the legacy-scan fallback each get their own equal budget, so one request can run
/// up to twice it in total (see `MAX_HISTORY_WALKS_PER_REQUEST`, which explains why
/// the split is what keeps the fallback from inheriting a provenance phase's spent
/// remainder).
#[arg(
long,
env = "GITLAWB_IPFS_MAX_REPOS_WALKED",
@@ -569,6 +659,40 @@ pub struct Config {
)]
pub ipfs_request_budget_secs: u64,
/// Budget for the PRE-WALK CID resolve inside `get_by_cid`, in seconds: the
/// `oids_for_cid` lookup that maps the requested CID to its git oid(s), which runs
/// while the scarce walk admission (the global pool permit plus the per-source
/// sub-permit) is already held.
///
/// It exists because that one await decides whether the request does any admitted
/// work at all. A syntactically valid CID with no `pinned_cids` row runs zero probes
/// and zero walks, so under a stalled or saturated pool it would otherwise occupy a
/// walk slot for the whole `ipfs_request_budget_secs` window (600s by default) while
/// nothing is walking, and enough distinct source keys doing that reject every real
/// `/ipfs` retrieval at admission. The other repair, resolving the CID before taking
/// admission, was rejected: admission stays FIRST so an anonymous flood sheds before
/// touching the database at all, and moving the read ahead of it would let arbitrarily
/// many unadmitted permissionless callers stack concurrent DB queries.
///
/// The effective deadline is the lesser of this and the remaining request budget, so a
/// value larger than `ipfs_request_budget_secs` degrades to the request budget rather
/// than extending it. Only the resolve is on this clock; every later stage stays on the
/// full request budget, because from the second oid candidate on those run after real
/// probe and walk work and a short deadline anchored at admission would shed a
/// legitimately slow but progressing scan.
///
/// Must be positive, and no larger than `GIT_SERVICE_TIMEOUT_SECS_MAX`, for the same
/// representability reason as the request budget above: `get_by_cid` derives the
/// resolve deadline as `Instant::now() + Duration::from_secs(this)`, and that addition
/// panics on overflow in release builds too. Default: 10s.
#[arg(
long,
env = "GITLAWB_IPFS_RESOLVE_BUDGET_SECS",
default_value_t = 10,
value_parser = clap::value_parser!(u64).range(1..=GIT_SERVICE_TIMEOUT_SECS_MAX)
)]
pub ipfs_resolve_budget_secs: u64,
/// 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
@@ -638,25 +762,37 @@ impl Config {
/// the pool must clear the concurrent-write cap by at least this margin.
pub const DB_POOL_APP_HEADROOM: u32 = 8;
/// Lock-pool slots reserved for non-push `acquire_write` callers (issue create,
/// close, merge) so a saturated push budget cannot turn ordinary mutations into
/// lock-pool exhaustion on unrelated repositories.
pub const DB_LOCK_POOL_NON_PUSH_HEADROOM: u32 = 8;
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
/// catches combinations that ship a denial-of-service under otherwise-valid
/// values. Call once at startup and fail fast on `Err`.
pub fn validate(&self) -> Result<(), String> {
// A write pins one pooled connection for its whole duration (the
// connection-affine advisory lock in repo_store::acquire_write), and
// concurrent writes are capped at max_concurrent_git_pushes. If the pool
// does not exceed that cap by DB_POOL_APP_HEADROOM, a burst of slow pushes
// drains every connection and every other DB path 503s. (#174 F1)
let floor = (self.max_concurrent_git_pushes as u64) + (Self::DB_POOL_APP_HEADROOM as u64);
if (self.db_max_connections as u64) < floor {
// Concurrent git writes pin the dedicated lock pool for their whole
// duration (connection-affine advisory lock in acquire_write). The main
// pool no longer carries that occupancy.
let lock_floor = self
.max_concurrent_git_pushes
.saturating_add(Self::DB_LOCK_POOL_NON_PUSH_HEADROOM as usize);
if (self.db_lock_pool_max_connections as usize) < lock_floor {
return Err(format!(
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least max_concurrent_git_pushes ({}) \
+ {} headroom = {}: each concurrent write pins one pooled connection for its whole \
duration, so a smaller pool lets a burst of slow pushes starve every other DB path.",
self.db_max_connections,
"GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS ({}) must be at least \
max_concurrent_git_pushes ({}) plus {} for non-push mutations \
that share the lock pool",
self.db_lock_pool_max_connections,
self.max_concurrent_git_pushes,
Self::DB_POOL_APP_HEADROOM,
floor
Self::DB_LOCK_POOL_NON_PUSH_HEADROOM
));
}
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
if (self.db_max_connections as u64) < main_floor {
return Err(format!(
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least {} for non-git \
database paths",
self.db_max_connections, main_floor
));
}
Ok(())
@@ -667,6 +803,25 @@ impl Config {
mod tests {
use super::*;
#[test]
fn lock_pool_size_defaults_to_40_and_rejects_zero() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).db_lock_pool_max_connections,
40
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "8"])
.db_lock_pool_max_connections,
8
);
// A zero-sized lock pool would deny every write, so clap must reject it
// rather than let a node boot into a state where no repo can be written.
assert!(
Config::try_parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "0"])
.is_err()
);
}
#[test]
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
assert_eq!(
@@ -951,11 +1106,13 @@ mod tests {
}
/// 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.
/// a hard floor of one complete COMBINED resolution per window: the provenance
/// phase's walk term plus a full legacy search (the effective
/// `ipfs_max_legacy_probes` plus the row ceiling's page toll). 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 budget back to
/// back.
#[test]
fn ipfs_work_budget_derives_from_route_limit_and_clears_the_probe_floor() {
use crate::state::AppState;
@@ -970,20 +1127,45 @@ mod tests {
"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.
// Tight route limit (1): the floor lifts the work budget to one complete
// COMBINED resolution, the 256-probe budget PLUS the page toll a 2048-row
// ceiling costs at 128 rows per page (16) PLUS the provenance phase's walk term
// min(17, 64) = 17, so 289, NOT down to 1. The provenance walks come off the
// same bucket before the fallback runs, so a floor without that term hands the
// legacy search a bucket the provenance phase already spent from. This case
// also carries the walk term's ABOVE-constant direction: the repos-walked knob
// is at its default 64, so `MAX_HISTORY_WALKS_PER_REQUEST` (17) is what binds.
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"
289,
"a tight route limit is floored at probes + pages + walks \
(256 + 16 + min(17, 64) = 17), not clamped to 1"
);
// The walk term's BELOW-constant direction: a repos-walked knob under the
// history-walk constant is what the resolver's own `walk_cap` min() selects, so
// it is what the floor must carry too. 256 + 16 + min(17, 3) = 275.
let narrow_walk = Config::parse_from([
"gitlawb-node",
"--ipfs-rate-limit",
"1",
"--ipfs-max-repos-walked",
"3",
]);
assert_eq!(
AppState::ipfs_work_budget(&narrow_walk),
275,
"the walk term takes min(17, repos-walked 3) = 3, the resolver's own \
walk_cap, so the floor is 256 + 16 + 3"
);
// Raised probe budget lifts the floor with it (the work budget tracks the
// effective probe budget, not the constant). The walk cap is set to a DIFFERENT
// value in the same config on purpose: the two were one field before the split,
// so a floor that silently read the walk cap would return 7 here and still look
// plausible. Only the legacy-probe knob may drive this budget.
// effective probe budget, not the constant). The walk cap here is a SECOND
// below-constant proof at a different pair of values: min(17, 7) = 7, and the
// probe knob is raised at the same time so a floor that folded the two terms
// together (they were one field before the split) reads visibly wrong rather
// than plausibly right.
let raised = Config::parse_from([
"gitlawb-node",
"--ipfs-rate-limit",
@@ -995,8 +1177,30 @@ mod tests {
]);
assert_eq!(
AppState::ipfs_work_budget(&raised),
1000,
"the floor tracks the operator-raised legacy-probe budget, not the walk cap"
1023,
"the floor tracks the operator-raised legacy-probe budget (1000) plus the \
default row ceiling's page toll (16) plus the walk term min(17, 7) = 7"
);
// The scan-rows knob is coupled to the floor too, and this EXECUTES the coupling
// rather than describing it: every page the ceiling permits is charged to the
// caller's work bucket, so a raised ceiling that did not lift the floor would
// 429 an honest caller part-way down their own token ladder. 4096 rows at 128
// rows per page is 32 pages, so the floor is 256 + 32 + the default walk term
// of 17.
let wide_scan = Config::parse_from([
"gitlawb-node",
"--ipfs-rate-limit",
"10",
"--ipfs-max-legacy-scan-rows",
"4096",
]);
assert_eq!(
AppState::ipfs_work_budget(&wide_scan),
305,
"raising the row ceiling must raise the work floor by the pages it buys \
(256 probes + 4096/128 = 32 pages + min(17, 64) = 17 walks), or a full \
deep scan self-throttles"
);
// 0 route limit disables the derived bucket too (a 0-capacity limiter admits all).
@@ -1008,7 +1212,8 @@ mod tests {
);
// 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.
// a whole combined resolution's worth of charges 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));
@@ -1020,7 +1225,7 @@ mod tests {
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)"
"charge {i} of one full combined resolution must be admitted (no mid-scan throttle)"
);
}
assert!(
@@ -1084,6 +1289,52 @@ mod tests {
);
}
#[test]
fn ipfs_resolve_budget_secs_defaults_to_10_and_rejects_zero() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).ipfs_resolve_budget_secs,
10
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--ipfs-resolve-budget-secs", "3"])
.ipfs_resolve_budget_secs,
3
);
// 0 would shed every /ipfs request at the pre-walk resolve (unconditional
// 503); clap must reject it.
assert!(
Config::try_parse_from(["gitlawb-node", "--ipfs-resolve-budget-secs", "0"]).is_err()
);
// The ceiling is shared with the request budget: at the max it parses and the
// derived deadline is still representable, past it clap rejects.
let at_max = Config::try_parse_from([
"gitlawb-node",
"--ipfs-resolve-budget-secs",
&GIT_SERVICE_TIMEOUT_SECS_MAX.to_string(),
])
.expect("the documented maximum must parse");
assert_eq!(
at_max.ipfs_resolve_budget_secs,
GIT_SERVICE_TIMEOUT_SECS_MAX
);
assert!(std::time::Instant::now()
.checked_add(std::time::Duration::from_secs(
at_max.ipfs_resolve_budget_secs
))
.is_some());
for over in [GIT_SERVICE_TIMEOUT_SECS_MAX + 1, u64::MAX] {
assert!(
Config::try_parse_from([
"gitlawb-node",
"--ipfs-resolve-budget-secs",
&over.to_string(),
])
.is_err(),
"{over} is past the representable ceiling and must be rejected at parse time"
);
}
}
/// #174 (RED-before/GREEN-after): the upper bound is what keeps the deadline derived
/// from this knob in range. `get_by_cid` builds the request budget as
/// `Instant::now() + Duration::from_secs(this)` (api/ipfs.rs), and that addition is an
@@ -1180,41 +1431,108 @@ mod tests {
);
}
/// #174 F1: a connection-affine write lock pins a pooled connection per
/// concurrent write, so the pool must clear `max_concurrent_git_pushes` by
/// `DB_POOL_APP_HEADROOM` or a push burst starves every other DB path.
/// `validate()` must reject an under-sized pool at boot.
/// #174 F1: concurrent git writes pin the dedicated lock pool, not the main
/// pool. `validate()` must reject an under-sized lock pool at boot.
#[test]
fn db_pool_must_clear_the_git_push_cap() {
// Shipped defaults validate (48 >= 32 + 8).
// Shipped defaults validate (lock pool 40 >= pushes 32 + non-push headroom 8).
Config::parse_from(["gitlawb-node"])
.validate()
.expect("default config must validate");
// An under-sized pool relative to the push cap is rejected (20 < 32 + 8).
let under = Config::parse_from([
// An under-sized lock pool relative to the push cap is rejected.
let under_lock = Config::parse_from([
"gitlawb-node",
"--db-max-connections",
"20",
"--db-lock-pool-max-connections",
"16",
"--max-concurrent-git-pushes",
"32",
]);
assert!(
under.validate().is_err(),
"db_max_connections 20 below max_concurrent_git_pushes 32 + headroom must be rejected"
under_lock.validate().is_err(),
"db_lock_pool_max_connections below max_concurrent_git_pushes + headroom must be rejected"
);
// Exactly at the floor validates (40 == 32 + 8).
let at_floor = Config::parse_from([
// Exactly at the push cap without non-push headroom is rejected.
let push_only = Config::parse_from([
"gitlawb-node",
"--db-lock-pool-max-connections",
"32",
"--max-concurrent-git-pushes",
"32",
]);
assert!(
push_only.validate().is_err(),
"db_lock_pool_max_connections equal to max_concurrent_git_pushes must be rejected"
);
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
let split = Config::parse_from([
"gitlawb-node",
"--db-max-connections",
"16",
"--db-lock-pool-max-connections",
"40",
"--max-concurrent-git-pushes",
"32",
]);
assert!(
at_floor.validate().is_ok(),
"db_max_connections at the floor (pushes + headroom) must validate"
split.validate().is_ok(),
"a small main pool with a large lock pool must validate"
);
// Main pool still needs the app headroom floor.
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
assert!(
under_main.validate().is_err(),
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
);
}
/// The DECLARED default, read off the parser rather than out of a parse.
///
/// `Config::parse_from` consults the process environment, so on a host that
/// exports `GITLAWB_ENFORCE_OWNER_PUSH=false` a parse-based assertion says
/// nothing about what this crate declares — it reports the operator's setting.
/// Asserting the declaration is the env-independent form, and it is the one that
/// actually fails if someone flips `default_value_t` back.
#[test]
fn enforce_owner_push_is_declared_true_independent_of_the_environment() {
use clap::CommandFactory;
let cmd = Config::command();
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == "enforce_owner_push")
.expect("the argument must exist");
assert_eq!(
arg.get_default_values(),
["true"],
"owner-only push must be the declared default; a node started with no \
configuration cannot accept a push from a self-minted key"
);
}
/// The flip must not strand an operator mid-upgrade.
///
/// Turning the gate on is a breaking change for any deployment whose pushers are
/// not yet the repo owner, so the escape hatch has to keep working. As a
/// presence-only flag `--enforce-owner-push false` is not "false", it is an
/// "unexpected argument" error; the value-taking action is what makes that form
/// parse.
///
/// This pins the CLI form only. The environment form resolved to `false` under
/// the presence-only declaration too, so it is not what this change fixed, and it
/// is not exercised here because the process environment is global and these
/// tests run in parallel.
#[test]
fn enforce_owner_push_stays_disableable_for_rolling_upgrades() {
assert!(
!Config::parse_from(["gitlawb-node", "--enforce-owner-push", "false"])
.enforce_owner_push,
"operators must still be able to opt out during a rolling upgrade"
);
assert!(
Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push
);
}
}
File diff suppressed because it is too large. Load diff
+115 -9
View File
@@ -50,8 +50,15 @@ pub enum AppError {
#[error("incomplete: {0}")]
Incomplete(String),
#[error("search incomplete: {0}")]
SearchIncomplete(String),
/// A bounded search that could not complete. `continuation`, when present, is the
/// sealed scan position the caller echoes as `?scan=` to resume where the search
/// stopped (#173 round 13, F2). It is AEAD-sealed at the mint site, never plaintext:
/// the row it names is by construction one the caller was denied (INV-13).
#[error("search incomplete: {message}")]
SearchIncomplete {
message: String,
continuation: Option<String>,
},
#[error("git error: {0}")]
Git(String),
@@ -62,6 +69,15 @@ pub enum AppError {
#[error("server overloaded: {0}")]
Overloaded(String),
#[error("repository is busy")]
RepoBusy,
#[error("repository is temporarily unavailable")]
RepoUnavailable,
#[error("repository write was fenced by a concurrent publish")]
RepoWriteFenced,
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
@@ -103,7 +119,31 @@ impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
match err.downcast::<sqlx::Error>() {
Ok(sql) => AppError::Db(sql),
Err(err) => AppError::Internal(err),
// Lock contention is transient and ordinary, so it must not land as a
// 500. The internal message names the owner slug and repo, so the
// variant carries nothing: the detail stays in the log at the raise
// site and the client gets a fixed retryable body.
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
Ok(_) => AppError::RepoBusy,
// Same reasoning one rung down: a refused under-lock refresh is a
// transient storage condition, and its internal message names the
// owner slug and repo, so the variant carries nothing.
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
Ok(_) => AppError::RepoUnavailable,
// And one more rung: a publish the store refused twice is
// transient in the same way, and the retry is the client's
// to make. The variant carries nothing for the same reason
// as the two above.
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
{
Ok(_) => AppError::RepoWriteFenced,
Err(err) => AppError::Internal(err),
},
},
},
},
}
}
}
@@ -168,15 +208,38 @@ impl IntoResponse for AppError {
// 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) => (
AppError::SearchIncomplete { message, .. } => (
StatusCode::SERVICE_UNAVAILABLE,
"search_incomplete",
msg.clone(),
message.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.
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
// 503 with a FIXED body: the caller should retry, and must not be told
// which repo is contended or for how long.
AppError::RepoBusy => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_busy",
"repository is busy — retry".into(),
),
// 503 with a FIXED body for the same reason: the caller should retry, and
// must not be told which repo could not be refreshed or why.
AppError::RepoUnavailable => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_unavailable",
"repository is temporarily unavailable, retry".into(),
),
// 503 with a FIXED body again, and its own code: the caller should
// retry, but the condition is not contention, so a client that
// distinguishes them should be able to. The body must not say which
// repo lost its publish or to whom.
AppError::RepoWriteFenced => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_write_fenced",
"repository changed underneath this write, retry".into(),
),
AppError::Db(e) if db_unavailable(e) => (
StatusCode::SERVICE_UNAVAILABLE,
DB_UNAVAILABLE_CODE,
@@ -213,19 +276,35 @@ impl IntoResponse for AppError {
}
};
let body = Json(json!({
let mut body = json!({
"error": code,
"message": message,
}));
});
// A truncated CID search may carry the sealed position the caller echoes as
// `?scan=` to resume. Rendered as a third body field, present only when the
// shed actually left something to resume: a wrapped scan and a throttled
// request both omit it, and its ABSENCE is what tells a caller the ladder is
// over. It is opaque ciphertext; see `gitlawb_core::scan_token`.
if let AppError::SearchIncomplete {
continuation: Some(token),
..
} = &self
{
body["continuation"] = json!(token);
}
let mut resp = (status, body).into_response();
let mut resp = (status, Json(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(_)
AppError::Overloaded(_)
| AppError::SearchIncomplete { .. }
| AppError::RepoBusy
| AppError::RepoUnavailable
| AppError::RepoWriteFenced
) {
resp.headers_mut().insert(
axum::http::header::RETRY_AFTER,
@@ -343,4 +422,31 @@ mod tests {
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
#[test]
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
for err in [
AppError::RepoBusy,
AppError::RepoUnavailable,
AppError::RepoWriteFenced,
] {
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
}
}
+42
View File
@@ -61,6 +61,27 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
Ok(())
}
/// Remove a single issue ref while the write guard still holds the advisory lock,
/// after a definite publish refusal. Best-effort: a failed delete leaves a
/// local-only orphan, which is still better than telling the client to retry into a
/// duplicate id. Not used on `UploadUnknowable`, where the PUT may still land.
pub fn delete_issue_ref(
git_bin: &str,
repo_path: &Path,
issue_id: &str,
deadline: std::time::Instant,
) -> Result<()> {
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
crate::git::visibility_pack::run_bounded_git(
git_bin,
&["update-ref", "-d", &ref_name],
repo_path,
b"",
deadline,
)?;
Ok(())
}
/// List all issue refs and return their JSON content.
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
// List all refs under refs/gitlawb/issues/
@@ -278,6 +299,27 @@ mod tests {
assert!(result.unwrap_err().to_string().contains("ambiguous"));
}
#[test]
fn delete_issue_ref_removes_a_created_ref() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "eee88888-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"eee88888-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
delete_issue_ref(
"git",
dir.path(),
full_id,
std::time::Instant::now() + std::time::Duration::from_secs(30),
)
.unwrap();
assert_eq!(resolve_issue_id(dir.path(), full_id).unwrap(), None);
}
#[test]
fn test_close_issue_via_prefix() {
let dir = TempDir::new().unwrap();
File diff suppressed because it is too large. Load diff
+163 -6
View File
@@ -606,8 +606,20 @@ pub(crate) fn object_store_readable_store_wide(repo_path: &Path) -> bool {
/// 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 `deadline` 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.
/// `Ok(n)` on success, and otherwise a [`ProbeError`] in the same vocabulary the type and
/// content stages use: a reaped child is `Transient` (retryable), and every other failure
/// goes through [`classify_store_fault`], so it is `Transient` when the object store is
/// not readable and `Deterministic` when it is.
///
/// There is deliberately NO absence value (#173 round 12). This returned
/// `Ok(None)` for every non-timeout failure, which `gate_and_serve` reads as a verified
/// absence and does not taint the search for, so a corrupt object, an unreadable pack, or
/// a failed spawn handed an authorized caller a definitive 404 instead of the retryable
/// 503 tail. Absence is also not this stage's question: the caller has already had a
/// `Present` verdict from [`object_type_bounded`], so an object that cannot be sized here
/// is a fault, not a not-found, and the one honest exception (a concurrent gc between the
/// two stages) is classified by store readability like any other. Making the absence
/// unrepresentable is what keeps the next caller from reintroducing the swallow.
///
/// Takes the shared `deadline` rather than its own timeout so a caller that pairs this
/// size check with a later read spends ONE budget across the pair, not one per stage.
@@ -616,7 +628,7 @@ pub fn object_size_bounded(
repo_path: &Path,
sha256_hex: &str,
deadline: std::time::Instant,
) -> Result<Option<u64>> {
) -> std::result::Result<u64, ProbeError> {
match crate::git::visibility_pack::run_bounded_git(
git_bin,
&["cat-file", "-s", sha256_hex],
@@ -624,9 +636,25 @@ pub fn object_size_bounded(
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),
Ok(out) => {
let text = String::from_utf8_lossy(&out);
text.trim().parse::<u64>().map_err(|e| {
// git exited 0 but did not print a size: the store answered something
// this code cannot read, which is a fault and never an absence.
classify_store_fault(
repo_path,
sha256_hex,
anyhow::anyhow!("unparseable `cat-file -s` output {:?}: {e}", text.trim()),
)
})
}
// The watchdog reaped the child at `deadline`; retryable whatever the store looks
// like, so it is routed before readability gets a say (same rule as the content
// stage in `read_object_bounded`).
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => {
Err(ProbeError::Transient(e))
}
Err(e) => Err(classify_store_fault(repo_path, sha256_hex, e)),
}
}
@@ -842,6 +870,7 @@ pub fn merge_branch(
}
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
#[allow(dead_code)] // exercised from test_support and state tests; production uses validated_repo_disk_path
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
// Sanitize the DID for use as a directory name
let owner_slug = owner_did.replace([':', '/'], "_");
@@ -1488,6 +1517,134 @@ mod tests {
);
}
/// #173 round 12 (jatmn): the SIZE probe must use the same absence-versus-fault
/// vocabulary the type probe does. It mapped every non-timeout failure to `Ok(None)`,
/// which `gate_and_serve` reads as a verified absence and does not taint, so a
/// corrupt object, an unreadable pack, or a failed spawn ended the search cleanly and
/// handed an authorized caller a definitive 404 instead of the retryable 503 tail.
///
/// A corrupt loose object is the same fixture the type stage uses for this, and it is
/// the honest case: the object EXISTS, the type probe says so, and only the size read
/// fails. RED before the change (`Ok(None)`).
#[cfg(unix)]
#[test]
fn object_size_bounded_corrupt_loose_object_is_fault_not_absence() {
use std::os::unix::fs::PermissionsExt;
let td = tempfile::TempDir::new().unwrap();
let work = td.path().join("sizecorrupt");
std::fs::create_dir_all(&work).unwrap();
let g = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&work)
.status()
.unwrap()
.success(),
"git {args:?}"
);
};
g(&["init", "-q", "--object-format=sha256", "."]);
g(&["config", "user.email", "t@t"]);
g(&["config", "user.name", "t"]);
std::fs::write(work.join("f.txt"), b"loose object content\n").unwrap();
g(&["add", "f.txt"]);
g(&["commit", "-qm", "c1"]);
let blob = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD:f.txt"])
.current_dir(&work)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
// Healthy first: the probe reads the real size, so the fault case below is not
// passing for the trivial reason that this fixture never worked.
let healthy = super::object_size_bounded("git", &work, &blob, deadline);
assert!(
matches!(healthy, Ok(n) if n > 0),
"a readable object reports its size; got {healthy:?}"
);
let obj = work.join(".git/objects").join(&blob[0..2]).join(&blob[2..]);
let mut perms = std::fs::metadata(&obj).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&obj, perms).unwrap();
std::fs::write(&obj, b"garbage not a zlib stream").unwrap();
let res = super::object_size_bounded("git", &work, &blob, deadline);
assert!(
res.is_err(),
"a corrupt object must surface as a probe fault, never as an absence the \
resolver renders as a clean 404; got {res:?}"
);
}
/// #173 round 12 (jatmn), the transient arm: a store this process cannot read is the
/// retryable case, so the size probe classifies it `Transient` exactly as the type
/// probe does. Distinguishing the two arms is the whole point of routing through
/// `classify_store_fault` rather than returning one undifferentiated error.
#[cfg(unix)]
#[test]
fn object_size_bounded_unreadable_store_is_transient() {
use std::os::unix::fs::PermissionsExt;
let td = tempfile::TempDir::new().unwrap();
let work = td.path().join("sizeunreadable");
std::fs::create_dir_all(&work).unwrap();
let g = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&work)
.status()
.unwrap()
.success(),
"git {args:?}"
);
};
g(&["init", "-q", "--object-format=sha256", "."]);
g(&["config", "user.email", "t@t"]);
g(&["config", "user.name", "t"]);
std::fs::write(work.join("f.txt"), b"loose object content\n").unwrap();
g(&["add", "f.txt"]);
g(&["commit", "-qm", "c1"]);
let blob = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD:f.txt"])
.current_dir(&work)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
// Make this oid's loose fan-out unreadable: git fails, and the store cannot
// certify absence, so the fault is retryable rather than terminal.
let fanout = work.join(".git/objects").join(&blob[0..2]);
let mut perms = std::fs::metadata(&fanout).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&fanout, perms).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let res = super::object_size_bounded("git", &work, &blob, deadline);
let mut restore = std::fs::metadata(&fanout).unwrap().permissions();
restore.set_mode(0o755);
std::fs::set_permissions(&fanout, restore).unwrap();
assert!(
matches!(res, Err(super::ProbeError::Transient(_))),
"an unreadable object store is the retryable arm; got {res:?}"
);
}
/// #174 F5/U4: a corrupt LOOSE object makes `git cat-file --batch-check` print
/// `<oid> missing` on stdout (exit 0) yet emit `error:` diagnostics on stderr. The
/// clean-`missing` absence path must NOT fire here — the `error:` line disqualifies
+460 -44
View File
@@ -5,12 +5,41 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context, Result};
use aws_sdk_s3::Client as S3Client;
use tracing::{debug, info};
/// The precondition an upload is fenced on.
///
/// Object storage is the only place a fence can hold. Dropping the future of an
/// in-flight PUT does not cancel the request the server is already processing,
/// so no amount of local locking stops an abandoned writer's bytes from landing
/// after a successor has published. A conditional PUT the store itself refuses
/// is what actually stops it.
#[derive(Clone, Debug)]
pub enum UploadPrecondition {
/// Publish only if the stored object is still the generation we observed.
IfMatch(String),
/// Publish only if nothing is stored under the key yet.
IfAbsent,
/// No fence. Last writer wins.
Unconditional,
}
/// Why an upload failed, split so a caller can tell "someone else already
/// published this key" (expected, and dropping our bytes is the correct
/// outcome) from a real storage failure.
#[derive(Debug, thiserror::Error)]
pub enum UploadError {
#[error("upload precondition lost (HTTP {status})")]
PreconditionLost { status: u16 },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
/// Wrapper around the S3 client with the configured bucket.
#[derive(Clone)]
pub struct TigrisClient {
@@ -31,21 +60,29 @@ impl TigrisClient {
})
}
/// Test-only constructor with an explicit S3 endpoint, region, and static
/// credentials — no env-var reads, so parallel tests cannot race each other's
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
/// the client at a non-routable endpoint to exercise acquire-stall paths.
/// Build a client pointed at an arbitrary endpoint, for tests.
///
/// The production constructor reads the endpoint and credentials from the
/// environment, which a test cannot steer without mutating process-global
/// state. This takes both explicitly so a test can aim the client at a
/// closed port and get a prompt transport error out of `exists()`.
///
/// `RetryConfig::disabled()` is load-bearing, not tidiness: the SDK's default
/// policy retries a connection refusal with backoff, which turns each failing
/// call into seconds of waiting.
#[cfg(test)]
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.endpoint_url(endpoint_url)
.region(aws_config::Region::new("auto"))
.credentials_provider(creds)
.load()
.await;
pub fn for_testing_with_endpoint(bucket: &str, endpoint: &str) -> Self {
use aws_sdk_s3::config::{retry::RetryConfig, Credentials, Region};
let config = aws_sdk_s3::config::Config::builder()
.endpoint_url(endpoint)
.credentials_provider(Credentials::new("test", "test", None, None, "test"))
.region(Region::new("auto"))
.retry_config(RetryConfig::disabled())
.behavior_version_latest()
.build();
Self {
s3: S3Client::new(&config),
s3: S3Client::from_conf(config),
bucket: bucket.to_string(),
}
}
@@ -77,8 +114,47 @@ impl TigrisClient {
}
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
/// Read the ETag of a repo archive, or `None` when nothing is stored under
/// the key. The ETag identifies the generation a later conditional upload
/// can fence itself on.
///
/// Separate from `exists` rather than folded into it: `exists` has callers
/// that only want the boolean, and widening its return type would churn
/// every one of them for no benefit.
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
{
Ok(out) => Ok(Some(
out.e_tag()
.context(format!("tigris HEAD {key}: hit carried no ETag"))?
.to_string(),
)),
Err(e) => {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
Ok(None)
} else {
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
}
}
}
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive,
/// fenced by `precondition`.
pub async fn upload(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
precondition: UploadPrecondition,
) -> std::result::Result<(), UploadError> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
@@ -93,15 +169,57 @@ impl TigrisClient {
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
self.s3
let mut req = self
.s3
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(body)
.content_type("application/zstd")
.send()
.await
.context(format!("tigris PUT {key}"))?;
.content_type("application/zstd");
match &precondition {
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
UploadPrecondition::Unconditional => {}
}
if let Err(e) = req.send().await {
// `PutObjectError` models no PreconditionFailed variant (its arms are
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
// TooManyParts, Unhandled), so a refused precondition arrives as
// `Unhandled` and matching the enum would classify it as a generic
// failure. The raw HTTP status off the response is the only place the
// answer actually lives.
//
// Read it via `raw_response()`, not a `ServiceError`-only match: the
// SDK exposes the raw response for BOTH `ServiceError` and
// `ResponseError`, and a refused conditional PUT whose error body the
// SDK cannot parse (malformed XML, premature close) surfaces as
// `ResponseError`. Matching only `ServiceError` would classify that
// unparsable 409/412 as a generic failure, and `RepoWriteGuard::release`
// would log-and-succeed instead of taking the supersede retry,
// acknowledging a write that was definitively not published.
let status = e.raw_response().map(|raw| raw.status().as_u16());
// 412 is always a lost precondition. 409 is one only when we asked
// for create-only, which is how S3-compatible stores report "the key
// already exists". Everything else, 404 included, is a real failure:
// archive keys are never deleted (`delete` has no callers), so a 404
// here means something permanent like a missing bucket or a
// misrouted endpoint, and reporting that as a lost precondition
// would tell a caller to expect a successor that does not exist.
let lost = match status {
Some(412) => true,
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
_ => false,
};
if lost {
return Err(UploadError::PreconditionLost {
status: status.expect("a lost precondition came from a status"),
});
}
return Err(UploadError::Other(
anyhow::Error::new(e).context(format!("tigris PUT {key}")),
));
}
info!(key = %key, "uploaded repo to tigris");
Ok(())
@@ -112,10 +230,32 @@ impl TigrisClient {
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
local_path: &super::repo_store::ValidatedRepoDiskPath,
swap_authority: Option<Arc<AtomicBool>>,
) -> Result<()> {
self.download_to(owner_slug, repo_name, local_path, true, swap_authority)
.await
.map(|_| ())
}
/// Download a repo archive from Tigris and extract it, returning the
/// directory that was populated.
///
/// `publish` controls whether the extract is swapped into `target` in place
/// (the live-path mutation used by writes; returns `target`) or unpacked
/// into a fresh temp directory under `target`'s parent (a non-mutating
/// snapshot read; returns the temp dir, which the caller owns and cleans
/// up). The snapshot form never touches the live repo path.
pub async fn download_to(
&self,
owner_slug: &str,
repo_name: &str,
target: &super::repo_store::ValidatedRepoDiskPath,
publish: bool,
swap_authority: Option<Arc<AtomicBool>>,
) -> Result<DownloadExtract> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
let resp = self
.s3
@@ -133,21 +273,62 @@ impl TigrisClient {
.context("reading tigris response body")?
.into_bytes();
// Extract tar.zst to local path
tokio::task::spawn_blocking({
let local_path = local_path.to_path_buf();
move || decompress_repo(&data, &local_path)
// The snapshot temp dir is decided HERE, in the async layer, before the
// extraction runs. Cleanup ownership moves into the blocking task so a
// cancelled async future cannot drop it while extraction is still running.
let snapshot_tmp = if publish {
None
} else {
let parent = target.parent().context("snapshot path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = target
.file_name()
.context("snapshot path has no file name")?
.to_string_lossy();
Some(parent.join(format!(
".{file_name}.tmp-snapshot.{}",
uuid::Uuid::new_v4()
)))
};
// Extract tar.zst to a directory.
let extracted = tokio::task::spawn_blocking({
let target = target.clone();
let snapshot_tmp = snapshot_tmp.clone();
move || -> Result<DownloadExtract> {
let result = (|| -> Result<DownloadExtract> {
if publish {
decompress_repo(&data, &target, swap_authority.as_ref())?;
return Ok(DownloadExtract::Published(()));
}
// Non-mutating snapshot: unpack into the temp dir decided above.
// The live repo path is never touched.
let tmp_dir = snapshot_tmp.expect("snapshot path was decided above");
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
let unpack = (|| -> Result<()> {
let decoder = zstd::stream::Decoder::new(&data[..])?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
Ok(())
})();
if let Err(e) = unpack {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(e);
}
Ok(DownloadExtract::Snapshot(TempSnapshotDir { path: tmp_dir }))
})();
result
}
})
.await
.context("extract task panicked")?
.context("extracting repo")?;
info!(key = %key, path = %local_path.display(), "downloaded repo from tigris");
Ok(())
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
Ok(extracted)
}
/// Delete a repo archive from Tigris.
#[allow(dead_code)]
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
self.s3
@@ -180,7 +361,7 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
// entry accrues per distinct repo path for the process lifetime. Bounded by
// the number of repos a node hosts, so it's negligible for normal use, but
@@ -193,6 +374,35 @@ fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
.clone()
}
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
/// before the outer future resumes still removes the directory.
pub(crate) struct TempSnapshotDir {
path: PathBuf,
}
impl TempSnapshotDir {
pub(crate) fn into_repo_snapshot(self) -> super::repo_store::RepoSnapshot {
let path = self.path.clone();
std::mem::forget(self);
super::repo_store::RepoSnapshot::from_owned_path(path)
}
}
impl Drop for TempSnapshotDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// What [`TigrisClient::download_to`] produced.
pub enum DownloadExtract {
/// Published into the validated live repo path.
Published(()),
/// Unpacked into a throwaway temp dir that cleans up on drop until adopted.
Snapshot(TempSnapshotDir),
}
/// Decompress a tar.zst byte vector into a local directory.
///
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
@@ -200,11 +410,16 @@ fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
/// good existing copy at `local_path` — on failure we discard the temp dir and
/// leave `local_path` exactly as it was.
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
let parent = local_path.parent().context("repo path has no parent")?;
fn decompress_repo(
data: &[u8],
local_path: &super::repo_store::ValidatedRepoDiskPath,
swap_authority: Option<&Arc<AtomicBool>>,
) -> Result<()> {
let live = local_path.as_path();
let parent = live.parent().context("repo path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = local_path
let file_name = live
.file_name()
.context("repo path has no file name")?
.to_string_lossy();
@@ -229,17 +444,218 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
return Err(e);
}
// Swap the freshly-extracted repo into place. rename within the same parent
// is effectively atomic, but most platforms refuse to rename onto a
// non-empty dir, so remove the old copy first. Serialize this per repo path:
// concurrent extractions unpack into isolated temp dirs, but their swaps
// must not interleave or they race to a nondeterministic overwrite/failure.
let lock = publish_lock(local_path);
let _publish = lock.lock().expect("publish lock poisoned");
if local_path.exists() {
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
}
std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place")?;
// Swap through the validated-path helper so CodeQL sees the barrier before the
// remove/rename sink (`rust/path-injection`).
super::repo_store::swap_extracted_into_validated_repo(local_path, &tmp_dir, swap_authority)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aws_sdk_s3::primitives::ByteStream;
use futures::FutureExt;
/// The envs the probe needs, all of them, or it does not run.
///
/// `AWS_ENDPOINT_URL_S3` is included on purpose: without it the SDK resolves
/// to real AWS S3, and a probe that passed there would say nothing about
/// Tigris.
fn probe_env() -> Option<String> {
if std::env::var("GITLAWB_TIGRIS_PROBE").ok().as_deref() != Some("1") {
return None;
}
for name in [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_ENDPOINT_URL_S3",
] {
if std::env::var(name).is_err() {
eprintln!("tigris conditional-write probe: {name} is unset, skipping");
return None;
}
}
match std::env::var("GITLAWB_TIGRIS_BUCKET") {
Ok(b) if !b.is_empty() => Some(b),
_ => {
eprintln!(
"tigris conditional-write probe: GITLAWB_TIGRIS_BUCKET is unset, skipping"
);
None
}
}
}
/// One conditional PUT, reported as the status that REFUSED it, or `None`
/// when the store accepted the write.
///
/// Accepted is the interesting answer here, not an error: it means the
/// endpoint ignored the header we fenced on.
async fn conditional_put(
s3: &S3Client,
bucket: &str,
key: &str,
body: &'static [u8],
if_match: Option<&str>,
if_none_match: Option<&str>,
) -> Result<Option<u16>, String> {
let mut req = s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body));
if let Some(v) = if_match {
req = req.if_match(v);
}
if let Some(v) = if_none_match {
req = req.if_none_match(v);
}
match req.send().await {
Ok(_) => Ok(None),
Err(e) => match e.raw_response() {
// Same raw-response rule as `upload`: a refused conditional PUT
// whose body the SDK cannot parse surfaces as `ResponseError`, and
// the status has to come off the raw response for both variants.
// Without it, an unparsable 409/412 would report "no HTTP
// response" here and skip the supersede retry.
Some(raw) => Ok(Some(raw.status().as_u16())),
None => Err(format!("conditional PUT {key}: no HTTP response: {e}")),
},
}
}
/// The probe body, written to RETURN its failures rather than panic on
/// them, so the caller's cleanup is reached on every arm.
async fn conditional_write_probe(s3: &S3Client, bucket: &str, key: &str) -> Result<(), String> {
// 1. A plain PUT under a throwaway key.
let seeded = s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"probe-one"))
.send()
.await
.map_err(|e| format!("seeding PUT {key}: {e}"))?;
// 2. Its ETag, which is the generation the next arm fences against.
let etag = seeded
.e_tag()
.ok_or_else(|| format!("seeding PUT {key} returned no ETag"))?
.to_string();
// 3. A deliberately wrong If-Match. A store honoring it answers 412.
let wrong = format!("\"{}\"", "0".repeat(32));
if etag.trim_matches('"') == wrong.trim_matches('"') {
return Err(format!(
"the seeded ETag {etag} collides with the deliberately wrong one, \
so this arm would prove nothing"
));
}
match conditional_put(s3, bucket, key, b"probe-two", Some(&wrong), None).await? {
Some(412) => {}
Some(status) => {
return Err(format!(
"a stale If-Match must be refused with 412, the endpoint answered {status}"
))
}
None => {
return Err(
"a stale If-Match was ACCEPTED: this endpoint does not honor If-Match, so \
the release fence cannot hold here"
.to_string(),
)
}
}
// 4. If-None-Match `*` over the object that now exists. This arm matters
// MORE than the one above. An ignored If-Match eventually surfaces as
// odd behavior, because a stale writer overwrites and someone notices
// the lost tree. An ignored If-None-Match just returns 200, so a publish
// that should have been fenced lands with no error anywhere: the silent
// no-op the bucket-type caveat on this test describes.
match conditional_put(s3, bucket, key, b"probe-three", None, Some("*")).await? {
// Either status is a pass, and the asymmetry with the If-Match arm
// above mirrors `upload`'s classifier exactly: 412 is always a lost
// precondition, and 409 is one too when we asked for create-only.
// AWS documents 409 for a create-only conflict racing a delete, so a
// store answering it is enforcing the precondition and we already
// handle it. Pinning 412 alone here would fail the probe against a
// backend that is behaving correctly, which sends whoever runs it
// chasing a fault that is not there.
Some(412) | Some(409) => {}
Some(status) => {
return Err(format!(
"create-only over an existing object must be refused with 412 or 409, \
the endpoint answered {status}"
))
}
None => {
return Err(
"If-None-Match * was ACCEPTED over an existing object: this endpoint does \
not honor create-only, so a fenced publish lands silently"
.to_string(),
)
}
}
Ok(())
}
/// Probe the REAL Tigris endpoint for the conditional-write semantics the
/// release fence depends on.
///
/// UNTIL THIS IS RUN AGAINST REAL CREDENTIALS, the fence is verified against
/// vendor documentation and an in-process mock, not against the backend it
/// runs on. The mock implements the semantics we believe Tigris has; it
/// cannot tell us whether Tigris actually has them.
///
/// The bucket matters, not just the endpoint. Tigris documents conditional
/// operations as supported on Single-region and Multi-region buckets only.
/// Global and Dual-region buckets are eventually consistent, and a
/// conditional PUT evaluated against a stale replica would make the fence a
/// silent no-op rather than an error. So point `GITLAWB_TIGRIS_BUCKET` at a
/// throwaway bucket of the SAME type production uses.
///
/// Ignored by default and additionally gated on `GITLAWB_TIGRIS_PROBE=1`,
/// because it writes to a real bucket and costs real requests. Run with:
/// `GITLAWB_TIGRIS_PROBE=1 cargo test -p gitlawb-node --bin gitlawb-node
/// tigris_honors_conditional_writes -- --ignored --nocapture`
#[tokio::test]
#[ignore = "writes to a real Tigris bucket; needs GITLAWB_TIGRIS_PROBE=1 plus credentials"]
async fn tigris_honors_conditional_writes() {
let Some(bucket) = probe_env() else {
eprintln!(
"tigris conditional-write probe: skipped. Set GITLAWB_TIGRIS_PROBE=1, \
GITLAWB_TIGRIS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and \
AWS_ENDPOINT_URL_S3 to run it."
);
return;
};
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let s3 = S3Client::new(&config);
// A fresh key per run, so a probe that somehow orphaned an object on an
// earlier run cannot change what this one observes.
let key = format!("probe/conditional-write-{}.bin", uuid::Uuid::new_v4());
// CLEANUP MUST RUN ON EVERY ARM, and a failing assertion is precisely
// the case this probe exists to catch, so the delete cannot sit after
// the checks. The body returns its failures rather than panicking, and
// `catch_unwind` covers the panic an SDK call could still raise; either
// way the delete below is reached before the verdict is re-raised.
let outcome = std::panic::AssertUnwindSafe(conditional_write_probe(&s3, &bucket, &key))
.catch_unwind()
.await;
if let Err(e) = s3.delete_object().bucket(&bucket).key(&key).send().await {
eprintln!("tigris conditional-write probe: cleanup of {key} failed: {e}");
}
match outcome {
Ok(Ok(())) => {}
Ok(Err(msg)) => panic!("tigris conditional-write probe: {msg}"),
Err(payload) => std::panic::resume_unwind(payload),
}
}
}
File diff suppressed because it is too large. Load diff
+59 -228
View File
@@ -59,32 +59,6 @@ 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 48) + 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()
@@ -116,6 +90,7 @@ async fn main() -> Result<()> {
// Load or generate the node's identity keypair
let keypair = load_or_create_keypair(&config)?;
let scan_token_key = AppState::derive_scan_token_key(&keypair);
let node_did = keypair.did();
// One-time metrics init. Must run before any handler that calls into
@@ -313,20 +288,20 @@ async fn main() -> Result<()> {
None
};
// Repo write locks run on their own pool, never the main query pool: each push
// holds its connection for the whole receive-pack, so a burst of concurrent
// pushes drawing from the main pool would park that many connections for the
// duration of their receive-packs and starve every other query. That holds
// whatever the two pools are sized at, which is why the separation is
// structural rather than a consequence of the defaults; config validate()
// separately requires db_max_connections >= max_concurrent_git_pushes + 8. 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),
// Advisory-lock connections come from their own pool: a write guard pins one
// for its whole lifetime, and sharing the application pool would let a push
// burst starve ordinary request handlers.
let lock_pool = db::Db::lock_pool(
&config.database_url,
config.db_lock_pool_max_connections,
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,
std::time::Duration::from_secs(config.lock_held_transfer_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.
@@ -371,6 +346,23 @@ async fn main() -> Result<()> {
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
}
// close_issue drives a full archive download for non-owner author pre-checks.
// Keep it on its own bucket so it cannot drain the receive-pack limit.
let close_issue_limit = std::env::var("GITLAWB_CLOSE_ISSUE_RATE_LIMIT")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(120);
let close_issue_rate_limiter = rate_limit::RateLimiter::new_bounded(
close_issue_limit,
std::time::Duration::from_secs(3600),
200_000,
);
if close_issue_limit == 0 {
tracing::warn!(
"GITLAWB_CLOSE_ISSUE_RATE_LIMIT=0 — per-IP close_issue rate limiting disabled"
);
}
// Which forwarded header the edge is trusted to set. Default None (trust
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
// a node behind Caddy/NGINX sets it to x-forwarded-for.
@@ -420,11 +412,13 @@ async fn main() -> Result<()> {
rate_limiter,
create_ip_rate_limiter,
push_rate_limiter,
close_issue_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_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
ipfs_max_legacy_scan_rows: AppState::ipfs_legacy_scan_row_budget(&config),
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust,
sync_trigger_rate_limiter,
@@ -500,10 +494,6 @@ async fn main() -> Result<()> {
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),
@@ -543,16 +533,14 @@ async fn main() -> Result<()> {
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
{
let cleanup_state = state.clone();
let sweep_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)) => {
// 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;
sweep_state.sweep_rate_limiters().await;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -695,49 +683,6 @@ 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)]
@@ -1179,20 +1124,20 @@ mod rate_limiter_sweep_tests {
state.rate_limiter = RateLimiter::new(10, window);
state.create_ip_rate_limiter = RateLimiter::new(10, window);
state.push_rate_limiter = RateLimiter::new(10, window);
state.close_issue_rate_limiter = RateLimiter::new(10, window);
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
state.peer_write_rate_limiter = RateLimiter::new(10, window);
state.ipfs_rate_limiter = RateLimiter::new(10, window);
state.ipfs_work_rate_limiter = RateLimiter::new(10, window);
let limiters = |s: &crate::state::AppState| {
[
s.rate_limiter.clone(),
s.create_ip_rate_limiter.clone(),
s.push_rate_limiter.clone(),
s.close_issue_rate_limiter.clone(),
s.sync_trigger_rate_limiter.clone(),
s.peer_write_rate_limiter.clone(),
s.ipfs_rate_limiter.clone(),
s.ipfs_work_rate_limiter.clone(),
]
};
for l in limiters(&state) {
@@ -1209,6 +1154,26 @@ mod rate_limiter_sweep_tests {
}
}
/// it.
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! {
_ = ipfs_pin::run_sweep_rearmed(
&repos_dir, &git_bin, git_timeout, batch, delay,
ipfs_pin::SWEEP_REARM_DELAY, &db,
) => {}
_ = shutdown_rx.changed() => {}
}
})
}
async fn gossip_ping_round(
db: &Db,
client: &reqwest::Client,
@@ -1385,140 +1350,6 @@ 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::{
+4 -4
View File
@@ -262,7 +262,7 @@ pub async fn pin_new_objects(
);
if let Err(e) = crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
db.mark_pin_sources_incomplete(&sha),
db.mark_pin_sources_incomplete(&sha, repo_id),
)
.await
{
@@ -279,7 +279,7 @@ pub async fn pin_new_objects(
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
if let Err(e) = crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
db.mark_pin_sources_incomplete(&sha),
db.mark_pin_sources_incomplete(&sha, repo_id),
)
.await
{
@@ -457,7 +457,7 @@ pub async fn pin_new_objects(
);
if let Err(e) = crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
db.mark_pin_sources_incomplete(&sha),
db.mark_pin_sources_incomplete(&sha, repo_id),
)
.await
{
@@ -468,7 +468,7 @@ pub async fn pin_new_objects(
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
if let Err(e) = crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
db.mark_pin_sources_incomplete(&sha),
db.mark_pin_sources_incomplete(&sha, repo_id),
)
.await
{
+247 -8
View File
@@ -8,6 +8,19 @@ use crate::git::repo_store::RepoStore;
use crate::p2p::P2pHandle;
use crate::rate_limit::RateLimiter;
/// HKDF salt for [`AppState::derive_scan_token_key`]. A constant, not a secret: HKDF's
/// salt is a domain qualifier, and the confidentiality of the derived key rests entirely
/// on the node's private seed being the input keying material.
const SCAN_TOKEN_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/ipfs-scan-token";
/// HKDF `info` for [`AppState::derive_scan_token_key`]: the domain separation AND the
/// rotation handle in one string. Nothing else in the node derives from this label, so
/// the token key is unrelated to the signing key it shares an input with; bumping the
/// trailing version rotates every node's token key on its next boot, which invalidates
/// outstanding continuations (they simply fail to open and the caller restarts at the
/// front) and needs no migration, no config, and no change to this function.
const SCAN_TOKEN_KEY_INFO: &[u8] = b"gitlawb/ipfs-scan-token/v1";
#[derive(Clone, Debug)]
pub struct RefUpdateBroadcast {
pub repo: String,
@@ -66,6 +79,10 @@ 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 rate limiter for `close_issue`'s pre-lock snapshot path.
/// Distinct from `push_rate_limiter` so a flood of close attempts cannot
/// drain the receive-pack budget for the same source IP.
pub close_issue_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
@@ -99,6 +116,59 @@ pub struct AppState {
/// 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,
/// How many repo rows the CID resolver's legacy scan pulls per keyset page
/// (default `api::ipfs::LEGACY_SCAN_PAGE_ROWS`). Bounds the DATABASE-facing half
/// of the same fan-out `ipfs_max_legacy_probes` bounds on the probe side: without
/// it the scan materialized every repo row and every matching visibility rule
/// before spending a single probe (#173, INV-10). A field for the same test-seam
/// reason as the sibling caps.
pub ipfs_legacy_scan_page_rows: usize,
/// Per-request ceiling on how many repo ROWS the CID resolver's legacy scan may
/// fetch (default `api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST`, operator-tunable via
/// `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS`). `ipfs_max_legacy_probes` bounds the PROBE
/// fan-out but only starts counting once a probe runs, and quarantine plus a
/// root-scope visibility deny both return before a probe or a visit is spent, so an
/// all-denying inventory paged the whole repo table at zero probes (#173 round 13, F2).
/// Truncating here sheds a retryable 503 carrying a sealed continuation token.
pub ipfs_max_legacy_scan_rows: usize,
/// Per-request ceiling on the BYTES of visibility rules the CID resolver's legacy
/// scan may retain (default `api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST`). The
/// row ceiling above bounds the row count but not the memory each row drags in: the
/// pager keeps every fetched page's rules for the whole request, and neither the
/// number of rules per repo nor the length of a rule's reader list is capped, so a
/// rule COUNT would be the wrong unit. Enforced by the rules QUERY
/// (`Db::list_visibility_rules_for_repos_bounded`) rather than by a sum taken once the
/// page has landed, so the oversized page is never materialized at all. Deliberately
/// NOT an operator knob (it is a
/// memory guard, not a reach tradeoff); a field only for the same test-seam reason as
/// the sibling caps.
pub ipfs_max_legacy_scan_rule_bytes: usize,
/// Key sealing the legacy scan's continuation tokens (INV-13), derived from the
/// node's persistent identity by [`AppState::derive_scan_token_key`].
///
/// The token is minted from a FETCHED row on a scan that served nothing, so by
/// construction that row is a private or quarantined repo the caller may not read:
/// its `created_at` and its `id` (which carries the owner's DID) are withheld
/// fields. The token is therefore AEAD-SEALED, never signed plaintext and never
/// base64-of-plaintext, since integrity is not confidentiality.
///
/// DERIVED, not random per boot. This reverses an earlier revision of this design,
/// which argued that derivation "would make old tokens valid across restarts for no
/// benefit and tie a throwaway transport secret to a long-lived signing key." Both
/// halves were wrong. Surviving a restart IS the benefit: the token is the ONLY way
/// a caller resumes a ladder, an unopenable one is treated as absent, and a caller
/// treated as absent silently restarts at the front of the scan. A node whose
/// inventory needs several ladder steps and which deploys more often than a caller
/// can climb therefore keeps that caller from ever reaching a holder, which
/// contradicts the reach bound the README states. And the tie to the signing key is
/// what the HKDF domain separation removes: the derived key is a one-way function of
/// the seed under an `info` string nothing else uses, so it is not the signing key
/// and cannot be worked back into one.
///
/// Persisting a random key instead would need a schema change for a value the node
/// already has on disk, so derivation is also the smaller mechanism. Rotation is a
/// bump of the version in [`SCAN_TOKEN_KEY_INFO`].
pub ipfs_scan_token_key: Arc<[u8; 32]>,
/// 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
@@ -276,6 +346,7 @@ impl AppState {
self.rate_limiter.cleanup().await;
self.create_ip_rate_limiter.cleanup().await;
self.push_rate_limiter.cleanup().await;
self.close_issue_rate_limiter.cleanup().await;
self.ipfs_rate_limiter.cleanup().await;
self.ipfs_work_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
@@ -315,25 +386,116 @@ impl AppState {
config.ipfs_max_legacy_probes as u32
}
/// Legacy-scan ROW budget wired from the `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` knob,
/// the same helper shape as the probe budget above so the knob cannot become a
/// silent no-op if a struct literal drifts back to the bare constant.
pub(crate) fn ipfs_legacy_scan_row_budget(config: &crate::config::Config) -> usize {
config.ipfs_max_legacy_scan_rows
}
/// The key sealing legacy-scan continuation tokens (INV-13), DERIVED from the node's
/// persistent identity rather than minted per boot.
///
/// HKDF-SHA256 over the node's Ed25519 seed, the same key material
/// `load_or_create_keypair` reads back from its PKCS#8 PEM on every boot, so the
/// derived key is byte-identical after a restart or a rolling deploy.
///
/// Two properties the derivation has to carry, both load-bearing:
///
/// * DOMAIN SEPARATION. The `info` string below is unique to this use, so the
/// derived key is not the signing seed and is not any other secret derived from
/// it. HKDF is one-way, so a leaked token key yields nothing about the signing
/// key and cannot be turned against a signature.
/// * A VERSION component, carried in the same `info` string
/// ([`SCAN_TOKEN_KEY_INFO`]) alongside a fixed [`SCAN_TOKEN_KEY_SALT`]. Bumping
/// the version rotates every token key on the next boot without touching the
/// identity, the token format, or any caller, so rotation is a constant change
/// rather than a fork of this derivation.
pub(crate) fn derive_scan_token_key(keypair: &Keypair) -> [u8; 32] {
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<sha2::Sha256>;
let seed = keypair.to_seed();
// HKDF-Extract: PRK = HMAC(salt, ikm).
let mut extract =
HmacSha256::new_from_slice(SCAN_TOKEN_KEY_SALT).expect("HMAC takes a key of any size");
extract.update(seed.as_slice());
let prk = extract.finalize().into_bytes();
// HKDF-Expand, one block: T(1) = HMAC(PRK, info || 0x01). One 32-byte output
// needs exactly one block of SHA-256, so there is no counter loop to get wrong.
let mut expand =
HmacSha256::new_from_slice(prk.as_slice()).expect("HMAC takes a key of any size");
expand.update(SCAN_TOKEN_KEY_INFO);
expand.update(&[0x01]);
expand.finalize().into_bytes().into()
}
/// 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_LEGACY_PROBES`
/// knob), so a single default-config deep search cannot self-throttle mid-scan and
/// recreate the admit-then-429 for a legitimate caller. `GITLAWB_IPFS_RATE_LIMIT=0`
/// contract and the amplification bound hold. Floor: at least one complete COMBINED
/// resolution per window, the provenance phase's walks plus a full legacy search
/// (probes plus page tolls), so a single default-config resolution cannot
/// self-throttle part-way and recreate the 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).
///
/// The floor is the LEGACY-PROBE knob, not `ipfs_max_repos_walked`. Those were one
/// field before the walk cap and the probe budget were split apart, and reading the
/// walk cap here would silently size this bucket at 64 instead of 256.
/// The floor carries a WALK term (#173 round 15, F2). The provenance visibility walk
/// in `gate_and_serve` debits this SAME bucket (its `!legacy_scan` charge), once per
/// path-scoped source, before the legacy fallback runs at all, and the walk cap is
/// charged per phase. A floor counting only the search therefore under-sizes the
/// window by up to that cap exactly when the floor binds (a route limit set below
/// it): the provenance phase spends from the budget the floor reserved for the
/// search, the fallback 429s short of its configured reach, and the retry re-pays the
/// same provenance charges. The term is
/// `min(MAX_HISTORY_WALKS_PER_REQUEST, ipfs_max_repos_walked)` because that is what
/// the resolver's own `walk_cap` in `gate_and_serve` evaluates to. The two
/// expressions are separate, not one shared value: this one reads the CONSTANT
/// `crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST`, while `walk_cap` reads the
/// `AppState` seam `state.ipfs_max_history_walks`. They agree only because every
/// construction seeds that seam from that constant: the production one in `main.rs`,
/// and the two test ones in `auth`'s test module and `test_support`. Nothing
/// mechanically ties the two expressions beyond this comment and its counterpart at
/// `walk_cap` (no shared helper, no type, no assertion outside the one fixture that
/// pins the seam as a precondition), so the two `min()`s have to be moved together
/// by hand, and a construction that seeded the seam from anything else would size
/// the floor for a cap the resolver does not enforce. It reads the
/// CONSTANT and the CONFIG knob for the same reason the page term below reads the
/// constant page size: `ipfs_max_history_walks` is an `AppState` test seam, and
/// sizing a production floor from a seam would inflate the budget by whatever a test
/// chose.
///
/// The PROBE term is the LEGACY-PROBE knob, not `ipfs_max_repos_walked`. Those were
/// one field before the walk cap and the probe budget were split apart, and sizing
/// the probe term off the walk cap would silently read 64 instead of 256. That is a
/// statement about which knob sizes the PROBE term; it is not a claim that the walk
/// cap has no place in the floor, since the walk term above is added to this one
/// rather than substituted for it.
///
/// The floor also carries the scan's PAGE toll (#173 round 13, F2): every page the
/// legacy scan buys is charged to this same bucket, so a deep scan spends
/// `ceil(ipfs_max_legacy_scan_rows / LEGACY_SCAN_PAGE_ROWS)` tokens on pages on top
/// of its probes. Leaving those out would 429 an honest caller part-way down their
/// own continuation-token ladder, which is the F6 admit-then-429 shape in a new
/// place. The page term uses the CONSTANT page size, not `AppState`'s field: the
/// field is a test seam that shrinks pages to make paging observable, and sizing a
/// production floor from it would inflate the budget by whatever a test chose.
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_legacy_probes)
let pages = config
.ipfs_max_legacy_scan_rows
.div_ceil(crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS);
let walks = std::cmp::min(
crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST as usize,
config.ipfs_max_repos_walked,
);
config
.ipfs_rate_limit
.max(config.ipfs_max_legacy_probes + pages + walks)
}
}
@@ -1306,3 +1468,80 @@ mod repo_write_lease_tests {
}
}
}
#[cfg(test)]
mod scan_token_key_tests {
use super::AppState;
use gitlawb_core::identity::Keypair;
use gitlawb_core::scan_token::{open_scan_token, seal_scan_token, ScanPosition};
const CID: &str = "bafkreiscantokenkeyfixtureaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
fn pos() -> ScanPosition {
ScanPosition {
created_at_key: "2020-01-01T12:00:00+00:00".to_string(),
id: "did:key:z6MkScanTokenOwner/repo".to_string(),
// 40 hex: the production shape, since the node's own repos are sha1.
sha256_hex: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".to_string(),
}
}
/// The RESTART case. A token minted before a restart must still open after it, or a
/// caller laddering a deep inventory is silently returned to the front of the scan on
/// every rolling deploy and can never reach a holder buried past one window.
///
/// The second key is derived from the identity RELOADED THROUGH ITS ON-DISK PEM,
/// which is exactly what `load_or_create_keypair` does on boot, so this exercises the
/// real restart path rather than a clone of the in-memory keypair.
#[test]
fn scan_token_key_survives_a_restart_of_the_same_identity() {
let kp = Keypair::generate();
let pem = kp.to_pem().expect("the identity serializes");
let reloaded = Keypair::from_pem(&pem).expect("the identity reloads");
let before = AppState::derive_scan_token_key(&kp);
let after = AppState::derive_scan_token_key(&reloaded);
let token = seal_scan_token(&before, CID, &pos(), i64::MAX - 1).expect("seal");
assert_eq!(
open_scan_token(&after, CID, &token, 0),
Some(pos()),
"a continuation minted before a restart must open after it: the node's \
identity is the same, so the derived sealing key must be too"
);
}
/// The must-not: a DIFFERENT node identity must derive a DIFFERENT key, so a token is
/// no more portable between nodes than it was when the key was random per boot.
#[test]
fn scan_token_key_does_not_open_under_a_different_identity() {
let mine = Keypair::generate();
let theirs = Keypair::generate();
let token = seal_scan_token(
&AppState::derive_scan_token_key(&mine),
CID,
&pos(),
i64::MAX - 1,
)
.expect("seal");
assert_eq!(
open_scan_token(&AppState::derive_scan_token_key(&theirs), CID, &token, 0),
None,
"a continuation sealed by one node must not open under another node's identity"
);
}
/// Domain separation, executed rather than asserted in prose: the derived token key
/// must not be the signing seed itself. Compromising a token key must not hand an
/// attacker the material that signs.
#[test]
fn scan_token_key_is_not_the_signing_seed() {
let kp = Keypair::generate();
assert_ne!(
AppState::derive_scan_token_key(&kp),
*kp.to_seed(),
"the token key must be a DERIVED secret, never the Ed25519 signing seed"
);
}
}
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -225,7 +225,8 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
let repo_store = src("git/repo_store.rs");
let rel_start = repo_store
.find("pub async fn release(mut self")
.find("pub async fn release(self")
.or_else(|| repo_store.find("pub async fn release(mut self"))
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
let rel_end = repo_store[rel_start..]
.find("impl Drop for RepoWriteGuard")
@@ -257,10 +258,10 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
);
}
/// F6/KTD-5 (initial IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
/// the scarce walk permits (RAII, held for the whole request) BEFORE its two initial
/// metadata queries, and the per-repo loop's first budget gate runs only later. So
/// both `list_all_repos` and `list_visibility_rules_for_repos` must be clamped to the
/// F6/KTD-5 (IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
/// the scarce walk permits (RAII, held for the whole request) BEFORE its metadata
/// queries, and the per-repo loop's first budget gate runs only later. So
/// both `list_repos_page_for_scan` and `list_visibility_rules_for_repos` must be clamped to the
/// remaining request budget — otherwise a query blocked in Postgres pins the walk slot
/// for the whole stall, past the budget. This scans the PRODUCTION half of `api/ipfs.rs`
/// (the `mod tests` half names the same calls in its own harness and would make the
@@ -285,7 +286,7 @@ fn f6_ipfs_metadata_queries_are_deadline_wrapped() {
// every time a call site is added, which is how a guard quietly stops covering the
// site that matters. Requiring all of them scales with the code instead.
for call in [
".list_all_repos()",
".list_repos_page_for_scan(",
".list_visibility_rules_for_repos(",
".get_repo_by_id(",
".is_repo_quarantined(",
+1 -1
View File
@@ -11,7 +11,7 @@ name = "gl"
path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
icaptcha-client = { path = "../icaptcha-client" }
base64 = { workspace = true }
tokio = { workspace = true }
+259 -8
View File
@@ -12,9 +12,58 @@ use serde::Deserialize;
use std::path::Path;
use std::process::Command;
use crate::http::NodeClient;
use crate::http::{sanitize_node_msg, NodeClient};
use crate::identity::load_keypair_from_dir;
/// Report why one candidate blob could not be recovered, in the register of the
/// recovery loop's other warnings.
///
/// Both halves are gateway-supplied text on their way to a terminal: the oid comes
/// out of a manifest fetched from a caller-chosen Arweave gateway, and a transport
/// error carries the URL the same manifest chose. So the whole line is defanged and
/// length-capped, like every other node/gateway string this crate prints.
///
/// Under `cfg(test)` the line is also mirrored into a per-thread buffer, so a test
/// can assert the loop actually says what it skipped rather than only that it
/// returned nothing.
fn warn_skip(oid: &str, why: &str) {
emit_warning(&format!(
"warning: could not fetch encrypted blob {oid}: {why}; skipping"
));
}
/// The same report for a manifest rather than a blob. The manifest is one step
/// earlier in the same recovery: losing it silently means every blob it would have
/// named is missing with no reason given anywhere.
fn warn_skip_manifest(id: &str, why: &str) {
emit_warning(&format!(
"warning: could not fetch blob manifest {id}: {why}; skipping"
));
}
/// Sanitize a warning and write it, once.
///
/// The write goes to [`warn_sink`], which is stderr in a normal build and the tests'
/// per-thread mirror under `cfg(test)`. That indirection is the point: the mirror
/// used to sit BESIDE an `eprintln!`, so deleting the user-visible write left every
/// assertion on the mirror green and the shipped behaviour could be removed with the
/// tests unchanged. There is one write now, and the tests observe it.
fn emit_warning(line: &str) {
use std::io::Write;
let line = sanitize_node_msg(line);
let _ = writeln!(warn_sink(), "{line}");
}
#[cfg(not(test))]
fn warn_sink() -> impl std::io::Write {
std::io::stderr()
}
#[cfg(test)]
fn warn_sink() -> impl std::io::Write {
tests::WarnMirror
}
#[derive(Args)]
pub struct CloneArgs {
/// Repo to clone: gitlawb://<owner_did>/<name> or <owner>/<name>.
@@ -312,10 +361,25 @@ async fn recover_encrypted_blobs(
.await
{
Ok(r) if r.status().is_success() => r,
_ => continue,
// The node path has the same silent exit the gateway path had: a 403, a
// 404, or a dead connection all reached the caller as "blob not
// recoverable" with no reason attached. One unreachable blob still must
// not end the recovery of the rest, so it warns and moves on.
Ok(r) => {
warn_skip(oid, &format!("node returned {}", r.status()));
continue;
}
Err(e) => {
warn_skip(oid, &format!("node request failed: {e}"));
continue;
}
};
let Ok(envelope) = env_resp.bytes().await else {
continue;
let envelope = match env_resp.bytes().await {
Ok(b) => b,
Err(e) => {
warn_skip(oid, &format!("reading the envelope failed: {e}"));
continue;
}
};
let plaintext = match open_blob(&envelope, keypair) {
Ok(p) => p,
@@ -642,7 +706,14 @@ async fn recover_from_arweave(
for r in refs {
let m = match client.get(format!("{ag}/{}", r.id)).send().await {
Ok(resp) if resp.status().is_success() => resp,
_ => continue,
Ok(resp) => {
warn_skip_manifest(&r.id, &format!("gateway returned {}", resp.status()));
continue;
}
Err(e) => {
warn_skip_manifest(&r.id, &format!("gateway request failed: {e}"));
continue;
}
};
if let Ok(parsed) = m.json::<Manifest>().await {
manifests.push((parsed, r.height));
@@ -684,10 +755,31 @@ async fn recover_from_arweave(
}
let env_resp = match client.get(format!("{ig}/ipfs/{cid}")).send().await {
Ok(r) if r.status().is_success() => r,
_ => continue,
// Every other outcome used to leave through a bare `continue`, so a
// gateway that answered 503, 404, or nothing at all was reported to the
// caller as "blob not recoverable" with no reason attached. This is the
// second in-repo client of GET /ipfs/{cid} and it has no resume ladder,
// so saying what happened is all the recourse there is. It still skips to
// the next candidate either way: one unreachable blob must not end the
// recovery of the rest.
Ok(r) => {
warn_skip(&oid, &format!("gateway returned {}", r.status()));
continue;
}
Err(e) => {
warn_skip(&oid, &format!("gateway request failed: {e}"));
continue;
}
};
let Ok(envelope) = env_resp.bytes().await else {
continue;
// A body that dies part-way through is the same mid-read failure the capped
// read exists to stop rendering as silence, and it sits INSIDE the loop whose
// status arms were already fixed.
let envelope = match env_resp.bytes().await {
Ok(b) => b,
Err(e) => {
warn_skip(&oid, &format!("reading the envelope failed: {e}"));
continue;
}
};
// open_blob succeeds only if this caller is a recipient: this is the
// authorization gate (no node, no DID check needed).
@@ -801,9 +893,42 @@ pub async fn run(args: CloneArgs) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::process::Command;
use tempfile::TempDir;
thread_local! {
/// Test-visible copy of the stderr warnings `warn_skip` emits.
/// `#[tokio::test]` runs the future on the test's own thread, so a
/// thread-local is enough.
static WARNINGS: RefCell<String> = const { RefCell::new(String::new()) };
}
/// The `cfg(test)` warning sink. `emit_warning` writes here instead of to
/// stderr, so the assertions below observe the shipped write rather than a copy
/// made beside it: delete that write and they go red.
pub(super) struct WarnMirror;
impl std::io::Write for WarnMirror {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let text = String::from_utf8_lossy(buf).into_owned();
WARNINGS.with(|w| w.borrow_mut().push_str(&text));
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn reset_warnings() {
WARNINGS.with(|w| w.borrow_mut().clear());
}
fn warnings() -> String {
WARNINGS.with(|w| w.borrow().clone())
}
fn g(args: &[&str], dir: &Path) {
assert!(Command::new("git")
.args(args)
@@ -1255,6 +1380,132 @@ mod tests {
assert_eq!(a.get("o1").map(String::as_str), Some("cidTS"));
}
/// A gateway that refuses one blob must SAY so, and must not take the rest of the
/// recovery down with it.
///
/// The IPFS fetch used to leave through a bare `_ => continue` on every
/// non-success, so a 503 (the truncation status a tuned node makes more likely),
/// a 404, and a dead connection all reached the caller as the same silent "blob
/// not recoverable". Two withheld blobs here: the gateway answers 503 for the
/// first and serves the second. The recovered path proves the loop continued; the
/// warning proves the skip was reported and names both the object and the status.
#[tokio::test]
async fn recover_from_arweave_reports_a_refusing_gateway_and_continues() {
use gitlawb_core::encrypt::seal_blob;
use gitlawb_core::identity::Keypair;
reset_warnings();
let (td, url) = bare_remote(&[
("public/a.txt", b"pub\n"),
("secret/b.txt", b"SECRET B\n"),
("secret/c.txt", b"SECRET C\n"),
]);
let dest = td.path().join("dest");
let bare = url.strip_prefix("file://").unwrap();
assert!(Command::new("git")
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
.status()
.unwrap()
.success());
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
let oid_of = |path: &str| {
let out = Command::new("git")
.args([
"-C",
dest.to_str().unwrap(),
"rev-parse",
&format!("HEAD:{path}"),
])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let refused_oid = oid_of("secret/b.txt");
let served_oid = oid_of("secret/c.txt");
// Origin death, so recovery has to go through the gateways.
std::fs::remove_dir_all(bare).unwrap();
let reader = Keypair::generate();
let envelope = seal_blob(b"SECRET C\n", &[reader.verifying_key()]).unwrap();
let refused_cid = "cidrefused";
let served_cid = "cidserved";
let mut server = mockito::Server::new_async().await;
let _gql = server
.mock("POST", "/graphql")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}}]}}}"#)
.create_async()
.await;
let manifest_body = serde_json::json!({
"timestamp": "2026-06-11T00:00:00Z",
"blobs": [
{ "oid": refused_oid, "cid": refused_cid },
{ "oid": served_oid, "cid": served_cid },
],
})
.to_string();
let _tx = server
.mock("GET", "/TX1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(manifest_body)
.create_async()
.await;
let refusal = server
.mock("GET", format!("/ipfs/{refused_cid}").as_str())
.with_status(503)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"search_incomplete","message":"scan truncated"}"#)
.expect(1)
.create_async()
.await;
let ok = server
.mock("GET", format!("/ipfs/{served_cid}").as_str())
.with_status(200)
.with_body(envelope)
.expect(1)
.create_async()
.await;
let paths = recover_from_arweave(
&server.url(),
&server.url(),
"alice",
"myrepo",
&dest,
&reader,
)
.await
.unwrap();
refusal.assert_async().await;
ok.assert_async().await;
assert_eq!(
paths,
vec!["secret/c.txt".to_string()],
"a refused candidate must not stop the loop reaching the next one"
);
let warnings = warnings();
assert!(
warnings.contains(&refused_oid),
"the skip must name the object it could not fetch, got: {warnings}"
);
assert!(
warnings.contains("503"),
"the skip must name the gateway's status rather than failing silently, \
got: {warnings}"
);
assert!(
!warnings.contains(&served_oid),
"the blob that was served must not be reported as skipped, got: {warnings}"
);
}
/// Read-path end-to-end over a mocked Arweave + IPFS gateway: discover the
/// manifest via GraphQL, fetch it, fetch the envelope, decrypt with the
/// caller's key, and install the previously-withheld blob.
+621 -4
View File
@@ -15,6 +15,47 @@ use icaptcha_client::IcaptchaCfg;
/// (absorbs proof expiry / first-seen replay).
const MAX_ICAPTCHA_RETRIES: usize = 2;
/// Total request timeout: from the start of connecting through the end of the
/// response body, so it bounds a slow download and not just a slow handshake.
const TOTAL_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Follow a redirect only when it stays on the origin that issued it AND re-issues the
/// identical request-target, and only for as long as the chain bound allows.
///
/// The decision itself is [`gitlawb_core::redirect::may_follow`], shared with
/// `git-remote-gitlawb` so the two signing clients cannot drift apart on it. Refusal
/// is `stop`, not `error`: the 3xx comes back as an ordinary response and each caller
/// reports it through the status path it already has.
///
/// `Policy::custom` replaces reqwest's built-in limit, so the chain bound is restated
/// here. Same-origin redirects can cycle, and this is what stops a node answering 302
/// to itself from being followed indefinitely. It is not what makes the request
/// finite: `.timeout(...)` on the same builder is a TOTAL request timeout covering the
/// whole chain, so without this bound the worst case is a 30 second spin, not an
/// endless one. The bound is what keeps that spin from costing the node a request per
/// round trip for the full 30 seconds.
///
/// `>` and not `>=`: reqwest pushes the redirecting URL onto `previous` before
/// consulting the policy, so on the first redirect `previous.len()` is already 1, and
/// `>=` would permit `MAX_REDIRECTS - 1` follows. `Policy::limited(max)` refuses at
/// `previous.len() > max`, and matching it is the point of reusing its value.
fn same_origin_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
let Some(previous) = attempt.previous().last() else {
// No previous URL to compare against. Unreachable through reqwest, which
// pushes the redirecting URL before consulting the policy, but the safe
// reading of "cannot prove same-origin" is to refuse.
return attempt.stop();
};
if attempt.previous().len() > gitlawb_core::redirect::MAX_REDIRECTS {
return attempt.stop();
}
if gitlawb_core::redirect::may_follow(previous, attempt.url()) {
attempt.follow()
} else {
attempt.stop()
}
}
pub struct NodeClient {
inner: reqwest::Client,
pub node_url: String,
@@ -23,8 +64,20 @@ pub struct NodeClient {
impl NodeClient {
pub fn new(node_url: impl Into<String>, keypair: Option<Keypair>) -> Self {
Self::with_timeout(node_url, keypair, TOTAL_REQUEST_TIMEOUT)
}
/// `new` with the total request timeout as a parameter, so a test can drive the
/// timeout's behaviour without waiting out the shipped value. `new` supplies the
/// shipped one, which is what makes the scaled-down test cover the real client.
fn with_timeout(
node_url: impl Into<String>,
keypair: Option<Keypair>,
timeout: std::time::Duration,
) -> Self {
let inner = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.timeout(timeout)
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
.user_agent(format!("gl/{} gitlawb-cli", env!("CARGO_PKG_VERSION")))
.build()
.expect("failed to build HTTP client");
@@ -203,24 +256,64 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
.context("iCaptcha solver task panicked")?
}
/// What a capped body read produced. `text` is the bytes that arrived; the two flags
/// say why the read stopped where it did, which a caller that CLASSIFIES on the body
/// cannot work out from the text alone.
pub(crate) struct CappedBody {
/// The bytes read, lossily decoded.
pub(crate) text: String,
/// The cap cut the body short.
pub(crate) truncated: bool,
/// A chunk read FAILED part-way through, so the body is not merely short, it is
/// unfinished and the node may have had more to say.
pub(crate) read_failed: bool,
}
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
/// hostile or broken node returning a huge error body — the display is capped
/// separately, but the read itself must not be unbounded (INV-6, read half).
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
///
/// `truncated` reports whether the cap cut the body short. A caller that CLASSIFIES
/// on the body needs it: a cut body fails JSON parse, and a parse failure is
/// indistinguishable from a node that sent no code at all, so without this flag an
/// oversized body silently picks a different arm.
///
/// `read_failed` reports the other way a body can end early. A mid-body read error
/// used to leave through the same exit as a clean end of stream, so a 500 whose body
/// died in transit surfaced as an empty message and the caller was told
/// `node returned 500: ` with nothing after the colon. That is a report of what the
/// node said, and the node never got to say it.
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> CappedBody {
let mut buf: Vec<u8> = Vec::new();
let mut truncated = false;
let mut read_failed = false;
while buf.len() < cap {
match resp.chunk().await {
Ok(Some(chunk)) => {
let take = (cap - buf.len()).min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if take < chunk.len() {
truncated = true;
break; // hit the cap mid-chunk
}
}
_ => break, // end of body or read error — return what we have
Ok(None) => break, // clean end of body
Err(_) => {
read_failed = true;
break;
}
}
}
String::from_utf8_lossy(&buf).into_owned()
// A body that lands exactly on the cap may or may not have more behind it;
// report it as cut, since the classification that follows cannot tell either.
if buf.len() >= cap {
truncated = true;
}
CappedBody {
text: String::from_utf8_lossy(&buf).into_owned(),
truncated,
read_failed,
}
}
/// Strip terminal-dangerous characters from (and cap the length of) a
@@ -621,6 +714,530 @@ mod tests {
ic.answer.assert();
}
// ── redirect policy ─────────────────────────────────────────────────
/// The signed headers must not survive a redirect off the node's origin.
///
/// reqwest strips only `Authorization`, `Cookie`, `Proxy-Authorization` and
/// `WWW-Authenticate` across hosts, so `Signature` and `Signature-Input` used to
/// ride a 302 straight to whatever origin the node named. The signature binds
/// `@method`, `@path` and `content-digest` and nothing about the authority, so the
/// receiving host holds a credential that reads path-scoped objects as the caller
/// at any node until the clock-skew window closes.
///
/// Two mockito servers are two ports on one host, which is exactly the boundary
/// this policy draws (and the one reqwest's own header stripping draws). The
/// second server answers everything and expects nothing: a followed redirect
/// fails the expectation whether or not the signature came with it. MUTATION
/// (RED): drop the `.redirect(...)` line and the second server is hit.
#[tokio::test]
async fn cross_origin_redirect_is_not_followed() {
let mut elsewhere = Server::new_async().await;
let never = elsewhere
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body("bytes from the redirect target")
.expect(0)
.create_async()
.await;
let signature_seen = elsewhere
.mock("GET", mockito::Matcher::Any)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.expect(0)
.create_async()
.await;
let mut node = Server::new_async().await;
let bounce = node
.mock("GET", "/api/v1/thing")
.with_status(302)
.with_header("location", &format!("{}/api/v1/thing", elsewhere.url()))
.expect(1)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(test_keypair()));
let resp = client.get_signed("/api/v1/thing").await.unwrap();
assert_eq!(
resp.status(),
302,
"a refused redirect stops rather than errors, so the caller sees the 3xx \
and reports it through the status path it already has"
);
let body = resp.text().await.unwrap();
assert!(
!body.contains("bytes from the redirect target"),
"the redirect target's bytes must never reach the caller, got: {body}"
);
bounce.assert_async().await;
never.assert_async().await;
signature_seen.assert_async().await;
}
/// A node redirecting to itself is same-origin, so the origin predicate follows it
/// every time and only the chain bound ends the loop. Deleting the bound left the
/// whole suite green, because nothing here had ever built a cycle.
///
/// It has a second job now. A redirect back to the identical path and query is the
/// only same-origin hop the predicate still follows, so the eleven hits below are
/// also this crate's executed proof that such a hop IS followed. The old positive
/// fixture drove a trailing-slash rewrite, which the request-target rule refuses,
/// and an http-to-https upgrade cannot be mocked over mockito's plain http.
///
/// The route answers 301 pointing back at itself. Bounded, the handler is hit
/// once for the original request plus `MAX_REDIRECTS` follows and the call returns
/// the 301 (a refused redirect stops rather than errors). Unbounded, it runs until
/// the client's total request timeout cuts it off, which is a 30 second spin at the
/// shipped value and a request per round trip for the node.
///
/// MUTATION (RED): delete the `previous().len()` check.
#[tokio::test]
async fn a_self_redirect_stops_at_the_chain_bound() {
let mut node = Server::new_async().await;
let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let h = hits.clone();
let loop_route = node
.mock("GET", "/api/v1/loop")
.with_status(301)
.with_header("location", "/api/v1/loop")
.with_body_from_request(move |_req| {
h.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Vec::new()
})
.expect_at_least(1)
.create_async()
.await;
let client = NodeClient::with_timeout(
node.url(),
Some(test_keypair()),
std::time::Duration::from_secs(5),
);
let resp = client
.get_signed("/api/v1/loop")
.await
.expect("the bound must end the chain, not the timeout");
assert_eq!(
resp.status(),
301,
"the chain ends by refusing the next hop, so the last 3xx is what comes back"
);
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
gitlawb_core::redirect::MAX_REDIRECTS + 1,
"one original request plus MAX_REDIRECTS follows, matching what \
Policy::limited(MAX_REDIRECTS) would have permitted"
);
loop_route.assert_async().await;
}
/// A same-origin hop that REWRITES the request-target is refused, even though the
/// origin never changes.
///
/// The signature binds `@path` as the literal path-and-query the client signed, and
/// the node rebuilds it from the URI it received. A `/api/v1/thing` to
/// `/api/v1/thing/` bounce therefore presents a signature over a target the node
/// never saw, and every signed read behind such a proxy 401s. Refusing the hop
/// hands the caller the 3xx that names what happened instead.
///
/// The target mock expects zero hits and is asserted: mockito only checks
/// `.expect(N)` when `.assert()` runs, so an unbound or unasserted mock passes
/// vacuously.
#[tokio::test]
async fn same_origin_path_changing_redirect_is_refused() {
let mut node = Server::new_async().await;
let bounce = node
.mock("GET", "/api/v1/thing")
.with_status(301)
.with_header("location", "/api/v1/thing/")
.expect(1)
.create_async()
.await;
let target = node
.mock("GET", "/api/v1/thing/")
.with_status(200)
.with_body("bytes from the rewritten target")
.expect(0)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(test_keypair()));
let resp = client.get_signed("/api/v1/thing").await.unwrap();
assert_eq!(
resp.status(),
301,
"a refused redirect stops rather than errors, so the caller sees the 3xx \
and reports it through the status path it already has"
);
let body = resp.text().await.unwrap();
assert!(
!body.contains("bytes from the rewritten target"),
"the rewritten target's bytes must never reach the caller, got: {body}"
);
bounce.assert_async().await;
target.assert_async().await;
}
// ── the node's own verification, run over what the client actually sent ──
/// What the verifying mock made of a request that reached it.
///
/// The empty slot (`None`) is its own state and means the mock was never hit at
/// all, which is what the refusal test asserts. It must stay distinguishable from
/// [`Verdict::WrongIdentity`], because "nobody verified anything" and "something
/// verified against the wrong key" are opposite findings.
///
/// The payloads are read through `Debug` in the assertion messages and nowhere
/// else, which the dead-code pass does not count; they carry the detail that makes
/// a failure legible, so they stay.
#[derive(Debug)]
#[allow(dead_code)]
enum Verdict {
/// The chain accepted the signature AND the key it resolved is the test's DID.
Accepted,
/// The chain refused it. Carries the error so a failure reads as the actual
/// rejection rather than a bare hit count.
Rejected(String),
/// The chain accepted a signature made by somebody else. A key resolved from
/// the parsed `key_id` is read out of the artifact under verification, so an
/// accept on it alone proves consistency, never authenticity.
WrongIdentity { expected: String, got: String },
}
/// The node's `require_signature` verification, over a request this crate did not
/// build: parse the headers, recompute the content-digest from the body, rebuild
/// the signing string over `@method`/`@path`/`content-digest`, Ed25519-verify.
/// Returns the DID the signature resolved to, so a caller can pin the identity.
///
/// A hand-copy of its twin in `crates/git-remote-gitlawb/src/main.rs`, which gl
/// cannot import (`git-remote-gitlawb` is a binary crate and this is its test
/// module). Keep the two textually identical apart from the mockito seam around
/// them, so an edit to one is visibly an edit to both.
///
/// The production verifier both copies mirror is `crate::auth::require_signature` in
/// `crates/gitlawb-node/src/auth/mod.rs`. This is a re-implementation, not a call, so
/// an edit to that middleware has to land here too: otherwise the copies drift and
/// this test keeps passing against a rule the node has stopped applying.
///
/// It asserts internally, which is deliberate but constrains its callers: inside
/// `with_body_from_request` those assertions fire on the server thread and reach
/// the client as a transport error, not as a recorded verdict. So the identity
/// check lives in the caller as a [`Verdict`] variant, never as an assert in here.
fn node_verifies(
method: &str,
path_and_query: &str,
body: &[u8],
sig_input: &str,
sig_header: &str,
content_digest: &str,
) -> anyhow::Result<String> {
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
use gitlawb_core::identity::verify;
use std::collections::HashMap;
let sig = HttpSignature::parse(sig_input, sig_header)?;
sig.check_created()?;
assert!(
sig.missing_components().is_empty(),
"signature must cover all required components"
);
assert_eq!(sig.alg, "ed25519");
assert_eq!(
content_digest,
compute_content_digest(body),
"content-digest must match the body"
);
let vk = sig.key_id.to_verifying_key()?;
let mut values = HashMap::new();
values.insert("@method".to_string(), method.to_uppercase());
values.insert("@path".to_string(), path_and_query.to_string());
values.insert("content-digest".to_string(), content_digest.to_string());
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
verify(&vk, signing_string.as_bytes(), &sig_array)?;
Ok(sig.key_id.to_string())
}
/// Pull a header value off a received mockito request, or explain which one the
/// client failed to send.
fn received_header(req: &mockito::Request, name: &str) -> String {
req.header(name)
.first()
.unwrap_or_else(|| panic!("the client sent no {name} header"))
.to_str()
.unwrap()
.to_string()
}
/// Run [`node_verifies`] over a GET that arrived at the mock and record what the
/// node would have made of it, pinned to `expected_did`.
fn record_get_verdict(
req: &mockito::Request,
expected_did: &str,
slot: &std::sync::Arc<std::sync::Mutex<Option<Verdict>>>,
) {
let verdict = match node_verifies(
"GET",
req.path_and_query(),
b"",
&received_header(req, "signature-input"),
&received_header(req, "signature"),
&received_header(req, "content-digest"),
) {
Ok(did) if did == expected_did => Verdict::Accepted,
Ok(did) => Verdict::WrongIdentity {
expected: expected_did.to_string(),
got: did,
},
Err(e) => Verdict::Rejected(e.to_string()),
};
*slot.lock().unwrap() = Some(verdict);
}
/// The finding's repro, now a guard: a rewritten same-origin target must never
/// receive the signature, and the proof is the node's own verification, not a hit
/// count.
///
/// The target mock runs the full `require_signature` chain over the request it
/// receives. Post-fix the hop is refused, so the slot stays empty. Pre-fix the hop
/// is followed and the slot records the Ed25519 rejection of a signature made over
/// `/api/v1/thing` and presented at `/api/v1/thing/`, which is the 401 an operator
/// behind such a proxy actually sees. The verdict is asserted first, so a failure
/// speaks about verification rather than about reachability.
///
/// Its paired positive control is
/// `a_direct_signed_get_verifies_under_the_node_verifier`: without it, an empty
/// slot would be satisfied just as well by a harness that can never record
/// anything.
#[tokio::test]
async fn a_rewritten_target_never_receives_the_signature() {
let kp = test_keypair();
let expected_did = kp.did().to_string();
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
let mut node = Server::new_async().await;
let bounce = node
.mock("GET", "/api/v1/thing")
.with_status(301)
.with_header("location", "/api/v1/thing/")
.expect(1)
.create_async()
.await;
let recorder = slot.clone();
let did_for_target = expected_did.clone();
let target = node
.mock("GET", "/api/v1/thing/")
.with_status(200)
.with_body_from_request(move |req| {
record_get_verdict(req, &did_for_target, &recorder);
Vec::new()
})
.expect(0)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(kp));
let resp = client.get_signed("/api/v1/thing").await.unwrap();
let verdict = slot.lock().unwrap().take();
assert!(
verdict.is_none(),
"the node's own verifier must never see this request: the signature covers \
/api/v1/thing and the rewritten target is /api/v1/thing/, so what arrives \
there is a stale request-target; recorded verdict: {verdict:?}"
);
assert_eq!(
resp.status(),
301,
"the caller sees the 3xx, not the rewritten target's answer"
);
bounce.assert_async().await;
target.assert_async().await;
}
/// The positive control for the test above, and the proof that the client signs
/// the query it sends.
///
/// A direct signed GET, no redirect anywhere, through the same verifying mock. The
/// verdict must be `Accepted`, which is what makes the refusal test's empty slot
/// attributable to the refusal rather than to a harness that cannot record. The
/// path carries a query, so a client that signed the bare path would land here as
/// `Rejected`.
#[tokio::test]
async fn a_direct_signed_get_verifies_under_the_node_verifier() {
let kp = test_keypair();
let expected_did = kp.did().to_string();
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
let mut node = Server::new_async().await;
let recorder = slot.clone();
let did_for_target = expected_did.clone();
let route = node
.mock("GET", "/api/v1/thing?x=1")
.with_status(200)
.with_body_from_request(move |req| {
record_get_verdict(req, &did_for_target, &recorder);
Vec::new()
})
.expect(1)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(kp));
let resp = client.get_signed("/api/v1/thing?x=1").await.unwrap();
assert_eq!(resp.status(), 200);
let verdict = slot.lock().unwrap().take();
assert!(
matches!(verdict, Some(Verdict::Accepted)),
"a direct signed GET must verify under the node's own chain and resolve to \
{expected_did}, or the refusal test's empty slot proves nothing; recorded \
verdict: {verdict:?}"
);
route.assert_async().await;
}
// ── read_body_capped ────────────────────────────────────────────────
/// A body whose read FAILS mid-stream must be distinguishable from a body that
/// ended. Both used to leave through the same `_ => break`, so a 500 whose body
/// died in transit produced an empty message and the caller was told
/// `node returned 500: ` with nothing after the colon.
///
/// The fixture is a raw listener that promises 64 bytes in `Content-Length`,
/// writes 5, and closes. mockito cannot express that: it always completes the
/// response it advertises. MUTATION (RED): restore the single `_ => break` arm
/// (or hard-code `read_failed: false`) and the flag reads false.
#[tokio::test]
async fn read_body_capped_flags_a_mid_body_read_failure() {
let addr = spawn_short_body_listener().await;
let resp = reqwest::get(format!("http://{addr}/truncated"))
.await
.expect("headers arrive before the body is cut");
let body = read_body_capped(resp, 8192).await;
assert!(
body.read_failed,
"a body cut off mid-stream must be reported as a failed read, not as a \
body that ended: got {:?}",
body.text
);
assert!(
!body.truncated,
"the cap did not cut this one; 5 bytes are nowhere near 8 KiB"
);
}
/// The must-not half: a body that ends cleanly must NOT be flagged, or the flag
/// means nothing and every terminal starts claiming the node went quiet.
#[tokio::test]
async fn read_body_capped_does_not_flag_a_clean_body() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ok")
.with_status(500)
.with_body("node said this")
.create_async()
.await;
let resp = reqwest::get(format!("{}/ok", server.url())).await.unwrap();
let body = read_body_capped(resp, 8192).await;
assert_eq!(body.text, "node said this");
assert!(!body.read_failed, "a complete body is not a failed read");
assert!(!body.truncated, "a complete body is not a truncated one");
}
/// Answer one request with headers promising more body than gets written, then
/// close the connection. Returns the listener's address.
async fn spawn_short_body_listener() -> std::net::SocketAddr {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut scratch = [0u8; 1024];
let _ = sock.read(&mut scratch).await;
let _ = sock
.write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 64\r\n\r\nshort")
.await;
let _ = sock.flush().await;
// Drop closes the socket with 59 of the promised bytes never sent.
});
addr
}
// ── total request timeout ───────────────────────────────────────────
/// The client's timeout is a TOTAL request timeout, so it bounds a download and
/// not just the handshake. `gl ipfs get`'s documentation leans on exactly that:
/// the wall-clock deadline covers the search and deliberately stops at the
/// response headers, and this timeout is the only thing left bounding the body.
///
/// Driven at 250ms through `with_timeout`, the seam `new` itself calls with
/// `TOTAL_REQUEST_TIMEOUT`, because a test at the shipped 30s has no place in this
/// suite. What that costs is the value; what it proves is the SHAPE, which is the
/// part in doubt: that the deadline keeps running once the headers have landed. A
/// timeout that covered only the handshake would let this request hang until the
/// listener gives up.
#[tokio::test]
async fn total_timeout_cuts_off_a_body_that_outruns_it() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut scratch = [0u8; 1024];
let _ = sock.read(&mut scratch).await;
// Headers land immediately, then the body stalls indefinitely.
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1024\r\n\r\nfirst")
.await;
let _ = sock.flush().await;
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
});
let client = NodeClient::with_timeout(
format!("http://{addr}"),
None,
std::time::Duration::from_millis(250),
);
let started = std::time::Instant::now();
let resp = client.get("/slow").await.expect("headers arrive promptly");
assert_eq!(
resp.status(),
200,
"the stall is in the body, not the status"
);
let err = resp
.bytes()
.await
.expect_err("a body still arriving past the total timeout must be cut off");
let elapsed = started.elapsed();
assert!(
err.is_timeout(),
"the body read must end in a timeout, got: {err}"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"the timeout must fire on its own schedule, not wait out the listener; \
took {elapsed:?}"
);
}
#[test]
fn shipped_client_uses_the_documented_total_timeout() {
// The scaled-down test above proves the shape at 250ms. This pins the value
// `new` actually ships, so the two together cover the documented behaviour.
assert_eq!(TOTAL_REQUEST_TIMEOUT, std::time::Duration::from_secs(30));
}
#[test]
fn sanitize_strips_controls_bidi_and_caps_length() {
// C0 (ESC/BEL) and the Cf bidi override (U+202E) are both removed; the
+1934 -37
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -199,7 +199,7 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
// in the command: bound the read, and defang the message before it reaches
// the terminal through the error return. An announce reply is a DID, a URL
// and a count, so 8 KiB is well past what the shape needs.
let raw = read_body_capped(resp, 8 * 1024).await;
let raw = read_body_capped(resp, 8 * 1024).await.text;
if let Some(failure) = remote_announce_failure(status, &raw) {
anyhow::bail!("{failure}");
@@ -237,7 +237,7 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
// node (or a MITM on plain http) must not force an unbounded
// read. A body that does not parse stays `Null`, which still
// routes a non-success status to the warning.
let raw = read_body_capped(resp, 8 * 1024).await;
let raw = read_body_capped(resp, 8 * 1024).await.text;
let result: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
let (is_warning, line) = local_add_report(status, &result);
if is_warning {
+2 -2
View File
@@ -46,7 +46,7 @@ pub async fn run(args: SyncArgs) -> Result<()> {
if !status.is_success() {
// Bound the read: a hostile or broken node must not force an
// unbounded allocation just to surface a denial (INV-6, read half).
let raw = read_body_capped(resp, 8 * 1024).await;
let raw = read_body_capped(resp, 8 * 1024).await.text;
let msg = serde_json::from_str::<serde_json::Value>(&raw)
.ok()
.and_then(|v| {
@@ -261,7 +261,7 @@ mod tests {
.create_async()
.await;
let resp = reqwest::get(format!("{}/big", server.url())).await.unwrap();
let out = read_body_capped(resp, 8192).await;
let out = read_body_capped(resp, 8192).await.text;
assert!(out.len() <= 8192, "read not bounded: {} bytes", out.len());
assert!(!out.is_empty(), "expected some body");
}
+334 -14
View File
@@ -1,11 +1,11 @@
//! `gl whoami` — print current identity and optional node registration info.
use anyhow::Result;
use anyhow::{bail, Result};
use clap::Args;
use serde_json::{json, Value};
use std::path::PathBuf;
use crate::http::NodeClient;
use crate::http::{read_body_capped, sanitize_node_msg, NodeClient};
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
@@ -22,6 +22,10 @@ pub struct WhoamiArgs {
}
pub async fn run(args: WhoamiArgs) -> Result<()> {
run_to_writer(args, &mut std::io::stdout()).await
}
pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write) -> Result<()> {
let keypair = load_keypair_from_dir(args.dir.as_deref())?;
let did = keypair.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
@@ -41,7 +45,7 @@ pub async fn run(args: WhoamiArgs) -> Result<()> {
if let Some(caps) = info["capabilities"].as_array() {
capabilities = caps
.iter()
.filter_map(|c| c.as_str().map(String::from))
.filter_map(|c| c.as_str().map(sanitize_node_msg))
.collect();
}
// Try to get repo count
@@ -51,11 +55,35 @@ pub async fn run(args: WhoamiArgs) -> Result<()> {
}
}
}
Ok(_) => {
Ok(resp) if resp.status().as_u16() == 404 => {
registered = Some(false);
}
Err(_) => {
registered = Some(false);
Ok(resp) => {
let status = resp.status();
let raw = read_body_capped(resp, 8 * 1024).await.text;
let msg = serde_json::from_str::<Value>(&raw)
.ok()
.and_then(|v| {
let non_empty = |m: Option<&Value>| {
m.and_then(|m| m.as_str())
.map(String::from)
.filter(|s| !s.is_empty())
};
non_empty(v.get("message")).or_else(|| non_empty(v.get("error")))
})
.unwrap_or(raw);
bail!(
"agent lookup failed ({status}): {}",
sanitize_node_msg(&msg)
);
}
Err(e) => {
let detail: String = e
.chain()
.map(|e| sanitize_node_msg(&e.to_string()))
.collect::<Vec<_>>()
.join(": ");
bail!("agent lookup failed: {detail}");
}
}
}
@@ -77,21 +105,21 @@ pub async fn run(args: WhoamiArgs) -> Result<()> {
if let Some(rc) = repo_count {
out["repos"] = json!(rc);
}
println!("{}", serde_json::to_string_pretty(&out)?);
writeln!(w, "{}", serde_json::to_string_pretty(&out)?)?;
} else {
println!("DID: {did}");
println!("Short: {short}");
writeln!(w, "DID: {did}")?;
writeln!(w, "Short: {short}")?;
if let Some(reg) = registered {
println!("Registered: {}", if reg { "yes" } else { "no" });
writeln!(w, "Registered: {}", if reg { "yes" } else { "no" })?;
}
if let Some(ts) = trust_score {
println!("Trust: {ts:.2}");
writeln!(w, "Trust: {ts:.2}")?;
}
if !capabilities.is_empty() {
println!("Caps: {}", capabilities.join(", "));
writeln!(w, "Caps: {}", capabilities.join(", "))?;
}
if let Some(rc) = repo_count {
println!("Repos: {rc}");
writeln!(w, "Repos: {rc}")?;
}
}
Ok(())
@@ -192,7 +220,236 @@ mod tests {
node: Some(server.url()),
json: false,
};
run(args).await.unwrap();
let mut out = Vec::new();
run_to_writer(args, &mut out).await.unwrap();
let out = String::from_utf8(out).unwrap();
assert!(out.contains("Registered: no"), "unexpected output: {out}");
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: true,
};
let mut out = Vec::new();
run_to_writer(args, &mut out).await.unwrap();
let out = String::from_utf8(out).unwrap();
assert!(
out.contains("\"registered\": false"),
"unexpected output: {out}"
);
}
#[tokio::test]
async fn test_whoami_with_node_forbidden() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let did = kp.did().to_string();
let mut server = mockito::Server::new_async().await;
let _agent = server
.mock("GET", format!("/api/v1/agents/{did}").as_str())
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"forbidden"}"#)
.create_async()
.await;
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("403"), "expected 403 error, got: {msg}");
assert!(
msg.contains("forbidden"),
"expected 'forbidden' in error, got: {msg}"
);
}
#[tokio::test]
async fn test_whoami_with_node_server_error() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let did = kp.did().to_string();
let mut server = mockito::Server::new_async().await;
let _agent = server
.mock("GET", format!("/api/v1/agents/{did}").as_str())
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"internal error"}"#)
.create_async()
.await;
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("500"), "expected 500 error, got: {msg}");
assert!(
msg.contains("internal error"),
"expected 'internal error' in error, got: {msg}"
);
}
#[tokio::test]
async fn test_whoami_server_error_unusable_message_uses_error() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let did = kp.did().to_string();
let mut server = mockito::Server::new_async().await;
let _agent = server
.mock("GET", format!("/api/v1/agents/{did}").as_str())
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"","error":"boom"}"#)
.create_async()
.await;
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("boom"),
"expected 'boom' from error field, got: {msg}"
);
}
#[tokio::test]
async fn test_whoami_with_node_transport_error() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let node = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(node),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("agent lookup failed"),
"expected transport error, got: {msg}"
);
}
#[tokio::test]
async fn test_whoami_transport_sanitizes_control_chars_in_node_url() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let node = format!("http://127.0.0.1:{port}/\u{1b}]0;PWNED\u{7} \u{202e}gnitirw");
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(node),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("agent lookup failed"),
"expected transport error, got: {msg}"
);
assert!(!msg.contains('\u{1b}'), "ESC leaked into output: {msg:?}");
assert!(!msg.contains('\u{7}'), "BEL leaked into output: {msg:?}");
assert!(!msg.contains('\u{202e}'), "RLO leaked into output: {msg:?}");
}
#[tokio::test]
async fn test_whoami_server_error_body_display_bounded() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let did = kp.did().to_string();
let mut server = mockito::Server::new_async().await;
let _agent = server
.mock("GET", format!("/api/v1/agents/{did}").as_str())
.with_status(502)
.with_header("content-type", "application/json")
.with_body("x".repeat(100_000))
.create_async()
.await;
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("502"),
"expected 502 error with bounded body, got: {msg}"
);
let display = format!("{err}");
assert!(
display.len() < 1000,
"error message too long ({} bytes) — body was not capped",
display.len()
);
}
#[tokio::test]
async fn test_whoami_server_error_sanitizes_controls() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let did = kp.did().to_string();
let mut server = mockito::Server::new_async().await;
let _agent = server
.mock("GET", format!("/api/v1/agents/{did}").as_str())
.with_status(500)
.with_header("content-type", "application/json")
.with_body("{\"message\":\"\\u001b[31mowned\\u0007\\u202eevil\"}")
.create_async()
.await;
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: false,
};
let err = run(args).await.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("owned"),
"expected sanitized error body, got: {msg}"
);
assert!(!msg.contains('\u{1b}'), "ESC control char leaked: {msg}");
assert!(!msg.contains('\u{07}'), "BEL control char leaked: {msg}");
assert!(!msg.contains('\u{202e}'), "RTL override leaked: {msg}");
}
#[tokio::test]
@@ -230,4 +487,67 @@ mod tests {
};
run(args).await.unwrap();
}
#[tokio::test]
async fn test_whoami_sanitizes_hostile_capabilities() {
let dir = TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap().to_string();
let mut server = mockito::Server::new_async().await;
let _agent = server
.mock("GET", format!("/api/v1/agents/{did}").as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
"{\"trust_score\":0.5,\"capabilities\":[\"\\u001b]0;PWNED\\u0007repo:write\",\"\\u202egnitirw-tfel\"]}",
)
.create_async()
.await;
let _repos = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/api/v1/repos\?owner={short}")),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create_async()
.await;
// Human mode: no control bytes or bidi overrides reach the terminal
let mut buf = Vec::new();
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: false,
};
run_to_writer(args, &mut buf).await.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(!out.contains('\u{1b}'), "ESC leaked in human mode: {out:?}");
assert!(!out.contains('\u{07}'), "BEL leaked in human mode: {out:?}");
assert!(
!out.contains('\u{202e}'),
"RLO leaked in human mode: {out:?}"
);
assert!(out.contains("repo:write"), "benign text missing: {out:?}");
assert!(out.contains("tfel"), "reversed text missing: {out:?}");
// JSON mode: serde escapes C0 but passes bidi — ensure no U+202E
let mut buf = Vec::new();
let args = WhoamiArgs {
dir: Some(dir.path().to_path_buf()),
node: Some(server.url()),
json: true,
};
run_to_writer(args, &mut buf).await.unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
!out.contains('\u{202e}'),
"RLO leaked in JSON mode: {out:?}"
);
}
}
+4
View File
@@ -44,6 +44,10 @@ services:
GITLAWB_BOOTSTRAP_PEERS: ${GITLAWB_BOOTSTRAP_PEERS:-}
GITLAWB_BOOTSTRAP_DISABLE_SEEDS: ${GITLAWB_BOOTSTRAP_DISABLE_SEEDS:-false}
GITLAWB_REQUIRE_SIGNED_PEER_WRITES: ${GITLAWB_REQUIRE_SIGNED_PEER_WRITES:-false}
# Owner-only push. The default matches the node's own, so the container path
# behaves like a source build; forwarding it is what makes the documented
# rolling-upgrade opt-out reachable for an operator running compose.
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
GITLAWB_P2P_BOOTSTRAP: ${GITLAWB_P2P_BOOTSTRAP:-}
GITLAWB_PUBLIC_READ: ${GITLAWB_PUBLIC_READ:-true}
GITLAWB_MAX_PACK_BYTES: ${GITLAWB_MAX_PACK_BYTES:-2147483648}
+2 -2
View File
@@ -38,8 +38,8 @@ Owner focus: node reliability.
Owner focus: protocol/auth.
- Implement repo write authorization: repo owner checks, protected branches, UCAN capability checks, and clear delegation semantics.
- Implement private-read enforcement or remove private-repo affordances until it exists.
- Close default-open repo write authorization and wire trusted UCAN delegation into repository permissions.
- Close the remaining visibility gaps: restrict or document task, pin, and anchor metadata routes, and publish an irreversible-publication policy.
- Add UCAN revocation or blocklisting, with an emergency compromised-key runbook.
- Add mutation-aware GraphQL auth before GraphQL becomes a public write API surface.
- Harden peer registration and outbound peer calls against SSRF and peer-list poisoning.
+6 -1
View File
@@ -3,6 +3,11 @@
Date: 2026-05-28
Repo state inspected: `main` tracking `origin/main`, starting at `b12c6bc feat: per-DID rate limiting on creation endpoints (10/hour) (#13)`.
> **Historical snapshot.** This audit describes the repository at the commit above. Its
> statements about incomplete UCAN chain validation and absent per-repository read
> enforcement have been superseded; see [`SECURITY.md`](../SECURITY.md) for the current
> security posture.
## Commands run
```sh
@@ -88,7 +93,7 @@ Fixed or staged in this pass:
Live-network blockers to prioritize:
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization; unprotected repo branches do not yet enforce owner/UCAN capability checks.
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization. Owner checks are now enforced on every branch, protected or not (`GITLAWB_ENFORCE_OWNER_PUSH`, on by default); what remains is that a UCAN `git/push` capability is not yet honored, so a delegated or CI key cannot push.
- UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature.
- Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired.
- Peer URLs are self-asserted by DIDs. Signatures prove control of the DID key when present, not ownership/safety of the announced URL.
+32 -19
View File
@@ -141,31 +141,43 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
---
## Hardening: owner-only push
## Owner-only push
By default the node authenticates every `git-receive-pack` push (a valid RFC 9421
`did:key` signature) but does **not** check that the pusher owns the repo, except
on branches that are explicitly protected. Because `did:key` is self-certifying,
any party can generate a key, derive its DID, sign, and push to an unprotected
branch — authentication is not authorization.
The node requires the authenticated pusher to be the repo owner on **every**
branch. A push whose authenticated DID is not the repo owner is rejected with
HTTP 403 before any ref update is applied. The owner is matched in both the full
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
To require the authenticated pusher to be the repo owner on **every** branch, set:
This is on by default, and the default is the point. The node authenticates every
`git-receive-pack` push with a valid RFC 9421 `did:key` signature, but `did:key`
is self-certifying: any party can generate a key, derive its DID and sign.
Authentication is not authorization, so without this gate every signed caller can
push to every repository — private ones included — on any branch that is not
explicitly protected.
### Turning it off
```bash
GITLAWB_ENFORCE_OWNER_PUSH=true
GITLAWB_ENFORCE_OWNER_PUSH=false
```
- **Default `false`** — preserves current behavior so live nodes are unaffected by
an upgrade. Turn it on once you're ready for owner-only writes.
- **When `true`** — a push whose authenticated DID is not the repo owner is
rejected (HTTP 403) before any ref update is applied. The owner is matched in
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
- **Caution: this blocks every non-owner pusher, including your own delegated and
CI agents.** Push authorization is owner-only today — a UCAN `git/push`
capability is verified but not yet honored for authorization, so delegated keys
cannot push while this is on. Don't enable it until every identity that pushes
to your repos is the owner, or you'll lock out your own automation. Scoped
collaborator / UCAN-delegated push rights are a planned follow-up.
Both the environment variable and `--enforce-owner-push false` disable it; the
bare `--enforce-owner-push` flag still means `true`.
Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
treat it as temporary: while it is off, your node accepts a push to any repository
from anyone who can generate a keypair.
### Caution: delegated and CI keys are non-owners
Push authorization is owner-only today. A UCAN `git/push` capability is verified
but **not yet honored for authorization**, so a delegated key cannot push while
this gate is on, even when it holds a valid capability for the repo.
If your automation pushes under its own DID rather than the owner's, it will start
getting 403s. Either have it push as the owner, or set
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
rights land — that work is what removes this trade-off.
---
@@ -178,6 +190,7 @@ GITLAWB_ENFORCE_OWNER_PUSH=true
| Operator key | Dedicated wallet, small ETH balance, not your main treasury |
| Monitoring | Watch `lastHeartbeat` on-chain; alert if > 22h since last beat |
| Public URL | Must resolve and serve `/health` for liveness and DB-aware `/ready` for peer readiness |
| Rolling upgrade (advisory-lock algorithm) | The SHA-256 advisory-lock key swap (replacing `DefaultHasher`) re-keys every repo in the first release that carries it. During a shared-Postgres rolling upgrade, old nodes compute a different `i64` key than new nodes for the same repo, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost. **Drain in-flight writes** or cut over through a **single node** before bringing new-binary nodes online. |
---
+60 -11
View File
@@ -91,13 +91,39 @@ User-data only runs at first boot, so upgrades go through SSM:
$(terraform output -raw upgrade_command)
```
This runs `docker compose pull && docker compose up -d` on the instance.
This reinstalls the rendered `/opt/gitlawb/compose.yaml`, then runs
`docker compose pull && docker compose up -d` on the instance.
**Run `terraform apply` before the upgrade command.** The command is an SSM
document managed by Terraform, and it embeds the compose file rendered from your
current variables. An instance that has not had a fresh `apply` still holds the
previous document and will reinstall the previous compose file.
- With `image_tag = "latest"` (default) that picks up the newest release.
- With a **pinned tag**, first edit the tag in `/opt/gitlawb/compose.yaml` on
the instance (via SSM session), then run the upgrade command — and keep
`image_tag` in terraform.tfvars in sync so a future instance replacement
boots the same version.
- With a **pinned tag**, set `image_tag` in `terraform.tfvars` and `apply` — the
rendered compose carries it, so the upgrade installs the right version.
- **`compose.yaml` is Terraform-owned and is overwritten on every upgrade.**
Per-instance settings belong in `/opt/gitlawb/.env`, which is never touched.
- **The upgrade reconciles the service set, not just the image.** The rendering is
conditional — `postgres` exists only when `db_host` is empty, `caddy` only when
`domain_name` is set — and the command runs `--remove-orphans`. So setting
`use_rds`, or clearing `domain_name`, and then upgrading will **stop and remove**
the local postgres or the TLS terminator. That is the intended outcome of those
variables, but it happens on the upgrade rather than on `apply`. Data survives
either way: both bind-mount under `/mnt/data`.
### Why the upgrade rewrites the compose file
`aws_instance` deliberately ignores `user_data` drift, so an instance keeps the
compose file it was created with. Compose passes only the variables named in a
service's `environment:` block into the container, so **a node setting added to
the template never reaches an existing instance** — writing it to
`/opt/gitlawb/.env` and restarting leaves the node on its built-in default, with
no error to indicate it. `pull && up -d` alone cannot fix that, because the file
on disk is the authoritative one.
Reinstalling the rendering first makes the upgrade a real migration. It is
idempotent: on an already-current instance it writes identical bytes.
Replace the instance itself (OS/AMI/instance-type changes) with
`terraform apply -replace=aws_instance.node` — the data volume reattaches and
@@ -105,12 +131,24 @@ Replace the instance itself (OS/AMI/instance-type changes) with
## Changing configuration
User-data only runs at first boot, and the instance ignores `user_data` drift
(`ignore_changes`), so editing terraform.tfvars values that feed the bootstrap
(`bootstrap_peers`, `public_url`, integrations, `image_tag`) does **not**
affect a running instance on `terraform apply`. To roll out such changes,
either edit `/opt/gitlawb/.env` on the instance (SSM session, then
`docker compose up -d`), or replace the instance:
`terraform apply` alone never changes a running instance: user-data runs once and
the instance ignores `user_data` drift. How a change rolls out depends on which
of the two files carries it.
**Rendered into `compose.yaml`** — `image_tag`, `gitlawb_port`, `metrics_port`,
`domain_name`, `db_host`, the `icaptcha_*` values. `apply`, then run
`upgrade_command`: it installs the new rendering and restarts.
```sh
terraform apply
$(terraform output -raw upgrade_command)
```
**Written into `/opt/gitlawb/.env` at first boot** — `public_url`,
`bootstrap_peers`, `auto_sync`, `max_pack_bytes`, and the integration secrets.
Editing these in `terraform.tfvars` does **not** reach a running instance, because
nothing rewrites `.env` after the first boot. Either edit `/opt/gitlawb/.env` on
the instance and `docker compose up -d`, or replace it:
```sh
terraform apply -replace=aws_instance.node
@@ -118,6 +156,17 @@ terraform apply -replace=aws_instance.node
The data volume reattaches; repos, postgres data, and the identity key survive.
**A node setting the installed `compose.yaml` does not already name** —
`GITLAWB_ENFORCE_OWNER_PUSH` on an instance created before it was added, for
example. Editing `.env` is **not enough**: compose only passes through the
variables its `environment:` block lists, so the value is read from `.env` and
then dropped. Run `apply` plus `upgrade_command` first, which installs a compose
file that names the key; after that, `.env` governs it like any other.
That last case is the one worth remembering: `.env` reaches the container only for
keys the *installed* compose interpolates, and the upgrade is what makes a newly
added key one of them.
## Remote state (optional)
Local state is the default. To move state to S3: create a versioned bucket,
+3
View File
@@ -72,6 +72,9 @@ services:
GITLAWB_BOOTSTRAP_PEERS: $${GITLAWB_BOOTSTRAP_PEERS}
GITLAWB_AUTO_SYNC: $${GITLAWB_AUTO_SYNC}
GITLAWB_MAX_PACK_BYTES: $${GITLAWB_MAX_PACK_BYTES}
# Owner-only push. Matches the node's own default; forwarding it is what makes
# the documented rolling-upgrade opt-out reachable on the container path.
GITLAWB_ENFORCE_OWNER_PUSH: $${GITLAWB_ENFORCE_OWNER_PUSH:-true}
# On-chain PoS (optional — empty unless set in terraform.tfvars)
GITLAWB_CHAIN_RPC_URL: $${GITLAWB_CHAIN_RPC_URL}
GITLAWB_CONTRACT_NODE_STAKING: $${GITLAWB_CONTRACT_NODE_STAKING}
+62 -7
View File
@@ -350,10 +350,16 @@ resource "aws_instance" "node" {
# ami: new AL2023 releases shouldn't churn the instance; replace
# deliberately for OS upgrades.
# user_data: only runs at first boot, so re-rendering it on a live
# instance is a pointless stop/start. Config changes that feed user-data
# (bootstrap peers, integrations, image tag) require a deliberate
# `terraform apply -replace=aws_instance.node` — see README "Changing
# configuration".
# instance is a pointless stop/start. How a config change reaches a running
# instance depends on which file carries it:
# - rendered into compose.yaml (image_tag, ports, domain_name, db_host,
# icaptcha_*): `terraform apply` then `upgrade_command`, which
# reinstalls the rendering and restarts.
# - written into /opt/gitlawb/.env at first boot (public_url,
# bootstrap_peers, integrations): nothing rewrites .env afterwards, so
# these need an edit on the instance or
# `terraform apply -replace=aws_instance.node`.
# See README "Changing configuration".
ignore_changes = [ami, user_data]
}
}
@@ -437,13 +443,62 @@ resource "aws_ssm_document" "upgrade" {
content = jsonencode({
schemaVersion = "2.2"
description = "Pull the latest gitlawb node image and restart the compose stack"
description = "Reinstall the rendered compose file, pull the latest image, and restart"
mainSteps = [{
action = "aws:runShellScript"
name = "upgrade"
inputs = {
runCommand = [
"cd /opt/gitlawb && docker compose pull && docker compose up -d --remove-orphans && docker image prune -f"
# Reinstall the CURRENT rendering before restarting.
#
# `aws_instance` ignores user_data drift, so an instance created before a
# template change keeps the compose file it was born with. Compose passes
# only the variables named in its `environment:` block into the container,
# so a node setting added to the template never reaches an existing
# instance — the operator writes it to /opt/gitlawb/.env, restarts, and the
# node silently keeps the built-in default. `docker compose pull && up -d`
# cannot fix that: the authoritative file is the one on disk.
#
# Idempotent: writing the same bytes and restarting is a no-op. This does
# overwrite hand-edits to compose.yaml, which is intended — the file is
# Terraform-owned; per-instance settings belong in /opt/gitlawb/.env.
#
# One shell step, under flock, writing through mktemp. Two overlapping
# executions — a double run, a retry while the first is still pulling, two
# operators — otherwise share one fixed temp path: the second truncates the
# file the first is about to install, and once the first renames, the
# second's open descriptor keeps writing into the live compose.yaml inode.
# Both outcomes stay valid YAML, so the `up -d --remove-orphans` on the next
# line accepts a blended or truncated service set and deletes whatever fell
# off — on a live node, the local postgres or the TLS terminator. The lock
# also covers pull and up, so a second run cannot restart against a file the
# first is mid-install.
runCommand = [<<-UPGRADE
set -euo pipefail
install -d -m 0755 /opt/gitlawb
exec 9>/opt/gitlawb/.upgrade.lock
if ! flock -n 9; then
echo "another upgrade is already running on this instance" >&2
exit 1
fi
tmp="$(mktemp /opt/gitlawb/compose.yaml.XXXXXX)"
trap 'rm -f "$tmp"' EXIT
# base64 rather than a nested heredoc: the rendered compose contains
# `$${VAR}` passthroughs that must reach the file unexpanded, and a
# heredoc inside an indented Terraform heredoc depends on the dedent
# landing the delimiter at column 0. Decoding a single line has neither
# failure mode, and no delimiter can collide with the content.
echo '${base64encode(local.compose_yaml)}' | base64 -d > "$tmp"
chmod 0644 "$tmp"
mv "$tmp" /opt/gitlawb/compose.yaml
trap - EXIT
cd /opt/gitlawb
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
UPGRADE
]
}
}]
@@ -19,6 +19,11 @@ class Config {
var operatorPrivateKey: String = ""
var tigrisBucket: String = ""
var autoSync: Bool = false
/// Owner-only push. Persisted as a setting rather than read from a hand-edited
/// `.env`, because `writeEnvFile()` regenerates that file on every start and
/// would discard the edit. Matches the node's own default; set false only for a
/// rolling upgrade whose pushers are not yet the repo owner.
var enforceOwnerPush: Bool = true
var repoPathString: String = ""
// MARK: - Paths
@@ -76,6 +81,7 @@ class Config {
"operatorPrivateKey": operatorPrivateKey,
"tigrisBucket": tigrisBucket,
"autoSync": autoSync,
"enforceOwnerPush": enforceOwnerPush,
"repoPath": repoPathString,
]
@@ -105,6 +111,7 @@ class Config {
operatorPrivateKey = dict["operatorPrivateKey"] as? String ?? operatorPrivateKey
tigrisBucket = dict["tigrisBucket"] as? String ?? tigrisBucket
autoSync = dict["autoSync"] as? Bool ?? autoSync
enforceOwnerPush = dict["enforceOwnerPush"] as? Bool ?? enforceOwnerPush
repoPathString = dict["repoPath"] as? String ?? repoPathString
}
@@ -121,6 +128,7 @@ class Config {
lines.append("GITLAWB_PORT=\(httpPort)")
lines.append("GITLAWB_P2P_PORT=\(p2pPort)")
lines.append("GITLAWB_AUTO_SYNC=\(autoSync)")
lines.append("GITLAWB_ENFORCE_OWNER_PUSH=\(enforceOwnerPush)")
if !chainRpcURL.isEmpty {
lines.append("GITLAWB_CHAIN_RPC_URL=\(chainRpcURL)")
@@ -294,6 +294,10 @@ services:
GITLAWB_HOST: 0.0.0.0
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
GITLAWB_P2P_PORT: 7546
# Owner-only push. Must match the bundled Resources/docker-compose.yml: this
# generated file — not the bundled resource — is what a packaged app runs, so
# omitting it here leaves the documented opt-out unreachable for those users.
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-}
GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-}
GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-}
@@ -33,6 +33,9 @@ services:
GITLAWB_PORT: ${GITLAWB_PORT:-7545}
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546}
# Owner-only push. Matches the node's own default; forwarding it is what makes
# the documented rolling-upgrade opt-out reachable on the container path.
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
# Sync
GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false}
# On-chain PoS (optional — leave unset for local/dev)
@@ -28,6 +28,7 @@ struct SettingsView: View {
@State private var operatorPrivateKey: String = Config.shared.operatorPrivateKey
@State private var tigrisBucket: String = Config.shared.tigrisBucket
@State private var autoSync: Bool = Config.shared.autoSync
@State private var enforceOwnerPush: Bool = Config.shared.enforceOwnerPush
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -53,6 +54,15 @@ struct SettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 2) {
Toggle("Require repo owner to push", isOn: $enforceOwnerPush)
Text("A signature proves who is pushing, not that they own the repo. "
+ "Turn this off only during a rolling upgrade whose pushers are "
+ "not yet the owner — while off, anyone who can sign may push to "
+ "any repo on this node.")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
@@ -96,6 +106,7 @@ struct SettingsView: View {
Config.shared.operatorPrivateKey = operatorPrivateKey
Config.shared.tigrisBucket = tigrisBucket
Config.shared.autoSync = autoSync
Config.shared.enforceOwnerPush = enforceOwnerPush
Config.shared.persist()
Config.shared.writeEnvFile()