Compare commits

..
Author SHA1 Message Date
Kevin Codex fdf716dd25 Merge pull request #281 from Gitlawb/release-please--branches--main--components--gitlawb-node
chore(main): release 0.7.1
2026-08-05 13:06:15 +08:00
Kevin Codex c926e1ede5 Merge pull request #292 from Gitlawb/chore/bump-ruint-rustsec-2026-0220
chore(deps): bump ruint to 1.20.0 for RUSTSEC-2026-0220
2026-08-03 10:44:46 +08:00
github-actions[bot] 5f445152a8 chore: sync Cargo.lock with the release version bump 2026-08-03 02:44:15 +00:00
github-actions[bot] cd810b3375 chore(main): release 0.7.1 2026-08-03 02:44:00 +00:00
Kevin Codex acb47145be Merge pull request #298 from euxaristia/fix/get-profile-strict-did-matching
fix: strict DID matching in get_profile
2026-08-03 10:43:29 +08:00
euxaristia b4016dce9d fix(db): pin profile DID matching with lone non-key fixture and boundary matrix
Add a lone non-key DID fixture check to get_profile_does_not_match_non_key_did to verify that a bare short query returns None when only a non-key profile exists. Add profile_did_case_sql_matches_normalize_owner_key to pin PROFILE_DID_CASE_SQL against Rust normalize_owner_key across the full owner-key boundary matrix. Add cfg(unix) target gates to Unix-specific symlink and permission test helpers.

Refs #298
2026-08-01 10:20:22 -04:00
euxaristia ee9bc93198 chore(deps): bump ruint to 1.20.0 for RUSTSEC-2026-0220
cargo audit fails on ruint 1.17.2 (incorrect overflow flags / truncated
shift amounts). Upgrade the lockfile to the patched 1.20.0 release.
2026-08-01 05:47:26 -04:00
euxaristia 9836c3a21b fix: target stored did on profile update aliases
get_profile equates bare short ids with did:key forms. upsert_profile
must UPDATE by existing.did so a did🔑 alias against a bare-stored
profile actually persists. set_profile_cid uses the same CASE match.
Add a regression test covering both write paths.
2026-08-01 05:41:31 -04:00
euxaristia 13b565bcac fix: strict DID matching in get_profile
Replace the broad LIKE suffix match in profile resolution with the same
normalize_owner_key / CASE pattern already used by get_repo, so non-key
DID methods that share a short-id suffix cannot resolve the wrong profile.

Add a sqlx test locking in bare, did:key, and non-key DID behavior.
2026-08-01 05:27:58 -04:00
t 0e42eb3343 chore(deps): bump ruint to 1.20.0 for RUSTSEC-2026-0220
cargo audit fails on main, so every open PR inherits a red check for a
problem that is not in its diff.

ruint 1.17.2 carries RUSTSEC-2026-0220: Uint shift operations report
incorrect overflow flags and truncate shift amounts. It reaches us
transitively through alloy-primitives, which alloy pulls into both
gitlawb-node and gl for the on-chain operator client. Our own U256 use in
operator.rs is pow, mul, div, and comparison rather than shifts, so the
direct exposure looks limited, but alloy's internals are not audited here
and the fix is a semver-compatible bump.

Lockfile only; no manifest change. The bump adds six arkworks crates
(ark-ff, ark-ff-asm, ark-ff-macros, ark-serialize, ark-serialize-derive,
ark-std), taking the tree from 715 to 721 dependencies. That surface
increase is the cost of the fix and is worth noting rather than passing
over: it arrives through ruint's own feature set, not through anything we
asked for.

None of it reaches gitlawb-core, so the dependency purity check still
passes at 83 allowlisted normal deps.

Verified: cargo audit exits 0, cargo build --locked --release, 564 node
tests and 83 core tests pass.
2026-07-30 16:05:23 -05:00
Kevin Codex c83cbc56e0 Merge pull request #243 from Gitlawb/ci/242-lock-sync-release
ci: keep Cargo.lock in sync through releases and build --locked (#242)
2026-07-30 09:35:55 +08:00
Kevin Codex 1964f88378 Merge pull request #280 from Gitlawb/fix/270-reset-peer-reachability-on-url-change
fix(node): reset a peer's reachability gate when an announce changes its http_url (#270)
2026-07-30 09:15:01 +08:00
Kevin Codex 99b2b9a166 Merge pull request #267 from Gitlawb/fix/docker-build-cache-and-msrv
fix(ci): make the docker dependency cache work and unbreak Dockerfile.bins
2026-07-30 09:14:22 +08:00
Kevin Codex 9418e315bf Merge pull request #213 from Gravirei/fix/issue-212-icaptcha-client-api-key-gate-test
test(icaptcha-client): add test that new() honors key_trusted gate (#212)
2026-07-30 09:13:24 +08:00
Kevin Codex c70b1df42d Merge pull request #274 from Gitlawb/fix/272-sync-slug-path-escape
fix(node): stop a peer-supplied repo slug from escaping repos_dir (#272)
2026-07-30 09:12:23 +08:00
t 811ad504bc fix(review): correct two comment claims and guard two more wrong shapes (#270)
Review of the previous two commits found the SQL correct and the comment
wrong in two places, plus two implementations of the intent that all four
tests accepted.

Comment fixes:

The claim that four http_url consumers "never read the flag at all" named
the public resolve route, which does read it: api/resolve.rs republishes it
as `reachable`, as does the peer listing. The accurate split is that only
the federated repo fan-out in api/repos.rs GATES on the flag, four consumers
act on a repointed URL regardless of it, and two surfaces republish it.
Three reviewers caught this independently.

The atomicity sentence implied the conflict row lock protects the flag. It
orders announces against each other and nothing else: mark_peer_ping writes
by DID with no http_url predicate, so a probe of the previous URL that lands
after a reset can still re-grant the flag until the next round. Said so, and
qualified the self-healing claim on cosmetic differences, which holds only
if no further announce lands first.

Two tests added, each proven load-bearing against a shape the original four
accept:

  cosmetic_url_difference_counts_as_a_change
    Pins the exact-comparison decision. Normalizing the comparison
    (rtrim(peers.http_url, '/') vs rtrim($2, '/')) passes the other five and
    fails only this one. The original four only ever compare identical or
    wholly different hosts, so none of them constrain the comparison.

  same_url_reannounce_still_advances_last_seen
    The reset must ride the existing UPDATE, not gate it. Hoisting the
    condition to a statement-level WHERE passes the other five while
    skipping the update entirely on a same-URL re-announce, so liveness
    stops advancing and the peer ages out on last_seen. Observed with both
    timestamps identical.

Gates: cargo test -p gitlawb-node 516 passed 0 failed; clippy --all-targets
-D warnings clean workspace-wide; fmt --check clean.

Refs #270
2026-07-29 17:25:05 -05:00
t e2560ee46c fix(node): reset a peer's reachability gate when an announce changes its http_url (#270)
The ON CONFLICT branch rewrote http_url and left last_ping_ok alone, so an
unsigned announce could repoint an existing peer's URL and inherit the
reachable=true that the previous host earned. api/repos.rs filters the
federated fan-out on that flag and then stamps each entry from the peer's
response body with the peer's DID, so the inheritance is what made the
injection instant rather than probe-delayed.

Clear the flag when the URL actually changes, and leave it alone when it does
not. In the DO UPDATE branch peers.http_url is the existing pre-update row and
the comparison runs under the conflict row lock, so this needs no second round
trip and does not race a read-then-write. Both writers funnel through
upsert_peer, so the announce handler and the bootstrap announce-back are both
covered.

Observed transition on the tests from the previous commit:

  before  url_change_clears_reachability ... FAILED (last_ping_ok assertion)
  after   url_change_clears_reachability ... ok
          the other three ... ok throughout

Both guards were confirmed load-bearing by reverting, not by reading a green
suite. Removing the CASE clause entirely turns url_change_clears_reachability
RED again. Flattening it to last_ping_ok = (peers.http_url IS NOT DISTINCT FROM
$2) still passes three of the four cases and is caught only by
same_url_reannounce_does_not_grant_reachability, which is why that case exists.

This bounds the automatic inheritance; it does not close the rewrite. Four
other http_url consumers never read this flag, and until #248 lands the
unauthenticated ping route can write it back from the attacker's own host.
#273 is the closure.

Closes #270
2026-07-29 17:02:21 -05:00
t 75e3e03c09 test(node): pin peer reachability across a URL change, a repeat, and a fresh insert (#270)
upsert_peer's ON CONFLICT branch rewrites http_url and says nothing about
last_ping_ok, so a repointed peer keeps the reachability the previous host
earned. These four cases state the behavior the fix has to produce, and they
land before it so the failure is observed rather than assumed.

Pre-fix results:

  url_change_clears_reachability ... FAILED
  same_url_reannounce_keeps_reachability ... ok
  fresh_peer_inserts_unreachable ... ok
  same_url_reannounce_does_not_grant_reachability ... ok

  panicked at crates/gitlawb-node/src/db/mod.rs:5737:
  a repointed peer must re-earn reachability, not inherit it

The failure is on the last_ping_ok assertion; the http_url assertion above it
passes, so the row is being rewritten and only the gate is wrong.

The three that already pass are regression pins, not filler.
same_url_reannounce_keeps_reachability guards against clearing the flag on
every conflict rather than only on a change.
same_url_reannounce_does_not_grant_reachability guards the other direction and
is the only case that fails if the conditional is flattened to
last_ping_ok = (peers.http_url IS NOT DISTINCT FROM $2), which would let any
unsigned same-URL re-announce set the flag TRUE with no probe.
fresh_peer_inserts_unreachable guards the INSERT branch.

Refs #270
2026-07-29 16:56:42 -05:00
beardthelion fbc5868bd1 fix(node): move the attempted_at migration to v17, clear of two open branches
v12 was main's current_max + 1, which INV-7a says is necessary and not
sufficient: the runner keys the applied set on the integer alone, so a
version another in-flight branch also claims is skipped in full on
whichever side merges second. No error, no warning, schema_migrations
still reading healthy, and the column simply absent, after which
dequeue_pending_syncs fails on every poll and sync ingestion stalls.

Two open branches already claim into that range. #135/#173 holds
through 14 (15 once it rebases past main's v11) and #253 took 16. 17
clears both, and the reservation is recorded in a comment so the next
author can see it. Gaps are harmless; the runner iterates the array and
never requires contiguity.

Not doing the other half here. INV-7a's load-bearing rule is the name
check that makes the silent skip impossible rather than merely
unlikely, and that lands with #253 on test/replay-guards-253; adding it
on this branch would collide with that work.
2026-07-29 13:16:41 -05:00
beardthelion 457be11d72 fix(review): stop the dequeue overclaiming, and pin the starvation premise
Three review fixes on the two commits above.

The docstring claimed more than the code delivers. UPDATE ... RETURNING
does not order its output, so the batch is the right set in no
particular order (nothing in process_batch depends on the order); and
nothing is claimed, since the rows stay pending with no lock held past
the statement, so two workers against one database can still be handed
the same batch. Single-worker is the existing assumption and
FOR UPDATE SKIP LOCKED is what would change it. It also implied the
caller handles a failed stamp robustly; the caller logs and skips the
poll, which is worth stating plainly now that this writes and can fail
for reasons a plain SELECT could not.

The outer UPDATE now repeats status = 'pending'. Between the subquery
and the update a concurrent worker can settle a row, and without the
predicate the statement would stamp and return a row that had already
left the pending set.

Both starvation tests now assert the healthy row is still pending
before the poll that frees it. Without that they would keep passing if
the batch size ever grew past the stuck set, having silently stopped
exercising head-of-line yield, and the larger test now also asserts the
stuck rows rotated rather than settled.
2026-07-29 12:45:03 -05:00
beardthelion 6e64d0d0a7 fix(node): stamp every dequeued sync row so a stuck one yields the batch
A row the worker cannot make progress on stays pending, and while its
ordering key never moved it stayed among the oldest rows forever,
holding the fixed-size window against every healthy repo behind it.
Classifying more error kinds as terminal does not close this: an owner
directory that exists but denies traversal lets create_dir_all succeed
and defers at path_within_root instead, per-repo rather than as a
whole-repos_dir outage.

dequeue_pending_syncs now selects and stamps in one statement, ordering
by COALESCE(attempted_at, enqueued_at), so the key means least recently
handed out. Doing it in the dequeue rather than at each deferral branch
makes it hold by construction: no call site can forget it, a batch that
dies mid-loop still leaves its rows stamped, and a failed stamp is a
failed dequeue the caller already handles. enqueued_at keeps meaning
enqueue time so backlog age stays measurable.

The column lands as migration v12, not a v1 append: v1 never re-runs on
an existing install, so appending there would leave every deployed node
dequeueing against a column that does not exist. The upgrade-path test
simulates a v11 node and is the only test that catches that placement,
since every #[sqlx::test] otherwise starts from a fresh database.

N stuck rows still cost N slots per rotation, delaying a healthy row by
up to ceil(N/10) polls, so the attempts column and a retry cap remain
worth doing; this removes the starvation, not the residual cost.
2026-07-29 12:27:47 -05:00
beardthelion 2e664b7885 fix(node): fail a sync row terminally when a non-directory holds the owner path
create_dir_all reports AlreadyExists when a regular file, dangling
symlink, or link loop occupies repos_dir/<owner>, and none of those
clear on a retry. Classifying them as transient left the row pending,
and since dequeue_pending_syncs is oldest-first over a fixed batch of
10, a handful of them held the window and starved every healthy repo
behind them. This restores the pre-#272 behavior, where the same state
failed the clone and hit the terminal Err arm.

A concurrent mkdir resolves to Ok rather than AlreadyExists, because
create_dir_all falls back to is_dir() on EEXIST, so the race is not
caught by this classification.
2026-07-29 12:15:44 -05:00
beardthelion 9aa4c17aef fix(node): make a stalled sync queue visible, and fix two test claims
Two follow-ups from code review.

The pending-on-IoError arm had no operator-visible signal: the only
record_sync_processed calls were the done and failed arms, so a queue
stalled on an unmounted repos_dir looked identical to an idle one. Both
deferring arms now record "deferred" and the guard rejections record
"rejected", which also distinguishes a retryable stall from a terminal
refusal. There is still no attempt cap; bounding retries needs an attempts
column on sync_queue, which is a migration and belongs in its own change.

The two escape tests were asserting that repos_dir stays empty, but the
owner directory is created before containment has a parent to canonicalize,
so that assertion was detecting the create_dir_all side effect rather than
an escape. They now assert no mirror exists anywhere under repos_dir.

Their comments were also wrong about which layer holds. Mutation testing
says the slug rule and containment are each independently sufficient for
that input: removing either alone leaves the tests green, and only removing
both turns them red, with the mirror observed written outside repos_dir.
The comments now say that, and point at the per-layer witnesses instead:
./hello isolates the slug rule because it resolves back inside repos_dir
and containment approves it, and the two symlink tests isolate containment
because no character rule can see a symlink.
2026-07-29 04:14:07 -05:00
beardthelion 27b0495a6f fix(node): bound the slug owner by NAME_MAX, not the DID limit (#272)
Code review caught an availability regression introduced by the guard added
earlier on this branch. validate_owner_did bails only above 256 chars, but
the owner half becomes a single path component and Linux NAME_MAX is 255, so
a 256-char owner passed validation and then failed create_dir_all with
ENAMETOOLONG on every attempt. The worker left such a row pending, and
dequeue_pending_syncs is oldest-first with a fixed batch of ten, so ten
unsigned notify calls held the entire window and starved every healthy repo
behind them. Before this branch the same row reached git clone, failed, and
was retired.

Two changes. Reject the length in the shared validator, so an undeliverable
slug never enters the queue from either boundary. Then split the
create_dir_all arm by whether the error can ever clear: a transient one
still leaves the row pending to be retried, a permanently uncreatable path
is marked failed rather than re-picked forever.

The earlier test missed this by asserting 257, one past the bound. It now
pins both sides: 256 rejected, 255 still accepted.

Also make the two permission-bit tests fail loudly instead of returning
early. One probed with symlink_metadata on a path that was never created,
which is Err for every user, so its root guard could never fire; the other
returned green after an eprintln that cargo swallows on a passing test, so
a root runner would have reported ok while exercising nothing.
2026-07-29 03:41:55 -05:00
beardthelion 6456bfb0ba test(node): commit the #272 probes as regression tests
The two throwaway probes from the investigation, in attack form, asserting
the closed outcome. The router-level one drives the full router with no auth
extension, because the point it pins is reachability: the attack needs no
signature and no operator config change, only an unsigned announce followed
by an unsigned notify.

The worker-level one seeds the peer row directly, since process_batch
resolves the peer URL before it looks at the slug and an unseeded row dies
at the no-peer arm, which would leave the test green with the guard gone.
It asserts on the parent directory too, because git clone removes the
destination on failure but leaves the parent.

Note for reviewers: reverting the worker slug check alone does not recreate
the absolute-path escape, since containment catches it and leaves the row
pending. That check is independently load-bearing for ./hello, which
canonicalizes back inside repos_dir and so passes containment.
2026-07-29 03:16:55 -05:00
beardthelion 3ca94739ed fix(node): prove the mirror path is inside repos_dir before running git (#272)
The character rules on the slug cannot see a symlink. Two cases get through
them: a symlinked owner directory, and a symlinked <name>.git leaf. The leaf
is the one a parent-only check misses, since the parent canonicalizes clean,
exists() follows the link, and the fetch branch then runs git -C through it
with the mirror refspec, force-overwriting refs outside repos_dir.

So the predicate canonicalizes the candidate when symlink_metadata says it
exists, and its parent only when it does not. Canonicalizing the candidate
unconditionally would reject every first clone, which is total loss of
mirroring, and the must-not-break test covers that direction.

Three-valued rather than a bool because the worker has to tell a hostile
path from a filesystem that could not answer. Outside is terminal; an I/O
error leaves the row pending, since dequeue only selects pending and a
transient EACCES would otherwise retire a legitimate repo permanently.

The predicate is pure. create_dir_all lives at the call site so the admin
purge path can ask a containment question without mutating anything.
2026-07-29 03:03:30 -05:00
beardthelion c587a40a50 fix(node): reject a malformed slug at the notify boundary (#272)
/api/v1/sync/notify accepts unsigned callers by default, and its only gate
was that node_did appear in the peer table, which one unsigned announce of
the caller's own DID satisfies. req.repo then reached enqueue_sync verbatim.

Validate after the peer check so an unknown peer still gets the unknown-peer
message, and before enqueue_sync so nothing is written. The 400 names the
field and carries no path: the validator's text never interpolates the slug,
so a hostile string is not echoed back either.

NOTIFY_BODY used "demo", a slug with no separator, so the three brake tests
would have started asserting against the new 400 instead of the brake.
2026-07-29 02:40:48 -05:00
beardthelion 9f00e24aec fix(node): validate the queued slug before the worker derives a path (#272)
process_batch split item.repo on the first '/' and joined both halves onto
repos_dir. Since PathBuf::join does not normalize and an absolute second
component replaces the path, a queued `a//tmp/x` cloned an attacker's
origin to /tmp/x.git with repos_dir left empty.

The worker is the real choke point rather than defence in depth: three
writers reach sync_queue (the notify handler, trigger_sync, and the
gossipsub ref-update handler at p2p/mod.rs), and rows queued before this
change are already in the table.

The tests serve the composed remote URL for every hostile slug, not just
the escaping one. With an unreachable remote those rows end failed anyway
because the clone fails, so the scenarios would have passed before the
guard existed and proven nothing.
2026-07-29 02:27:35 -05:00
beardthelion 51d82ded57 fix(node): add a shared owner/name slug validator (#272)
The sync queue carries a peer-supplied `repo` string that the worker turns
into a filesystem path, and `PathBuf::join` does not normalize, so an
absolute second component replaces the accumulated path. `a//tmp/x`
resolved to `/tmp/x.git`, outside repos_dir entirely.

Validate the slug once, in the module that already owns the owner and name
rules, and return the two halves so callers cannot re-split and drift. The
only rule added here is the leading `.`/`-` check on the owner half, which
`validate_owner_did` lacks because it also serves full DIDs.

Each guard is covered by a case that isolates it: the earlier rules shadow
the obvious candidates, so `../hello` does not actually exercise the
owner delegation and `a//tmp/x` does not exercise the separator count.
2026-07-29 02:27:35 -05:00
t a49e04ddcd fix(ci): make the docker dependency cache work and unbreak Dockerfile.bins
The Dockerfile cache layer primed from four crate manifests while the
workspace has six members, and crates/gl takes a path dependency on
icaptcha-client. Cargo loads the whole workspace before resolving, so the
layer aborted on the missing manifest and || true swallowed it. Every
image build has been a full cold build since icaptcha-client landed,
including the docker-build smoke test on every PR. Copy all six
manifests, and drop the || true: the layer is expected to succeed, and
tolerating its failure is what let this sit dead unnoticed.

Dockerfile.bins pinned rust:1.85-bookworm against a workspace
rust-version of 1.91. An unmet MSRV is a hard cargo error, so both Linux
musl targets of scripts/build-bins.sh have been unbuildable. Nothing in
CI builds that file, which is why nothing reported it.

Closes #265
Closes #266
2026-07-28 17:26:24 -05:00
t ebfe89d1c9 ci: declare the lock-sync toolchain and extend --locked to the shipping builds
sync-release-lock ran cargo update --workspace on whatever Rust the
ubuntu-latest image shipped, so the lock it wrote for the release PR came
off an undeclared input. Install the same stable toolchain the other
cargo jobs use. This does not make resolution identical (stable moves,
and the MSRV gate checks the same lock on 1.91), it just removes the
runner image from the equation.

The release-binaries build, both Dockerfiles and the darwin path of
build-bins.sh also resolved without --locked, so a lockfile disagreeing
with the manifests was caught on the PR gates but not where artifacts
are actually produced. Add --locked to all five.

Two of those five cannot reach resolution today and are hardened for
when they can: the Dockerfile dep-cache layer at line 24 copies four of
the workspace's six member manifests, so cargo dies at workspace load and
|| true swallows it, and Dockerfile.bins pins rust:1.85 against a 1.91
MSRV. Both are pre-existing and left alone here. The live coverage comes
from release.yml, Dockerfile:37 and build-bins.sh.

The gitlawb-core dependency-purity gate still resolves without --locked
on purpose, but its comment justified that by saying the build and test
jobs do the same, which stopped being true when those jobs gained
--locked. Restate the real reason: a stale lock would red that gate for
something unrelated to the dependency closure it checks.
2026-07-28 16:28:27 -05:00
t 5091f2dba0 ci: fail the release-lock job on a failed dispatch, and scope release-PR discovery
Two review findings on the sync-release-lock job.

Fail on every dispatch failure. The step tolerated the 422 a release branch
cut before pr-checks gained its workflow_dispatch trigger returns, on the
theory that release-please would rewrite the branch and heal it. A rewrite
only happens when a later releasable commit lands, so an open release PR
nobody touches is never rewritten, and the tolerated path left the lock-sync
commit this job had just pushed on the release head with no --locked
validation. That is the exact state the job exists to prevent. No release PR
is open today, and after this lands every release branch is cut from a main
that already carries the trigger, so the tolerance protects nothing.

Scope the fallback discovery. `gh pr list` includes fork PRs and a fork's
headRefName is bare, so matching on the `release-please--` prefix alone let
any fork nominate the branch this job checks out, commits to, and dispatches
on: actions/checkout then cannot resolve that fork-only ref here, failing the
job and leaving the real release PR unsynced and unchecked. Discovery now
requires the head repo to be this repository and the author to be the
release-please app, and selects the highest PR number instead of relying on
gh's undocumented default ordering.

Verified the new filter against the hostile inputs, using the jq program as
extracted from the parsed YAML: a fork lookalike, a fork PR claiming the
app author, and a same-repo human branch named `release-please--*` are all
rejected; the real release PR is still selected when listed after a fork and
when a second component release PR is present; an empty listing yields empty.
The previous expression selected the fork's branch on all three hostile cases.
2026-07-27 18:34:28 -05:00
t f2f99c6684 chore: regenerate Cargo.lock for the 0.7.0 manifests after rebase
The branch forked before v0.7.0 merged, so the rebase left main's 0.7.0
manifests against workspace lock records still at 0.6.0. That state fails
the gate this PR adds: `cargo metadata --locked` exits 101 with "cannot
update the lock file because --locked was passed".

Regenerated with `cargo update --workspace`, the same command the new
sync-release-lock job runs. Only the 5 workspace crates move 0.6.0 ->
0.7.0; all 252 external dependencies are unchanged, so this is a version
resync and not a dependency bump. `cargo metadata --locked` now exits 0.
2026-07-27 18:28:00 -05:00
t b605b2269a ci: dispatch pr-checks on the release branch so GITHUB_TOKEN pushes still get validated 2026-07-27 18:27:13 -05:00
t 52a8f010b7 ci: lock-sync the release PR and build --locked so lock drift fails loudly (#242) 2026-07-27 18:27:13 -05:00
t b8eff30fc2 chore: sync Cargo.lock with the 0.6.0 manifests 2026-07-27 18:26:45 -05:00
Kevin CodexandClaude Fable 5 111cff7e20 infra: add production fly configs for gitlawb-node-2 (sjc) and gitlawb-node-3 (nrt)
Fleet configs from the 2026-07-22 multi-node deploy: iCaptcha enforce
(level 3), cross-node bootstrap peers, 500/400 connection limits.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 09:55:22 +08:00
Kevin Codex e9da517fde Merge pull request #240 from Gitlawb/release-please--branches--main--components--gitlawb-node
chore(main): release 0.7.0
2026-07-23 08:27:35 +08:00
Kevin Codex 9ca94c4d47 Merge pull request #241 from Gitlawb/ci/gitlawb-core-dep-purity
ci(core): add dependency-purity allowlist gate for gitlawb-core
2026-07-23 07:06:43 +08:00
beardthelion c0ad500b55 ci(core): allowlist curve25519-dalek-derive
Proc-macro helper for curve25519-dalek (already allowlisted). Newer cargo
versions list it under `cargo tree --edges normal` while older ones do not;
the allowlist is a superset so it passes on both. Caught by core-deps-purity
on the first CI run of this branch.
2026-07-22 12:36:25 -05:00
beardthelion e9d6aadd58 ci(core): add dependency-purity allowlist gate for gitlawb-core
gitlawb-core is embedded by gl, git-remote-gitlawb, and the node daemon,
so it must stay lean. Add a CI gate that fails if its normal (non-dev,
non-build) transitive dependency tree gains any crate not on an explicit
allowlist.

The allowlist is exhaustive-by-construction: a new heavy dependency reds
CI whether or not anyone thought to ban it, which a denylist cannot do.
The checker resolves like the build/test jobs (no --locked, since the
committed lockfile can lag the manifests) and snapshots/restores
Cargo.lock so a local run leaves no working-tree side effects.

- ci/gitlawb-core-allowed-deps.txt: the 83 current normal deps, plus the
  regeneration command and rationale in the header
- scripts/check-gitlawb-core-deps.sh: recompute and diff; hard-fail on a
  non-allowlisted crate, note-only on a stale (removed) allowlist entry
- pr-checks.yml: new hard-fail core-deps-purity job, matching the pinned
  action SHAs and style of the sibling jobs
2026-07-22 12:04:34 -05:00
github-actions[bot] 32006b26a2 chore(main): release 0.7.0 2026-07-22 15:28:32 +00:00
Gravirei 1ba9ee505a test(icaptcha-client): use is_some() instead of != None 2026-07-22 19:18:03 +06:00
Gravirei e1e6e1cf83 test(icaptcha-client): cover https port isolation through new(), collect failures 2026-07-22 18:54:33 +06:00
Gravirei 82fec97a97 test(icaptcha-client): cover operator+hostile-advert and no-operator+no-advert arms 2026-07-20 14:26:18 +06:00
Gravirei 9be5eac3a2 fix(icaptcha-client): update dependencies to version 0.5.1 and enhance operator fallback handling in tests 2026-07-19 11:28:41 +06:00
Gravirei 6b2ec93de1 test(icaptcha-client): add test that new() honors key_trusted gate (#212) 2026-07-19 01:55:57 +06:00
40 changed files with 3718 additions and 10666 deletions

No files matched your search

-30
View File
@@ -13,16 +13,9 @@ GITLAWB_PUBLIC_URL=https://your-node.example.com
# ── Server ────────────────────────────────────────────────────────────────
GITLAWB_HOST=0.0.0.0
GITLAWB_PORT=7545
# Optional address to bind a Prometheus /metrics exposition endpoint on (e.g.
# 127.0.0.1:9091). Leave empty (default) to disable. Bind to localhost or a
# private interface — the metrics endpoint is unauthenticated.
GITLAWB_METRICS_ADDR=
# ── Storage ───────────────────────────────────────────────────────────────
GITLAWB_REPOS_DIR=/data/repos
# Tigris (S3-compatible) bucket for repo storage. Leave empty (default) to
# disable Tigris and use local-only storage.
GITLAWB_TIGRIS_BUCKET=
# PostgreSQL connection URL. Required.
# When using the bundled docker-compose, this is wired automatically.
@@ -33,12 +26,6 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
# connections open lazily. Size against the DB server's max_connections,
# remembering admin tooling opens its own pool.
GITLAWB_DB_MAX_CONNECTIONS=20
# Maximum connections in the dedicated advisory-lock pool (separate from the
# pool above). Each in-flight repo write pins one connection here for its whole
# lifetime, so size it to peak concurrent distinct-repo writers — keeping it
# separate is what stops a push burst from starving request handlers. Keep
# (main pool + lock pool) within the DB server's max_connections.
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=32
# 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
@@ -52,9 +39,6 @@ GITLAWB_DB_RETRY_INITIAL_SECS=5
GITLAWB_DB_RETRY_MAX_SECS=60
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
# URL of a local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001). Leave
# empty (default) to disable local IPFS.
GITLAWB_IPFS_API=
# Get a JWT at https://app.pinata.cloud/developers/api-keys
GITLAWB_PINATA_JWT=
GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files
@@ -143,20 +127,6 @@ GITLAWB_PUSH_RATE_LIMIT=600
# the client IP. 0 disables. Default 120.
GITLAWB_CREATE_RATE_LIMIT=120
# ── Write rate limiting (non-creation authenticated writes) ───────────────
# Max non-creation write requests per client IP per hour: issue/PR comments,
# labels, stars, merges, protect/unprotect, replicas, visibility, tasks,
# bounties, profile, and all GraphQL HTTP requests. The brake wraps the whole
# /graphql route, so queries and the playground GET consume this bucket too, not
# only mutations (GraphQL WebSocket subscriptions are excluded). Its own bucket,
# separate from the creation and push brakes. Uses GITLAWB_TRUSTED_PROXY to
# resolve the client IP.
# NOTE: this is a per-IP aggregate across ALL those write actions, so behind a
# shared NAT/egress IP (or with GITLAWB_TRUSTED_PROXY unset) many users collapse
# onto one bucket — raise this for automation-heavy or multi-user single-IP
# deployments. 0 disables. Default 600.
GITLAWB_WRITE_RATE_LIMIT=600
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
# known peers and run at higher frequency, so a generous bucket. Separate from
+41 -5
View File
@@ -6,6 +6,13 @@ on:
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
# and the lock-sync job), and GitHub's recursion guard means those pushes
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
# the release branch to attach check runs to its head. Read-only workflow
# (contents: read), so the dispatch surface adds no privilege.
workflow_dispatch:
push:
branches:
- main
@@ -46,7 +53,7 @@ jobs:
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo clippy --locked --workspace --all-targets -- -D warnings
test:
name: test (${{ matrix.toolchain }})
@@ -94,7 +101,7 @@ jobs:
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --workspace
run: cargo test --locked --workspace
build-release:
name: build --release
@@ -117,7 +124,7 @@ jobs:
key: release
- name: cargo build --release
run: cargo build --release --workspace
run: cargo build --locked --release --workspace
audit:
name: cargo audit
@@ -199,7 +206,7 @@ jobs:
key: msrv
- name: cargo check on MSRV
run: cargo check --workspace --all-targets
run: cargo check --locked --workspace --all-targets
docker-build:
name: Docker build smoke test
@@ -246,4 +253,33 @@ jobs:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test -p gl -p git-remote-gitlawb
run: cargo test --locked -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
# dependency reds CI whether or not anyone thought to ban it, which a denylist
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: core-deps-purity
- name: Check gitlawb-core dependency allowlist
run: bash scripts/check-gitlawb-core-deps.sh
+139 -1
View File
@@ -41,6 +41,7 @@ jobs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
pr: ${{ steps.release.outputs.pr }}
steps:
- name: Run release-please
id: release
@@ -48,6 +49,143 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
# The release PR bumps the workspace crate versions in the Cargo.tomls but
# release-please cannot update Cargo.lock, so without this job every release
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
# the release branch so the tagged tree is internally consistent.
#
# Runs on every push-triggered release run, not only when release-please
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
# the release PR, so gating on it would leave an already-open release PR
# (opened before this job existed, or untouched by a non-releasable push)
# permanently without the sync and without check runs. The resolve step
# discovers any open release-please PR itself and no-ops when there is none.
sync-release-lock:
name: Sync Cargo.lock on the release PR
needs: release-please
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
# actions: write lets the final step dispatch pr-checks on the release
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
# scope it to one workflow); acceptable here because reaching this job's
# steps already requires write to main. Needed because every push to the
# release branch (release-please's and the lock sync below) is made with
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
# dispatch the release PR's head carries no check runs and the --locked
# gate never validates it.
actions: write
steps:
# Prefer the fresh `pr` output when this run just created/updated the
# release PR; otherwise fall back to querying for an open release-please
# branch (prefix match: the branch carries `--components--` suffixes).
- name: Resolve the open release PR branch
id: resolve
env:
GH_TOKEN: ${{ github.token }}
OWNER: ${{ github.repository_owner }}
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
run: |
branch="$BRANCH_FROM_OUTPUT"
if [ -z "$branch" ]; then
# The branch name alone is not a trust signal. `gh pr list` includes
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
# prefix match by itself lets any fork nominate the branch this job
# checks out, commits to, and dispatches checks on. Require the head
# repo to be THIS repository and the author to be the release-please
# app, so neither a fork nor a same-repo human branch named
# `release-please--*` can be selected. Then take the highest PR
# number: `[0]` depended on gh's default ordering, which is not a
# documented guarantee, and picking a stable one keeps a second
# (component) release PR from making the choice flap between runs.
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
--json headRefName,headRepositoryOwner,number,author \
--jq '[ .[]
| select(.headRepositoryOwner.login == env.OWNER)
| select(.author.login == "app/github-actions")
| select(.headRefName | startswith("release-please--"))
] | max_by(.number).headRefName // empty')
fi
if [ -z "$branch" ]; then
echo "no open release PR; nothing to sync"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- name: Checkout release branch
if: ${{ steps.resolve.outputs.branch != '' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.resolve.outputs.branch }}
# Resolve on a declared toolchain instead of whatever Rust the runner
# image happens to ship, so the lock this job writes comes off the same
# stable channel the other cargo jobs use. Not a claim of identical
# resolution: `stable` still moves between runs, and the MSRV gate
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
# runner image as an undeclared input.
- name: Install Rust toolchain
if: ${{ steps.resolve.outputs.branch != '' }}
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Sync workspace crate versions into Cargo.lock
if: ${{ steps.resolve.outputs.branch != '' }}
run: cargo update --workspace
- name: Commit and push when the lock changed
if: ${{ steps.resolve.outputs.branch != '' }}
run: |
if git diff --quiet Cargo.lock; then
echo "Cargo.lock already in sync"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.lock
git commit -m "chore: sync Cargo.lock with the release version bump"
git push
# Every push to the release branch is made with GITHUB_TOKEN (release-
# please's own pushes and the lock sync above), and events created by
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
# branch to attach check runs to its current head. Unconditional (also on
# the already-in-sync path) because the branch's ORIGINAL head has the
# same no-runs problem. Loud on failure by design: a silent fallback
# would recreate the checkless-head problem, and a job rerun is safe
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
# is the sync commit. Merge-queue note: this validates the branch tip;
# the enforced merge queue's merge_group run still re-validates the true
# merged result before landing.
- name: Dispatch PR checks on the release branch
if: ${{ steps.resolve.outputs.branch != '' }}
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.resolve.outputs.branch }}
run: |
# Every dispatch failure fails the job, including the 422 a release
# branch cut before pr-checks gained its workflow_dispatch trigger
# returns ("does not have a workflow_dispatch trigger", reported
# against the dispatched ref's copy of the file). An earlier revision
# tolerated exactly that 422 on the theory that release-please would
# rewrite the branch and heal it, but a rewrite only happens when a
# later releasable commit lands: an open release PR nobody touches is
# never rewritten. Tolerating it therefore left the lock-sync commit
# this job had just pushed sitting on the release head with no
# --locked validation, which is the precise state this workflow exists
# to prevent. The tolerance is also no longer needed: once this lands,
# every release branch is cut from a main that already carries the
# trigger, so the only branch that could 422 is one open at merge time
# (there is none), and a job rerun after a manual rebase is safe (the
# sync no-ops, the dispatch retries).
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
-f "ref=$BRANCH"
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
@@ -308,7 +446,7 @@ jobs:
set -euo pipefail
args=""
for b in $BINS; do args="$args -p $b"; done
cargo build --release --target ${{ matrix.target }} $args
cargo build --release --locked --target ${{ matrix.target }} $args
# Run each packaged binary's --version so a broken release artifact fails the
# build instead of shipping. Every target builds on a matching-arch runner,
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.6.0"
".": "0.7.1"
}
+44
View File
@@ -1,5 +1,49 @@
# Changelog
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
### Bug Fixes
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
### Features
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
### Bug Fixes
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
Generated
+86 -12
View File
@@ -770,6 +770,23 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ark-ff"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
dependencies = [
"ark-ff-asm 0.6.0",
"ark-ff-macros 0.6.0",
"ark-serialize 0.6.0",
"ark-std 0.6.0",
"digest 0.10.7",
"educe",
"num-bigint",
"num-traits",
"zeroize",
]
[[package]]
name = "ark-ff-asm"
version = "0.3.0"
@@ -800,6 +817,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-asm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.3.0"
@@ -838,6 +865,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
dependencies = [
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-serialize"
version = "0.3.0"
@@ -871,6 +911,30 @@ dependencies = [
"num-bigint",
]
[[package]]
name = "ark-serialize"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
dependencies = [
"ark-serialize-derive",
"ark-std 0.6.0",
"digest 0.10.7",
"num-bigint",
"serde_with",
]
[[package]]
name = "ark-serialize-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-std"
version = "0.3.0"
@@ -901,6 +965,16 @@ dependencies = [
"rand 0.8.6",
]
[[package]]
name = "ark-std"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
dependencies = [
"num-traits",
"rand 0.8.6",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2556,7 +2630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@@ -2928,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3300,7 +3374,7 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"anyhow",
"gitlawb-core",
@@ -3312,7 +3386,7 @@ dependencies = [
[[package]]
name = "gitlawb-attest"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3329,7 +3403,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"anyhow",
"base64",
@@ -3356,14 +3430,13 @@ dependencies = [
[[package]]
name = "gitlawb-node"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"alloy",
"anyhow",
"async-compression",
"async-graphql",
"async-graphql-axum",
"async-trait",
"aws-config",
"aws-sdk-s3",
"axum",
@@ -3413,7 +3486,7 @@ dependencies = [
[[package]]
name = "gl"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"alloy",
"anyhow",
@@ -5742,14 +5815,15 @@ dependencies = [
[[package]]
name = "ruint"
version = "1.17.2"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
dependencies = [
"alloy-rlp",
"ark-ff 0.3.0",
"ark-ff 0.4.2",
"ark-ff 0.5.0",
"ark-ff 0.6.0",
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
@@ -5823,7 +5897,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6717,7 +6791,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+18 -5
View File
@@ -8,20 +8,33 @@ FROM rust:1.91-bookworm AS builder
WORKDIR /build
# Cache dependencies first for faster rebuilds
# Cache dependencies first for faster rebuilds.
# EVERY workspace member's manifest must be copied here, not just the ones this
# image ships: cargo loads the whole workspace before it resolves anything, so a
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
# layer before a single crate is fetched.
COPY Cargo.toml Cargo.lock ./
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
COPY crates/gl/Cargo.toml crates/gl/
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
# Now copy real sources and build for real.
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
@@ -34,7 +47,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
target/release/.fingerprint/gl-* \
target/release/.fingerprint/git-remote-gitlawb-* && \
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
# ── Runtime stage ───────────────────────────────────────────────────────────
+5 -2
View File
@@ -3,7 +3,10 @@
# Used by scripts/build-bins.sh — not used for node deployment.
ARG TARGET=x86_64-unknown-linux-musl
FROM rust:1.85-bookworm AS builder
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
# base image here fails only when someone runs scripts/build-bins.sh.
FROM rust:1.91-bookworm AS builder
ARG TARGET
WORKDIR /build
@@ -23,7 +26,7 @@ COPY crates/ crates/
# Configure cross-linker for aarch64
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
FROM debian:bookworm-slim
ARG TARGET
+112
View File
@@ -0,0 +1,112 @@
# Dependency allowlist for the `gitlawb-core` crate.
#
# gitlawb-core is the shared-primitives crate every consumer embeds (the `gl`
# CLI, git-remote-gitlawb, the node daemon). It must stay lean so those
# consumers do not inherit daemon-weight dependencies. This file is the source
# of truth for the crates gitlawb-core's NORMAL (non-dev, non-build) transitive
# dependency closure is allowed to contain. CI (`scripts/check-gitlawb-core-deps.sh`,
# wired into the `core-deps-purity` job in .github/workflows/pr-checks.yml)
# fails if gitlawb-core's tree contains any crate not listed here.
#
# The allowlist is exhaustive-by-construction: a NEW dependency reds CI whether
# or not anyone thought to ban it, which a denylist cannot do.
#
# Measured in-workspace, so feature unification across the workspace can, in
# rare cases, surface a crate here that gitlawb-core would not pull standalone.
# An unexpected new entry is a prompt to check what pulled it in, not noise to
# rubber-stamp.
#
# Regenerate (from repo root, after an INTENTIONAL dependency change):
# cargo tree -p gitlawb-core --edges normal --prefix none \
# | sed -E 's/ v[0-9].*$//' \
# | grep -v '^gitlawb-core$' \
# | sort -u
# then paste the result below this header block. (The checker snapshots and
# restores Cargo.lock; a manual regen may refresh it — `git checkout Cargo.lock`
# afterward if you did not intend that.)
#
# Lines starting with `#` and blank lines are ignored by the checker.
aead
anyhow
base-x
base256emoji
base64
base64ct
block-buffer
cfg-if
chacha20
chacha20poly1305
chrono
cid
cipher
const-oid
const-str
core2
cpufeatures
crypto-common
crypto_box
crypto_secretbox
curve25519-dalek
curve25519-dalek-derive
data-encoding
data-encoding-macro
data-encoding-macro-internal
der
digest
ed25519
ed25519-dalek
equivalent
generic-array
getrandom
hashbrown
hex
iana-time-zone
indexmap
inout
itoa
libc
match-lookup
memchr
multibase
multihash
multihash-codetable
multihash-derive
multihash-derive-impl
num-traits
opaque-debug
pem-rfc7468
pkcs8
poly1305
ppv-lite86
proc-macro-crate
proc-macro2
quote
rand
rand_chacha
rand_core
salsa20
serde
serde_core
serde_derive
serde_json
sha2
signature
spki
subtle
syn
synstructure
thiserror
thiserror-impl
toml_datetime
toml_edit
toml_parser
typenum
unicode-ident
universal-hash
unsigned-varint
uuid
winnow
zerocopy
zeroize
zeroize_derive
zmij
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "git-remote-gitlawb"
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
version = "0.6.0" # x-release-please-version
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version = "0.6.0" # x-release-please-version
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.6.0" # x-release-please-version
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version = "0.6.0" # x-release-please-version
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -12,7 +12,6 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
async-trait = "0.1"
ed25519-dalek = { workspace = true }
base64 = { workspace = true }
tokio = { workspace = true }
File diff suppressed because it is too large. Load diff
+7 -490
View File
@@ -70,37 +70,11 @@ pub async fn create_issue(
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
// Always release the advisory lock, even on error; upload to Tigris only on
// success. A false return means the durable upload failed/timed out: the
// issue ref applied to local disk but never reached object storage, so the
// next acquire_write would revert it from the stale archive. Report 5xx so
// the client retries rather than trusting an issue that never landed durably,
// and skip the trust bump below. (Same P1 data-loss guard as receive-pack.)
// On that failure, roll back the local ref BEFORE the lock releases — an
// orphan ref would make the retry a duplicate, and in an unlock-to-delete
// window a concurrent same-repo write could upload an archive still
// carrying it. Best-effort: a failed delete logs and the 5xx still returns.
let upload_ok = guard
.release_with_failure_cleanup(create_result.is_ok(), |path| {
if let Err(e) = git_issues::delete_issue_ref(path, &issue_id) {
tracing::warn!(issue = %issue_id, err = %e,
"failed to roll back local issue ref after failed durable upload; \
a retry may duplicate this issue");
}
})
.await;
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(create_result.is_ok()).await;
create_result.map_err(|e| AppError::Git(e.to_string()))?;
if !upload_ok {
tracing::error!(repo = %record.name,
"durable upload failed after issue create; reporting failure so the client retries");
return Err(AppError::Git(format!(
"durable storage upload failed for {}",
record.name
)));
}
// Bump trust score for the issue author — increment current score by 0.05
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only file issues)
if let Some(ref author_did) = issue.author {
@@ -270,14 +244,11 @@ pub async fn close_issue(
.ok()
.and_then(|i| i.author),
Ok(None) => {
// release(false): no upload attempted, so the bool is always true
// (nothing to fail on). Ignore it on this pre-write error path.
let _ = guard.release(false).await;
guard.release(false).await;
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
}
Err(e) => {
// release(false): no upload attempted, nothing to fail on.
let _ = guard.release(false).await;
guard.release(false).await;
return Err(AppError::Git(e.to_string()));
}
};
@@ -286,57 +257,21 @@ pub async fn close_issue(
.as_deref()
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
if !is_owner && !is_author {
// release(false): no upload attempted, nothing to fail on.
let _ = guard.release(false).await;
guard.release(false).await;
return Err(AppError::Forbidden(
"only the repo owner or the issue author can close this issue".into(),
));
}
// Snapshot the issue ref's pre-close OID so a failed durable upload can roll
// the close back on local disk, mirroring create_issue. Best-effort: if the
// snapshot read fails we proceed without a rollback closure (the close still
// applies and a failed upload still surfaces as a 5xx below).
let prior_ref = git_issues::issue_ref_oid(&disk_path, &issue_id)
.ok()
.flatten();
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. A false return means the durable upload failed: the close applied
// to local disk but never reached object storage, so a later acquire_write
// reverts it. Report 5xx so the client retries. (P1 data-loss guard.)
// On that failure, roll the local close BACK to the captured open ref BEFORE
// the lock releases — otherwise the local fast path in RepoStore::acquire
// serves the "closed" state until a later write refreshes the archive, even
// though the client got a 5xx. The cleanup runs only when an upload was
// attempted and failed, while the advisory lock is still held.
let upload_ok = guard
.release_with_failure_cleanup(close_result.is_ok(), |path| {
if let Some((full_id, oid)) = &prior_ref {
if let Err(e) = git_issues::restore_issue_ref(path, full_id, oid) {
tracing::warn!(issue = %issue_id, err = %e,
"failed to roll back local issue close after failed durable upload; \
a local-fast-path read may serve a stale closed state");
}
}
})
.await;
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(close_result.is_ok()).await;
let updated = close_result
.map_err(|e| AppError::Git(e.to_string()))?
.ok_or_else(|| AppError::RepoNotFound(format!("issue {issue_id} not found")))?;
if !upload_ok {
tracing::error!(repo = %repo,
"durable upload failed after issue close; reporting failure so the client retries");
return Err(AppError::Git(format!(
"durable storage upload failed for {}",
record.name
)));
}
let issue: serde_json::Value = serde_json::from_str(&updated)
.map_err(|e| AppError::BadRequest(format!("invalid issue data: {e}")))?;
@@ -344,421 +279,3 @@ pub async fn close_issue(
Ok(Json(issue))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::{Path as AxPath, State};
use axum::Extension;
use std::path::Path as StdPath;
use std::time::Duration;
// Object store whose upload() parks forever, so release()'s bounded timeout
// is the only thing that completes it, modeling a stalled durable PUT. exists()
// is false so acquire_write never downloads over the fresh local bare repo.
struct StallStore;
#[async_trait::async_trait]
impl crate::git::tigris::ObjectStore for StallStore {
async fn exists(&self, _o: &str, _r: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn upload(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
std::future::pending::<()>().await;
Ok(())
}
async fn download(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
Ok(())
}
async fn delete(&self, _o: &str, _r: &str) -> anyhow::Result<()> {
Ok(())
}
}
// Object store whose upload succeeds immediately. exists() is false so
// acquire_write never performs a reverting refresh over the local dir —
// the retry test below pins that contract explicitly.
struct OkStore;
#[async_trait::async_trait]
impl crate::git::tigris::ObjectStore for OkStore {
async fn exists(&self, _o: &str, _r: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn upload(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
Ok(())
}
async fn download(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
Ok(())
}
async fn delete(&self, _o: &str, _r: &str) -> anyhow::Result<()> {
Ok(())
}
}
// Shared setup for the failed-upload rollback tests: a state whose repo
// store stalls uploads (200ms release timeout), a public repo row for
// `owner/name`, and an initialized bare repo at the returned path.
async fn stall_state_with_repo(
pool: sqlx::PgPool,
repos_dir: &tempfile::TempDir,
owner: &str,
name: &str,
) -> (crate::state::AppState, std::path::PathBuf) {
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir.path().to_path_buf(),
Some(std::sync::Arc::new(StallStore)),
pool.clone(),
)
.with_release_upload_timeout(Duration::from_millis(200));
let owner_slug = owner.replace([':', '/'], "_");
let bare = repos_dir
.path()
.join(&owner_slug)
.join(format!("{name}.git"));
state
.db
.upsert_mirror_repo(owner, name, &bare.to_string_lossy(), None, false)
.await
.unwrap();
std::fs::create_dir_all(&bare).unwrap();
let out = std::process::Command::new("git")
.args(["init", "--bare", "-q", &bare.to_string_lossy()])
.output()
.unwrap();
assert!(out.status.success(), "git init --bare failed");
(state, bare)
}
async fn create_issue_status(
state: &crate::state::AppState,
owner: &str,
name: &str,
) -> axum::http::StatusCode {
use axum::response::IntoResponse;
let resp = super::create_issue(
State(state.clone()),
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
AxPath((owner.to_string(), name.to_string())),
axum::Json(CreateIssueRequest {
title: "t".into(),
body: None,
signed_payload: None,
}),
)
.await;
match resp {
Ok((code, _)) => code,
Err(e) => e.into_response().status(),
}
}
async fn close_issue_status(
state: &crate::state::AppState,
owner: &str,
name: &str,
issue_id: &str,
) -> axum::http::StatusCode {
use axum::response::IntoResponse;
let resp = super::close_issue(
State(state.clone()),
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
AxPath((owner.to_string(), name.to_string(), issue_id.to_string())),
)
.await;
match resp {
Ok(_) => axum::http::StatusCode::OK,
Err(e) => e.into_response().status(),
}
}
// Seed an OPEN issue directly on local disk, bypassing the (stalling) upload
// path so the close test starts from a durably-consistent open issue.
fn seed_open_issue(bare: &StdPath, owner: &str, issue_id: &str) {
let issue = IssueRecord {
id: issue_id.to_string(),
title: "t".into(),
body: None,
author: Some(owner.to_string()),
created_at: Utc::now().to_rfc3339(),
status: "open".to_string(),
signed_payload: None,
};
let json = serde_json::to_string(&issue).unwrap();
git_issues::create_issue(bare, issue_id, &json).unwrap();
}
fn issue_status(bare: &StdPath, issue_id: &str) -> String {
let raw = git_issues::get_issue(bare, issue_id).unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
v["status"].as_str().unwrap().to_string()
}
// U2 [F2, P1] — the failed-upload rollback on the close path. close_issue
// applies the "closed" ref to LOCAL disk, then release() uploads to durable
// storage. When that upload times out the local close is NOT persisted, so a
// later acquire_write reverts it from the stale archive — but until then, a
// local-fast-path read via RepoStore::acquire serves the stale "closed"
// state even though the client got a 5xx. The fix rolls the close back to the
// captured open ref while the advisory lock is held. RED with the cleanup
// closure reverted to `|_| {}`: the read sees "closed".
#[sqlx::test]
async fn issue_close_failed_upload_rolls_back_to_open(pool: sqlx::PgPool) {
let repos_dir = tempfile::TempDir::new().unwrap();
let owner = "z6issuecloserollbackowner";
let name = "issuecloserollback";
let (state, bare) = stall_state_with_repo(pool, &repos_dir, owner, name).await;
let issue_id = "aaaa1111-0000-0000-0000-000000000000";
seed_open_issue(&bare, owner, issue_id);
assert_eq!(issue_status(&bare, issue_id), "open", "seed must be open");
// Close via the handler — the durable upload stalls and times out (5xx).
let status = close_issue_status(&state, owner, name, issue_id).await;
assert!(
status.is_server_error(),
"a stalled durable upload on issue-close must 500, got {status}"
);
// The local fast path must still see the issue OPEN: the failed upload
// rolled the close back while the lock was held.
assert_eq!(
issue_status(&bare, issue_id),
"open",
"a failed durable upload must roll the local close back to open; a \
local-fast-path read served the stale closed state"
);
}
// Success path: a working store closes the issue (2xx) and the read reflects
// the close. Confirms the rollback wiring does not fire on success.
#[sqlx::test]
async fn issue_close_success_persists_close(pool: sqlx::PgPool) {
let repos_dir = tempfile::TempDir::new().unwrap();
let owner = "z6issueclosesuccessowner";
let name = "issueclosesuccess";
let (mut state, bare) = stall_state_with_repo(pool.clone(), &repos_dir, owner, name).await;
// Swap the stalling store for one whose upload succeeds immediately.
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir.path().to_path_buf(),
Some(std::sync::Arc::new(OkStore)),
pool.clone(),
)
.with_release_upload_timeout(Duration::from_millis(200));
let issue_id = "bbbb2222-0000-0000-0000-000000000000";
seed_open_issue(&bare, owner, issue_id);
let status = close_issue_status(&state, owner, name, issue_id).await;
assert_eq!(status, StatusCode::OK, "working store must close (200)");
assert_eq!(
issue_status(&bare, issue_id),
"closed",
"a successful close must persist as closed"
);
}
// Pre-write error path: closing a nonexistent issue returns 404 and never
// attempts an upload (release(false)), so the stalling store cannot hang it.
#[sqlx::test]
async fn issue_close_not_found_releases_without_upload(pool: sqlx::PgPool) {
let repos_dir = tempfile::TempDir::new().unwrap();
let owner = "z6issueclosemissingowner";
let name = "issueclosemissing";
let (state, _bare) = stall_state_with_repo(pool, &repos_dir, owner, name).await;
let status =
close_issue_status(&state, owner, name, "cccc3333-0000-0000-0000-000000000000").await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"closing a missing issue must 404 without attempting an upload"
);
}
fn issue_refs(bare: &StdPath) -> Vec<String> {
let out = std::process::Command::new("git")
.args([
"for-each-ref",
"--format=%(refname)",
"refs/gitlawb/issues/",
])
.current_dir(bare)
.output()
.unwrap();
assert!(out.status.success(), "git for-each-ref failed");
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
.collect()
}
// Rollback half of the #196 round-four issue-create fix. When the durable
// upload fails/times out, the handler 500s (covered below) — but the issue
// ref it wrote to LOCAL disk must also be rolled back, while the advisory
// lock is still held. An orphan ref makes the 500 dishonest: a retry
// re-creates the issue under a new uuid while the orphan is still listed,
// and a concurrent same-repo write can upload an archive carrying it.
// RED pre-fix: the orphan ref survives the failed create.
#[sqlx::test]
async fn issue_create_failed_upload_rolls_back_local_ref(pool: sqlx::PgPool) {
let repos_dir = tempfile::TempDir::new().unwrap();
let owner = "z6issuerollbackowner";
let name = "issuerollback";
let (state, bare) = stall_state_with_repo(pool, &repos_dir, owner, name).await;
let status = create_issue_status(&state, owner, name).await;
assert!(
status.is_server_error(),
"stalled upload must 500, got {status}"
);
let refs = issue_refs(&bare);
assert!(
refs.is_empty(),
"a failed durable upload must roll back the local issue ref while \
the advisory lock is held; orphan ref(s) survived: {refs:?}"
);
}
// Retry half: after the failed-upload 500, a retry with a WORKING store
// must yield exactly one issue. Pre-fix the orphan ref from the failed
// attempt makes the retry a duplicate (two issues in list_issues).
#[sqlx::test]
async fn issue_create_failed_upload_retry_does_not_duplicate(pool: sqlx::PgPool) {
use crate::git::tigris::ObjectStore as _;
let repos_dir = tempfile::TempDir::new().unwrap();
let owner = "z6issueretryowner";
let name = "issueretry";
let (state, bare) = stall_state_with_repo(pool.clone(), &repos_dir, owner, name).await;
let status = create_issue_status(&state, owner, name).await;
assert!(
status.is_server_error(),
"stalled upload must 500, got {status}"
);
// PIN the double's contract: exists() stays false after the failed
// upload, so the retry's acquire_write performs no reverting refresh
// from a stale archive. Without this pin, a download could revert the
// orphan ref and the assertion below would pass without the rollback
// fix — it must rest on the rollback, not a stale-archive revert.
let owner_slug = owner.replace([':', '/'], "_");
assert!(
!OkStore.exists(&owner_slug, name).await.unwrap(),
"test double contract broken: exists() must stay false"
);
// Retry with a store whose upload succeeds.
let mut retry_state = state.clone();
retry_state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir.path().to_path_buf(),
Some(std::sync::Arc::new(OkStore)),
pool.clone(),
)
.with_release_upload_timeout(Duration::from_millis(200));
let status = create_issue_status(&retry_state, owner, name).await;
assert_eq!(
status,
StatusCode::CREATED,
"retry with working store must 201"
);
let issues = crate::git::issues::list_issues(&bare).unwrap();
assert_eq!(
issues.len(),
1,
"retry after a failed-upload 500 must yield exactly ONE issue; the \
failed attempt's orphan ref duplicated it (got {} issues)",
issues.len()
);
}
// P1-class data-loss regression, issue-create path. create_issue writes the
// issue ref to local disk, then release(true) uploads the repo to durable
// storage. When that upload times out the local write is NOT persisted, so
// the next acquire_write reverts it from the stale archive. The pre-fix
// handler ignored release()'s bool and still returned 201 AND bumped the
// author's trust score, so the client trusted an issue that never landed
// durably. The fix surfaces a failed/timed-out upload as a 5xx and skips the
// success tail (the trust bump). Stall the upload past a tiny release timeout
// and assert (a) the handler returns 5xx and (b) the trust bump did not run.
// RED pre-fix: 201 + trust score bumped to 0.05.
#[sqlx::test]
async fn issue_create_durable_upload_timeout_fails_and_skips_side_effects(pool: sqlx::PgPool) {
use axum::response::IntoResponse;
let mut state = crate::test_support::test_state(pool.clone()).await;
let repos_dir = tempfile::TempDir::new().unwrap();
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir.path().to_path_buf(),
Some(std::sync::Arc::new(StallStore)),
pool.clone(),
)
.with_release_upload_timeout(Duration::from_millis(200));
let owner = "z6issueownerdurablefail";
let name = "issuedurablefail";
let owner_slug = owner.replace([':', '/'], "_");
let bare = repos_dir
.path()
.join(&owner_slug)
.join(format!("{name}.git"));
// Public mirror repo so the owner clears authorize_repo_read, and a real
// agent row so the post-write trust bump is an observable durable side
// effect (register_agent seeds trust_score = 0.0).
state
.db
.upsert_mirror_repo(owner, name, &bare.to_string_lossy(), None, false)
.await
.unwrap();
state.db.register_agent(owner, &[]).await.unwrap();
std::fs::create_dir_all(&bare).unwrap();
let out = std::process::Command::new("git")
.args(["init", "--bare", "-q", &bare.to_string_lossy()])
.output()
.unwrap();
assert!(out.status.success(), "git init --bare failed");
// acquire_write → git_issues::create_issue (writes the ref) → release
// (upload stalls, times out after 200ms). The client MUST see a failure.
let resp = super::create_issue(
State(state.clone()),
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
AxPath((owner.to_string(), name.to_string())),
axum::Json(CreateIssueRequest {
title: "t".into(),
body: None,
signed_payload: None,
}),
)
.await;
let status = match resp {
Ok((code, _)) => code,
Err(e) => e.into_response().status(),
};
assert!(
status.is_server_error(),
"a timed-out durable upload on issue-create must fail (5xx) so the \
client retries (got {status}), which the client trusts as a landed \
issue that a later acquire_write would silently revert"
);
// The success tail (trust bump) must NOT have run: the issue was never
// durably accepted. register_agent seeded 0.0; a bump would make it 0.05.
let score = state.db.get_trust_score(owner).await.unwrap();
assert_eq!(
score, 0.0,
"the trust bump must NOT run when the durable upload failed; the \
issue was not durably accepted (score bumped to {score})"
);
}
}
+270 -1
View File
@@ -381,6 +381,14 @@ pub async fn notify_sync(
)));
}
// #272: the slug reaches a PathBuf::join in the sync worker, where an extra
// separator or a traversal segment places the mirror outside repos_dir. Reject
// it here, before the queue row and the ref-update row are written. The message
// names the field and nothing about where repos live on disk.
if let Err(e) = crate::git::repo_store::validate_repo_slug(&req.repo) {
return Err(AppError::BadRequest(format!("invalid repo field: {e}")));
}
state
.db
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
@@ -584,7 +592,9 @@ mod tests {
state.config = Arc::new(cfg);
}
const NOTIFY_BODY: &str = r#"{"repo":"demo","ref_name":"refs/heads/main","new_sha":"0000000000000000000000000000000000000000","node_did":"PEER_DID"}"#;
// The slug must be a well-formed owner/name (#272); these brake tests are about
// the rate limiter, so the body carries a valid slug and reaches the brake.
const NOTIFY_BODY: &str = r#"{"repo":"z6Mkfoo/hello","ref_name":"refs/heads/main","new_sha":"0000000000000000000000000000000000000000","node_did":"PEER_DID"}"#;
// ── trigger: mandatory signature ──────────────────────────────────────────
@@ -866,6 +876,265 @@ mod tests {
);
}
// ── notify: repo slug validation (#272) ───────────────────────────────────
//
// An unsigned caller who has announced their own DID is a "known peer", so
// the slug they send reaches the sync worker's path join. `a//tmp/probe`
// resolved to /tmp/probe.git, outside repos_dir entirely. notify must reject
// anything that is not a well-formed owner/name slug before it is queued.
/// Every slug shape the boundary must refuse. `a//tmp/gitlawb-probe` is the
/// verified escape; the rest are the traversal and empty-half neighbours.
const MALFORMED_SLUGS: [&str; 9] = [
"a//tmp/gitlawb-probe",
"../hello",
"./hello",
"../../etc/evil",
"a/../../x",
"..",
"demo",
"/hello",
"z6Mkfoo/",
];
fn notify_body(repo: &str, peer_did: &str) -> String {
serde_json::json!({
"repo": repo,
"ref_name": "refs/heads/main",
"new_sha": "0000000000000000000000000000000000000000",
"node_did": peer_did,
})
.to_string()
}
#[sqlx::test]
async fn sync_notify_rejects_malformed_repo_slugs(pool: PgPool) {
let state = test_state(pool).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
for (i, slug) in MALFORMED_SLUGS.iter().enumerate() {
// Distinct source IPs so the per-IP brake never masks the 400.
let ip = format!("198.51.100.{}:5000", i + 1);
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body(slug, &peer_did),
&ip,
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"slug {slug:?} must be rejected"
);
}
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert!(
queued.is_empty(),
"no malformed slug may be queued, got {:?}",
queued.iter().map(|q| &q.repo).collect::<Vec<_>>()
);
}
#[sqlx::test]
async fn sync_notify_rejection_body_names_field_not_path(pool: PgPool) {
// R3: the client learns which field was bad, never where repos live on
// disk. A message carrying the resolved path would hand an unsigned
// caller the server's layout.
let state = test_state(pool).await;
let repos_dir = state.config.repos_dir.display().to_string();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &peer_did),
"198.51.100.20:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("repo"), "body must name the field: {body}");
assert!(
!body.contains(&repos_dir),
"body must not leak repos_dir ({repos_dir}): {body}"
);
for trimmed in repos_dir.trim_start_matches("./").split('/') {
assert!(
trimmed.is_empty() || !body.contains(trimmed),
"body must not leak a repos_dir segment ({trimmed}): {body}"
);
}
// A rooted path is a leading '/' with something after it. A bare quoted
// '/' is the separator the message is allowed to talk about.
assert!(
!body
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.any(|tok| tok.starts_with('/') && tok.len() > 1),
"body must not contain a rooted filesystem path: {body}"
);
}
#[sqlx::test]
async fn sync_notify_rejection_writes_no_ref_update(pool: PgPool) {
// The second half of R1: the ref-update feed row sits after enqueue_sync,
// so the early return must skip it too. Asserted, not argued.
let state = test_state(pool.clone()).await;
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &peer_did),
"198.51.100.21:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM received_ref_updates")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 0,
"a rejected notify must not seed the ref-update feed"
);
}
#[sqlx::test]
async fn sync_notify_accepts_valid_slug_and_records_ref_update(pool: PgPool) {
// must-not-break, and the control that keeps the two negative assertions
// above from being vacuous: a well-formed slug still queues and still
// writes its ref-update row.
let state = test_state(pool.clone()).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("z6Mkfoo/hello", &peer_did),
"198.51.100.22:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].repo, "z6Mkfoo/hello");
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM received_ref_updates WHERE repo = $1")
.bind("z6Mkfoo/hello")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 1,
"the valid path must still write the ref-update row"
);
}
#[sqlx::test]
async fn sync_notify_unknown_peer_wins_over_malformed_slug(pool: PgPool) {
// Ordering: the slug check sits after the peer gate, so an unknown peer
// still gets the unknown-peer message rather than a slug complaint.
let state = test_state(pool).await;
let stranger = Keypair::generate().did().to_string();
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &stranger),
"198.51.100.23:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
body.contains("unknown peer DID"),
"peer gate must still fire first: {body}"
);
}
#[sqlx::test]
async fn unsigned_announce_then_notify_cannot_queue_a_repos_dir_escape(pool: PgPool) {
// The reachability case for #272, committed in its attack form. Two
// unsigned requests through the shipped router, no signature and no
// config change: announce the attacker's own DID (which is what makes
// them a "known peer"), then notify with `a/<abs>/gitlawb-probe`. That
// slug used to reach PathBuf::join in the sync worker, where the
// absolute second component discarded repos_dir and planted a mirror at
// /<abs>/gitlawb-probe.git. The notify must now be refused outright.
//
// This has to run over crate::server::build_router rather than a
// directly mounted handler: the point of the test is that the default
// layer stack (optional_signature, not require_signature) leaves the
// route open, so mounting the handler alone would assert nothing about
// reachability.
let state = test_state(pool).await;
let db = state.db.clone();
let attacker = Keypair::generate().did().to_string();
let router = crate::server::build_router(state);
let announce =
format!(r#"{{"did":"{attacker}","http_url":"https://attacker.example.com"}}"#);
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/peers/announce",
&announce,
"198.51.100.40:5000",
))
.await
.unwrap();
// Asserted, not assumed: this plan does not change announce, and the
// whole attack rests on an unsigned announce still succeeding.
assert_eq!(
resp.status(),
StatusCode::OK,
"unsigned announce still admits an attacker-generated DID"
);
let outside = tempfile::TempDir::new().unwrap();
let escape_dir = outside.path().join("gitlawb-probe");
let slug = format!("a/{}", escape_dir.display());
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body(&slug, &attacker),
"198.51.100.41:5000",
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"notify must refuse the escaping slug {slug:?}"
);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert!(
queued.is_empty(),
"sync_queue must stay empty, got {:?}",
queued.iter().map(|q| &q.repo).collect::<Vec<_>>()
);
assert!(
!escape_dir.with_extension("git").exists(),
"nothing may be written at the escape target"
);
}
#[sqlx::test]
async fn announce_still_accepts_unsigned_in_default_mode(pool: PgPool) {
// must-not-over-reach: adding the brake must not tighten announce's
+2 -202
View File
@@ -216,14 +216,6 @@ pub async fn merge_pr(
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
// Snapshot the target branch's pre-merge tip so a failed durable upload can
// roll the merge commit back on local disk (mirrors create_issue). Without
// this the local fast path in RepoStore::acquire serves the merged tree until
// a later write refreshes the archive, even though the client got a 5xx.
let target_ref = format!("refs/heads/{}", pr.target_branch);
let prior_target = store::ref_oid(&disk_path, &target_ref).ok().flatten();
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
@@ -232,37 +224,11 @@ pub async fn merge_pr(
&pr.title,
);
// Always release the advisory lock, even on error; upload to Tigris only on
// success. A false return means the durable upload failed/timed out: the merge
// commit applied to local disk but never reached object storage, so the next
// acquire_write reverts it from the stale archive. Report 5xx so the client
// retries rather than marking the PR merged in the DB while the merged tree
// was silently lost. Fail BEFORE merge_pr / touch_repo / webhooks. (P1
// data-loss guard, same as receive-pack.) On that failure, roll the local
// merge back to the captured tip BEFORE the lock releases.
let upload_ok = guard
.release_with_failure_cleanup(merge_result.is_ok(), |path| {
if let Some(oid) = &prior_target {
if let Err(e) = store::set_ref(path, &target_ref, oid) {
tracing::warn!(repo = %record.name, err = %e,
"failed to roll back local merge commit after failed durable upload; \
a local-fast-path read may serve the un-uploaded merged tree");
}
}
})
.await;
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(merge_result.is_ok()).await;
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
if !upload_ok {
tracing::error!(repo = %record.name,
"durable upload failed after merge; reporting failure so the client retries");
return Err(AppError::Git(format!(
"durable storage upload failed for {}",
record.name
)));
}
state.db.merge_pr(&pr.id, &merger_did).await?;
let _ = state.db.touch_repo(&record.id).await;
@@ -458,169 +424,3 @@ pub async fn list_comments(
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
#[cfg(test)]
mod tests {
// super::* brings in the handler plus the axum Extension/Path/State
// extractors and the Utc/Uuid/PullRequest types the seed uses.
use super::*;
use std::path::Path as StdPath;
use std::time::Duration;
// Object store whose upload() parks forever, so release()'s bounded timeout
// is the only thing that completes it, modeling a stalled durable PUT.
// exists() is false so acquire_write never downloads over the local bare repo.
struct StallStore;
#[async_trait::async_trait]
impl crate::git::tigris::ObjectStore for StallStore {
async fn exists(&self, _o: &str, _r: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn upload(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
std::future::pending::<()>().await;
Ok(())
}
async fn download(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
Ok(())
}
async fn delete(&self, _o: &str, _r: &str) -> anyhow::Result<()> {
Ok(())
}
}
// The failed-upload rollback on the merge path. merge_pr writes the merge
// commit to LOCAL disk, then release() uploads to durable storage. When
// that upload times out the handler must 5xx (before merge_pr/webhooks)
// AND roll the target branch back to its pre-merge tip while the advisory
// lock is held, so a local-fast-path read never serves the un-uploaded
// merged tree. RED with the cleanup closure at ~pulls.rs:244 reverted to
// `|_| {}`: main stays at the merge commit.
#[sqlx::test]
async fn merge_failed_upload_rolls_back_target_ref(pool: sqlx::PgPool) {
use axum::response::IntoResponse;
use std::process::Command;
let mut state = crate::test_support::test_state(pool.clone()).await;
let repos_dir = tempfile::TempDir::new().unwrap();
state.repo_store = crate::git::repo_store::RepoStore::new(
repos_dir.path().to_path_buf(),
Some(std::sync::Arc::new(StallStore)),
pool.clone(),
)
.with_release_upload_timeout(Duration::from_millis(200));
let owner = "z6mergerollbackowner";
let name = "mergerollback";
let owner_slug = owner.replace([':', '/'], "_");
let bare = repos_dir
.path()
.join(&owner_slug)
.join(format!("{name}.git"));
state
.db
.upsert_mirror_repo(owner, name, &bare.to_string_lossy(), None, false)
.await
.unwrap();
fn git(args: &[&str], dir: &StdPath) -> String {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
// Scratch repo: c1 on main, c2 on feature; the bare clone carries both
// branches, so the merge of feature into main has real work to do.
let work = tempfile::TempDir::new().unwrap();
git(&["init", "-q", "-b", "main", "."], work.path());
git(&["config", "user.email", "t@t"], work.path());
git(&["config", "user.name", "t"], work.path());
std::fs::write(work.path().join("f.txt"), "one").unwrap();
git(&["add", "f.txt"], work.path());
git(&["commit", "-q", "-m", "c1"], work.path());
git(&["checkout", "-q", "-b", "feature"], work.path());
std::fs::write(work.path().join("g.txt"), "two").unwrap();
git(&["add", "g.txt"], work.path());
git(&["commit", "-q", "-m", "c2"], work.path());
std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
let out = Command::new("git")
.args([
"clone",
"--bare",
"-q",
&work.path().to_string_lossy(),
&bare.to_string_lossy(),
])
.output()
.unwrap();
assert!(
out.status.success(),
"clone --bare failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let pre_merge_tip = store::ref_oid(&bare, "refs/heads/main")
.unwrap()
.expect("main exists before the merge");
// Seed the open PR row the handler looks up.
let record = state.db.get_repo(owner, name).await.unwrap().unwrap();
let now = Utc::now().to_rfc3339();
state
.db
.create_pr(&PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: record.id.clone(),
number: 1,
title: "t".into(),
body: None,
author_did: owner.to_string(),
source_branch: "feature".into(),
target_branch: "main".into(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
})
.await
.unwrap();
// Merge as the owner: the merge commit applies locally, the durable
// upload stalls and times out, and the handler must report 5xx.
let resp = super::merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), name.to_string(), 1)),
)
.await;
let status = match resp {
Ok(_) => StatusCode::OK,
Err(e) => e.into_response().status(),
};
assert!(
status.is_server_error(),
"a stalled durable upload on merge must 5xx, got {status}"
);
// The target branch must still point at the pre-merge tip: the failed
// upload rolled the merge commit back while the lock was held.
let tip = store::ref_oid(&bare, "refs/heads/main")
.unwrap()
.expect("main still exists after the rollback");
assert_eq!(
tip, pre_merge_tip,
"a failed durable upload must roll the target branch back to its \
pre-merge tip; a local-fast-path read served the un-uploaded merge"
);
}
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -515,7 +515,6 @@ mod tests {
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
write_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
+5 -48
View File
@@ -1,45 +1,18 @@
use clap::{Parser, Subcommand};
use clap::Parser;
use std::path::PathBuf;
/// Optional admin subcommands. When none is given, the binary runs the node
/// daemon as before — the default (no-subcommand) startup path is unchanged.
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Dry-run (default) or, with --execute, delete empty spam-burst repos owned
/// by the known burst DID. Never touches the hard-excluded DIDs, and verifies
/// each repo is empty (zero git refs) per repo before selecting it.
PurgeSpam {
/// Actually delete the candidates. Omit for a dry-run that prints the
/// candidate list and deletes nothing.
#[arg(long, default_value_t = false)]
execute: bool,
},
}
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
pub struct Config {
/// Admin subcommand to run instead of the node daemon. Absent = run the node.
#[command(subcommand)]
pub command: Option<Command>,
/// Directory where bare git repositories are stored. `global` so it can
/// follow a subcommand (e.g. `gitlawb-node purge-spam --repos-dir …`).
#[arg(
long,
env = "GITLAWB_REPOS_DIR",
default_value = "./data/repos",
global = true
)]
/// Directory where bare git repositories are stored
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
pub repos_dir: PathBuf,
/// PostgreSQL connection URL (Supabase or any Postgres instance). `global` so
/// admin subcommands accept it in either position.
/// PostgreSQL connection URL (Supabase or any Postgres instance)
#[arg(
long,
env = "DATABASE_URL",
default_value = "postgresql://localhost/gitlawb",
global = true
default_value = "postgresql://localhost/gitlawb"
)]
pub database_url: String,
@@ -221,22 +194,6 @@ pub struct Config {
)]
pub db_max_connections: u32,
/// Maximum connections in the dedicated advisory-lock pool, separate from
/// the main pool above. Every in-flight repo write (git-receive-pack, fork,
/// merge) pins one connection here for its whole lifetime — the write, its
/// metadata/fan-out tail, and the bounded post-write archive upload — so
/// size it to the expected peak number of concurrent distinct-repo writers,
/// NOT small. Keeping it separate from GITLAWB_DB_MAX_CONNECTIONS is what
/// stops a push burst from starving normal request handlers. Keep
/// (main pool + lock pool) within the database server's max_connections.
#[arg(
long,
env = "GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS",
default_value_t = 32,
value_parser = clap::value_parser!(u32).range(1..)
)]
pub db_lock_pool_max_connections: u32,
/// 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(
File diff suppressed because it is too large. Load diff
+6 -27
View File
@@ -35,6 +35,9 @@ pub enum AppError {
#[error("invalid request: {0}")]
BadRequest(String),
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("incomplete: {0}")]
Incomplete(String),
@@ -44,9 +47,6 @@ pub enum AppError {
#[error("git service timed out: {0}")]
Timeout(String),
#[error("service unavailable: {0}")]
Unavailable(String),
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
@@ -132,6 +132,9 @@ impl IntoResponse for AppError {
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::TooManyRequests(msg) => {
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
}
AppError::Incomplete(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
}
@@ -139,14 +142,6 @@ impl IntoResponse for AppError {
// 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, retryable: a transient resource contention (e.g. the repo lock
// pool is pinned by a write burst or a purge holds the key), distinct
// from the 500 git_error so the client knows to back off and retry.
AppError::Unavailable(msg) => (
StatusCode::SERVICE_UNAVAILABLE,
"service_unavailable",
msg.clone(),
),
AppError::Db(e) if db_unavailable(e) => (
StatusCode::SERVICE_UNAVAILABLE,
DB_UNAVAILABLE_CODE,
@@ -187,20 +182,4 @@ mod tests {
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn unavailable_maps_to_503_distinct_from_git_500() {
// The create-repo lock-acquire failure returns Unavailable so a client sees
// a retryable 503, not a terminal 500 git_error.
assert_eq!(
AppError::Unavailable("lock pool pinned".into())
.into_response()
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
AppError::Git("x".into()).into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
-45
View File
@@ -61,51 +61,6 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
Ok(())
}
/// Delete an issue ref. Used to roll back a locally written issue whose
/// durable upload failed, so a retry does not duplicate it. The dangling
/// blob object is harmless.
pub fn delete_issue_ref(repo_path: &Path, issue_id: &str) -> Result<()> {
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
let output = Command::new("git")
.args(["update-ref", "-d", &ref_name])
.current_dir(repo_path)
.output()
.context("failed to run git update-ref -d")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git update-ref -d failed: {stderr}");
}
Ok(())
}
/// Read the object id an issue ref currently points to (resolving an 8-char
/// prefix), returning `(full_id, oid)`. Returns None if no such issue exists.
/// Used to snapshot an issue ref's pre-mutation state so a failed durable
/// upload can restore it, mirroring `create_issue`'s rollback.
pub fn issue_ref_oid(repo_path: &Path, issue_id: &str) -> Result<Option<(String, String)>> {
let full_id = match resolve_issue_id(repo_path, issue_id)? {
Some(id) => id,
None => return Ok(None),
};
let ref_name = format!("refs/gitlawb/issues/{full_id}");
let oid = match crate::git::store::ref_oid(repo_path, &ref_name)? {
Some(oid) => oid,
None => return Ok(None),
};
Ok(Some((full_id, oid)))
}
/// Reset an issue ref back to a captured object id — rolls back a local
/// mutation (e.g. a close) whose durable upload failed, so a local-fast-path
/// read does not serve the un-uploaded state. `full_id` must be the resolved
/// UUID (from [`issue_ref_oid`]). The now-dangling object is harmless.
pub fn restore_issue_ref(repo_path: &Path, full_id: &str, oid: &str) -> Result<()> {
let ref_name = format!("refs/gitlawb/issues/{full_id}");
crate::git::store::set_ref(repo_path, &ref_name, oid)
}
/// 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/
File diff suppressed because it is too large. Load diff
@@ -1,431 +0,0 @@
//! Read-path download coordination (KTD-3) for [`RepoStore`].
//!
//! The read path's cache-miss handling lives here: the per-repo async mutex
//! that serializes concurrent readers of the same repo, the holder's fetch +
//! extract + under-lock publish, and the RAII temp-dir cleanup. `acquire` and
//! `acquire_fresh` stay in the parent module and call into `download_published`.
//! These methods reach the parent's private fields (`download_locks`) and
//! private methods (`try_lock_repo`) directly, because a child module can see
//! its parent's private items.
use super::*;
/// Outcome of a coordinated read-path download (KTD-3).
pub(super) enum DownloadOutcome {
/// The local dir is present: this reader downloaded and published it under
/// the advisory lock, or a concurrent reader did and this one served it.
Published,
/// The download was discarded without publishing: the advisory lock was
/// contended (a live writer or purge holds it), the lock pool errored, or
/// the archive vanished under the lock (purged mid-download). The caller
/// degrades to its missing-path or serve-local-copy outcome.
Skipped,
}
/// RAII cleanup for a read-path download temp dir (finding 1). Its `Drop`
/// removes the dir (best effort), so a handler future cancelled mid-download
/// (axum drops it on client disconnect, the same hazard `RepoWriteGuard` /
/// `RepoLockGuard` Drop impls exist for) cannot leak a fully-populated repo
/// dir. `disarm` forgets the dir once the publish rename has consumed it.
struct TempDownloadDir {
path: PathBuf,
armed: bool,
}
impl TempDownloadDir {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn path(&self) -> &Path {
&self.path
}
/// Disarm: the temp has been consumed (renamed into place), so `Drop` must
/// not try to remove it.
fn disarm(mut self) {
self.armed = false;
}
}
impl Drop for TempDownloadDir {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_dir_all(&self.path);
}
}
}
/// RAII removal of a download-coordination map entry (finding 4). Its `Drop`
/// removes the entry from `download_locks` whenever the map still holds THIS
/// Arc (ptr_eq-guarded so a newer entry inserted after an earlier removal is
/// never clobbered). Removal is guaranteed on every path (a normal return or
/// `?` drops it in the holder; once a download is in flight it travels with
/// the spawned task and drops at settle, covering holder timeout and a
/// handler future cancelled mid cold-read, which axum triggers on client
/// disconnect), which is what keeps the map from growing per arbitrary
/// requested name: the pre-fix explicit removals ran only at the labelled
/// return points, which a cancelled future skipped, leaking the entry. The
/// outer map is a std::sync::Mutex so
/// this Drop can prune it synchronously (Drop cannot be async); it is only ever
/// held briefly for a get/insert/remove, never across an await.
struct DownloadEntryGuard {
locks: Arc<std::sync::Mutex<HashMap<String, Arc<Mutex<()>>>>>,
key: String,
entry: Arc<Mutex<()>>,
}
impl Drop for DownloadEntryGuard {
fn drop(&mut self) {
// A poisoned map is still safe to prune — recover the guard and remove.
let mut map = self.locks.lock().unwrap_or_else(|p| p.into_inner());
if map
.get(&self.key)
.is_some_and(|cur| Arc::ptr_eq(cur, &self.entry))
{
map.remove(&self.key);
}
}
}
impl RepoStore {
/// Coordinated read-path download (KTD-3). Serializes concurrent readers
/// of the same repo on a per-repo async mutex: the first one in becomes
/// the holder and runs [`download_and_publish`](Self::download_and_publish);
/// a contended reader awaits the holder, re-checks the local dir on wake,
/// and serves what the holder published instead of re-downloading. Only
/// callers that already confirmed the archive exists reach this, and the
/// map entry is removed on completion, so the map cannot grow per
/// arbitrary requested name.
pub(super) async fn download_published(
&self,
owner_did: &str,
repo_name: &str,
owner_slug: &str,
local_path: &Path,
store: &Arc<dyn ObjectStore>,
local_existed_at_entry: bool,
) -> Result<DownloadOutcome> {
let map_key = format!("{owner_slug}/{repo_name}");
let entry = {
let mut map = self.download_locks.lock().unwrap();
Arc::clone(
map.entry(map_key.clone())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
};
let held = match Arc::clone(&entry).try_lock_owned() {
Ok(g) => g,
Err(_) => {
// Bounded coalescing wait: once a holder's download is in
// flight, the spawned task owns this lock (and the map entry)
// until it settles, whatever happens to the holder (timeout or
// cancellation), so an unbounded lock().await here would park
// this reader for as long as a stall lasts. On elapse, report
// the same timeout error the holder would. Do NOT touch the
// map entry on this path: pruning it while the download is in
// flight would let the next arrival start a duplicate download.
let waited = tokio::time::timeout(
self.release_upload_timeout,
Arc::clone(&entry).lock_owned(),
)
.await;
let g = match waited {
Ok(g) => g,
Err(_) => {
if local_path.exists() {
// A concurrent reader published while we waited.
return Ok(DownloadOutcome::Published);
}
return Err(anyhow::anyhow!("read download timed out"));
}
};
if local_path.exists() {
// A concurrent reader published while we waited — serve it.
// The holder's own guard prunes the map entry on its exit.
return Ok(DownloadOutcome::Published);
}
// The holder degraded without publishing; this reader takes over.
// The prior holder removed the map entry on its way out, so
// re-register THIS entry before downloading, so readers arriving
// now coalesce onto it instead of starting a duplicate download
// (finding 3). Narrows the duplicate-download window; publishes
// stay serialized by the advisory lock regardless.
{
let mut map = self.download_locks.lock().unwrap();
map.entry(map_key.clone())
.or_insert_with(|| Arc::clone(&entry));
}
g
}
};
// RAII: remove the map entry when this holder's work is done (finding
// 4). Created only once THIS reader holds the per-repo lock, so a
// parked or timed-out waiter never prunes the entry out from under a
// live holder; its Drop is ptr_eq-guarded, so it never clobbers a
// newer entry. Ownership moves into download_and_publish and from
// there into the spawned download task itself, so on EVERY settle path
// (success, download error, holder timeout, holder cancellation) the
// entry stays registered and the lock stays held until the in-flight
// download settles: waiters queue on the ONE download instead of each
// timeout or cancel wave starting a fresh full download of the same
// object.
let entry_guard = DownloadEntryGuard {
locks: Arc::clone(&self.download_locks),
key: map_key,
entry: Arc::clone(&entry),
};
self.download_and_publish(
owner_did,
repo_name,
owner_slug,
local_path,
store,
local_existed_at_entry,
entry_guard,
held,
)
.await
}
/// The holder's half of the coordinated download: fetch and extract the
/// archive into a temp sibling with NO advisory lock held (the network
/// phase must never pin a lock-pool connection — a cold-read burst across
/// distinct repos must not drain the writer-sized pool), then take the
/// per-repo advisory lock only around the publish: re-check the archive
/// still exists under the lock (a purge deletes it under this same lock,
/// so a post-purge downloader discards rather than resurrecting), swap
/// the temp copy into place, release. Advisory contention or a lock-pool
/// error discards the temp copy and degrades — no blocking lock or pool
/// wait exists anywhere on the read path.
///
/// `local_existed_at_entry` selects the stale-swap guard (finding 2): a cold
/// `acquire` passes `false`, an `acquire_fresh` refresh passes `true`. Under
/// the lock, before the swap, we detect a writer that published a fresher
/// copy during our unlocked download window and serve theirs instead of
/// clobbering it with our now-stale temp.
///
/// `entry_guard` and `held` are this holder's per-repo coordination (the
/// download_locks map entry and the locked per-repo mutex). Both move into
/// the spawned download task, which returns them through its JoinHandle on
/// success; on any other settle path (download error, task panic, holder
/// timeout, holder cancellation) they drop when the task settles, so
/// waiters keep queueing on the SAME in-flight download until it settles
/// rather than each starting a duplicate one.
#[allow(clippy::too_many_arguments)]
async fn download_and_publish(
&self,
owner_did: &str,
repo_name: &str,
owner_slug: &str,
local_path: &Path,
store: &Arc<dyn ObjectStore>,
local_existed_at_entry: bool,
entry_guard: DownloadEntryGuard,
held: tokio::sync::OwnedMutexGuard<()>,
) -> Result<DownloadOutcome> {
let parent = local_path.parent().context("repo path has no parent")?;
std::fs::create_dir_all(parent).context("creating repo parent dir")?;
let file_name = local_path
.file_name()
.context("repo path has no file name")?
.to_string_lossy();
// Best-effort sweep of leftover temp siblings from a prior download whose
// cleanup never ran (a process crash or kill mid-download, where no Drop
// and no owning task survive to remove the temp). Serialized per
// repo by the download mutex, so no concurrent same-repo download owns a
// live temp here; scoped to THIS repo's prefix, so other repos are
// untouched.
let tmp_prefix = format!(".{file_name}.tmp-download.");
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().starts_with(&tmp_prefix) {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
// Stale-swap signal (finding 2): capture the local dir's mtime BEFORE the
// unlocked download. Under the lock, a changed mtime (acquire_fresh,
// refreshing over an existing copy) or the dir's mere appearance (cold
// acquire) means a writer published a fresher copy during our download
// window, so we serve theirs rather than swap our stale temp over it.
let entry_mtime = std::fs::metadata(local_path)
.and_then(|m| m.modified())
.ok();
// Unique per-download temp target (same parent as local_path so the
// publish rename stays on one filesystem); mirrors the extract temp
// naming in tigris.rs. Wrapped in an RAII guard so a handler future
// cancelled mid-download (axum drops it on client disconnect) cannot
// leak the populated temp dir (the same hazard the guard Drop impls
// below exist for, finding 1).
let temp = TempDownloadDir::new(parent.join(format!(
".{file_name}.tmp-download.{}",
uuid::Uuid::new_v4()
)));
// Ownership of the download AND the temp-dir guard moves into a spawned
// task, so timeout and handler cancellation share ONE cleanup path
// (finding F3 and its cancellation twin). `store.download` runs its tar
// extraction in `spawn_blocking`, and neither a tokio timeout nor a
// dropped handler future cancels a spawn_blocking task: pre-fix,
// dropping the download future (a timed-out await, or axum dropping the
// whole handler on client disconnect) detached the extraction, whose
// later rename resurrected `temp.path()` AFTER the guard's Drop had
// removed it, leaving an orphan only a later same-repo download sweeps.
// The spawned task is never dropped mid-flight, so the download future
// always runs to settle. On success it returns the still-armed guard
// through the JoinHandle: the caller consumes it and publishes below.
// If the caller timed out or was cancelled, the JoinHandle is gone, so
// the runtime drops the returned guard when the task settles and its
// Drop removes the temp dir; cleanup is tied to the extraction's
// completion, never racing it (this subsumes the previous detached
// janitor). A download error drops the guard inside the task with the
// same at-settle timing.
let store_dl = Arc::clone(store);
let (dl_owner, dl_repo, dl_target) = (
owner_slug.to_string(),
repo_name.to_string(),
temp.path().to_path_buf(),
);
// The per-repo coordination (`held`, `entry_guard`) moves into the task
// alongside the temp guard: whatever happens to THIS caller (timeout
// below, or a handler future cancelled by axum on client disconnect),
// the lock stays held and the map entry stays registered until the
// in-flight download settles, so waiters and new arrivals queue on the
// ONE download instead of starting duplicates. On success the tuple
// returns through the JoinHandle: the caller publishes under `held` as
// before. If the caller timed out or was cancelled, the runtime drops
// the returned tuple when the task settles, in field order: `temp`
// (dir removed), then `held` (lock released), then `entry_guard` (map
// entry pruned), the same unlock-before-prune order as a normal
// return.
let dl_task = tokio::spawn(async move {
// The guards travel as one `(held, entry_guard)` tuple so that
// wherever it drops (here on error, in the runtime at settle after
// a caller timeout or cancellation, or in the caller after a
// normal publish), its field order gives unlock-before-prune.
match store_dl.download(&dl_owner, &dl_repo, &dl_target).await {
Ok(()) => Ok((temp, (held, entry_guard))),
// The download settled, so cleanup is safe. Explicit drops pin
// the order (async-block captures have no guaranteed one):
// temp removed, lock released, map entry pruned last.
Err(e) => {
drop(temp);
drop(held);
drop(entry_guard);
Err(e)
}
}
});
// Bound the wait, not the work: a stalled GET would park this reader
// indefinitely. On timeout the caller gets the same error as before,
// but the coordination does NOT unwind: it lives in the task (see
// above) and is released only at settle. Waiters therefore stay queued
// on the SAME in-flight download (each bounded by its own timeout in
// download_published) instead of each timeout wave starting a fresh
// full download of the same object; the task keeps running and cleans
// up its temp when the download settles.
// `_coordination` is `(held, entry_guard)`, kept alive to the end of
// this function exactly as the separate guards were before: the
// publish below still runs with the per-repo lock held, and the map
// entry is pruned only on exit (unlock first, prune second, per the
// tuple's field order).
let (temp, _coordination) =
match tokio::time::timeout(self.release_upload_timeout, dl_task).await {
Ok(Ok(Ok(returned))) => returned,
Ok(Ok(Err(e))) => return Err(e),
// The task panicked; its unwind dropped the guards, removing
// the dir, releasing the lock, and pruning the map entry.
Ok(Err(join_err)) => {
return Err(anyhow::Error::from(join_err).context("read download task failed"))
}
Err(_) => {
warn!(repo = %repo_name, timeout_secs = self.release_upload_timeout.as_secs(),
"read download timed out — discarding");
// Dropping the JoinHandle detaches the task; the runtime
// frees temp dir, lock, and map entry at settle (see the
// spawn comment above).
return Err(anyhow::anyhow!("read download timed out"));
}
};
let guard = match self.try_lock_repo(owner_did, repo_name).await {
Ok(Some(g)) => g,
Ok(None) => {
debug!(repo = %repo_name,
"read download discarded — repo lock held by a live writer or purge");
return Ok(DownloadOutcome::Skipped);
}
Err(e) => {
warn!(repo = %repo_name, err = %e,
"read download discarded — could not acquire repo lock");
return Ok(DownloadOutcome::Skipped);
}
};
// Re-check under the lock: the archive gone here means a purge won the
// key mid-download — discard, never publish (the read-side twin of the
// upload paths' under-lock dir re-check). Bounded so a stalled HEAD
// cannot pin the advisory lock + its lock-pool connection. Both a
// timeout and an exists() error collapse to "not present" and take the
// discard arm: fail closed rather than publish unconfirmed state.
let archive_present = matches!(
tokio::time::timeout(
self.release_upload_timeout,
store.exists(owner_slug, repo_name),
)
.await,
Ok(Ok(true))
);
if !archive_present {
warn!(repo = %repo_name,
"read download discarded — archive gone under lock (purged?)");
guard.release().await;
return Ok(DownloadOutcome::Skipped);
}
// Stale-swap guard (finding 2): a writer that locked, published a fresher
// copy, and released during our unlocked download window must not have
// its work clobbered by our now-stale temp. The only concurrent mutator
// of local_path is a writer or a purge (readers of the same repo
// serialize on the download mutex), so the signals below unambiguously
// mean "a writer published here."
let writer_published = match (local_existed_at_entry, entry_mtime) {
// acquire_fresh over an existing copy: presence is always true, so it
// cannot signal a writer; a CHANGED mtime can (the writer replaced
// the dir via rename, minting a fresh mtime).
(true, Some(entry_m)) => std::fs::metadata(local_path)
.and_then(|m| m.modified())
.map(|now| now != entry_m)
.unwrap_or(false),
// Cold acquire, or acquire_fresh whose dir was absent at entry: the
// dir being present now means a writer published it during the window.
_ => local_path.exists(),
};
if writer_published {
warn!(repo = %repo_name,
"read download discarded: a writer published a fresher copy during the download window");
guard.release().await;
// temp guard's Drop removes the stale temp; serve the fresher local copy.
return Ok(DownloadOutcome::Published);
}
let swapped = (|| -> std::io::Result<()> {
if local_path.exists() {
std::fs::remove_dir_all(local_path)?;
}
std::fs::rename(temp.path(), local_path)
})();
guard.release().await;
match swapped {
Ok(()) => {
temp.disarm(); // the rename consumed the temp; nothing to clean up
Ok(DownloadOutcome::Published)
}
// temp guard's Drop removes the temp if the rename left it behind.
Err(e) => Err(e).context("publishing downloaded repo into place"),
}
}
}
+1 -266
View File
@@ -63,105 +63,6 @@ pub fn list_refs(repo_path: &Path) -> Result<Vec<(String, String)>> {
Ok(refs)
}
/// Read the object id a single ref currently points to. Returns None if the
/// ref does not exist. Used to snapshot a ref's pre-write state so a failed
/// durable upload can roll the local write back.
pub fn ref_oid(repo_path: &Path, ref_name: &str) -> Result<Option<String>> {
let output = Command::new("git")
.args(["rev-parse", "--verify", ref_name])
.current_dir(repo_path)
.output()
.context("failed to run git rev-parse")?;
if !output.status.success() {
return Ok(None);
}
let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if oid.is_empty() {
return Ok(None);
}
Ok(Some(oid))
}
/// Force a single ref back to a captured object id — rolls back a local write
/// (e.g. a merge commit) whose durable upload failed, so a local-fast-path
/// read does not serve the un-uploaded state. The now-dangling objects are
/// harmless.
pub fn set_ref(repo_path: &Path, ref_name: &str, oid: &str) -> Result<()> {
let output = Command::new("git")
.args(["update-ref", ref_name, oid])
.current_dir(repo_path)
.output()
.context("failed to run git update-ref")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git update-ref failed: {stderr}");
}
Ok(())
}
/// Restore a repo's refs to a previously captured snapshot: reset every
/// snapshot ref to its captured object id, and delete any ref that exists now
/// but was absent from the snapshot (created by the intervening write). Used
/// to roll back a receive-pack whose durable upload failed, so the next
/// local-fast-path read does not serve refs that never reached object storage.
/// Best-effort per ref: an individual failure is collected and reported, but
/// the remaining refs are still attempted.
pub fn restore_refs(repo_path: &Path, snapshot: &[(String, String)]) -> Result<()> {
use std::collections::HashSet;
let mut errors: Vec<String> = Vec::new();
// Reset every snapshot ref to its captured oid (recreates refs the write
// deleted, and rewinds refs the write advanced).
for (ref_name, oid) in snapshot {
if let Err(e) = set_ref(repo_path, ref_name, oid) {
errors.push(format!("{ref_name}: {e}"));
}
}
// Delete refs that exist now but were not in the snapshot (created by the
// write being rolled back). A failure to LIST the current refs is collected
// like any per-ref failure, never returned early: the snapshot-reset half
// above already ran, and bailing here would discard its collected errors.
let snapshot_names: HashSet<&str> = snapshot.iter().map(|(r, _)| r.as_str()).collect();
match list_refs(repo_path) {
Ok(current) => {
for (ref_name, _) in &current {
if !snapshot_names.contains(ref_name.as_str()) {
match Command::new("git")
.args(["update-ref", "-d", ref_name])
.current_dir(repo_path)
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
errors.push(format!("{ref_name} (delete): {stderr}"));
}
Err(e) => {
errors.push(format!(
"{ref_name} (delete): failed to run git update-ref -d: {e}"
));
}
}
}
}
}
Err(e) => {
errors.push(format!("(extra-ref sweep) listing current refs: {e}"));
}
}
if !errors.is_empty() {
bail!(
"failed to restore {} ref(s): {}",
errors.len(),
errors.join("; ")
);
}
Ok(())
}
/// Read the current HEAD commit hash of a repository.
/// Returns None if the repo is empty (no commits yet).
pub fn head_commit(repo_path: &Path) -> Result<Option<String>> {
@@ -554,176 +455,10 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
#[cfg(test)]
mod tests {
use super::{branch_diff_names, list_refs, restore_refs};
use super::branch_diff_names;
use std::path::Path;
use std::process::Command;
fn run_git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
/// Bare repo with main at c2 and keeper at c2, whose "pre-push" state was
/// main and keeper both at c1. Returns (workdir, baredir, bare_path, c1, c2).
fn advanced_bare_repo() -> (
tempfile::TempDir,
tempfile::TempDir,
std::path::PathBuf,
String,
String,
) {
let work = tempfile::TempDir::new().unwrap();
run_git(work.path(), &["init", "-q", "-b", "main", "."]);
run_git(work.path(), &["config", "user.email", "t@t"]);
run_git(work.path(), &["config", "user.name", "t"]);
std::fs::write(work.path().join("f.txt"), "one").unwrap();
run_git(work.path(), &["add", "f.txt"]);
run_git(work.path(), &["commit", "-qm", "c1"]);
let c1 = run_git(work.path(), &["rev-parse", "HEAD"]);
std::fs::write(work.path().join("f.txt"), "two").unwrap();
run_git(work.path(), &["add", "f.txt"]);
run_git(work.path(), &["commit", "-qm", "c2"]);
let c2 = run_git(work.path(), &["rev-parse", "HEAD"]);
let dir = tempfile::TempDir::new().unwrap();
let bare = dir.path().join("repo.git");
let out = Command::new("git")
.args([
"clone",
"--bare",
"-q",
&work.path().to_string_lossy(),
&bare.to_string_lossy(),
])
.output()
.unwrap();
assert!(out.status.success(), "clone --bare failed");
// "The push" advanced main to c2 (already there from the clone), moved
// keeper c1 -> c2, and created feature at c2.
run_git(&bare, &["update-ref", "refs/heads/keeper", &c2]);
run_git(&bare, &["update-ref", "refs/heads/feature", &c2]);
(work, dir, bare, c1, c2)
}
fn sorted_refs(bare: &Path) -> Vec<(String, String)> {
let mut refs = list_refs(bare).unwrap();
refs.sort();
refs
}
/// A single unrestorable snapshot entry (invalid oid, `set_ref` fails) must
/// not abort the restore: every OTHER snapshot ref is still reset and the
/// push-created extra ref is still deleted, and the fn returns Err carrying
/// the failure. RED with the reset loop reverted to `set_ref(..)?`: the
/// broken entry is first in the snapshot, so an early return leaves keeper
/// at c2 and feature alive.
#[test]
fn restore_refs_partial_failure_still_resets_rest_and_deletes_extras() {
let (_work, _dir, bare, c1, c2) = advanced_bare_repo();
let snapshot = vec![
(
"refs/heads/broken".to_string(),
"not-a-valid-oid".to_string(),
),
("refs/heads/main".to_string(), c1.clone()),
("refs/heads/keeper".to_string(), c1.clone()),
];
let err = restore_refs(&bare, &snapshot).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("refs/heads/broken"),
"the aggregated error names the failed ref: {msg}"
);
let refs = sorted_refs(&bare);
assert_eq!(
refs,
vec![
("refs/heads/keeper".to_string(), c1.clone()),
("refs/heads/main".to_string(), c1.clone()),
],
"keeper and main are reset to c1 and feature is deleted despite the \
broken entry (c2 was {c2})"
);
}
/// Happy path unchanged by the hardening: a fully valid snapshot restores
/// exactly (refs rewound, created ref deleted) and returns Ok.
#[test]
fn restore_refs_happy_path_restores_snapshot_exactly() {
let (_work, _dir, bare, c1, _c2) = advanced_bare_repo();
let snapshot = vec![
("refs/heads/main".to_string(), c1.clone()),
("refs/heads/keeper".to_string(), c1.clone()),
];
restore_refs(&bare, &snapshot).unwrap();
let refs = sorted_refs(&bare);
assert_eq!(
refs,
vec![
("refs/heads/keeper".to_string(), c1.clone()),
("refs/heads/main".to_string(), c1),
],
"restore must reproduce the snapshot exactly"
);
}
/// When the internal `list_refs` (extra-ref sweep) fails, the snapshot-reset
/// half must still have run and its collected failures must surface in the
/// aggregated error, not be discarded by an early `?` return. The repo is a
/// real bare repo declaring repositoryformatversion=999, so both `set_ref`
/// and `list_refs` fail deterministically. RED with the sweep reverted to
/// `let current = list_refs(repo_path)?;`: the raw for-each-ref error
/// propagates without the "failed to restore" aggregate or the ref name.
#[test]
fn restore_refs_aggregates_when_internal_list_refs_fails() {
let dir = tempfile::TempDir::new().unwrap();
let bare = dir.path().join("repo.git");
let out = Command::new("git")
.args(["init", "--bare", "-q", &bare.to_string_lossy()])
.output()
.unwrap();
assert!(out.status.success(), "git init --bare failed");
std::fs::write(
bare.join("config"),
"[core]\n\trepositoryformatversion = 999\n\tbare = true\n",
)
.unwrap();
assert!(
list_refs(&bare).is_err(),
"precondition: list_refs must fail on the corrupted repo"
);
let snapshot = vec![("refs/heads/main".to_string(), "a".repeat(40))];
let err = restore_refs(&bare, &snapshot).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("failed to restore"),
"must return the aggregated error, not the raw list_refs error: {msg}"
);
assert!(
msg.contains("refs/heads/main"),
"the set_ref failure from the reset half must survive the sweep \
failure: {msg}"
);
assert!(
msg.contains("listing current refs"),
"the sweep failure itself is also collected: {msg}"
);
}
#[test]
fn branch_diff_names_lists_changed_paths() {
let td = tempfile::TempDir::new().unwrap();
+10 -22
View File
@@ -11,21 +11,6 @@ use anyhow::{Context, Result};
use aws_sdk_s3::Client as S3Client;
use tracing::{debug, info};
/// Object storage for git bare-repo archives. The seam that lets `RepoStore`
/// (and tests) depend on storage behavior without a live bucket. `TigrisClient`
/// is the production implementation.
#[async_trait::async_trait]
pub trait ObjectStore: Send + Sync {
/// Whether an archive exists for this repo.
async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool>;
/// Upload a local bare repo directory as an archive.
async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()>;
/// Download and extract a repo archive to local disk.
async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()>;
/// Delete a repo archive.
async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()>;
}
/// Wrapper around the S3 client with the configured bucket.
#[derive(Clone)]
pub struct TigrisClient {
@@ -50,12 +35,9 @@ impl TigrisClient {
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
}
}
#[async_trait::async_trait]
impl ObjectStore for TigrisClient {
/// Check if a repo archive exists in Tigris.
async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
@@ -77,7 +59,7 @@ impl ObjectStore for TigrisClient {
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
@@ -107,7 +89,12 @@ impl ObjectStore for TigrisClient {
}
/// Download a repo archive from Tigris and extract to local disk.
async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
pub async fn download(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
@@ -141,7 +128,8 @@ impl ObjectStore for TigrisClient {
}
/// Delete a repo archive from Tigris.
async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
#[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
.delete_object()
+36 -179
View File
@@ -1,4 +1,3 @@
mod admin;
mod api;
mod arweave;
mod auth;
@@ -71,12 +70,6 @@ async fn main() -> Result<()> {
let mut config = Config::parse();
// Admin subcommands run out-of-band and exit, never starting the node. The
// no-subcommand path below is the unchanged daemon startup.
if let Some(command) = config.command.clone() {
return run_admin_command(command, &config).await;
}
// Merge the embedded seed list of public network nodes into the runtime
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
bootstrap::merge_seeds(&mut config);
@@ -286,22 +279,8 @@ async fn main() -> Result<()> {
None
};
let object_store =
tigris.map(|t| std::sync::Arc::new(t) as std::sync::Arc<dyn git::tigris::ObjectStore>);
// Advisory-lock guards get their own pool, separate from the app pool
// (db.pool()) that serves normal handlers, so a concurrent-push burst pins
// lock connections here instead of starving request handlers. Sized to peak
// writers via GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS.
let lock_pool_acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
let lock_pool = Db::connect_lock_pool(
&config.database_url,
config.db_lock_pool_max_connections,
lock_pool_acquire_timeout,
)
.await
.context("connecting advisory-lock pool")?;
let repo_store =
git::repo_store::RepoStore::new(config.repos_dir.clone(), object_store, lock_pool);
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
// DID (attacker-varied), so bound its key set to cap memory.
@@ -315,24 +294,36 @@ async fn main() -> Result<()> {
// capped regardless of how many identities it mints. Sized well above any
// legitimate per-IP creation rate; GITLAWB_CREATE_RATE_LIMIT overrides, 0
// disables. Bounded key set — the key is a client-influenced IP.
let create_ip_rate_limiter = build_ip_limiter("GITLAWB_CREATE_RATE_LIMIT", 120, "creation");
// Per-IP brake for the authenticated non-creation write surface (issue/PR
// comments, labels, stars, merges, protect, replicas, visibility, tasks,
// bounties, profile, GraphQL mutations). Its own bucket, separate from the
// creation brake, so a write flood cannot drain the creation budget and vice
// versa (same rationale as the sync_trigger / peer_write split). Sized above
// any legitimate per-IP write rate — real agent automation makes many small
// writes per hour. GITLAWB_WRITE_RATE_LIMIT overrides; 0 disables. Bounded
// key set — the key is a client-influenced IP.
let write_rate_limiter = build_ip_limiter("GITLAWB_WRITE_RATE_LIMIT", 600, "write");
let create_limit = std::env::var("GITLAWB_CREATE_RATE_LIMIT")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(120);
let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
create_limit,
std::time::Duration::from_secs(3600),
200_000,
);
if create_limit == 0 {
tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
}
// Push-path flood brake: max git-receive-pack requests per client IP per
// hour (counts both the info/refs advertisement and the push POST). Sized
// for heavy agent automation while still stopping flood traffic (the June
// 2026 attack pushed several times per second per IP). GITLAWB_PUSH_RATE_LIMIT
// overrides; 0 disables. Bounded key set — the key is a client-influenced IP.
let push_rate_limiter = build_ip_limiter("GITLAWB_PUSH_RATE_LIMIT", 600, "push");
let push_limit = std::env::var("GITLAWB_PUSH_RATE_LIMIT")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(600);
let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
push_limit,
std::time::Duration::from_secs(3600),
200_000,
);
if push_limit == 0 {
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
}
// 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;
@@ -340,7 +331,7 @@ async fn main() -> Result<()> {
let push_limiter_trust = rate_limit::TrustedProxy::from_env_value(
&std::env::var("GITLAWB_TRUSTED_PROXY").unwrap_or_default(),
);
tracing::info!(trust = ?push_limiter_trust, "push rate limiter trust configured");
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
// Peer-sync flood brakes, keyed on the resolved client IP (per-DID is useless
// here — a did:key farm self-registers). Two buckets so an unsigned notify
@@ -382,7 +373,6 @@ async fn main() -> Result<()> {
repo_store,
rate_limiter,
create_ip_rate_limiter,
write_rate_limiter,
push_rate_limiter,
push_limiter_trust,
sync_trigger_rate_limiter,
@@ -418,17 +408,22 @@ async fn main() -> Result<()> {
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
{
// Clone the whole state so the sweep uses AppState::cleanup_rate_limiters
// (the single source of truth that reaps EVERY limiter, including
// write_rate_limiter) rather than a hand-maintained list that can omit one.
let state = state.clone();
let rl = state.rate_limiter.clone();
let create_ip_rl = state.create_ip_rate_limiter.clone();
let push_rl = state.push_rate_limiter.clone();
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
let peer_write_rl = state.peer_write_rate_limiter.clone();
let 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)) => {
state.cleanup_rate_limiters().await;
rl.cleanup().await;
create_ip_rl.cleanup().await;
push_rl.cleanup().await;
sync_trigger_rl.cleanup().await;
peer_write_rl.cleanup().await;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -569,110 +564,6 @@ async fn main() -> Result<()> {
Ok(())
}
/// Pure resolver for a usize rate-limit env value. Returns `(resolved, invalid)`:
/// an absent or empty value resolves to `default` and is NOT invalid; a non-empty
/// value that fails to parse resolves to `default` and IS invalid (so the caller
/// warns rather than silently disabling the intended, usually stricter, brake).
fn resolve_rate_limit(raw: Option<&str>, default: usize) -> (usize, bool) {
match raw.map(str::trim) {
None | Some("") => (default, false),
Some(t) => match t.parse::<usize>() {
Ok(n) => (n, false),
Err(_) => (default, true),
},
}
}
/// Read a rate-limit env var, warning on a present-but-unparseable value. A typo
/// like `GITLAWB_WRITE_RATE_LIMIT=5O` must not silently revert to the default and
/// leave the operator believing a stricter cap is in force.
fn rate_limit_from_env(var: &str, default: usize) -> usize {
let raw = std::env::var(var).ok();
let (value, invalid) = resolve_rate_limit(raw.as_deref(), default);
if invalid {
tracing::warn!(
var,
value = raw.as_deref().unwrap_or(""),
default,
"invalid rate-limit value — using default; the intended brake is NOT applied"
);
}
value
}
/// Build a bounded per-client-IP rate limiter from an env var: resolve the limit
/// (honoring the present-but-unparseable warning in `rate_limit_from_env`), build
/// the limiter with the shared 1-hour window and 200k-key cap, and warn when the
/// limit is 0 (disabled). Collapses the identical create/write/push setup.
fn build_ip_limiter(var: &str, default: usize, label: &str) -> rate_limit::RateLimiter {
let limit = rate_limit_from_env(var, default);
let limiter =
rate_limit::RateLimiter::new_bounded(limit, std::time::Duration::from_secs(3600), 200_000);
if limit == 0 {
tracing::warn!("{var}=0 — per-IP {label} rate limiting disabled");
} else {
tracing::info!(%var, limit, "per-IP {label} rate limiter configured");
}
limiter
}
/// Dispatch an admin subcommand. Connects the database directly (no server, no
/// p2p, no metrics) and runs the requested tool to completion, then exits.
async fn run_admin_command(command: config::Command, config: &Config) -> Result<()> {
match command {
config::Command::PurgeSpam { execute } => {
let acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
let db = Db::connect(
&config.database_url,
config.db_max_connections,
acquire_timeout,
)
.await
.context("connecting to database for purge-spam")?;
// Build the object store so purge is authoritative against remote
// state: the emptiness recheck consults the archive (not just
// possibly-stale local disk) and a successful purge deletes the
// archive too. When no bucket is configured this stays None and purge
// is local-only, exactly as before.
let object_store: Option<std::sync::Arc<dyn git::tigris::ObjectStore>> = if config
.tigris_bucket
.is_empty()
{
None
} else {
match git::tigris::TigrisClient::new(&config.tigris_bucket).await {
Ok(client) => Some(std::sync::Arc::new(client)),
Err(e) => {
// Fail closed: without the configured store we cannot
// do the authoritative recheck, and a local-only purge
// on a Tigris deployment is the stale-view delete this
// guards against. Refuse to run rather than fall back.
anyhow::bail!(
"purge-spam: GITLAWB_TIGRIS_BUCKET is set but the object-store client failed to initialize ({e}); refusing to run a local-only purge on a Tigris deployment"
);
}
}
};
// Give the purge-spam store a dedicated advisory-lock pool, separate
// from `db`'s app pool. The guard then holds a lock-pool connection
// while `delete_repo_by_id` runs on the app pool, so a purge at
// GITLAWB_DB_MAX_CONNECTIONS=1 does not self-deadlock (KTD3).
let lock_pool = Db::connect_lock_pool(
&config.database_url,
config.db_lock_pool_max_connections,
acquire_timeout,
)
.await
.context("connecting advisory-lock pool for purge-spam")?;
let repo_store =
git::repo_store::RepoStore::new(config.repos_dir.clone(), object_store, lock_pool);
admin::run_purge_spam(&db, &repo_store, &config.repos_dir, execute)
.await
.map(|_| ())
}
}
}
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
tokio::spawn(async move {
#[cfg(unix)]
@@ -1133,40 +1024,6 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
}
}
#[cfg(test)]
mod rate_limit_env_tests {
use super::resolve_rate_limit;
#[test]
fn absent_uses_default_without_warning() {
assert_eq!(resolve_rate_limit(None, 600), (600, false));
}
#[test]
fn empty_or_whitespace_uses_default_without_warning() {
assert_eq!(resolve_rate_limit(Some(""), 600), (600, false));
assert_eq!(resolve_rate_limit(Some(" "), 600), (600, false));
}
#[test]
fn valid_value_parses() {
assert_eq!(resolve_rate_limit(Some("50"), 600), (50, false));
assert_eq!(resolve_rate_limit(Some(" 50 "), 600), (50, false));
// 0 is a VALID value (disables the brake); the ==0 warning is handled
// separately at the call site. It must NOT be flagged invalid here.
assert_eq!(resolve_rate_limit(Some("0"), 600), (0, false));
}
#[test]
fn present_but_unparseable_defaults_and_flags_invalid() {
// The L8 case: a fat-fingered stricter cap ("5O", letter O) must default
// AND be flagged so the caller warns, not silently disable the brake.
assert_eq!(resolve_rate_limit(Some("5O"), 600), (600, true));
assert_eq!(resolve_rate_limit(Some("abc"), 600), (600, true));
assert_eq!(resolve_rate_limit(Some("-1"), 600), (600, true));
}
}
#[cfg(test)]
mod gossip_ssrf_tests {
use super::ping_peer_health;
+5 -1
View File
@@ -235,7 +235,11 @@ pub fn record_auth_failure(route: &str, reason: &str) {
}
}
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped}.
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped,
/// rejected, deferred}. `rejected` is terminal and caused by a guard (a
/// malformed slug, or a path resolving outside repos_dir); `deferred` leaves
/// the row pending for a later pass, so a queue stalled on an operator
/// condition is visible rather than looking idle.
pub fn record_sync_processed(status: &str) {
if let Some(c) = SYNC_PROCESSED.get() {
c.with_label_values(&[status]).inc();
+19 -273
View File
@@ -70,23 +70,10 @@ impl RateLimiter {
self.window.min(Duration::from_secs(1))
}
/// Returns `true` if the request is allowed. Thin wrapper over
/// [`check_retry`](Self::check_retry) for callers that only need the
/// allow/deny decision, not the rejection's Retry-After delay. Test-only now:
/// production 429 sites use `check_retry` so they can advertise Retry-After.
#[cfg(test)]
pub(crate) async fn check(&self, key: &str) -> bool {
self.check_retry(key).await.is_none()
}
/// Record a request against `key`. Returns `None` when it is allowed, or
/// `Some(retry_after)` when it is rejected — the delay a client should wait
/// before retrying, so a 429 can advertise a value consistent with the
/// actual window instead of a constant.
pub(crate) async fn check_retry(&self, key: &str) -> Option<Duration> {
// max_requests == 0 means the limiter is disabled, not "block all".
if self.max_requests == 0 {
return None;
return true;
}
let now = Instant::now();
let mut state = self.state.lock().await;
@@ -98,25 +85,10 @@ impl RateLimiter {
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
// Insertion order is preserved, so after the retain the first
// element is the oldest live request. It ages out (freeing a
// slot) one window after it landed, so that remaining time is
// the delay to advertise. Clamp to >= 1 so a 429 never tells a
// client to retry immediately.
let retry = window
.timestamps
.first()
.map(|oldest| {
self.window
.as_secs()
.saturating_sub(now.duration_since(*oldest).as_secs())
})
.unwrap_or(0)
.max(1);
return Some(Duration::from_secs(retry));
return false;
}
window.timestamps.push(now);
return None;
return true;
}
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
@@ -137,11 +109,7 @@ impl RateLimiter {
}
drop(last_sweep);
if state.len() >= self.max_keys {
// The key is untracked (never inserted), so there is no oldest
// entry to derive a delay from. Advertise the full window as a
// conservative bound: capacity frees as other keys expire, at
// the latest one window from now.
return Some(self.window.max(Duration::from_secs(1)));
return false;
}
}
state.insert(
@@ -150,7 +118,7 @@ impl RateLimiter {
timestamps: vec![now],
},
);
None
true
}
pub async fn cleanup(&self) {
@@ -162,41 +130,6 @@ impl RateLimiter {
!w.timestamps.is_empty()
});
}
/// Test-only: insert a fully-expired entry (no live timestamps) that the next
/// `cleanup()` must reclaim. Models a key whose window has aged out, without
/// depending on wall-clock sleeps or a short window. Used cross-module by the
/// AppState cleanup guard test (H2).
#[cfg(test)]
pub(crate) async fn insert_reclaimable_for_test(&self, key: &str) {
self.state
.lock()
.await
.insert(key.to_string(), Window { timestamps: vec![] });
}
/// Test-only: number of distinct keys currently tracked.
#[cfg(test)]
pub(crate) async fn tracked_keys(&self) -> usize {
self.state.lock().await.len()
}
/// Test-only: seed a key's bucket with entries of the given ages (how long
/// ago each request landed), so a test can drive the Retry-After computation
/// off a controlled oldest-entry age without wall-clock sleeps. Ages that
/// would underflow the monotonic clock collapse to `now`.
#[cfg(test)]
pub(crate) async fn seed_aged_entry_for_test(&self, key: &str, ages: &[Duration]) {
let now = Instant::now();
let timestamps = ages
.iter()
.map(|a| now.checked_sub(*a).unwrap_or(now))
.collect();
self.state
.lock()
.await
.insert(key.to_string(), Window { timestamps });
}
}
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
@@ -208,8 +141,13 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
.map(|a| a.0.clone());
if let (Some(limiter), Some(did)) = (limiter, did) {
if let Some(retry_after) = limiter.check_retry(&did).await {
return too_many_requests(retry_after);
if !limiter.check(&did).await {
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response();
}
}
@@ -312,13 +250,13 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
}
}
/// The shared 429 response for the rate-limit middlewares (per-DID and per-IP
/// alike). Route-agnostic: the message stays generic (the offending path, when
/// logged, is recorded at the call site).
pub fn too_many_requests(retry_after: Duration) -> Response {
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
/// middleware now serves the push path AND the peer-sync routes, so the message
/// stays generic (the offending path is recorded in the warn log below).
pub fn too_many_requests() -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", retry_after.as_secs().to_string())],
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response()
@@ -336,9 +274,9 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
if let Some(limiter) = limiter {
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
if let Some(retry_after) = limiter.limiter.check_retry(&key).await {
if !limiter.limiter.check(&key).await {
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
return too_many_requests(retry_after);
return too_many_requests();
}
}
}
@@ -667,196 +605,4 @@ mod tests {
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
);
}
// ── Adversarial TrustedProxy verification through the middleware (U5) ──
// These complement the client_key unit tests above by driving hostile
// headers through rate_limit_by_ip on a real router: the property under test
// is that the *bucket key* cannot be rotated by a spoofer.
async fn post_with(
router: &axum::Router,
peer: SocketAddr,
hdrs: &[(&str, &str)],
) -> StatusCode {
use tower::ServiceExt;
let mut b = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/api/v1/repos");
for (k, v) in hdrs {
b = b.header(*k, *v);
}
let mut req = b.body(axum::body::Body::empty()).unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn xff_spoofed_leftmost_hop_cannot_rotate_bucket() {
// XForwardedFor mode, budget 1. A spoofer varies the client-controlled
// leftmost hop every request but the trusted proxy always appends the
// same rightmost hop. All requests must key to the rightmost hop, so the
// second is braked despite a fresh leftmost value.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::XForwardedFor,
});
let p1: SocketAddr = "10.0.0.1:1".parse().unwrap();
let p2: SocketAddr = "10.0.0.2:1".parse().unwrap();
// First request passes the brake, reaches (failing) auth.
assert_eq!(
post_with(&router, p1, &[("x-forwarded-for", "9.9.9.1, 203.0.113.5")]).await,
StatusCode::UNAUTHORIZED
);
// Different leftmost + different socket peer, SAME rightmost trusted hop:
// braked, because the key is the rightmost hop, not the spoofed value.
assert_eq!(
post_with(&router, p2, &[("x-forwarded-for", "9.9.9.2, 203.0.113.5")]).await,
StatusCode::TOO_MANY_REQUESTS,
"a spoofer varying the leftmost XFF hop must not rotate its bucket key"
);
}
#[tokio::test]
async fn xff_distinct_real_clients_get_distinct_buckets() {
// Distinct trusted (rightmost) hops are distinct clients: neither throttles
// the other, so a busy legitimate client cannot starve a different one.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::XForwardedFor,
});
let peer: SocketAddr = "10.0.0.9:1".parse().unwrap();
assert_eq!(
post_with(
&router,
peer,
&[("x-forwarded-for", "1.1.1.1, 203.0.113.5")]
)
.await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
post_with(
&router,
peer,
&[("x-forwarded-for", "1.1.1.1, 203.0.113.6")]
)
.await,
StatusCode::UNAUTHORIZED,
"a different trusted client hop must get its own bucket"
);
}
#[tokio::test]
async fn absent_trusted_header_collapses_to_socket_peer() {
// The documented fallback: in a trusted-proxy mode, a request with no
// forwarded header keys on the socket peer. Behind a real edge every
// request shares the proxy's socket, so they collapse onto one bucket —
// asserted here by name so any change to this behavior is deliberate.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::XForwardedFor,
});
let proxy: SocketAddr = "172.16.0.1:1".parse().unwrap();
assert_eq!(
post_with(&router, proxy, &[]).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
post_with(&router, proxy, &[]).await,
StatusCode::TOO_MANY_REQUESTS,
"with no trusted header, requests fall back to the socket peer key (collapse)"
);
}
// ── Retry-After derived from the window, not a constant 60 (U5) ───────
// A 429 must advertise the delay until the oldest live request ages out of
// the limiter's window, so the value tracks the real window instead of the
// hardcoded 60 that contradicted every limiter's 3600s window.
async fn resp_from(router: &axum::Router, peer: SocketAddr) -> Response {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/o/r/git-receive-pack")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap()
}
fn retry_after_secs(resp: &Response) -> u64 {
resp.headers()
.get("retry-after")
.expect("429 must carry a retry-after header")
.to_str()
.unwrap()
.parse()
.expect("retry-after must be an integer number of seconds")
}
#[tokio::test]
async fn retry_after_reflects_full_window_for_freshly_filled_bucket() {
// A just-filled bucket's oldest entry is ~now, so the advertised delay
// must be close to the whole window (100s), NOT the old constant 60.
let router = ip_limited_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(100)),
trust: TrustedProxy::None,
});
let peer: SocketAddr = "203.0.113.50:6000".parse().unwrap();
assert_eq!(resp_from(&router, peer).await.status(), StatusCode::OK);
let resp = resp_from(&router, peer).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry = retry_after_secs(&resp);
assert!(
(95..=100).contains(&retry),
"freshly-filled bucket must advertise ~window (95..=100s), got {retry} (constant-60 bug if 60)"
);
}
#[tokio::test]
async fn retry_after_shrinks_as_oldest_entry_ages() {
// Seed the sole slot with an entry already 95s into a 100s window: the
// next request is rejected and must advertise only the ~5s remaining,
// not 60.
let limiter = RateLimiter::new(1, Duration::from_secs(100));
let peer: SocketAddr = "203.0.113.51:6000".parse().unwrap();
limiter
.seed_aged_entry_for_test(&peer.ip().to_string(), &[Duration::from_secs(95)])
.await;
let router = ip_limited_router(IpRateLimiter {
limiter,
trust: TrustedProxy::None,
});
let resp = resp_from(&router, peer).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry = retry_after_secs(&resp);
assert!(
(1..=10).contains(&retry),
"an aged bucket must advertise the small remaining delay (1..=10s), got {retry}"
);
}
#[tokio::test]
async fn retry_after_clamps_to_at_least_one_second() {
// Oldest entry is 99s into a 100s window: the raw remainder rounds toward
// 1s and must never be advertised as 0 (a 0/blank retry lets a rejected
// client hammer immediately).
let limiter = RateLimiter::new(1, Duration::from_secs(100));
let peer: SocketAddr = "203.0.113.52:6000".parse().unwrap();
limiter
.seed_aged_entry_for_test(&peer.ip().to_string(), &[Duration::from_secs(99)])
.await;
let router = ip_limited_router(IpRateLimiter {
limiter,
trust: TrustedProxy::None,
});
let resp = resp_from(&router, peer).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry = retry_after_secs(&resp);
assert!(retry >= 1, "retry-after must clamp to >= 1, got {retry}");
assert!(
retry <= 2,
"with 99s of a 100s window elapsed, the remaining delay is ~1s, got {retry}"
);
}
}
+9 -52
View File
@@ -55,50 +55,18 @@ fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState
.layer(middleware::from_fn(auth::require_signature))
}
/// Attach the shared per-IP write brake to an authenticated write-route group,
/// outermost so a flood is rejected before signature verification. Every
/// authenticated non-creation write group MUST call this or it silently ships
/// unbraked with no compile error. Deliberately a separate call, NOT folded into
/// `add_auth_layers`: the creation routes use a different bucket
/// (`create_ip_rate_limiter`) and the graphql group is not built through
/// `add_auth_layers`, so a helper keyed on auth would attach the wrong limiter.
trait WriteBraked {
fn write_braked(self, limiter: &rate_limit::IpRateLimiter) -> Self;
}
impl WriteBraked for Router<AppState> {
fn write_braked(self, limiter: &rate_limit::IpRateLimiter) -> Self {
self.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(limiter.clone()))
}
}
pub fn build_router(state: AppState) -> Router {
// ── GraphQL routes ─────────────────────────────────────────────────────
let schema = state.graphql_schema.as_ref().clone();
// Per-IP write brake, shared across every authenticated non-creation write
// group (see `AppState::write_rate_limiter`). Built once here so each group
// clones the same bucket; attached outermost on each group so a flood is
// rejected before auth runs. Separate bucket from the creation brake.
let write_ip_limiter = rate_limit::IpRateLimiter {
limiter: state.write_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let graphql_routes = Router::new()
.route("/graphql", get(graphql_playground).post(graphql_handler))
// Attach the verified DID to /graphql when a signature is present. The
// layer covers only routes added before it, so /graphql/ws (added after,
// read-only subscriptions) stays open. The per-IP write brake covers the
// same /graphql (KTD-5: an HTTP-layer brake cannot tell a query POST from
// a mutation POST, so it counts every /graphql request — acceptable, both
// cost work); /graphql/ws subscriptions stay unbraked.
// read-only subscriptions) stays open.
.layer(middleware::from_fn(auth::optional_signature))
.write_braked(&write_ip_limiter)
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
// ── Task routes (write — require HTTP Signature + per-IP write brake) ──
// ── Task routes (write — require HTTP Signature) ───────────────────────
let task_write_routes = add_auth_layers(
Router::new()
.route("/api/v1/tasks", post(tasks::create_task))
@@ -106,8 +74,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
state.clone(),
)
.write_braked(&write_ip_limiter);
);
// ── Task routes (read — open) ──────────────────────────────────────────
let task_read_routes = Router::new()
@@ -144,13 +111,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(create_ip_limiter));
// ── Write routes — require HTTP Signature + per-IP write brake ────────
// Same per-IP flood defense as the creation routes, on its own `write`
// bucket (see `AppState::write_rate_limiter`). Per-DID is deliberately not
// paired (a DID farm never trips it; busy legitimate agents would false-
// positive). The bundled visibility GET is a read that also draws from the
// write bucket — harmless, since the bucket is sized far above any legit
// per-IP read rate.
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
let write_routes = add_auth_layers(
Router::new()
.route(
@@ -220,8 +181,7 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::delete(agents::deregister_agent),
),
state.clone(),
)
.write_braked(&write_ip_limiter);
);
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
// routes only — all other API routes keep axum's default 2 MB cap.
@@ -287,8 +247,7 @@ pub fn build_router(state: AppState) -> Router {
post(bounties::dispute_bounty),
),
state.clone(),
)
.write_braked(&write_ip_limiter);
);
// ── Bounty routes (read — open) ──────────────────────────────────────
let bounty_read_routes = Router::new()
@@ -309,10 +268,9 @@ pub fn build_router(state: AppState) -> Router {
let profile_write_routes = add_auth_layers(
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
)
.write_braked(&write_ip_limiter);
);
// ── Issue routes (write — require HTTP Signature + per-IP write brake) ─
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
let issue_write_routes = add_auth_layers(
Router::new()
.route(
@@ -324,8 +282,7 @@ pub fn build_router(state: AppState) -> Router {
post(issues::create_issue_comment),
),
state.clone(),
)
.write_braked(&write_ip_limiter);
);
// ── Peer discovery routes ─────────────────────────────────────────────
// Peer writes accept signatures when present and can require them after a
-54
View File
@@ -62,16 +62,6 @@ pub struct AppState {
/// resolved client IP is what actually stops a single-source flood (same
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
pub create_ip_rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for the authenticated write surface that is
/// not repo/agent creation: issue/PR comments, labels, stars, merges,
/// protect/unprotect, replicas, visibility, tasks, bounties, profile, and
/// the GraphQL `MutationRoot`. Separate bucket from `create_ip_rate_limiter`
/// (its own budget, per the `sync_trigger`/`peer_write` precedent) so a
/// write flood cannot drain the creation budget and vice versa. Per-DID is
/// deliberately NOT paired here: a DID farm never trips it, and busy
/// legitimate agents would false-positive. `GITLAWB_WRITE_RATE_LIMIT`
/// overrides the default, 0 disables. Keyed by `push_limiter_trust`.
pub write_rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
/// push path throttles on the resolved client IP instead.
@@ -128,48 +118,4 @@ impl AppState {
pub fn is_shutting_down(&self) -> bool {
*self.shutdown_tx.borrow()
}
/// Reclaim expired entries from EVERY rate limiter. Single source of truth for
/// the periodic cleanup loop (main.rs), co-located with the limiter fields so a
/// newly-added limiter is cleaned here rather than silently omitted from a
/// hand-maintained list in main() (H2: `write_rate_limiter` was the omitted
/// one). Every limiter field above MUST be swept here; the guard test
/// `cleanup_rate_limiters_reaps_the_write_limiter` fails if it is not.
pub(crate) async fn cleanup_rate_limiters(&self) {
self.rate_limiter.cleanup().await;
self.create_ip_rate_limiter.cleanup().await;
self.write_rate_limiter.cleanup().await;
self.push_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
self.peer_write_rate_limiter.cleanup().await;
}
}
#[cfg(test)]
mod tests {
/// H2 guard: the write-surface limiter must be reaped by the shared cleanup
/// routine. Seeds a reclaimable entry into `write_rate_limiter` and asserts
/// `cleanup_rate_limiters` removes it. Goes RED if `write_rate_limiter` is
/// dropped from `cleanup_rate_limiters` — the exact H2 omission, now guarded.
#[tokio::test]
async fn cleanup_rate_limiters_reaps_the_write_limiter() {
let state = crate::test_support::test_state_lazy();
state
.write_rate_limiter
.insert_reclaimable_for_test("some-ip")
.await;
assert_eq!(
state.write_rate_limiter.tracked_keys().await,
1,
"precondition: the reclaimable entry is tracked"
);
state.cleanup_rate_limiters().await;
assert_eq!(
state.write_rate_limiter.tracked_keys().await,
0,
"write_rate_limiter must be reaped by cleanup_rate_limiters (H2)"
);
}
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -77,7 +77,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
write_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gl"
description = "The gitlawb CLI — identity management, node control, MCP server"
version = "0.6.0" # x-release-please-version
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+170
View File
@@ -550,4 +550,174 @@ mod tests {
);
assert!(key_trusted, "key stays trusted with operator origin");
}
#[test]
fn new_withholds_key_from_untrusted_origin_but_attaches_for_operator() {
let guard = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev_key = std::env::var_os("GITLAWB_ICAPTCHA_API_KEY");
let prev_op = std::env::var_os("GITLAWB_ICAPTCHA_URL");
std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", "secret-bearer");
// Arm 1: No operator configured, node advertises an attacker origin.
// The key has no trusted destination; the advert is rejected.
std::env::remove_var("GITLAWB_ICAPTCHA_URL");
let untrusted =
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
// Arm 1b: No operator configured, no URL advertised. The fallback goes
// to the default public origin, and with no operator to trust, the key
// must not ride.
let no_operator_no_advert = IcaptchaCfg::new("did:key:zTEST", None, None);
// Arm 2: Operator origin configured and advertised — key attached.
std::env::set_var("GITLAWB_ICAPTCHA_URL", "https://icap.mynode.example");
let operator_match = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icap.mynode.example/v1".into()),
None,
);
// Arm 3: Operator configured, default host advertised. The default host
// is allowlisted and selected, but its origin differs from the
// operator's, so key must not ride.
let default_advert = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icaptcha.gitlawb.com/v2".into()),
None,
);
// Arm 4: Operator configured, no URL advertised (the node sent only
// x-icaptcha-level, not x-icaptcha-url). resolve_solver_url falls back
// to the operator origin and marks the key trusted. This is the common
// API-key deployment path — a regression that additionally required
// url.is_some() for key attachment would leave all current tests green
// but make these fallback challenge/answer requests omit Authorization
// and fail against the protected operator service.
let operator_fallback = IcaptchaCfg::new("did:key:zTEST", None, Some(3));
// Arm 5: Operator configured, node advertises an attacker origin.
// The advert is rejected and the solver falls back to the operator's
// own origin, which keeps the key trusted — this is the exfiltration
// scenario the test name describes (withhold key from *untrusted*
// origin, not from the operator's fallback).
let operator_hostile_advert =
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
// Arm 6: Operator configured, same host but different port.
// origin_key includes the port, so :8443 must not match the
// operator origin at the default :443 — the advert is rejected
// and the solver falls back to the operator origin.
let operator_diff_port = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icap.mynode.example:8443/evil".into()),
None,
);
// Restore env and release the lock BEFORE asserting, so a failing
// assertion can't poison the shared lock or cascade into a sibling.
match prev_key {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_API_KEY"),
}
match prev_op {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
}
drop(guard);
let mut failures: Vec<String> = Vec::new();
if untrusted.api_key.is_some() {
failures.push(format!(
"arm 1 (no operator, attacker advert): expected api_key=None, got {:?}",
untrusted.api_key
));
}
if untrusted.url != DEFAULT_URL {
failures.push(format!(
"arm 1 (no operator, attacker advert): expected url={DEFAULT_URL}, got {}",
untrusted.url
));
}
if no_operator_no_advert.api_key.is_some() {
failures.push(format!(
"arm 1b (no operator, no advert): expected api_key=None, got {:?}",
no_operator_no_advert.api_key
));
}
if no_operator_no_advert.url != DEFAULT_URL {
failures.push(format!(
"arm 1b (no operator, no advert): expected url={DEFAULT_URL}, got {}",
no_operator_no_advert.url
));
}
if operator_match.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 2 (operator match): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_match.api_key
));
}
if operator_match.url != "https://icap.mynode.example/v1" {
failures.push(format!(
"arm 2 (operator match): expected url=https://icap.mynode.example/v1, got {}",
operator_match.url
));
}
if default_advert.api_key.is_some() {
failures.push(format!(
"arm 3 (operator + default advert): expected api_key=None, got {:?}",
default_advert.api_key
));
}
if default_advert.url != "https://icaptcha.gitlawb.com/v2" {
failures.push(format!(
"arm 3 (operator + default advert): expected url=https://icaptcha.gitlawb.com/v2, got {}",
default_advert.url
));
}
if operator_fallback.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 4 (operator fallback, no advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_fallback.api_key
));
}
if operator_fallback.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 4 (operator fallback, no advert): expected url=https://icap.mynode.example, got {}",
operator_fallback.url
));
}
if operator_hostile_advert.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 5 (operator + hostile advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_hostile_advert.api_key
));
}
if operator_hostile_advert.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 5 (operator + hostile advert): expected url=https://icap.mynode.example, got {}",
operator_hostile_advert.url
));
}
if operator_diff_port.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 6 (operator + different port advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_diff_port.api_key
));
}
if operator_diff_port.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 6 (operator + different port advert): expected url=https://icap.mynode.example, got {}",
operator_diff_port.url
));
}
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
}
}
+65
View File
@@ -0,0 +1,65 @@
# fly.toml app configuration file generated for gitlawb-node-2 on 2026-07-22T09:47:18+08:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'gitlawb-node-2'
primary_region = 'sjc'
[build]
dockerfile = '../../Dockerfile'
[env]
GITLAWB_AUTO_SYNC = 'true'
GITLAWB_BOOTSTRAP_PEERS = 'https://node.gitlawb.com,https://node3.gitlawb.com'
GITLAWB_HOST = '0.0.0.0'
GITLAWB_KEY = '/data/keys/identity.pem'
GITLAWB_MAX_PACK_BYTES = '524288000'
GITLAWB_P2P_PORT = '7546'
GITLAWB_PORT = '7545'
GITLAWB_PUBLIC_URL = 'https://node2.gitlawb.com'
GITLAWB_REPOS_DIR = '/data/repos'
ICAPTCHA_MODE = 'enforce'
ICAPTCHA_PUBKEY = 'xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8'
ICAPTCHA_REQUIRED_LEVEL = '3'
[[mounts]]
source = 'gitlawb_data'
destination = '/data'
[http_service]
internal_port = 7545
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 1
[http_service.concurrency]
type = 'connections'
hard_limit = 500
soft_limit = 400
[http_service.http_options]
idle_timeout = 120
[[http_service.checks]]
interval = '15s'
timeout = '5s'
grace_period = '30s'
method = 'GET'
path = '/ready'
[[services]]
protocol = 'udp'
internal_port = 7546
[[services.ports]]
port = 7546
[[vm]]
size = 'shared-cpu-1x'
memory = '1024'
[[metrics]]
port = 9091
path = '/metrics'
+65
View File
@@ -0,0 +1,65 @@
# fly.toml app configuration file generated for gitlawb-node-3 on 2026-07-22T09:47:19+08:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'gitlawb-node-3'
primary_region = 'nrt'
[build]
dockerfile = '../../Dockerfile'
[env]
GITLAWB_AUTO_SYNC = 'true'
GITLAWB_BOOTSTRAP_PEERS = 'https://node.gitlawb.com,https://node2.gitlawb.com'
GITLAWB_HOST = '0.0.0.0'
GITLAWB_KEY = '/data/keys/identity.pem'
GITLAWB_MAX_PACK_BYTES = '524288000'
GITLAWB_P2P_PORT = '7546'
GITLAWB_PORT = '7545'
GITLAWB_PUBLIC_URL = 'https://node3.gitlawb.com'
GITLAWB_REPOS_DIR = '/data/repos'
ICAPTCHA_MODE = 'enforce'
ICAPTCHA_PUBKEY = 'xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8'
ICAPTCHA_REQUIRED_LEVEL = '3'
[[mounts]]
source = 'gitlawb_data'
destination = '/data'
[http_service]
internal_port = 7545
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 1
[http_service.concurrency]
type = 'connections'
hard_limit = 500
soft_limit = 400
[http_service.http_options]
idle_timeout = 120
[[http_service.checks]]
interval = '15s'
timeout = '5s'
grace_period = '30s'
method = 'GET'
path = '/ready'
[[services]]
protocol = 'udp'
internal_port = 7546
[[services.ports]]
port = 7546
[[vm]]
size = 'shared-cpu-1x'
memory = '1024'
[[metrics]]
port = 9091
path = '/metrics'
+1 -1
View File
@@ -46,7 +46,7 @@ build_darwin_arm64() {
echo "==> Building darwin-arm64 (native cargo)..."
cd "$ROOT"
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release -p gl -p git-remote-gitlawb
cargo build --release --locked -p gl -p git-remote-gitlawb
cp target/release/gl "$OUT/gl-darwin-arm64"
cp target/release/git-remote-gitlawb "$OUT/git-remote-gitlawb-darwin-arm64"
chmod +x "$OUT/gl-darwin-arm64" "$OUT/git-remote-gitlawb-darwin-arm64"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# Dependency-purity gate for the `gitlawb-core` crate.
#
# gitlawb-core is embedded by every consumer (the gl CLI, git-remote-gitlawb,
# the node daemon), so it must stay lean. This script recomputes gitlawb-core's
# NORMAL (non-dev, non-build) transitive dependency set and fails if it contains
# any crate not present in ci/gitlawb-core-allowed-deps.txt.
#
# Hard-fail direction: a crate present now but NOT allowlisted. That is the case
# the gate exists to catch (core silently gaining a heavy dependency).
# Informational only: an allowlisted crate no longer present (stale entry) — a
# legitimate dependency removal should not red CI, so it is reported, not failed.
#
# Runnable from anywhere in the repo.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
ALLOW="$ROOT/ci/gitlawb-core-allowed-deps.txt"
if [ ! -f "$ALLOW" ]; then
echo "ERROR: allowlist not found at $ALLOW" >&2
exit 1
fi
# `cargo tree` resolves WITHOUT --locked on purpose, unlike the blocking cargo
# jobs in pr-checks. This gate answers one question, whether gitlawb-core's
# dependency closure gained something unallowlisted, and a stale Cargo.lock
# would red it for a reason that has nothing to do with that closure. Lock
# staleness is already caught by the --locked cargo jobs, so it does not need a
# second gate here.
#
# Resolving can refresh Cargo.lock as a side effect, so snapshot and restore
# it: this check must never leave the working tree dirty when run locally.
lock_backup="$(mktemp)"
cp "$ROOT/Cargo.lock" "$lock_backup"
restore_lock() { cp "$lock_backup" "$ROOT/Cargo.lock"; rm -f "$lock_backup"; }
trap restore_lock EXIT
# Current normal-dependency closure of gitlawb-core, one crate name per line.
# Must match the regen command documented in the allowlist header exactly.
current="$(
cargo tree -p gitlawb-core --edges normal --prefix none --manifest-path "$ROOT/Cargo.toml" \
| sed -E 's/ v[0-9].*$//' \
| grep -v '^gitlawb-core$' \
| sort -u
)"
# Allowlist with comments and blank lines stripped.
allowed="$(grep -vE '^[[:space:]]*(#|$)' "$ALLOW" | sort -u)"
# comm needs sorted input; both sides are sorted above.
offenders="$(comm -23 <(printf '%s\n' "$current") <(printf '%s\n' "$allowed"))"
stale="$(comm -13 <(printf '%s\n' "$current") <(printf '%s\n' "$allowed"))"
if [ -n "$stale" ]; then
echo "NOTE: allowlisted crates no longer in gitlawb-core's dependency tree"
echo " (safe to prune from ci/gitlawb-core-allowed-deps.txt):"
printf ' %s\n' $stale
echo
fi
if [ -n "$offenders" ]; then
{
echo "ERROR: gitlawb-core gained dependencies not on the allowlist:"
printf ' %s\n' $offenders
echo
echo "gitlawb-core must stay embeddable and lean. If a new dependency is"
echo "intentional, add it to ci/gitlawb-core-allowed-deps.txt (regen command"
echo "is in that file's header). Otherwise, drop the dependency."
} >&2
exit 1
fi
echo "gitlawb-core dependency purity: OK ($(printf '%s\n' "$current" | wc -l | tr -d ' ') normal deps, all allowlisted)."