Compare commits

...
Author SHA1 Message Date
beardthelion e6f4b2fbac fix(node): close review residuals: coalesce cold downloads to one in-flight task, compensate a timed-out upload with the rolled-back tree, aggregate partial ref restores, race-test the anchor lock, fail-closed snapshot test 2026-07-22 17:29:06 -05:00
beardthelion d7551807e6 fix(node): slug-lock arweave anchor writes; fail closed when the pre-push ref snapshot fails 2026-07-22 16:55:28 -05:00
beardthelion 4329b2aa87 Merge origin/main into feat/write-sink-brake-coverage (reintegrate current base) 2026-07-22 16:35:07 -05:00
beardthelion ce25bba091 refactor(node): delegate issue ref helpers to the store ref primitives 2026-07-22 16:21:27 -05:00
beardthelion 1e79ae59a5 fix(node): own cold-download cleanup in a spawned task so handler cancellation cannot resurrect temp dirs 2026-07-22 16:21:27 -05:00
beardthelion 296704f7fa fix(node): slug-lock every peer-ingest writer and drive the real ingest in the purge-race test 2026-07-22 16:21:27 -05:00
beardthelion 9152178109 fix(node): never let a failed ref snapshot become a delete-all rollback; cover merge and push rollbacks 2026-07-22 16:21:27 -05:00
beardthelion 03cd81ee2b chore(node): fmt + clippy cleanup for the #196 fixes
Auto-deref (&mut tx) on the lock_repo_slug calls and rustfmt of the new test
lines. No behavior change.
2026-07-22 12:52:14 -05:00
beardthelion 1d0d3d2dca fix(node): tie cancelled cold-download cleanup to blocking extraction
On a download timeout the TempDownloadDir guard dropped while the detached
spawn_blocking extraction kept running and could rename its output back into
the just-removed temp path; that dir was only reclaimed on a later same-repo
request, so cold reads across distinct repos accumulated extracted repos on
disk. The timeout arm now hands the owned download future to a detached janitor
that awaits extraction completion before removing the temp dir, off the
critical path so waiters are still freed immediately. (F3, #196)
2026-07-22 12:44:17 -05:00
beardthelion 1d8c28113a fix(node): roll back local ref on failed durable upload (close/merge/receive-pack)
close_issue, merge_pr, and the receive-pack task applied a local ref write then
released with an empty failure cleanup, so a failed Tigris upload left stale
state that the local-fast-path read served despite the client getting a 5xx.
Each now captures the prior ref and restores it via release_with_failure_cleanup
when the upload fails, matching create_issue. (F2, #196)
2026-07-22 12:29:44 -05:00
beardthelion 556aa31e2c fix(node): serialize repo purge cascade with mirror ingest via advisory lock
delete_repo_by_id ran a point-in-time sibling check then cascaded slug-scoped
deletes; upsert_mirror_repo (peer sync) did not serialize against that window,
so a colliding mirror could land mid-purge and get half-wiped. Both now take a
transaction-scoped pg_advisory_xact_lock keyed on the normalized slug. (F1, #196)
2026-07-22 12:18:12 -05:00
Kevin Codex b484a24955 Merge pull request #237 from Gitlawb/fix/issue-228-windows-ci
ci: add a non-blocking Windows test lane for the shipped CLIs
2026-07-22 23:28:53 +08:00
Kevin Codex b75aa2f1da Merge pull request #236 from Gitlawb/fix/gl-acceptance-findings
Fix/gl acceptance findings
2026-07-22 23:28:09 +08:00
Kevin Codex c681af70db fix(gl): anchor cert --verify to a trusted issuer; pin canonical payload form
Settles the review's design question: a valid signature alone proves
internal consistency — the payload was signed by whatever key the
certificate itself names — so a hostile source could self-sign and
pass. --verify now also requires the issuing node_did to match a
trusted anchor: --expect-node when given, else the DID of the node
being queried; unanchorable (node info unreachable, no --expect-node)
fails closed. The displayed verdict wording says what it proves.

Also from review:
- A frozen-literal test pins gl's payload serialization to the node's
  canonical byte form, so serialization drift (new field, a
  preserve_order feature unifying across the workspace) fails a test
  instead of silently rendering every real certificate INVALID.
- The printed unalias remediation uses the idempotent-safe
  'unalias gl 2>/dev/null' form everywhere (doctor, install.sh, brew
  caveats), matching what doctor already suggested.
- Acknowledged, deliberately left: bash-side alias detection and
  127/8-range loopback matching (noted as out of scope for a
  warning-level heuristic).
2026-07-22 21:43:44 +08:00
Kevin Codex 3fa38dce4e fix(gl): address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias
- cert show: the node-DID comparison is contextual; a node-info fetch
  error now prints a note instead of failing the command after the
  certificate and verdict were already shown.
- doctor: loopback detection parses the URL host exactly — substring
  matching misread localhost.example and localhost-in-path URLs.
- init: a detached HEAD gets its own hint (switch to a branch first)
  instead of a guessed 'git push gitlawb main'.
- status: every fetch AND push URL of each remote is inspected — a
  gitlawb:// push URL behind an https fetch URL now counts.
- doctor/install.sh: unalias detection is command-aware — comments,
  'unalias global', and gl-only-in-a-trailing-comment no longer
  suppress the warning (BSD-grep-compatible pattern; ordering vs
  oh-my-zsh load documented as accepted slack). Eight new test cases.
2026-07-22 21:17:38 +08:00
t c71bba5cd1 ci: add a non-blocking Windows test lane for the shipped CLIs (#228)
We ship `gl` and `git-remote-gitlawb` to Windows users via release.yml but run
no Windows tests: pr-checks.yml is ubuntu-only, and the single windows-latest in
the repo is the release build step, which never runs `cargo test`. So changes to
the shipped Windows CLIs (e.g. the multi-round fetch fix in #192) reach Windows
users with zero test runs on Windows.

Add a `test-windows` job that runs `cargo test -p gl -p git-remote-gitlawb` on
windows-latest with `continue-on-error: true`, mirroring the informational beta
lane: a Windows-only failure is visible on the PR but does not block merge. The
two crates are the client CLIs and depend on no database, so the job needs no
Postgres service (service containers do not run on windows-latest regardless);
git is preinstalled on the runner for the real_git_fetch harness.

This makes the #[cfg(windows)] regressions from #192 load-bearing instead of
never-run. Un-gating the currently #[cfg(unix)] harness tests is the deferred
follow-on in #228.
2026-07-22 08:01:06 -05:00
Kevin Codex e3df51d76c fix: warn about the oh-my-zsh gl alias at install time
install.sh detects the common shadowing setup (omz git plugin, no
unalias in ~/.zshrc) and prints the fix right after installing; the
generated Homebrew formula gains a caveats block saying the same, so
brew install/upgrade surfaces it too. Catching this at install time
beats letting the first gl invocation print git's error.
2026-07-22 20:51:23 +08:00
Kevin Codex 0765c42fef feat(gl): doctor warns when a shell alias shadows the gl binary
oh-my-zsh's default git plugin ships gl='git pull', silently shadowing
gl in every interactive zsh — the symptom is the baffling 'fatal: not a
git repository'. Aliases beat PATH, so no installer can fix it; doctor
now detects the setup (explicit alias gl= in ~/.zshrc, or the omz git
plugin without an unalias) and prescribes the one-line fix. Best-effort
heuristic, warning-level, covered by four unit tests.
2026-07-22 20:51:23 +08:00
Kevin Codex 94c61f7942 fix(gl): doctor treats a reachable local node as configuration, not failure
A localhost GITLAWB_NODE red-flagged every self-hosted and dev-harness
setup even when the node was healthy; the connectivity check already
fails loudly when the configured node is unreachable. The env check now
passes with an explanatory note.
2026-07-22 20:37:33 +08:00
Kevin Codex b0a2ed8308 feat(gl): actually verify certificate signatures in gl cert show
show printed the signing payload and told the user to verify offline
themselves. It now rebuilds the node's canonical payload (field order
mirrors gitlawb-node cert issuance), derives the Ed25519 key from the
certificate's node DID, verifies, and prints a VALID/INVALID verdict;
--verify makes the exit code reflect it for scripting. Round-trip +
tamper + malformed-signature unit tests included.
2026-07-22 20:37:33 +08:00
Kevin Codex cb42a4830d fix(gl): make gl init's push guidance match the repo's actual state
- git init -b main (plain-init fallback for git <2.28) so a fresh repo
  can't land on master while every hint says main.
- The closing hint checks HEAD: with no commits it prints the
  add/commit step first — a bare 'git push gitlawb main' at that point
  fails with 'src refspec main does not match any', the exact trap the
  acceptance test walked into. The current branch name is used in the
  hint either way.
2026-07-22 20:37:33 +08:00
Kevin Codex 2d88937fba fix(gl): detect gitlawb remotes beyond origin in gl status
gl init names its remote 'gitlawb', but detect_gitlawb_remote() only
ever read origin — so gl status reported 'not in a gitlawb repo' in the
very repo gl init had just set up. Scan all remotes, preferring origin
(clones) then gitlawb, then any other gitlawb:// URL. (#231 acceptance
test finding)
2026-07-22 20:37:33 +08:00
Kevin Codex e14c2d3f3f Merge pull request #230 from Gitlawb/ci/merge-race-guards
ci: guard against semantic merge races breaking main
2026-07-22 16:35:22 +08:00
Kevin Codex 4465beb0c2 ci: make the red-main alert a state tracker, not an event echo
Address review: the alerter reflected CI events rather than main's
current state, which could both cry wolf and stay silent.

- Freshness gate: a run only opens or closes anything when its commit is
  still main's tip — re-running an old red run after a fix has landed no
  longer opens a false stop-ship issue (and a stale green can't close a
  current one).
- Trigger covers timed_out and startup_failure — both are red mains that
  previously alerted nothing. cancelled stays excluded as a human abort.
- Recovery: a green run on the tip comments and closes open ci-main-red
  issues, turning the alert into a current-state signal.
- queue: max — the single-slot pending queue let a later completion
  displace a pending red alert under burst merges.
- Issue lookup filters out pull requests carrying the label.
- github-script converged on v8.0.0 (SHA verified against the tag) in
  pr-triage too.
2026-07-22 16:20:15 +08:00
Kevin Codex 82efd60229 ci: serialize main-red-alert runs to prevent duplicate issues
Two near-simultaneous red-main events could both pass the open-issue
lookup and each create a ci-main-red issue (or 422 racing createLabel).
A workflow concurrency group with cancel-in-progress: false serializes
them; the default single-slot pending queue is enough since a superseded
alert's state is re-read by the newest run anyway.
2026-07-22 16:18:38 +08:00
Kevin Codex e8f193a5df ci: guard against semantic merge races breaking main
Two individually-green PRs can break main in combination when the second
merges on checks that ran against a stale base (#113 + #145 compiled fine
apart, but #145 added a field to ReceivedRefUpdate that #113's new test
initializer never set — E0063 on merged main, fixed by #229).

- pr-checks: add merge_group trigger so the suite can gate a GitHub merge
  queue, which re-validates every PR against the true merged result.
- main-red-alert: new workflow that opens/updates a labeled issue whenever
  a push-to-main PR Checks run fails, so a red main is loud instead of
  silent (release-please PRs inherit main breakage, as #182 showed).
2026-07-22 16:18:38 +08:00
Kevin Codex 07d76ba556 Merge pull request #235 from Gitlawb/ci/docker-native-arm
ci: build release Docker images natively per-arch, merge with a manifest
2026-07-22 15:37:45 +08:00
Kevin Codex 05e434d906 ci: lowercase the ghcr image reference for the digest push
buildx's --output name= does no lowercasing (unlike metadata-action),
so ghcr.io/Gitlawb/node is an invalid reference and the per-arch digest
push would fail outright. Compute the lowercase image once in the
resolve step and reuse it, matching the manifest job.
2026-07-22 15:23:42 +08:00
Kevin Codex 7593540f62 ci: build release Docker images natively per-arch, merge with a manifest
The v0.6.0 release run's multi-arch Docker build wedged for 3+ hours
emulating the arm64 Rust release build under QEMU, and — because the
workflow concurrency group is cancel-in-progress:false — every queued
release run sat blocked behind it.

- docker is now a matrix: amd64 on ubuntu-latest, arm64 on
  ubuntu-24.04-arm — both native, mirroring release-binaries. Each leg
  smoke-tests the image (--version must report the released version)
  and pushes by digest only.
- docker-manifest stitches the digests into the :X.Y.Z, :X.Y and
  :latest tags with buildx imagetools, so a half-finished matrix can
  never publish a partially-tagged image.
- workflow_dispatch grows docker_backfill_tag (and npm_backfill_tag
  becomes optional): each channel backfills independently for an
  already-cut release — needed to publish the missing v0.6.0 image.
2026-07-22 13:13:37 +08:00
Kevin Codex ccc2cf46f2 Merge pull request #233 from Gitlawb/ci/npm-trusted-publishing
ci: switch npm publishing to trusted publishing (OIDC) + manual backfill
2026-07-22 12:28:33 +08:00
Kevin Codex 1fddc81b7c ci: skip release-please on manual npm backfill dispatch
A backfill run must be a narrow npm-only operation. Without an event
guard, workflow_dispatch also ran release-please with contents+PR write
permissions and could create or advance a release PR as a side effect.
npm-publish is unaffected: its !cancelled() condition tolerates the
skipped need, and all other jobs already gate on release_created.
2026-07-22 12:10:25 +08:00
Kevin Codex 62d73c4165 ci: switch npm publishing to trusted publishing (OIDC) + manual backfill
The npm-publish job silently skipped every release since 0.4.x because
NPM_TOKEN was never configured — the guard made the job green while
@gitlawb/gl sat at 0.3.7 on the registry. Replace token auth entirely:

- npm Trusted Publishing: the job's GitHub OIDC identity (id-token: write)
  is exchanged with the registry; no long-lived secret to configure, leak,
  or rotate. Provenance is attested automatically. All five @gitlawb
  packages exist on the registry now, so OIDC no longer has the
  first-publish bootstrap problem that forced token auth in #127.
- Fail loud: the secret guard is gone. If a package's trusted publisher
  isn't configured on npmjs.com, the publish errors visibly instead of
  green-skipping.
- setup-node 24 + npm >=11.5.1 (OIDC exchange support; older npm falls
  back to absent token auth with a confusing error).
- workflow_dispatch backfill: publish an already-cut GitHub release
  (e.g. v0.6.0) to npm by tag — binaries come from the existing release
  assets, so only the npm-publish job runs.
2026-07-22 11:50:44 +08:00
Kevin Codex 2f622ae5fa Merge pull request #182 from Gitlawb/release-please--branches--main--components--gitlawb-node
chore(main): release 0.6.0
2026-07-22 09:32:24 +08:00
github-actions[bot] f9506b466e chore(main): release 0.6.0 2026-07-22 09:08:39 +08:00
Kevin Codex d5ec4695f4 test(node): add missing owner_did field to fails-closed seed update (#229)
PRs #113 and #145 raced: #145 added owner_did to ReceivedRefUpdate and
updated its own call sites, but the seed initializer in
list_repo_events_fails_closed_when_repo_lookup_errors (added by #113)
was never updated, so main fails to compile test targets (E0063).
This breaks clippy --all-targets, both test jobs, and MSRV on CI,
including the release PR #182.

owner_did stays None here on purpose: the test exercises the legacy
None-branch slug fallback.

Co-authored-by: Kevin Codex <[email protected]>
2026-07-22 09:03:07 +08:00
euxaristia 98d728c325 fix(gl): point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo (#198)
* fix(gl): point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo

check_version queried https://api.github.com/repos/gitlawb/releases/releases/latest.
Gitlawb/releases is a separate repo that stopped receiving tags after v0.3.8
(2026-03-30). Releases have shipped directly on Gitlawb/node since v0.4.0, via
release-please, and install.sh already treats Gitlawb/node as the canonical
release source. The old query target meant gl doctor's version check could
never again detect a real update.

Points the check at Gitlawb/node instead, matching install.sh's own default,
and makes the GitHub API base injectable so the fix is covered by a test
against a mocked server rather than the real GitHub API.

Closes #197

* fix(gl): check HTTP status before parsing the release response; strengthen up-to-date test

Addresses CodeRabbit review on #198:
- check_version now checks resp.status().is_success() before parsing the body,
  so a GitHub rate-limit or other API error surfaces as its actual HTTP status
  instead of a generic "could not parse latest tag".
- test_check_version_up_to_date now also asserts the detail message, so the
  test can't silently pass on an unmatched mock request (mockito's fallback for
  an unhandled request is a 501, which check_version also maps to Ok).

* test(gl): add unit test for non-2xx check_version response

* fix(gl): remove trailing blank line at EOF in doctor.rs

cargo fmt --all -- --check fails on the extra terminal blank line, and the
fmt + clippy workflow runs that check as a required gate.
2026-07-22 08:33:11 +08:00
euxaristia b6877a6598 fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by (#200)
* fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by

Ucan::can did strict equality on both resource and action, so a validly
delegated wildcard capability (with: "*", or can: "*" / "repo/admin") could
never match a concrete runtime request even though is_attenuated_by treats
those same values as wildcards during delegation-chain verification.

Not a security issue: the mismatch only produces false negatives (denying a
capability the delegation chain actually grants), never false positives.
Dormant today since no production code path calls can() yet — auth's
caller_authorized_to_push is pure did_matches(), with the UCAN-capability
check noted as future (Phase 2) work.

Makes can() mirror is_attenuated_by's semantics: the stored capability plays
the "parent" role, so with == "*" matches any resource and can == "*" or
"repo/admin" matches any action.

Closes #199

* test: add can_honors_action_wildcard unit test for Ucan::can
2026-07-22 08:32:29 +08:00
beardthelionandt b07d41ea97 fix: strip Cf bidi/format controls in terminal-bound error sanitizers (#183) (#188)
* feat(core): add shared is_bidi_format predicate for terminal sanitizers (#183)

Terminal-bound error sanitizers must strip Cf bidi/format controls as well as Cc control bytes (INV-6), or a right-to-left override in attacker-influenceable bytes can visually reorder the displayed line (Trojan Source, CWE-451). gl already did this with a local is_bidi_format; two sibling sanitizers shipped with only the Cc half (#183). Centralize the reordering-subset predicate here so all consumers reuse one audited definition instead of re-deriving the set. Deliberately excludes legitimate Cf chars (ZWJ, soft hyphen).

* refactor(gl): consume shared is_bidi_format from gitlawb-core (#183)

Replace gl's local is_bidi_format copy with the centralized gitlawb-core predicate so exactly one definition exists in the workspace and the three terminal sanitizers cannot drift apart. Behavior-preserving; the existing strip test is unchanged and a must-not-over-strip guard (ASCII + Arabic U+0627 + ZWJ U+200D survive) pins the reordering-only scope.

* fix(git-remote): strip Cf bidi controls from terminal-bound error bodies (#183)

safe_error_body_excerpt filtered only Cc control bytes, so a Cf bidi override in a hostile or MITM'd node's HTTP error body reached the operator's terminal and could visually reorder the line (Trojan Source, CWE-451). Drop the reordering Cf subset via the shared gitlawb-core::sanitize::is_bidi_format, without setting pending_space so stripped chars leave no phantom gap. Covered per bidi class plus a transport-path proof through handle_connect, RED observed before the fix.

* fix(icaptcha): strip Cf bidi controls from terminal-bound error excerpts (#183)

sanitize_excerpt filtered only Cc, so a Cf bidi override in a node-advertised iCaptcha service's reason/body reached the terminal on the bail! path (Trojan Source, CWE-451). Drop the reordering Cf subset via the shared gitlawb-core predicate, without pending_space. The existing sanitize_strips_control_chars_and_escapes test asserted only is_control() and so was vacuous for this class (why the gap shipped); a dedicated per-class bidi test now covers it, RED observed before the fix.

* style: apply rustfmt to #183 sanitizer additions

* test(#183): pin a leading-position bidi char in the strip tests

The per-class strip tests placed bidi chars only mid-string. A bidi char at index 0 exercises the empty-excerpt/out path (where the pending_space guard behaves differently), confirming a leading override still yields no phantom leading space. Traced correct; now asserted.

* test(review): pin that bidi chars don't consume the length cap (#183)

Correctness, adversarial, and testing reviewers all flagged that no test locks in the drop-before-count ordering: a dropped bidi char must not consume MAX_ERR_CHARS budget. Behavior verified correct by execution during review; this test guards it against regression (interleaved bidi across the cap still yields exactly MAX_ERR_CHARS visible chars, no phantom spaces).

---------

Co-authored-by: t <t@t>
2026-07-22 08:29:49 +08:00
beardthelionandt c7427e943d test(icaptcha): assert obtain_proof consumes the mocked service (#184)
* test(icaptcha): assert obtain_proof consumes the mocked service

Add an integration test that runs obtain_proof against a mockito server and
asserts both /v1/challenge and /v1/answer were consumed, so the flow is proven
to call the service rather than short-circuit or make a live call. Adds mockito
as a dev-dependency.

* test(icaptcha): block non-mock egress so the no-live-call guard is real (#184)

The mock-consumed assertions only prove the two mock endpoints were hit; they
cannot see a request that also leaks the DID, answer, or API key to DEFAULT_URL
or any other origin, so the test's stated "no live call" guarantee was untested.

Blackhole every non-loopback destination for the flow: NO_PROXY lets the
loopback mock through while ALL_PROXY routes any other host to a closed port, so
the client contacting anything but the injected mock fails the request. Verified
load-bearing: pointing cfg.url at DEFAULT_URL makes obtain_proof fail with a
proxy tunnel error (RED), and the loopback-mock path still passes (GREEN).

* test(icaptcha): blackhole scheme-specific proxy vars so the no-live-call guard covers https

The mock-consumed guard blackholed non-loopback egress with ALL_PROXY only, but
reqwest honors HTTPS_PROXY/https_proxy (and the http equivalents) OVER ALL_PROXY
for https URLs. On a runner whose environment has a working HTTPS_PROXY, a
regression could still leak the DID/answer/API key to the https DEFAULT_URL while
both loopback mock calls are consumed and the test stayed green. Override the
scheme-specific proxy vars (both cases) to the closed port too; NO_PROXY keeps the
loopback mock direct. Single #[test] per process, so the override needs no restore.

Verified: a throwaway repro showed reqwest routing an https request to HTTPS_PROXY
past the ALL_PROXY blackhole (the gap); with the fix the override sends that egress
to the closed port even when a working HTTPS_PROXY is pre-set; the guard still goes
RED when cfg.url points off-loopback.

---------

Co-authored-by: t <t@t>
2026-07-22 08:25:53 +08:00
GravireiandGravirei 69a62e51c2 fix(node): carry full owner DID on ref-update wire event (#144) (#145)
* fix(node): carry full owner DID on ref-update wire event (#144)

* test(node): add ref-update owner_did round-trip, DB, and API tests (#144)

* fix(node): move owner_did migration to v10, add upgrade-path test

* fix(node): add owner_did to GraphQL, broadcast, and REST feed surfaces (#144)

* fix(node): address review feedback — drop ipfs regression, fix doc, improve migration test, use local owner_did

* fix(node): fix migration v10 test to actually start from v9 schema; complete owner_did API assertion

* fix(node): show local owner on global feed, assert nullable in migration test, add owner_did echo assertions

* fix(node): prefer stored wire owner_did over lossy slug-local resolution; mirror projection in GraphQL

* fix: address review findings — restore iCaptcha PoW/origin matching, fix feed owner_did projections

* fix: match icaptcha-client Cargo.toml ordering with upstream/main

sha2 was at line 16 in our branch but at line 18 in upstream/main.
The 3-way merge in CI's pull/145/merge checkout kept both lines,
causing a 'duplicate key' error. Moving sha2 after tracing matches
upstream/main exactly, so the merge commit will have a single entry.

* fix(node): bind wire owner_did to local repo before surfacing as trusted

The peer-supplied owner_did (HTTP notify + gossipsub receive) and the
repo slug arrive over untrusted paths, yet REST and GraphQL projected the
wire value as trusted feed ownership.

- P1: add Db::resolve_ref_update_owner_did so a wire owner_did is echoed
  only when it matches the canonical owner of the actual local repo at that
  slug; otherwise it is dropped (returns null). The canonical DID is
  returned, never the raw wire bytes.
- P3: legacy owner_did = None rows are now attributed via an exact,
  normalized, unique local match (get_repo), not the prefix-tolerant drop
  gate, so a cross-method slug collision cannot synthesize the wrong owner.
- Unify REST (global + per-repo gossip) and GraphQL on the shared resolver
  so the two surfaces cannot drift. Add tests for forged-wire drop and
  exact-legacy attribution.

* style(node): apply cargo fmt to owner_did resolver

* fix: batch owner_did resolution, propagate errors, preserve full DID for mirrors

P2 batch: replace per-row join_all with resolve_ref_update_owner_dids
that issues at most one get_repo query per distinct local repo in the
page rather than one per event row. Applies to both REST and GraphQL
surfaces.

P2 error: REST handler now propagates DB errors (rather than silently
coercing them to null) by calling the batch method with ?.

P2 mirror: when the validated wire DID carries a full method prefix
(e.g. did:key:z...) and the matching repo is a mirror storing only
the bare key, return the full wire DID instead of the bare key to
preserve the DID-method contract.

P1/P3 already addressed in fb553e6.

* fix: rebase onto current main and use single batch SQL for owner DID resolution

Rebased from stale 3ec8cbf base onto current upstream/main (ad7c2b2),
dropping the already-merged iCaptcha work from the diff. The PR diff
is now only the 11 owner-DID files.

The batch owner-DID resolver now issues a single set-based SQL query
with one OR condition per distinct slug, replacing the loop of per-key
get_repo calls. A 200-row page of 200 distinct remote slugs now costs
1 SQL round-trip instead of 200.

P1/P3 already addressed in c64c082. P2 mirror, error propagation,
and the earlier batch refactor preserved in the rebased commits.

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-22 08:24:48 +08:00
beardthelionandt ee8d7c03b2 fix(node): gate /hooks and sibling read surfaces leaking private repo metadata (#94) (#113)
* fix(node): owner-gate list_webhooks to stop leaking webhook URLs (#94)

list_webhooks sat on the optional_signature read group with no auth or
ownership check, so any unauthenticated caller who knew a repo's owner/name
could enumerate its webhooks (target URL, created_by_did, events; only the
secret was redacted).

Gate it read-visibility-then-owner, mirroring create_webhook/delete_webhook:
headerless -> 401, non-reader of a private repo -> 404 (existence hidden,
uniform with the sibling read surfaces), non-owner of a public repo -> 403.
Add a list_webhooks row to the authz_guard drift table so the gate can't
silently regress.

* fix(node): read-visibility-gate list_replicas for private repos (#94)

list_replicas sat on optional_signature with no visibility check, so a
private repo's replica URLs were enumerable by anyone who knew owner/name.
Replica lists are a documented public mirror-discovery surface, so gate on
read visibility (authorize_repo_read) rather than owner-only: a public repo
stays anonymously listable, a private repo returns 404 to anyone who cannot
read it.

register_replica registers non-owner DIDs, and a replica operator is not a
visibility reader, so a non-owner replica operator of a private repo gets
404 — the intended contract, pinned by the new test. Add the authz_guard
row.

* fix(node): read-visibility-gate list_protected_branches (#94)

list_protected_branches sat on optional_signature with no visibility check,
leaking a private repo's branch names to any unauthenticated caller. Gate on
read visibility (authorize_repo_read at the root, INV-2 root-listing
exception): public repos stay anonymously listable, private repos 404 to a
non-reader. Add the authz_guard row.

* fix(node): gate list_repo_events for local private repos, keep gossip-only (#94)

list_repo_events served a private repo's ref certificates and gossip ref
metadata (ref names, SHAs) to any unauthenticated caller. The handler also
tolerates a repo it knows only via gossip (no local row) and legitimately
serves its events, so a blanket get_repo->authorize_repo_read swap would have
404'd that federation path.

Gate only the locally-hosted branch: when get_repo returns a record, run
visibility_check on it at the root and 404 on deny before fetching either the
cert or gossip events. A gossip-only repo (no local row) keeps its existing
public federation-feed behavior; the global /api/v1/events/ref-updates feed
remains a separately tracked deferral. Add the authz_guard row (visibility_check
marker, since the gate is direct to avoid a second get_repo).

* fix(gl): sign the webhook list request now that GET /hooks is owner-gated (#94)

`gl webhook list` built its NodeClient with no keypair, so the GET went out
anonymously. With the node now owner-gating GET /hooks, that request returns
401. Thread a --dir through the List subcommand and load the keypair, matching
`gl webhook create`/`delete`, so the listing is signed as the owner.

(Replica and protected-branch listings stay anonymous for public repos under
read-visibility gating, so their gl clients need no change.)

* fix(review): sign the webhook list GET (get_signed), not plain get (#94)

Code review caught that NodeClient::get() never attaches the RFC 9421
signature even when a keypair is loaded — only get_signed/post/put/delete
sign. The earlier gl webhook-list fix loaded the keypair but still called
get(), so `gl webhook list` (and the MCP webhook_list tool) 401'd against
the now-owner-gated endpoint. Switch both to get_signed.

Harden the two cmd_list tests to require a Signature/Signature-Input header on
the mock (verified: they fail against plain get(), pass with get_signed) — the
missing assertion that let the bug through. Add the untested owner-of-private-
repo webhooks path (200 + redaction). Fix the stale module doc and the
authz_guard bucket comment for list_webhooks.

* fix(gl): sign read-visibility GETs so private-repo owners can read their own repos (#94)

The #94 server change read-visibility-gates GET /replicas and
/branches/protected: public repos stay anonymous, private repos require the
owner/reader to authenticate. Four gl read commands sent unsigned GETs and so
broke (404) or silently degraded (empty output) for a private repo's owner:
gl protect list, gl repo owner (silent empty protected list), gl repo info
(silent missing replica count), gl repo replicas (and it had no --dir flag).

Add NodeClient::get_maybe_signed — signs when an identity is present, stays
anonymous otherwise (mirrors the conditional signing of post/put/delete) —
and route the four call sites through it. Add --dir to `gl repo replicas`.
Harden the protect-list tests to assert the request is signed (verified: they
fail on plain get()). Public-repo anonymous reads are unchanged.

* fix(review): sign the primary repo fetch in gl repo info/owner too (#94)

Round-2 review caught that cmd_info and cmd_owner loaded a keypair and signed
their replica/protected-branch sub-fetch but left the PRIMARY GET
/api/v1/repos/{owner}/{name} on plain get(). get_repo is read-visibility-gated,
so for a private repo that primary fetch 404'd first and the command bailed
before reaching the signed sub-fetch — the half-fix never actually let an owner
inspect their own private repo. Route both primary fetches through
get_maybe_signed.

Add the missing signing guards: harden test_cmd_owner_is_owner to require a
signature on both mocks, and add tests for cmd_replicas and cmd_info (both were
untested) asserting the request is signed when an identity is present.

Out of scope (pre-existing, gated before #94, untouched here): gl repo commits,
gl pr list/view/diff, and the MCP repo_get/repo_commits/pr_* tools call other
already-gated read endpoints unsigned and are broken for private repos
independently of this change.

* test(review): execute the previously-reasoned-only paths (#94)

Close the vetting gaps from the review:
- node: a listed reader (non-owner) passes the read gate but is still refused
  the webhook list (403), and headerless on an existing-private vs an absent
  repo both 401 — proving the 401 fires before any lookup, so it is not an
  existence oracle.
- node: the read-visibility surfaces (replicas, protected branches) admit a
  listed reader who is not the owner (the allow-list branch of visibility_check),
  while a non-reader stranger still 404s.
- gl: get_maybe_signed's anonymous fallback (no identity -> unsigned GET) is now
  exercised, RED-proven by flipping it to get_signed (which signs and fails the
  no-signature-header assertion).

* test(review): execute the MCP dispatch and gl->node signature seams (#94)

Close the last two reasoned-only paths by execution:

- MCP webhook_list: a call_tool("webhook_list", ...) dispatch test against a
  mock node asserts the /hooks request carries a signature (get_signed).
  RED-proven: flipping the arm back to get() fails the header assertion.

- gl->node end-to-end: a real RFC-9421 signature produced exactly as the gl
  client's get_signed does (gitlawb_core::http_sig::sign_request over GET +
  empty body) is run through the node's actual optional_signature middleware,
  which verifies it and authorizes the owner (200 + webhook body). RED-proven:
  signing over POST while sending GET makes the node reject it. This stitches
  the gl signing side and the node verifying side in one test rather than
  mocking one end.

* fix(review): fail closed when the repo lookup errors in list_repo_events (#94)

`get_repo(...).await.ok().flatten()` collapsed a lookup Err into None, so a
transient DB failure skipped the read-visibility gate and the handler served a
private repo's gossip ref-update events. Propagate with `?` instead: an error
now maps to AppError::Internal (500) and fails closed, while a genuine Ok(None)
(repo not hosted locally) stays the intentional ungated pass-through.

Add a regression test that forces a get_repo error (drop the column its SELECT
reads) and asserts 500 with no secret in the body. The injection is
self-verifying: it asserts the lookup succeeds pre-drop and errors post-drop, so
the test can't pass vacuously if get_repo's projection changes later.

* fix(node,gl): close residual gaps in repo-read gating (#113)

Address jatmn's review feedback on the read-surface gate:

- gl webhook: resolve owner from the signing identity, not the node DID.
  cmd_create/list/delete all routed through the local resolve_owner helper,
  which only ever returned the node root DID, so for a repo owned by the
  local identity the owner-gated /hooks route returned 404/403. Drop that
  helper and reuse repo::resolve_owner_repo_pair (accepts owner/repo, else
  derives from --dir/keypair). Fixed across all three subcommands so
  create-writes and list/delete-reads stay on the same owner.

- gl webhook list: check the HTTP status before reading `webhooks`. A
  401/403/404 body has no webhooks field and previously printed
  "No webhooks" and exited 0, hiding the rejection; now bails with the
  server message like cmd_create.

- visibility::is_owner: delegate to api::did_matches so the read gate and
  require_repo_owner share one normalization. Fixes the under-match (bare
  owner stored, full did:key caller, as mirror rows) that denied the owner
  with the opaque 404, and the cross-method over-match.

- authz_guard: pin both halves of list_webhooks (authorize_repo_read and
  require_repo_owner) so removing the existence-hiding gate fails the drift
  test.

cargo fmt/clippy clean; 644 workspace tests pass.

* test(node)+fix(gl): close events reader-coverage gap, align label-list signing

Self-review follow-ups on the read-gate work:

- list_repo_events: add list_repo_events_admits_listed_reader, pinning the
  allow-list branch of the events gate (a listed non-owner reader gets 200
  with the cert; a non-reader stranger 404s with no leak). Mirrors the
  read_visibility_admits_listed_reader coverage the sibling replica and
  protected-branch surfaces already had; the events handler was the only
  gated read surface missing it.

- gl repo: cmd_label_list now loads an identity and uses get_maybe_signed
  instead of a bare anonymous get(), matching cmd_replicas/cmd_info. Public
  repos stay anonymously listable; a private-repo owner can read their own
  labels once /labels is read-visibility-gated, with no silent client drift.

fmt/clippy clean; 645 workspace tests pass.

* fix(review): pin the label-list signing and the events 404 sha-no-leak

Final-review hardening of the prior two follow-ups:

- gl repo label-list tests: require the signature/signature-input headers on
  the signed-branch mocks, and add an anonymous (no-identity) test asserting
  the header is absent. Without the header assertion the tests passed even
  with a bare get(), so the signing change was not actually pinned
  (RED-verified: reverting to get() now fails both signed-branch tests).

- list_repo_events_admits_listed_reader: also assert the cert sha (not just
  the ref name) is absent from the stranger's 404 body, matching the
  list_repo_events_is_read_visibility_gated leak-check convention.

fmt/clippy clean; 646 workspace tests pass.

* fix(gl): error on 2xx webhook-list body missing the webhooks array (#113)

A 2xx /hooks response without a `webhooks` array could hide a node/API
contract regression. cmd_list now errors with context instead of mapping
it to "No webhooks". An empty-but-present array stays a valid no-webhooks
result. Adds a test for the missing-array case.

* fix(gl,node): scope MCP webhook_list owner to keypair, gate list_labels on read visibility (#113)

- MCP webhook_list: default owner to the signing keypair's short DID (explicit
  owner arg still overrides) instead of the node root DID, so the signed,
  owner-gated /hooks request resolves to the caller's own repo. Mirrors the
  gl webhook list fix. resolve_owner and webhook_create/delete left untouched.
- list_labels: gate on authorize_repo_read so a private repo's label names 404
  for non-readers (public repos stay anonymously listable), matching the sibling
  read surfaces and making the existing client comment true. Added the
  authz_guard marker and a read-visibility integration test.
- gl label list: check HTTP status before reading the body so the now-gated
  404 surfaces as an error instead of printing "No labels" and exiting 0,
  matching the sibling list clients (replicas, protected branches).

* fix(gl): surface denied repo info reads instead of fabricating a repo (#113)

cmd_info parsed the GET /api/v1/repos/{owner}/{name} body without checking
the HTTP status, so a private-repo JSON 404 was rendered as a fake repo
summary (owner / ? / public / main) and exited 0. Capture the status and
bail on non-2xx, matching the sibling list commands.

Also assert the anonymous 404 label-list body does not leak the label name,
closing the last unread-body branch in the read-visibility gate test.

* test(node): drop #113-gated handlers from KNOWN_UNGATED; sync lockfile to 0.4.0

The repo-scoped egress guard's allowlist (added in #122, merged after this
branch was opened) still listed the read surfaces this branch gates. Now that
list_labels, list_repo_events, list_webhooks, list_replicas, and
list_protected_branches are read-visibility gated, the staleness assert
requires removing them from the allowlist. The #120 surfaces (certs, issues,
bounties, stars) stay listed; git_info_refs stays until #119 lands. Lockfile
versions follow the 0.4.0 release the rebase landed on.

* fix(gl): fail the MCP webhook_list tool on non-2xx node responses

The MCP webhook_list path deserialized the response body and returned it as a
successful tool result without checking the HTTP status, so a 401/403/404 JSON
error (missing identity, wrong owner, private or deleted repo) was reported as
success with the error body as the result. Check status().is_success() before
deserializing and surface the node's message as a tool error, matching the
`gl webhook list` path. Covered by a non-2xx test (RED without the check).

* fix(gl): read the protected_branches key in repo owner

gl repo owner parsed the protected-branch response as a bare array or a
"branches" key, but the node returns {"protected_branches":[...],"count":N},
so it always printed "Protected: (none)" even when branches were protected.
Extract the parse into parse_protected_branches matching the gl protect list
shape, and update the owner tests to mock the real schema instead of bare
arrays (which is why the bug went uncaught).

* test(gl): cover the deny/negative arms reconciled by the merge

The merge changed three gl branches that no existing test executed (they
were only reasoned): resolve_owner_did with no local identity, cmd_info on
an authenticated-but-denied 403, and get_maybe_signed with no keypair. Add
a test per branch asserting the must-not case (error/unsigned, never a
fabricated repo card). Verified load-bearing by mutation: disabling the
cmd_info status gate turns the 403 test red.

---------

Co-authored-by: t <t@t>
2026-07-22 08:24:09 +08:00
beardthelion a12f21adb4 fix(node): roll back the fork mirror and archive on late clone/insert failures (#196)
The bounded-clone rework cleaned up the mirror on the timeout and spawn
arms but not on a clone that creates the destination then exits nonzero,
so a retry hit 'destination already exists' and could never complete
that fork name. And because upload_under_guard is now required and runs
before the row insert, a create_repo failure reliably orphaned the
durable archive too, not just the local dir. Both failure arms now
remove the mirror, delete the fork's archive (no-op on a store-less
node), release the lock, and return. RED observed on both new tests (a
non-empty-dest clone-128 repro and a decoy-row insert-failure repro).
2026-07-21 07:47:18 -05:00
beardthelion 4408d27058 fix(node): bound acquire_write's download and free the download-map entry on cancel (#196)
Two gaps a second-model adversarial pass caught that the in-family
review missed. acquire_write's under-lock exists()/download() ran with
no timeout, so a stalled Tigris GET pinned the write lock and its
lock-pool connection forever (the local-copy fallback only fires on an
error, never a hang) - both are now wrapped in
timeout(release_upload_timeout), a timeout mapping to the existing
download-error fallback, which also makes the prior 'bound every
under-lock call' claim true. The download_locks map entry was removed
only on normal return, not on a cancelled cold read, so a disconnecting
caller could grow the map across public repos; a DownloadEntryGuard now
prunes it in Drop (outer map moved to std::sync::Mutex, held only for
map ops, never across an await). RED observed on both new tests,
including a real mid-download cancellation.
2026-07-21 07:47:18 -05:00
beardthelion 03ef9b51bd refactor(node): move read-path download coordination into a repo_store submodule (#196)
repo_store.rs had grown past 2800 lines. The download-coordination code
(DownloadOutcome, TempDownloadDir, download_published,
download_and_publish, remove_download_entry) moves to a private
repo_store/download.rs child module, which reaches the parent's private
fields and methods without widening visibility beyond pub(super) on the
two items the parent names. Pure move: no behavior change, the same 44
git::repo_store tests pass unchanged.
2026-07-21 00:35:28 -05:00
beardthelion c3d6e73e50 fix(node): keep the fork's unbounded work out of the target-lock span (#196)
The fork tail held the target-namespace advisory lock across two
unbounded operations. The source acquire, which on a cold source
triggers a full object-store download plus a nested lock-pool acquire on
the same writer-sized pool, now runs before the target lock is taken (it
is read-only on a different namespace and nothing under the lock depends
on it) so a cold-source fork can no longer demand two connections from a
one-per-writer pool at once. The git clone --mirror now runs via
tokio::process with kill_on_drop under a timeout(git_service_timeout_secs)
ceiling, cleaning the partial mirror and releasing the lock on elapse.
RED observed by executing the pool-size-1 cold-source fork with the
source acquire moved back under the lock: PoolTimedOut, source never
publishes, clone fails.
2026-07-21 00:26:47 -05:00
beardthelion fa4322cb2e fix(node): harden the read-path download against cancellation, stale swaps, and lock contention (#196)
Four read-path gaps the review surfaced. A TempDownloadDir RAII guard
now cleans the temp download dir if the handler future is cancelled on
client disconnect (the read handlers have no spawn shield, and a
post-download disconnect leaked the full populated dir), plus an
entry-time sweep of stale siblings. A cold or refresh reader that
downloaded while a concurrent writer published fresher on-disk state now
discards its stale temp and serves the writer's copy instead of swapping
over it (detected by presence for the cold path, mtime for the refresh
path). The takeover path re-registers its download-mutex entry so late
readers coalesce instead of starting a duplicate download. And the
try_lock Ok(None) degrade arm - the KTD-3 invariant that a reader
degrades rather than resurrecting under a live writer/purge - is now
tested by holding the advisory lock via an out-of-pool observer. RED
observed on both stale-swap tests.
2026-07-21 00:13:35 -05:00
beardthelion b2ca5a7b2d fix(node): bound every under-lock object-store call and dedup the upload path (#196)
Round-two review found the durability fixes reintroduced the
unbounded-work-under-lock class they were meant to close: upload_locked's
PUT (now reachable under the lock via init's wait=true), the read-path
download, and the under-lock exists() re-check all ran with no timeout
while pinning the advisory lock and its lock-pool connection. All three
now wrap the call in timeout(release_upload_timeout) and fail closed on
elapse, matching RepoWriteGuard::release's existing INV-22 pattern.
upload_under_guard and upload_locked's duplicated resolve/recheck/upload
body is extracted into upload_dir_locked, threading the load-bearing
dir-gone signal (fork needs success, lazy migration needs
skip-and-retry) through a parameter rather than aligning it. RED
observed: upload_locked_put_stall_is_bounded failed (lock never freed)
against the un-timeout'd PUT.
2026-07-20 23:55:24 -05:00
beardthelion 2ad1c793ab fix(node): read-path downloads publish under the purge lock, without pinning it across the network (#196)
acquire's cache-miss and every acquire_fresh refresh now coalesce on a
per-repo download mutex, download to a temp sibling with no advisory
lock held, then try-lock, re-check the archive exists under the lock,
and swap - so an in-flight read can no longer resurrect a repository
purge just deleted (RED observed on both the cache-miss and
refresh-over-existing repros), a post-purge reader degrades instead of
publishing, and concurrent cold reads download once. The hit path stays
lock-free (deterministic held-lock fence) and no lock-pool connection is
held while a download is parked.
2026-07-20 23:27:33 -05:00
beardthelion c07435d245 fix(node): fork publishes only after a durable upload, serialized on the target-namespace lock (#196)
fork_repo now takes the per-repo advisory lock before its conflict check
and holds it through clone, upload, and row insert, 503ing on contention
exactly as create_repo does. The upload runs under the held guard via
upload_under_guard, PUT bounded by release_upload_timeout, returning
false only for an attempted upload that failed or timed out (no-store
nodes fork unchanged); on failure the mirror is removed and the fork
500s before any row is built. release_after_write is retired; its tests
migrated onto the new method. RED observed on the serialization,
durability, and contention tests; defect injection (row insert reordered
above the upload) turned the durability test RED and was reverted.
2026-07-20 23:13:06 -05:00
beardthelion 859240b3a3 fix(node): roll back the local issue ref before unlock when the durable upload fails (#196)
The failed-upload 500 previously left refs/gitlawb/issues/{id} on disk,
so a client retry minted a second issue once the store recovered. The
delete now runs via RepoWriteGuard::release_with_failure_cleanup while
the advisory lock is still held, closing the unlock-to-delete window a
concurrent write could have archived the orphan ref through. RED
observed on both new tests (orphan ref survived; retry yielded 2
issues); the retry test pins the store double's exists()=false contract
so it cannot pass via a stale-archive revert.
2026-07-20 23:11:45 -05:00
beardthelion ba572b3b68 fix(node): init's background upload waits out the creator's advisory lock (#196)
create_repo holds the per-repo lock across init, so the wait=false spawn
try-locked against its creator and died permanently, leaving a new repo
with no object-store archive until its first later access. RED observed:
init_upload_waits_out_creators_lock_then_uploads failed (PUT count 0)
against the old code; the bounded-skip negative is retained as
init_upload_gives_up_after_bounded_lock_wait.
2026-07-20 22:50:18 -05:00
beardthelion 16517f85ce refactor(node): route the per-DID 429 through the shared too_many_requests helper 2026-07-20 22:45:35 -05:00
beardthelion 2fa9fa37b3 chore: merge main (iCaptcha PoW + gl retry tests) into feat/write-sink-brake-coverage 2026-07-20 22:41:37 -05:00
beardthelion 5a46ef62cc fix(node): close the durable-upload data-loss class on every write path (#196)
The earlier fix that fails a push when its Tigris upload times out was
wired only into git_receive_pack; the merge, issue-create, and
issue-close write paths still ignored release()'s result and returned
2xx after a failed upload, so the next acquire_write reverted their
change from the stale archive. release() is now #[must_use], which
surfaces every caller: the production upload paths fail with a 5xx on a
false return (so the client retries), and the internal/test callers are
explicitly justified. create_repo's lock-acquire failure now returns a
retryable 503 (new AppError::Unavailable) instead of a 500, matching its
own contract.
2026-07-19 22:16:53 -05:00
beardthelion d2ba14edfe fix(node): correct the create-pool and bounty-tombstone remediations (#196)
A re-run adversarial panel caught three issues in the prior round's own
fixes:

- create_repo took its serialization lock from the app pool and then did
  its DB work on the same pool, so at MAX_CONNECTIONS=1 it self-deadlocked
  (a config finding E requires to work). Reverted to locking on the write
  lock_pool: lock and create-work use separate pools, and the DB-global
  advisory lock still serializes against a purge. The extreme-burst
  lock_pool wait is an accepted retryable-503 residual. Db::pool() is
  test-only again.

- The bounty tombstone matched only one owner form and ran
  unconditionally. It now normalizes BOTH sides (covering did:key-row +
  short-bounty AND bare-row + full-bounty) and is gated on the same
  no-surviving-sibling check as the slug cascade, so it never tombstones
  a bounty a surviving mirror row still serves.

Tests: create-at-1-app-connection (no self-deadlock), bare-row full-form
tombstone, and sibling-survives-keeps-bounty-open, each RED before / GREEN
after.
2026-07-19 21:46:18 -05:00
beardthelion 7dc7be2ecd fix(node): retry-after on the push-advert 429, and create_repo locks on the app pool (#196)
Two review follow-ups:

- The unauthenticated info/refs?service=git-receive-pack limiter still
  returned a bare 429 with no Retry-After while the other limiters
  advertise the window-derived delay; it now uses check_retry and the
  shared too_many_requests helper. Removed the orphaned
  AppError::TooManyRequests variant and gated the now-test-only check().

- create_repo's transient serialization lock drew from the write
  lock_pool, so a burst that pins lock_pool starved repo creation.
  Postgres advisory locks are database-global, so create now takes its
  lock from the app pool (via lock_repo_blocking_on) and still
  serializes against a purge holding the same key from lock_pool.
2026-07-19 21:12:45 -05:00
beardthelion ce5dcc3581 fix(node): fail closed on a symlink at the purge target (#196)
local_refs_on_disk used symlink_metadata only to detect NotFound, then
the HEAD/objects marker checks followed a symlink — so a symlink to a
real empty bare repo outside repos_dir was admitted as an empty
purgeable candidate, and the DB row was deleted before path_within
refused the dir removal. The symlink is now classified present/skipped
before the marker checks. Adds outside-empty-bare and dangling-symlink
tests.
2026-07-19 20:55:34 -05:00
beardthelion d1e596e580 fix(node): tombstone bounties under both owner forms on purge (#196)
The tombstone matched only the repo's full did:key owner, but
create_bounty stores repo_owner verbatim from the URL path — commonly
the bare short form. A short-owner-form bounty therefore stayed open and
re-attached to a recreated repo, the exact hole the tombstone closes for
the full form. The UPDATE now matches repo_owner IN (full, normalized
short) so both forms are tombstoned. Adds short-form + submitted-status
fixtures and asserts dispute cannot reopen a purged bounty.
2026-07-19 20:48:20 -05:00
beardthelion 8f3d643a72 fix(node): fail the push when the durable upload times out, don't 200 (#196)
The bounded release() upload (from the lock-pool fix) abandoned a slow
PUT, freed the lock, and returned no failure signal, so receive-pack
reported 200 while the archive never reached object storage — and the
next acquire_write downloaded the stale pre-push archive over local
disk, reverting the pushed refs. A timed-out or errored durable upload
now returns upload_ok=false; the handler sends a 5xx and skips the
success tail so the client re-pushes idempotently instead of trusting a
lost commit. The lock still frees on every path and the connection-hold
bound is unchanged.
2026-07-19 20:33:20 -05:00
beardthelion 9916bca29b test(node): prove purge deletes at MAX_CONNECTIONS=1 with the split pools (#196)
The split pool from the earlier fix already resolves the one-connection
self-deadlock (the guard holds a lock_pool connection while the delete
runs on the app pool), so this locks that wiring in: a purge at
app-pool MAX_CONNECTIONS=1 deletes the target row. The test is
load-bearing for the wiring - a shared-pool store (guard and delete on
one pool) deadlocks the delete's begin() on the held connection and
deletes nothing. No production change; U2 did the wiring.
2026-07-19 20:14:19 -05:00
beardthelion 3baa9ed4fc fix(node): serialize create_repo against the purge advisory lock (#196)
Purge holds a per-owner/name advisory lock across delete-row +
remove-dir, but create_repo took no synchronous lock, so a concurrent
same-key create slipped into the row-deleted-but-dir-not-removed window
and left a repos row pointing at storage the purge then removed.
create_repo now acquires the same lock (lock_repo_blocking -> the guard
the purge holds, keyed identically) around its existence-check, init,
and insert, releasing on every path. Serializes a same-key create
against a purge; the INV-9 colliding-sibling case stays the accepted
KTD4 residual.
2026-07-19 20:00:13 -05:00
beardthelion c24b5418a8 fix(node): tombstone bounties when their repo is purged (#196)
Bounties are keyed only on (repo_owner, repo_name) with no repo id, and
purge left them intact, so recreating the same name re-attached the old
open bounty and claim_bounty succeeded against it. Purge now transitions
the repo's non-terminal bounties (open/claimed/submitted) to a dead
'purged' status on the delete's own transaction, preserving the
financial record. 'purged' is inert against every mutating bounty
endpoint, including dispute (which would otherwise reset claimed/
submitted back to open).
2026-07-19 19:48:47 -05:00
beardthelion bf2a54e121 fix(node): run the push completion tail in the disconnect-surviving task (#196)
The receive-pack refactor detached only acquire/apply/release; the whole
success tail (push+trust records, ref certificates, webhooks,
replication/pinning, ref-update broadcast, peer-notify) ran inline in
the cancellable request future. A client disconnect mid-apply committed
and uploaded the git state but dropped that tail, leaving accepted git
history with none of the metadata or fan-out every other push produces.
The tail now runs inside the detached task, gated on receive-pack Ok;
report-status still returns to a connected client early over a oneshot
(never awaited on the JoinHandle), and a dropped receiver no longer
aborts the tail. guard.release runs on every path.
2026-07-19 19:35:18 -05:00
beardthelion 86a8228f47 fix(node): fail closed classifying the purge target directory (#196)
local_refs_on_disk returned None for a missing path, an existing
non-bare directory, AND an unreadable path alike; with a store
configured, None promotes the candidate to remote_unverified and the
archive refresh's remove_dir_all replaces whatever sits at the target.
It now returns None only for a verified NotFound (symlink_metadata +
ErrorKind::NotFound, so a dangling symlink counts as present); every
other state fails closed as present so a non-repo directory at the
target is never overwritten.
2026-07-19 19:17:02 -05:00
beardthelion 0c279c9d25 fix(node): skip slug-scoped purge cascade when a mirror row shares the slug (#196)
delete_repo_by_id cascaded branch_cids/sync_queue/received_ref_updates/
arweave_anchors by the normalized owner/name slug, which is non-unique
across repos rows (INV-9): a did:key:zX row and a bare zX mirror of the
same name share the slug. Purging an empty did:key row therefore wiped a
surviving non-empty mirror's branch->CID index and Arweave anchors. The
cascade now runs only when no other repos row resolves to the same slug
(checked with the same OWNER_KEY_CASE_SQL the slug is built from, on the
delete's own transaction); the physical row and id-keyed children are
always removed. The concurrent colliding-sibling-create window stays an
accepted operator-scoped residual.
2026-07-19 18:50:07 -05:00
beardthelion 49346464bf fix(node): advertise a window-derived Retry-After on rate-limit 429s (#196)
Both 429 sites hardcoded Retry-After: 60 while every limiter uses a
one-hour window, so a compliant client retrying at 60s kept hitting 429
for nearly another hour. The limiter now returns the delay from its
oldest live timestamp (window - elapsed, clamped >= 1); a cap-full
rejection with no bucket advertises the full window. The check() -> bool
API is preserved as a wrapper so unrelated callers are untouched.
2026-07-19 18:35:34 -05:00
beardthelion c4b337b194 fix(node): give advisory-lock guards a dedicated pool and bound the release upload (#196)
Repo-write guards pinned a connection from the shared app pool for the
whole receive-pack plus an untimed archive upload, so ~20 concurrent
distinct-repo pushes starved unrelated DB handlers. Guards now draw from
a dedicated lock_pool (GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS, default 32,
sized to peak writers) separate from the app pool that serves handlers
and the purge delete, and release()'s Tigris upload is wrapped in a
timeout so the connection hold is bounded on every path. The guard still
locks and unlocks on its single held connection (no off-session unlock).

Db::pool() is now test-only since nothing in the bin reads the raw app
pool directly.
2026-07-19 18:25:10 -05:00
beardthelion 2a78524826 docs(node): drop the .env.example shutdown-grace entry #196 added (#196)
The GITLAWB_SHUTDOWN_GRACE_SECS field, its doc-comment, and main.rs's
grace handling pre-date this PR (#22); #196's diff only added the
.env.example entry, whose comment promised a 503-on-expiry the node does
not implement. Remove just that entry. PR #194 owns the real bounded
drain and the config prose in disjoint files.
2026-07-19 17:53:33 -05:00
beardthelion 9f7757cbf5 refactor(node): dedup limiter setup + write-brake test driver; fix .env wording
- .env.example: the write brake wraps the whole /graphql route, so its docs now
  say it covers all GraphQL HTTP requests (queries and the playground GET), not
  only mutations, with WebSocket subscriptions called out as excluded.
- main.rs: collapse the identical create/write/push per-IP limiter setup into
  build_ip_limiter (resolve-env, build bounded, warn-on-zero), preserving the
  exact limits/defaults and the present-but-unparseable warning in
  rate_limit_from_env; each limiter now also info-logs its configured value.
- api/repos.rs: the nine per-IP write-brake tests share a send_from helper
  mirroring rate_limit.rs's post_from/post_with, replacing the repeated
  request-build + ConnectInfo + oneshot + status boilerplate. Behavior identical.
2026-07-16 10:26:42 -05:00
beardthelion 6ba698c99b fix(node): let purge-spam reach repos that exist only as archives
On a multi-machine (object-store) deployment a spam repo can exist only as a
Tigris archive with no local copy. Selection filtered on a ref count that
returns non-empty for a missing local path, so such a repo was never a
candidate and the tool it was hardened for could not reach it. Selection now
distinguishes missing-local (Option::None) from a one-ref repo and admits a
missing-local row as a *remote-unverified* candidate only when an object store
is configured (via RepoStore::has_object_store); partition_for_delete passes
those through rather than re-dropping them on the local ref count. The execute
loop is unchanged — it already refreshes from the archive and rechecks emptiness
under the per-repo lock — so a remote-only empty archive is deleted, one with
refs is refreshed and skipped, and missing-both fails closed (refresh no-ops,
the local recheck reports non-empty). Storeless deployments keep the old
fail-closed behavior. Dry-run marks remote-only candidates distinctly and counts
them in a new PurgeSummary field.

Tests (recording object-store double): remote-only empty archive deleted,
remote-only archive-with-refs skipped, missing-both fail-closed, storeless
missing-local not a candidate. Load-bearing per INV-21 — reverting the
store-configured admission turns the three remote-reach tests RED.
2026-07-16 10:18:15 -05:00
beardthelion e4c4161dbc fix(node): serialize archive uploads under the per-repo advisory lock
The lazy-migration upload in acquire(), init()'s background upload, and
release_after_write() (fork's upload) previously PUT to the object store with
no lock, so an in-flight upload could resurrect an archive purge-spam had just
deleted under the same lock, and init's background upload could clobber a
freshly-pushed archive. All three now go through upload_locked(): take the
per-repo lock, re-check the local dir exists under it (a purge removes the dir
under its lock, so a post-purge uploader finds it gone and skips), upload, then
release. The background migration/init uploads skip on contention and self-heal;
fork's foreground upload waits (bounded) instead, since a skipped fork upload
has no later retry.

Tests (recording object-store double): init upload skips under a held lock,
fork upload waits then PUTs once after release, a late upload after a
purge-style delete does not resurrect the archive, and an uncontended upload
PUTs once. Load-bearing per INV-21 — neutralizing the lock+dir-check turns the
three contention/resurrect tests RED.
2026-07-16 10:06:06 -05:00
beardthelion b94d440e9e fix(node): complete a fully-received push after client disconnect
git_receive_pack now runs the acquire -> receive-pack -> release core in a
spawned task the handler awaits but whose cancellation it does not propagate.
The pack body is fully buffered before the handler runs, so the task is
self-contained: a client that disconnects mid-apply drops the handler future
without cancelling the task, and the push still applies, uploads, and releases
its lock in order. Drop-safety (the guard's close-on-drop) remains the backstop;
this closes the dominant window where a disconnect both bricked the lock and
abandoned a received push. The post-push bookkeeping tail stays in the
cancellable handler (a disconnect still forgoes it, as today).

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

Disconnect regression drives a real push with a sleeping pre-receive hook and
drops the handler mid-hook: RED on the inline handler (the git group is killed,
ref absent), GREEN once detached.
2026-07-16 09:53:43 -05:00
beardthelion dde5ea9a3e fix(node): make advisory-lock guards drop-safe and reorder acquire_write
RepoWriteGuard and RepoLockGuard now hold their pooled connection as an
Option: release() takes it (returns it to the pool) while a Drop impl closes
the connection when release() was never reached — an early ?, a panic, or the
handler future cancelled on client disconnect. Closing the connection ends the
Postgres session so the server frees the session-scoped advisory lock, instead
of returning a lock-holding connection to the pool where it would block every
later write to that repo until the connection is reaped (up to ~30min at the
pool's default max_lifetime).

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

Tests observe the leak by disabling the pool's ambient reaping (which otherwise
masks it ~1.8s after drop), so the lock stays held until the fix's own unlock.
RED before / GREEN after for both guards and the acquire-side cancel; each
protection line proven load-bearing by revert (INV-21).
2026-07-16 09:38:54 -05:00
beardthelion cf669bb8aa fix(review): don't hold/leak the pool connection in acquire_write
Two regressions the connection-pinning fix (010c625) introduced, caught
in pre-push review:

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

Add acquire_write_guard_excludes_try_lock_across_connections: a
multi-connection test proving the exclusion holds across sessions (the
production topology), which also catches a pin-but-don't-lock regression
the single-connection test cannot.
2026-07-15 17:37:43 -05:00
beardthelion 3c1ceb8edc fix(node): make purge-spam Tigris-authoritative and archive-deleting
purge-spam built its RepoStore with no object store, so on a Tigris
deployment it rechecked emptiness against possibly-stale local disk
(deleting a repo with live remote refs) and left the archive behind (to
be re-downloaded into a later same-owner/name repo). Give the admin
command the configured object store, refresh the local copy from the
authoritative archive under the per-repo lock before the recheck
(fail-closed on a store error via a new skipped_store_error), and delete
the archive on a successful purge (failures counted in a new
archive_failed, never folded into success). When no bucket is configured
the None path is byte-for-byte unchanged.

Three load-bearing guards verified RED->GREEN on the Tigris-blind purge:
remote-refs-not-deleted, archive-deleted, fail-closed-on-unreachable.
2026-07-15 17:07:47 -05:00
beardthelion 68c1f5c2e6 refactor(node): introduce ObjectStore trait seam over TigrisClient
Extract an async ObjectStore trait (exists/upload/download/delete) that
TigrisClient implements, and hold Option<Arc<dyn ObjectStore>> in
RepoStore instead of Option<TigrisClient>. A dyn async trait needs
async-trait (added as a direct dep; already transitively present), which
E0038 would otherwise reject. Pure refactor — no behavior change; full
suite stays green. Enables an in-test fake for the purge Tigris work (U3).

Cargo.lock also picks up the pre-existing 0.5.0->0.5.1 version sync
(#185: the committed lock was stale vs the manifests) that any cargo run
regenerates.
2026-07-15 16:55:48 -05:00
beardthelion 4df79f22a3 fix(node): pin the writer's advisory-lock connection in RepoWriteGuard
acquire_write took the session-scoped advisory lock through the shared
pool and returned the lock-owning connection to the pool, so release
unlocked on a different connection (silent no-op) and a concurrent
try_lock_repo could be handed the idle lock-owning connection and
reentrantly re-grab the lock, defeating the purge tool's mutual
exclusion. Pin a dedicated PoolConnection for the guard's lifetime and
unlock on it, mirroring RepoLockGuard.

Load-bearing contention test (single-connection pool to force the
reentrant grab deterministic): RED (Ok(Some)) before, GREEN after.
2026-07-15 16:43:59 -05:00
beardthelion cdb102efbd style(node): cargo fmt for the #196 fix set 2026-07-14 22:27:00 -05:00
beardthelion bfbe5026ba test(node): exercise the dry-run guard with a real on-disk candidate present
dry_run_deletes_nothing ran against an empty repos_dir, so select_spam_candidates
found nothing and run_purge_spam hit the no-candidate early return — the
if !execute { return } guard was never exercised with candidates present (a
vacuous guard, INV-21). Materialize a real empty bare repo so a genuine candidate
exists, then assert dry-run leaves both the row and the on-disk dir. Verified
load-bearing: removing the !execute guard turns the test RED (dry-run deletes).
2026-07-14 22:26:29 -05:00
beardthelion 63254029e5 docs(node): document GITLAWB_IPFS_API/TIGRIS_BUCKET/METRICS_ADDR/SHUTDOWN_GRACE_SECS in .env.example
Four config knobs read in config.rs were absent from .env.example, so operators
had no template for them. Add each in its matching section with the config
default (INV-24: operator docs match the config the code reads, in the same PR
that touches the file).
2026-07-14 22:23:31 -05:00
beardthelion d9eebd29c9 fix(node): warn on unparseable rate-limit env vars instead of silently defaulting
GITLAWB_CREATE/WRITE/PUSH_RATE_LIMIT parsed with .ok().and_then(parse).unwrap_or,
so a typo (e.g. 5O with a letter O) silently reverted to the default and left the
operator believing a stricter cap was in force. Route all three through
rate_limit_from_env, which distinguishes absent/empty (default, silent) from
present-but-unparseable (default, WARN). Pure resolve_rate_limit is unit-tested
across absent/empty/valid/zero/unparseable — 0 stays valid (its own ==0 warn
handles the disable case).
2026-07-14 22:22:32 -05:00
beardthelion 1fcca0d433 fix(node): count purge-spam disk-removal failures separately from deletes
The Ok(n) arm incremented the deleted total before remove_dir_all, whose failure
only warn!'d, so the summary reported N deleted even when the on-disk dir
survived (DB/disk drift shown as clean success). run_purge_spam now returns a
PurgeSummary and counts a failed (or containment-refused) on-disk removal in
disk_failed, never folding it into deleted; the summary line surfaces
'M on-disk removal(s) failed'. RED->GREEN: a read-only parent dir forces the
removal to fail; deleted=1 AND disk_failed=1 with the dir surviving (neuter-check
confirms the counter is load-bearing).
2026-07-14 22:20:09 -05:00
beardthelion 15522d5616 fix(node): hold the per-repo advisory lock across purge recheck+delete
purge-spam took no write lock, so a concurrent receive-pack could land a ref
between the emptiness recheck and the delete, silently losing a live push.
Add RepoStore::try_lock_repo — a lock-only, non-blocking counterpart to
acquire_write (no Tigris I/O) whose guard holds a dedicated pooled connection so
the advisory lock and its unlock run on the same session. purge now locks each
candidate, rechecks emptiness UNDER the lock, deletes, and releases; a repo held
by a live writer is skipped, not force-deleted. RED->GREEN: a locked empty repo
survives purge, then deletes once the writer releases (neuter-check confirms the
lock is load-bearing).
2026-07-14 22:14:15 -05:00
beardthelion fde9cfe287 fix(node): reap write_rate_limiter in the periodic cleanup loop
The PR's new write_rate_limiter was the one limiter omitted from main()'s
hand-maintained cleanup list, so its key map never shed expired entries until
the 200k cap. Move the sweep into AppState::cleanup_rate_limiters (co-located
with the limiter fields, single source of truth) and call it from the loop, so
a newly-added limiter is reaped rather than silently dropped. Guard test seeds a
reclaimable entry into write_rate_limiter and asserts the sweep reaps it — RED
if write_rate_limiter is dropped from cleanup_rate_limiters (verified).
2026-07-14 22:03:49 -05:00
beardthelion a0fcbb0813 fix(node): delete child rows transactionally in delete_repo_by_id
delete_repo_by_id was a bare DELETE FROM repos with no cascade and the schema
has no FKs, so every child row (ref_certificates, pull_requests + pr_reviews/
pr_comments, webhooks, push_events, protected_branches, repo_stars, repo_replicas,
repo_labels, visibility_rules, encrypted_blobs, repo_icaptcha_proofs, agent_tasks,
and the slug-keyed branch_cids/sync_queue/received_ref_updates/arweave_anchors)
was orphaned and could re-join a later repo reusing the id. Wrap parent+child
deletes in one transaction, resolving the owner_short/name slug for the repo-TEXT
children. bounties (financial) and issue_comments (unmappable) are left intact by
design. RED->GREEN test seeds a repo_id child, a slug child, and a PR grandchild.
2026-07-14 21:58:31 -05:00
beardthelion 01354199d0 fix(node): reject path-traversal repo names in purge-spam before remove_dir_all
A peer-mirror row skips API name validation, so a burst-owned repo can carry
a '../' name; repo_disk_path joined it verbatim and the emptiness marker check
passed on the traversed target, so purge-spam selected and remove_dir_all'd a
repo OUTSIDE repos_dir. Validate the name via repo_store::validate_repo_name at
selection (fail closed -> non-candidate) and assert canonical containment inside
repos_dir immediately before the destructive remove. Adversarial RED->GREEN: a
victim bare repo reachable via '../../victim' is deleted before, survives after;
symlink-escape covered by path_within.
2026-07-14 21:53:23 -05:00
beardthelion dfd4f6c3d6 test(review): close the reasoned-not-run gaps by execution
- purge-spam execute now re-verifies emptiness immediately before delete (TOCTOU:
  a push between selection and delete no longer risks deleting a now-non-empty
  repo) and removes the on-disk bare repo dir so DB and disk stay consistent; a
  per-repo delete failure warns and continues rather than aborting the batch.
- Tests (RED-verified where they cover new behavior): partition_for_delete skips
  a repo no longer empty; execute removes the on-disk dir (RED without the
  removal); list_repos_by_owner_did finds a short-form burst row (RED without the
  SQL normalization); every braked write group passes under-limit (per-group
  grant path, not just the star representative).
2026-07-13 15:23:19 -05:00
beardthelion 090019deb7 fix(review): normalization-consistent purge, brake helper, CLI + coverage
Addresses the remaining code-review findings:
- purge-spam DID matching now uses did:key normalization on both sides
  (list_repos_by_owner_did via OWNER_KEY_CASE_SQL, is_excluded and the target
  scope via normalize_owner_key), so short/full form is consistent and an
  excluded identity is protected in either form. Adds a short-form exclusion test.
- Extract the per-IP write brake into a chainable WriteBraked helper so a future
  write group can't silently ship unbraked (kept separate from add_auth_layers
  by design; documented why).
- Mark --repos-dir / --database-url global so admin subcommands accept them after
  the subcommand (was an 'unexpected argument' parse error); verified.
- Document GITLAWB_WRITE_RATE_LIMIT in .env.example incl. the per-IP NAT-aggregate
  caveat so operators can size it.
- Coverage: adoption-floor (under-limit write passes), write-limit=0 disables,
  task/bounty/profile route-level 429s, /graphql/ws stays unbraked.

Full suite 927 pass / 0 fail; fmt + clippy -D warnings clean.
2026-07-13 15:03:41 -05:00
beardthelion 285d7c7f7a fix(review): make purge-spam empty-check reject git-discovery reads
P1 from code review: on_disk_ref_count shelled 'git for-each-ref' with only the
repo path as cwd and no --git-dir, so a path that exists but is not a bare repo
let git discover a parent .git (repos_dir may sit inside the operator's checkout)
and read a DIFFERENT repo's refs — possibly 0, deleting a real repo. Now require
the bare-repo markers (HEAD file + objects/ dir) before trusting any count;
anything else fails closed (skipped).

Tests: a non-git dir under a git ancestor (zero-ref) now fails closed — verified
RED without the guard (read the ancestor's 0 refs); target-DID-never-excluded
invariant pinned; write_flood test anchored to a genuinely-drained bucket so its
assert_ne can't pass vacuously.
2026-07-13 14:47:07 -05:00
beardthelion e6324d20df feat(node): purge-spam admin subcommand for the empty-burst cleanup
Adds a purge-spam subcommand (clap #[command(subcommand)] split; no-subcommand
path still runs the node unchanged). Candidate selection is signature-based, not
per-DID: a repo qualifies only if owned by the named burst DID AND verified empty
(zero git refs) per repo. A hard exclusion gate runs BEFORE the empty check, so
an empty repo owned by the content-bearing user or the mirror-bot DID is never a
candidate. Dry-run is the default; deletion needs an explicit --execute flag and
operates per-repo, never 'all repos of DID X'.

Tests (RED->GREEN, exclusion gate confirmed load-bearing by targeting an excluded
DID directly): empty target listed; target-with-refs spared; empty excluded/
intern repos spared (must-assert); dry-run deletes nothing; execute removes only
the empty target on disk.
2026-07-13 14:28:45 -05:00
beardthelion 6fded6ef65 test(node): adversarial TrustedProxy verification through the write brake
Middleware-level tests complementing the client_key unit coverage: a spoofer
varying the client-controlled leftmost X-Forwarded-For hop cannot rotate its
bucket key (keyed on the trusted rightmost hop); distinct trusted client hops
get distinct buckets (no cross-starvation); and a request with no trusted header
in a proxy mode falls back to the socket-peer key — the documented collapse,
asserted by name so any change is deliberate.

Operator-warning on missing-header deferred deliberately: a per-request warning
is a log-noise risk and the security-load-bearing fallback (peer key, never
skip) is what these tests lock down.
2026-07-13 14:14:15 -05:00
beardthelion 74e05be7f7 feat(node): extend the per-IP write brake to the remaining write sinks
Attaches rate_limit_by_ip (write bucket) to task_write_routes,
issue_write_routes, bounty_write_routes, profile_write_routes, and the /graphql
POST/GET surface (KTD-5 — an HTTP-layer brake counts every /graphql request;
/graphql/ws subscriptions stay unbraked). write_ip_limiter is now built once and
shared. Every non-git, non-creation authenticated write group is braked.

Tests (execution-verified against Postgres): /graphql POST is 429'd, and a
representative REST write sink (issue comment) is 429'd, by the write brake.
2026-07-13 14:11:38 -05:00
beardthelion 34df1fddc8 feat(node): add per-IP write-surface rate brake and apply to write_routes
Introduces a dedicated write_rate_limiter bucket (GITLAWB_WRITE_RATE_LIMIT,
default 600/hr, 0 disables), separate from the creation brake so a write flood
cannot drain the creation budget (KTD-1). Attaches rate_limit_by_ip to
write_routes, mirroring creation_routes. Per-DID deliberately not paired (a DID
farm never trips it).

Tests (route-level, execution-verified against Postgres): a write_routes sink
(PUT /star) is 429'd before auth; an exhausted write bucket does not throttle
repo creation (separate-bucket property).
2026-07-13 14:07:31 -05:00
GravireiandGravirei ad7c2b2b83 test(gl): add tests for iCaptcha transparent retry flow (#167) (#168)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.

* perf(ipfs): check object existence before allowed-blob walk

Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.

* refactor(ipfs): improve formatting and readability in get_by_cid function

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.

* refactor(ipfs): streamline object retrieval by separating type and content reading

* docs(ipfs): update get_by_cid comment to reflect split object retrieval

* Run cargo fmt on store.rs

* test(gl): add tests for iCaptcha transparent retry flow (#167)

* fix(clippy): remove redundant borrow in format! arg

* fix(review): use runtime GITLAWB_ICAPTCHA_INSECURE flag instead of cfg(test), add mock assertions

* fix(review): set mock expectations at creation time, tighten insecure scheme check

* fix(test): serialize icaptcha integration tests with global Mutex to prevent env-var race

* fix(icaptcha): compare origins (scheme+host+port) in resolve_solver_url; tighten proof assertion in test

* fix(test): save/restore inherited iCaptcha env vars instead of unconditional remove; share lock in icaptcha-client tests

* fix(test): constrain negative send_once mocks with Matcher::Missing so they actually protect the contract

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-10 22:22:30 +08:00
Kevin Codex c98b503f90 feat(icaptcha-client): solve the iCaptcha proof-of-work on answer (#181)
iCaptcha now requires a proof-of-work with each answer (enforce mode) so that
minting a proof costs CPU — the lever that stops a distributed, multi-IP
proof-farming flood that per-IP rate limits cannot. Teach the sanctioned
client to solve it: parse the `pow` field from the challenge, grind a nonce
whose sha256(challenge:nonce) has >= difficulty leading zero bits, and send it
as `powNonce` on /v1/answer. Bounded search so a hostile difficulty can't
hang the client. Mirrors icaptcha/src/pow.ts. Without this, agents on the
updated client cannot obtain a proof once enforce is on.

New pow module + unit tests; verified end-to-end against the live enforce-mode
service (obtained a level-3 proof incl. PoW).
2026-07-10 22:02:58 +08:00
61 changed files with 15408 additions and 933 deletions

No files matched your search

+30
View File
@@ -13,9 +13,16 @@ 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.
@@ -26,6 +33,12 @@ 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
@@ -39,6 +52,9 @@ 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
@@ -127,6 +143,20 @@ 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
+121
View File
@@ -0,0 +1,121 @@
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
# issue when a push-to-main run of PR Checks ends red, and closes it when a
# subsequent run on main's tip goes green.
#
# Why: two individually-green PRs can break main in combination when the
# second merges on checks that ran against a stale base (semantic merge
# race — #113 + #145 broke test compilation, found only via #229). A red main
# is otherwise silent, and release-please PRs inherit the breakage.
#
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
# commit is no longer main's tip are ignored (a re-run of an old red run
# after a fix has landed must not cry wolf), and a green run on the tip
# closes any open alert.
name: Main red alert
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
branches: [main]
permissions:
issues: write
contents: read # getBranch tip check
# Serialize runs: concurrent completions would race the label/issue lookups
# into duplicates (or 422 on createLabel). queue: max keeps every pending
# completion instead of letting a later (possibly green-skipped) arrival
# displace a pending red alert under burst merges; it is only incompatible
# with cancel-in-progress: true, which we don't use.
concurrency:
group: main-red-alert
cancel-in-progress: false
queue: max
jobs:
track:
name: Track main CI state
# timed_out (the test job runs 45 min against Postgres) and
# startup_failure (malformed workflow) are red too — only cancelled is
# excluded as a deliberate human abort. success runs are needed to close
# the alert once main recovers.
if: >-
github.event.workflow_run.event == 'push' &&
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reconcile the broken-main issue with main's current state
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const label = 'ci-main-red';
// Freshness gate: only the run for main's current tip may open or
// close anything. Re-runs of superseded commits are events about
// the past, not statements about main's state.
const { data: branch } = await github.rest.repos.getBranch({
...context.repo, branch: 'main',
});
if (branch.commit.sha !== run.head_sha) {
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
return;
}
// listForRepo returns PRs carrying the label too — those must
// never be mistaken for the tracking issue.
const open = (await github.rest.issues.listForRepo({
...context.repo, state: 'open', labels: label, per_page: 100,
})).data.filter(item => !item.pull_request);
if (run.conclusion === 'success') {
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
for (const issue of open) {
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number, body,
});
await github.rest.issues.update({
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
});
}
return;
}
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
const body = [
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
'',
`> ${headline}`,
'',
'Likely a semantic merge race: each merged PR was green on a stale base.',
'Fix main before merging anything else — release-please PRs inherit this breakage.',
'',
"This issue closes automatically when a run on main's tip goes green.",
].join('\n');
try {
await github.rest.issues.getLabel({ ...context.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
...context.repo,
name: label,
color: 'b60205',
description: 'CI is failing on main',
});
}
if (open.length > 0) {
await github.rest.issues.createComment({
...context.repo, issue_number: open[0].number, body,
});
} else {
await github.rest.issues.create({
...context.repo,
title: 'CI is red on main',
body,
labels: [label],
});
}
+35
View File
@@ -2,6 +2,10 @@ name: PR Checks
on:
pull_request:
# Run in the merge queue so a PR is re-validated against the true merged
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
push:
branches:
- main
@@ -212,3 +216,34 @@ jobs:
- name: Smoke test --version
run: docker run --rm gitlawb-node:ci-test --version
test-windows:
name: test (windows, non-blocking)
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
# test coverage, so run their suites here to make Windows-only regressions
# VISIBLE on the PR without gating merge (#228). These two are the client
# CLIs and depend on no database, so no Postgres service is needed (service
# containers do not run on windows-latest regardless). `git` is preinstalled
# on windows-latest, which the real_git_fetch harness relies on.
continue-on-error: true
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: windows
- name: cargo test (shipped Windows crates)
run: cargo test -p gl -p git-remote-gitlawb
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Label and guide
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const pr = context.payload.pull_request;
+223 -52
View File
@@ -4,6 +4,20 @@ on:
push:
branches:
- main
# Manual backfill: republish an already-cut release to a channel that
# missed it. Fill in only the channel(s) you want — an empty input leaves
# that channel's jobs skipped. npm reuses the existing release's binary
# assets; docker rebuilds the image from the tag and re-tags the manifest.
workflow_dispatch:
inputs:
npm_backfill_tag:
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
required: false
type: string
docker_backfill_tag:
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
required: false
type: string
concurrency:
group: release-${{ github.ref }}
@@ -14,7 +28,10 @@ permissions: {}
jobs:
release-please:
if: ${{ github.repository == 'Gitlawb/node' }}
# Skipped on manual dispatch: a backfill must never create or advance a
# release PR as a side effect (npm-publish tolerates the skipped need via
# !cancelled(), and every other job gates on release_created == 'true').
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
@@ -31,24 +48,61 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
# 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
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
# workflow concurrency group, blocked every queued release run behind it.
docker:
name: Build & Push Docker Image
name: Build & Push Docker Image (${{ matrix.arch }})
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-please is skipped on dispatch, hence !cancelled()).
if: >-
${{ !cancelled() && (
needs.release-please.outputs.release_created == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
) }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
os: ubuntu-latest
platform: linux/amd64
- arch: arm64
os: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.os }}
permissions:
contents: read
packages: write
steps:
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# ghcr requires a lowercase repository path, and unlike metadata-action,
# buildx's `--output name=` does no lowercasing — a mixed-case owner
# makes the digest push fail with "invalid reference format".
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@@ -59,45 +113,130 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally (smoke)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: ${{ matrix.platform }}
load: true
tags: gitlawb-node:smoke
cache-from: type=gha
cache-from: type=gha,scope=docker-${{ matrix.arch }}
# Native runner on both arches, so the built image can always execute.
# Assert the released version so a stale artifact fails here, not in prod.
- name: Smoke test
run: docker run --rm gitlawb-node:smoke --version
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
out="$(docker run --rm gitlawb-node:smoke --version)"
echo "$out"
grep -qF "$VERSION" <<<"$out" || {
echo "::error::image --version did not report expected version $VERSION (got: $out)"
exit 1
}
- name: Build and push (multi-arch)
# Push by digest only — tags are applied once by docker-manifest so a
# half-finished matrix can never publish a partially-tagged image.
# provenance:false keeps each push a plain single-arch manifest, which is
# what imagetools create expects to merge.
- name: Build and push by digest
id: push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
- name: Export digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: docker-digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-manifest:
name: Publish Docker manifest
needs: [release-please, docker]
if: ${{ !cancelled() && needs.docker.result == 'success' }}
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: docker-digest-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
# ghcr requires a lowercase repository path.
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
MAJOR_MINOR="${VERSION%.*}"
digests=""
for f in /tmp/digests/*; do
digests="$digests $IMAGE@sha256:$(basename "$f")"
done
# shellcheck disable=SC2086
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:$MAJOR_MINOR" \
-t "$IMAGE:latest" \
$digests
docker buildx imagetools inspect "$IMAGE:$VERSION"
- name: Release summary
env:
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
{
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo "## Released $TAG"
echo
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
} >> "$GITHUB_STEP_SUMMARY"
release-binaries:
@@ -248,37 +387,62 @@ jobs:
npm-publish:
name: Publish to npm
needs: [release-please, release-binaries]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
# @gitlawb package must have this repo + workflow (release.yml) configured
# as its trusted publisher on npmjs.com, or publish fails loudly here —
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
# never reached npm.
if: >-
${{ !cancelled() && (
(needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '')
) }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance
id-token: write # npm trusted publishing (OIDC) + provenance
steps:
- name: Guard on secret
id: guard
- name: Resolve release tag
id: rel
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::warning::NPM_TOKEN is not set — skipping npm publish."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
# package has a trusted publisher configured; older npm silently falls
# back to (absent) token auth and fails confusingly.
- name: Set up Node 24 + OIDC-capable npm
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "24"
- name: Ensure npm supports trusted publishing
run: |
set -euo pipefail
npm install -g npm@^11.5.1
npm --version
- name: Lay in release binaries
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
set -euo pipefail
# npm platform package -> Rust target triple (unix only; Windows is not
@@ -305,9 +469,8 @@ jobs:
done
- name: Set versions
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
VERSION: ${{ needs.release-please.outputs.version }}
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
node -e '
@@ -327,13 +490,12 @@ jobs:
'
- name: Publish
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
# No token: npm exchanges this job's GitHub OIDC identity with the
# registry (trusted publishing); provenance is attested automatically.
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
# Skip versions already on the registry so a rerun after a partial publish
# is idempotent instead of erroring on the first existing package.
@@ -429,6 +591,15 @@ jobs:
bin.install "git-remote-gitlawb"
end
def caveats
<<~CAVEATS
oh-my-zsh's git plugin aliases gl='git pull', which shadows this
binary in interactive shells. If \`gl\` prints "fatal: not a git
repository", run:
echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc
CAVEATS
end
test do
assert_match version.to_s, shell_output("#{bin}/gl --version")
end
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.5.1"
".": "0.6.0"
}
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
### Features
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
### Bug Fixes
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
Generated
+9 -5
View File
@@ -3300,7 +3300,7 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"gitlawb-core",
@@ -3312,7 +3312,7 @@ dependencies = [
[[package]]
name = "gitlawb-attest"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3329,7 +3329,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"base64",
@@ -3356,13 +3356,14 @@ dependencies = [
[[package]]
name = "gitlawb-node"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"alloy",
"anyhow",
"async-compression",
"async-graphql",
"async-graphql-axum",
"async-trait",
"aws-config",
"aws-sdk-s3",
"axum",
@@ -3412,10 +3413,11 @@ dependencies = [
[[package]]
name = "gl"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"alloy",
"anyhow",
"base64",
"chrono",
"clap",
"dirs",
@@ -3821,9 +3823,11 @@ version = "0.4.0"
dependencies = [
"anyhow",
"gitlawb-core",
"mockito",
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tracing",
]
+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.5.1" # x-release-please-version
version = "0.6.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+83
View File
@@ -570,6 +570,13 @@ fn safe_error_body_excerpt(body: &str) -> String {
continue;
}
if gitlawb_core::sanitize::is_bidi_format(ch) {
// Drop Cf bidi/format controls (INV-6, #183) without marking
// pending_space: unlike a newline/tab they are not whitespace, so
// "a\u{202e}c" collapses to "ac" with no phantom gap.
continue;
}
if ch.is_whitespace() {
pending_space = true;
continue;
@@ -1159,6 +1166,55 @@ mod tests {
assert!(!message.contains('\n'));
}
#[test]
fn http_error_message_strips_bidi_format_controls() {
// Each Cf bidi class must be stripped (INV-6, #183), not just U+202E:
// override (RLO), embedding + pop (LRE/PDF), isolate (LRI/PDI), and marks
// (LRM/ALM). Asserted per code point so a partial predicate fails loudly.
// The visible words must survive and JOIN with no phantom space at the
// strip points: a bidi char is dropped like a control byte, but unlike a
// newline/tab it is not whitespace, so it must not set pending_space. The
// leading bidi char (index 0) also exercises the empty-excerpt path.
let body = "\u{202e}err\u{202a}\u{202c}\u{2066}\u{2069}\u{200e}\u{061c}ok";
let message = http_error_message(
"GET",
"/info/refs",
reqwest::StatusCode::BAD_GATEWAY,
body,
None,
);
for c in [
'\u{202e}', '\u{202a}', '\u{202c}', '\u{2066}', '\u{2069}', '\u{200e}', '\u{061c}',
] {
assert!(
!message.contains(c),
"bidi U+{:04X} leaked: {message:?}",
c as u32
);
}
assert!(
message.contains("errok"),
"words should join with no phantom space: {message:?}"
);
}
#[test]
fn http_error_message_preserves_legitimate_and_rtl_text() {
// Must not over-strip: plain text, a genuine RTL SCRIPT letter (Arabic
// U+0627, category Lo), and ZWJ (U+200D, a legitimate Cf char) survive.
let message = http_error_message(
"GET",
"/info/refs",
reqwest::StatusCode::BAD_GATEWAY,
"ok \u{0627}\u{200D}b",
None,
);
assert!(
message.ends_with("ok \u{0627}\u{200D}b"),
"legitimate text was altered: {message:?}"
);
}
#[test]
fn http_error_message_truncates_long_response_body() {
let body = "x".repeat(MAX_ERROR_BODY_CHARS + 100);
@@ -1194,6 +1250,33 @@ mod tests {
assert_eq!(requests.len(), 1);
}
#[test]
fn connect_error_strips_bidi_from_response_body() {
// Transport-path proof: a bidi override in the node's real HTTP error body
// must not reach the surfaced error text. Drives the full handle_connect
// error path, not just the safe_error_body_excerpt unit boundary (INV-6).
let (base_url, server) = serve_http(vec![TestResponse {
request_line: "GET /z6Mk/myrepo/info/refs?service=git-upload-pack HTTP/1.1",
status: "404 Not Found",
body: "not\u{202e}found",
}]);
let repo_base = format!("{base_url}/z6Mk/myrepo");
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
let message = err.to_string();
assert!(
!message.contains('\u{202e}'),
"bidi override reached the terminal: {message:?}"
);
assert!(
message.contains("notfound"),
"body should survive with the bidi char stripped: {message:?}"
);
let _ = server.join();
}
#[test]
fn connect_post_error_includes_response_body() {
let (base_url, server) = serve_http(vec![
+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.5.1" # x-release-please-version
version = "0.6.0" # 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.5.1" # x-release-please-version
version = "0.6.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1
View File
@@ -5,6 +5,7 @@ pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod identity;
pub mod sanitize;
pub mod ucan;
pub use error::Error;
+69
View File
@@ -0,0 +1,69 @@
//! Text sanitization predicates shared across the workspace.
//!
//! Untrusted peer/remote bytes that reach an operator's terminal (node error
//! bodies, node-advertised strings) must be stripped of BOTH `Cc` control bytes
//! (`char::is_control()`) AND `Cf` bidi/format controls before display: a
//! right-to-left override or directional isolate can visually reorder the line
//! and spoof the text (Trojan Source, CWE-451). This is INV-6.
//!
//! `is_bidi_format` is the single workspace definition of the second half of
//! that obligation. It was split out of `gl`'s local copy so the two terminal
//! sanitizers that shipped with only the `Cc` half (#183) — and any future
//! sanitizer — reuse one audited predicate instead of re-deriving the set.
/// True for the Unicode bidirectional/directional-isolate format characters
/// (the reordering subset of category `Cf`) that can visually reorder a terminal
/// line. These are NOT matched by [`char::is_control()`], which covers `Cc` only.
///
/// This is deliberately the reordering subset, not the whole `Cf` category:
/// legitimate `Cf` characters such as ZWJ (`U+200D`, emoji sequences) and the
/// soft hyphen (`U+00AD`) are NOT matched, so honest international text passes
/// through unchanged. Widening this set is a behavior change for every consumer.
///
/// Pair it with `!c.is_control()` in a terminal-bound sanitizer (INV-6); see the
/// wrappers in `gl`, `git-remote-gitlawb`, and `icaptcha-client`.
pub fn is_bidi_format(c: char) -> bool {
matches!(c,
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_every_reordering_code_point() {
// Asserted individually so a partial predicate (e.g. one that covers the
// marks but not the isolates) fails loudly rather than passing.
for c in [
'\u{200E}', '\u{200F}', '\u{061C}', // LRM, RLM, ALM
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', // LRE, RLE, PDF, LRO, RLO
'\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // LRI, RLI, FSI, PDI
] {
assert!(
is_bidi_format(c),
"U+{:04X} must be a bidi-format char",
c as u32
);
}
}
#[test]
fn passes_legitimate_and_control_chars() {
// Must NOT over-strip: plain text, a genuine RTL SCRIPT letter (Lo, not a
// format char), ZWJ (legitimate Cf), and soft hyphen (legitimate Cf) are
// not bidi-format. ESC is a Cc control char — owned by is_control(), not
// this predicate, which is bidi-only.
for c in ['a', '\u{0627}', '\u{200D}', '\u{00AD}', '\u{1b}'] {
assert!(
!is_bidi_format(c),
"U+{:04X} must NOT be a bidi-format char",
c as u32
);
}
}
}
+67 -4
View File
@@ -187,11 +187,17 @@ impl Ucan {
}
/// Check if this UCAN grants a specific capability on a resource.
///
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
/// requested resource/action, since a valid delegation chain can produce
/// exactly that capability (see `is_attenuated_by`).
pub fn can(&self, resource: &str, action: &str) -> bool {
self.payload
.att
.iter()
.any(|cap| cap.with == resource && cap.can == action)
self.payload.att.iter().any(|cap| {
let resource_ok = cap.with == resource || cap.with == "*";
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
resource_ok && action_ok
})
}
/// Encode to a compact JSON string (the wire format).
@@ -312,6 +318,63 @@ mod tests {
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_resource_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("*", caps::GIT_PUSH)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_repo_admin_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new(
"gitlawb://repos/test/repo",
caps::REPO_ADMIN,
)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn can_honors_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("gitlawb://repos/test/repo", "*")],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn bootstrap_ucan() {
let issuer = Keypair::generate();
+2 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version = "0.5.1" # x-release-please-version
version = "0.6.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -12,6 +12,7 @@ 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
+75 -23
View File
@@ -138,9 +138,33 @@ pub async fn list_ref_updates(
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let updates = collect_visible_ref_updates(&state.db, None, limit, caller).await?;
// Resolve the trusted display owner_did per row. The stored wire value is
// untrusted (arrives over gossip / unsigned peer notify), so it is echoed
// only when it matches the canonical owner of the local repo the slug
// names (#P1); legacy None rows are attributed via an exact unique local
// match (#P3). Both surfaces share this resolver so they cannot drift.
// The batch resolver issues at most one query per distinct local repo
// rather than one per event row (#P2).
let raw_dids: Vec<Option<String>> = state
.db
.resolve_ref_update_owner_dids(
&updates
.iter()
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
.collect::<Vec<_>>(),
)
.await?;
let owner_dids: Vec<serde_json::Value> = raw_dids
.into_iter()
.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s)))
.collect();
let events: Vec<serde_json::Value> = updates
.iter()
.map(|u| {
.enumerate()
.map(|(i, u)| {
let owner_did = owner_dids[i].clone();
serde_json::json!({
"id": u.id,
"node_did": u.node_did,
@@ -153,6 +177,7 @@ pub async fn list_ref_updates(
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"owner_did": owner_did,
})
})
.collect();
@@ -225,6 +250,7 @@ pub async fn list_repo_events(
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"timestamp": c.issued_at,
"owner_did": record.owner_did,
"source": "local",
})
})
@@ -240,28 +266,46 @@ pub async fn list_repo_events(
// collect_visible_ref_updates drops any row whose slug matches a local repo the
// caller cannot read (fail-closed), and propagates DB errors instead of swallowing
// them.
let gossip_events: Vec<serde_json::Value> =
collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller)
.await?
.iter()
.map(|u| {
serde_json::json!({
"type": "gossipsub",
"id": u.id,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"pusher_did": u.pusher_did,
"node_did": u.node_did,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"source": "gossipsub",
})
let gossip_updates =
collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller).await?;
let gossip_raw: Vec<Option<String>> = state
.db
.resolve_ref_update_owner_dids(
&gossip_updates
.iter()
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
.collect::<Vec<_>>(),
)
.await?;
let gossip_owner_dids: Vec<serde_json::Value> = gossip_raw
.into_iter()
.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s)))
.collect();
let gossip_events: Vec<serde_json::Value> = gossip_updates
.iter()
.enumerate()
.map(|(i, u)| {
let owner_did = gossip_owner_dids[i].clone();
serde_json::json!({
"type": "gossipsub",
"id": u.id,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"pusher_did": u.pusher_did,
"node_did": u.node_did,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"owner_did": owner_did,
"source": "gossipsub",
})
.collect();
})
.collect();
// Merge both lists
let mut all_events: Vec<serde_json::Value> = cert_events;
@@ -326,6 +370,7 @@ mod ref_updates_feed_tests {
cert_id: None,
received_at: Utc::now().to_rfc3339(),
from_peer: "peer1".into(),
owner_did: None,
}
}
@@ -1105,7 +1150,14 @@ mod ref_updates_feed_tests {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(count(&body_json(resp).await), 2);
let body = body_json(resp).await;
assert_eq!(count(&body), 2);
for event in body["events"].as_array().unwrap() {
assert_eq!(
event["owner_did"], OWNER,
"each event must carry the local owner_did"
);
}
}
// Anon reads a quarantined mirror → 404 (withheld without disclosing existence
+490 -7
View File
@@ -70,11 +70,37 @@ 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.
guard.release(create_result.is_ok()).await;
// 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;
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 {
@@ -244,11 +270,14 @@ pub async fn close_issue(
.ok()
.and_then(|i| i.author),
Ok(None) => {
guard.release(false).await;
// 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;
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
}
Err(e) => {
guard.release(false).await;
// release(false): no upload attempted, nothing to fail on.
let _ = guard.release(false).await;
return Err(AppError::Git(e.to_string()));
}
};
@@ -257,21 +286,57 @@ pub async fn close_issue(
.as_deref()
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
if !is_owner && !is_author {
guard.release(false).await;
// release(false): no upload attempted, nothing to fail on.
let _ = 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.
guard.release(close_result.is_ok()).await;
// 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;
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}")))?;
@@ -279,3 +344,421 @@ 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})"
);
}
}
+4
View File
@@ -71,6 +71,10 @@ pub async fn remove_label(
}
/// GET /api/v1/repos/:owner/:repo/labels
///
/// Read-visibility-gated (INV-2 root listing): a public repo's labels stay
/// anonymously listable; a private repo's label names are hidden (404) from
/// anyone who cannot read it at the root.
pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
+15 -5
View File
@@ -172,6 +172,7 @@ mod authz_guard {
let issues = include_str!("issues.rs");
let bounties = include_str!("bounties.rs");
let replicas = include_str!("replicas.rs");
let events = include_str!("events.rs");
let tasks = include_str!("tasks.rs");
let stars = include_str!("stars.rs");
let protect = include_str!("protect.rs");
@@ -187,6 +188,13 @@ mod authz_guard {
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "require_repo_owner("),
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
// authorize_repo_read 404s a non-reader of a private repo, then
// require_repo_owner 403s a non-owner of a public one. Both halves
// are pinned: the authorize_repo_read marker guards the read half
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
@@ -203,6 +211,12 @@ mod authz_guard {
// get_by_cid gates each iterated repo row directly via visibility_check
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
(ipfs, "get_by_cid", "visibility_check("),
// #94 sibling read surfaces: gate private-repo metadata on read
// visibility (public repos stay anonymous; private repos 404).
(replicas, "list_replicas", "authorize_repo_read("),
(protect, "list_protected_branches", "authorize_repo_read("),
(labels, "list_labels", "authorize_repo_read("),
(events, "list_repo_events", "authorize_repo_read("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
@@ -509,11 +523,7 @@ mod authz_guard {
// Repo-scoped reads known to be ungated today, each tracked by an issue.
// Remove an entry the moment its gate lands (the staleness assert enforces it).
let known_ungated: &[(&str, &str)] = &[
("list_webhooks", "#94 (PR #113)"),
("list_replicas", "PR #113"),
("list_protected_branches", "PR #113"),
];
let known_ungated: &[(&str, &str)] = &[];
let is_known = |n: &str| known_ungated.iter().any(|(k, _)| *k == n);
// Any one of these = the handler binds the caller to an authz decision: the
// first two are read gates, the rest are the write/owner/self-binding forms.
+5
View File
@@ -347,6 +347,10 @@ pub struct NotifyRequest {
pub timestamp: Option<String>,
#[serde(default)]
pub cert_id: Option<String>,
/// Full owner DID — added in #144 for DID-aware feed gating.
/// Optional for backward compat with older senders.
#[serde(default)]
pub owner_did: Option<String>,
}
pub async fn notify_sync(
@@ -391,6 +395,7 @@ pub async fn notify_sync(
node_did: req.node_did.clone(),
pusher_did: req.pusher_did.clone().unwrap_or_default(),
repo: req.repo.clone(),
owner_did: req.owner_did.clone(),
ref_name: req.ref_name.clone(),
old_sha: req.old_sha.clone().unwrap_or_default(),
new_sha: req.new_sha.clone(),
+7 -5
View File
@@ -79,12 +79,14 @@ pub async fn unprotect_branch(
pub async fn list_protected_branches(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Read-visibility-gated (INV-2 root listing): a public repo's protected
// branches stay anonymously listable; a private repo's branch names are
// hidden (404) from anyone who cannot read it at the root.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let branches = state.db.list_protected_branches(&record.id).await?;
+202 -2
View File
@@ -216,6 +216,14 @@ 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,
@@ -224,11 +232,37 @@ pub async fn merge_pr(
&pr.title,
);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(merge_result.is_ok()).await;
// 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;
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;
@@ -424,3 +458,169 @@ 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"
);
}
}
+7 -6
View File
@@ -113,16 +113,17 @@ pub async fn unregister_replica(
}
/// GET /api/v1/repos/:owner/:repo/replicas
/// Public — returns the list of replicas (DID + URL + registration timestamp).
/// Read-visibility-gated: a PUBLIC repo's replica list stays anonymously
/// listable (mirror-discovery), but a PRIVATE repo's replica URLs are hidden
/// (404) from anyone who cannot read the repo at its root.
pub async fn list_replicas(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let replicas = state.db.list_replicas(&record.id).await?;
File diff suppressed because it is too large. Load diff
+22 -8
View File
@@ -1,8 +1,8 @@
//! Webhook CRUD API.
//!
//! POST /api/v1/repos/:owner/:repo/hooks — create (auth required)
//! GET /api/v1/repos/:owner/:repo/hooks — list
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (auth required)
//! POST /api/v1/repos/:owner/:repo/hooks — create (owner only)
//! GET /api/v1/repos/:owner/:repo/hooks — list (owner only; auth required)
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (owner only)
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
@@ -73,12 +73,26 @@ pub async fn create_webhook(
pub async fn list_webhooks(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// This route sits on `optional_signature`, so the DID is optional. Webhook
// callback URLs are owner-secret config and there is no anonymous form, so a
// headerless caller is rejected before any lookup (401, which fires for an
// existing-private and an absent repo alike, so it leaks no existence).
let Some(Extension(AuthenticatedDid(caller))) = auth else {
return Err(AppError::Unauthorized(
"listing webhooks requires authentication".into(),
));
};
// Read-visibility first, then owner. authorize_repo_read returns 404 on a
// visibility deny, so a non-reader of a private repo cannot tell it exists
// (uniform with the sibling read surfaces); require_repo_owner then yields
// 403 only for a non-owner of a public/readable repo, where existence is not
// secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(&caller), "/").await?;
crate::api::require_repo_owner(&record, &caller)?;
let hooks = state.db.list_webhooks(&record.id).await?;
// Redact secrets in list response
+1
View File
@@ -515,6 +515,7 @@ 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)),
+48 -5
View File
@@ -1,18 +1,45 @@
use clap::Parser;
use clap::{Parser, Subcommand};
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 {
/// Directory where bare git repositories are stored
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
/// 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
)]
pub repos_dir: PathBuf,
/// PostgreSQL connection URL (Supabase or any Postgres instance)
/// PostgreSQL connection URL (Supabase or any Postgres instance). `global` so
/// admin subcommands accept it in either position.
#[arg(
long,
env = "DATABASE_URL",
default_value = "postgresql://localhost/gitlawb"
default_value = "postgresql://localhost/gitlawb",
global = true
)]
pub database_url: String,
@@ -194,6 +221,22 @@ 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
+27 -6
View File
@@ -35,9 +35,6 @@ pub enum AppError {
#[error("invalid request: {0}")]
BadRequest(String),
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("incomplete: {0}")]
Incomplete(String),
@@ -47,6 +44,9 @@ 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,9 +132,6 @@ 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())
}
@@ -142,6 +139,14 @@ 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,
@@ -182,4 +187,20 @@ 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,6 +61,51 @@ 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
@@ -0,0 +1,431 @@
//! 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"),
}
}
}
+266 -1
View File
@@ -63,6 +63,105 @@ 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>> {
@@ -455,10 +554,176 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
#[cfg(test)]
mod tests {
use super::branch_diff_names;
use super::{branch_diff_names, list_refs, restore_refs};
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();
+22 -10
View File
@@ -11,6 +11,21 @@ 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 {
@@ -35,9 +50,12 @@ 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.
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
@@ -59,7 +77,7 @@ impl TigrisClient {
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
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");
@@ -89,12 +107,7 @@ impl TigrisClient {
}
/// Download a repo archive from Tigris and extract to local disk.
pub async fn download(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
) -> Result<()> {
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");
@@ -128,8 +141,7 @@ impl TigrisClient {
}
/// Delete a repo archive from Tigris.
#[allow(dead_code)]
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
self.s3
.delete_object()
+29 -5
View File
@@ -73,9 +73,25 @@ impl QueryRoot {
.await
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(updates
// Resolve the trusted display owner_did per row, identical to the REST
// feed: the stored wire value is untrusted, so it is echoed only when it
// matches the canonical owner of the local repo the slug names (#P1);
// legacy None rows are attributed via an exact unique local match (#P3).
// The batch resolver issues at most one query per distinct local repo
// rather than one per event row (#P2).
let pairs: Vec<(&str, Option<&str>)> = updates
.iter()
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
.collect();
let owner_dids = db
.resolve_ref_update_owner_dids(&pairs)
.await
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let resolved: Vec<RefUpdateType> = updates
.into_iter()
.map(|u| RefUpdateType {
.zip(owner_dids)
.map(|(u, owner_did)| RefUpdateType {
repo: u.repo,
ref_name: u.ref_name,
old_sha: u.old_sha,
@@ -83,8 +99,10 @@ impl QueryRoot {
pusher_did: u.pusher_did,
node_did: u.node_did,
timestamp: u.timestamp,
owner_did,
})
.collect())
.collect();
Ok(resolved)
}
async fn tasks(
@@ -162,6 +180,7 @@ mod tests {
cert_id: None,
received_at: Utc::now().to_rfc3339(),
from_peer: "peer1".into(),
owner_did: None,
}
}
@@ -245,10 +264,10 @@ mod tests {
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo refName } }"#;
let q = r#"{ refUpdates { repo refName ownerDid } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 1);
// The one row returned must be the public repo's.
// The one row returned must be the public repo's with owner_did echoed.
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
@@ -262,6 +281,11 @@ mod tests {
row.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo")
);
assert_eq!(
row.get("ownerDid").unwrap(),
&async_graphql::Value::from(OWNER),
"ownerDid must fall back to the local record's owner for legacy rows"
);
}
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
@@ -39,6 +39,7 @@ impl SubscriptionRoot {
pusher_did: ev.pusher_did,
node_did: ev.node_did,
timestamp: ev.timestamp,
owner_did: Some(ev.owner_did),
}),
_ => None,
}
+1
View File
@@ -57,6 +57,7 @@ pub struct RefUpdateType {
pub pusher_did: String,
pub node_did: String,
pub timestamp: String,
pub owner_did: Option<String>,
}
#[derive(SimpleObject, Clone)]
+179 -36
View File
@@ -1,3 +1,4 @@
mod admin;
mod api;
mod arweave;
mod auth;
@@ -70,6 +71,12 @@ 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);
@@ -279,8 +286,22 @@ 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(), tigris, db.pool().clone());
git::repo_store::RepoStore::new(config.repos_dir.clone(), object_store, lock_pool);
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
// DID (attacker-varied), so bound its key set to cap memory.
@@ -294,36 +315,24 @@ 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_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");
}
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");
// 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_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");
}
let push_rate_limiter = build_ip_limiter("GITLAWB_PUSH_RATE_LIMIT", 600, "push");
// 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;
@@ -331,7 +340,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_limit, "push rate limiter configured");
tracing::info!(trust = ?push_limiter_trust, "push rate limiter trust 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
@@ -373,6 +382,7 @@ 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,
@@ -408,22 +418,17 @@ async fn main() -> Result<()> {
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
{
let rl = state.rate_limiter.clone();
let create_ip_rl = state.create_ip_rate_limiter.clone();
let push_rl = state.push_rate_limiter.clone();
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
let peer_write_rl = state.peer_write_rate_limiter.clone();
// 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 db = state.db.clone();
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
rl.cleanup().await;
create_ip_rl.cleanup().await;
push_rl.cleanup().await;
sync_trigger_rl.cleanup().await;
peer_write_rl.cleanup().await;
state.cleanup_rate_limiters().await;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -564,6 +569,110 @@ 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)]
@@ -1024,6 +1133,40 @@ 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;
+70
View File
@@ -39,6 +39,11 @@ pub struct RefUpdateEvent {
pub pusher_did: String,
/// Repository identifier (owner/name)
pub repo: String,
/// Full owner DID — added in #144 for display and storage; not yet
/// wired into the feed gate matcher. Optional for backward compat with
/// older peers that don't include it.
#[serde(default)]
pub owner_did: Option<String>,
/// Git ref that changed (e.g., "refs/heads/main")
pub ref_name: String,
/// SHA before the push (all-zeros for new ref)
@@ -307,6 +312,7 @@ pub async fn start(
node_did: event.node_did.clone(),
pusher_did: event.pusher_did.clone(),
repo: event.repo.clone(),
owner_did: event.owner_did.clone(),
ref_name: event.ref_name.clone(),
old_sha: event.old_sha.clone(),
new_sha: event.new_sha.clone(),
@@ -432,3 +438,67 @@ pub async fn start(
Ok(handle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_update_event_round_trip_with_owner_did() {
let event = RefUpdateEvent {
node_did: "did:key:zNode".into(),
pusher_did: "did:key:zPusher".into(),
repo: "zOwner/myrepo".into(),
owner_did: Some("did:key:zOwner".into()),
ref_name: "refs/heads/main".into(),
old_sha: "0000000000000000000000000000000000000000".into(),
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
timestamp: "2026-07-02T12:00:00Z".into(),
cert_id: None,
cid: None,
};
let json = serde_json::to_value(&event).unwrap();
// owner_did must be present in the serialized output
assert_eq!(json["owner_did"], "did:key:zOwner");
assert_eq!(json["repo"], "zOwner/myrepo");
let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into()));
}
#[test]
fn ref_update_event_backward_compat_no_owner_did() {
let old_json = serde_json::json!({
"node_did": "did:key:zNode",
"pusher_did": "did:key:zPusher",
"repo": "zOwner/myrepo",
"ref_name": "refs/heads/main",
"old_sha": "0000000000000000000000000000000000000000",
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"timestamp": "2026-07-02T12:00:00Z",
"cert_id": null,
"cid": null
});
let deserialized: RefUpdateEvent = serde_json::from_value(old_json).unwrap();
assert_eq!(deserialized.owner_did, None);
assert_eq!(deserialized.repo, "zOwner/myrepo");
}
#[test]
fn ref_update_event_backward_compat_null_owner_did() {
let with_null = serde_json::json!({
"node_did": "did:key:zNode",
"pusher_did": "did:key:zPusher",
"repo": "zOwner/myrepo",
"owner_did": null,
"ref_name": "refs/heads/main",
"old_sha": "0000000000000000000000000000000000000000",
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"timestamp": "2026-07-02T12:00:00Z",
"cert_id": null,
"cid": null
});
let deserialized: RefUpdateEvent = serde_json::from_value(with_null).unwrap();
assert_eq!(deserialized.owner_did, None);
}
}
+273 -19
View File
@@ -70,10 +70,23 @@ 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 true;
return None;
}
let now = Instant::now();
let mut state = self.state.lock().await;
@@ -85,10 +98,25 @@ impl RateLimiter {
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
// 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));
}
window.timestamps.push(now);
return true;
return None;
}
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
@@ -109,7 +137,11 @@ impl RateLimiter {
}
drop(last_sweep);
if state.len() >= self.max_keys {
return false;
// 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)));
}
}
state.insert(
@@ -118,7 +150,7 @@ impl RateLimiter {
timestamps: vec![now],
},
);
true
None
}
pub async fn cleanup(&self) {
@@ -130,6 +162,41 @@ 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 {
@@ -141,13 +208,8 @@ 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 !limiter.check(&did).await {
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response();
if let Some(retry_after) = limiter.check_retry(&did).await {
return too_many_requests(retry_after);
}
}
@@ -250,13 +312,13 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
}
}
/// 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 {
/// 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 {
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
[("retry-after", retry_after.as_secs().to_string())],
"rate limit exceeded — try again later",
)
.into_response()
@@ -274,9 +336,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 !limiter.limiter.check(&key).await {
if let Some(retry_after) = limiter.limiter.check_retry(&key).await {
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
return too_many_requests();
return too_many_requests(retry_after);
}
}
}
@@ -605,4 +667,196 @@ 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}"
);
}
}
+52 -9
View File
@@ -55,18 +55,50 @@ 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.
// 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.
.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) ───────────────────────
// ── Task routes (write — require HTTP Signature + per-IP write brake) ──
let task_write_routes = add_auth_layers(
Router::new()
.route("/api/v1/tasks", post(tasks::create_task))
@@ -74,7 +106,8 @@ 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()
@@ -111,7 +144,13 @@ 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 (no rate limit) ─────────────
// ── 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.
let write_routes = add_auth_layers(
Router::new()
.route(
@@ -181,7 +220,8 @@ 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.
@@ -247,7 +287,8 @@ 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()
@@ -268,9 +309,10 @@ 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, no rate limit) ─────
// ── Issue routes (write — require HTTP Signature + per-IP write brake) ─
let issue_write_routes = add_auth_layers(
Router::new()
.route(
@@ -282,7 +324,8 @@ 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
+55
View File
@@ -17,6 +17,7 @@ pub struct RefUpdateBroadcast {
pub pusher_did: String,
pub node_did: String,
pub timestamp: String,
pub owner_did: String,
}
#[derive(Clone, Debug)]
@@ -61,6 +62,16 @@ 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.
@@ -117,4 +128,48 @@ 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
+2 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gl"
description = "The gitlawb CLI — identity management, node control, MCP server"
version = "0.5.1" # x-release-please-version
version = "0.6.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
icaptcha-client = { path = "../icaptcha-client" }
base64 = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+216 -27
View File
@@ -41,6 +41,16 @@ pub enum CertCmd {
node: String,
#[arg(long)]
dir: Option<PathBuf>,
/// Exit non-zero unless the Ed25519 signature verifies AND the
/// issuing node matches the queried node (or --expect-node)
#[arg(long)]
verify: bool,
/// Expected issuing node DID for --verify. A valid signature alone
/// only proves the cert is internally consistent — signed by whatever
/// key it names — so --verify also anchors the issuer to a DID you
/// trust: this value when given, else the queried node's DID.
#[arg(long, requires = "verify")]
expect_node: Option<String>,
},
}
@@ -52,7 +62,9 @@ pub async fn run(args: CertArgs) -> Result<()> {
id,
node,
dir,
} => cmd_show(repo, id, node, dir).await,
verify,
expect_node,
} => cmd_show(repo, id, node, dir, verify, expect_node).await,
}
}
@@ -114,7 +126,14 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
Ok(())
}
async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_show(
repo: String,
id: String,
node: String,
dir: Option<PathBuf>,
require_valid: bool,
expect_node: Option<String>,
) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = signed_client(&node, dir.as_deref());
@@ -148,39 +167,124 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
println!(" Signature: {signature}");
println!();
// Reconstruct the signing payload and verify
// Fetch the node's current public key to verify
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let current_node_did = info["did"].as_str().unwrap_or("");
// Verify the Ed25519 signature: rebuild the exact canonical payload the
// node signed (see gitlawb-node/src/cert.rs::issue_ref_certificate) and
// check it against the public key embedded in the certificate's node DID.
// This proves the cert is internally authentic — signed by the key it
// names; the node-DID comparison below covers *which* node that is.
let repo_id = cert["repo_id"].as_str().unwrap_or("");
let verdict = verify_signature(
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
);
println!("Signature verification:");
println!(" Signing payload would be:");
println!(" {{\"repo_id\": ..., \"ref\": \"{ref_name}\", \"old\": \"{old_sha}\",");
println!(" \"new\": \"{new_sha}\", \"pusher\": \"{pusher}\",");
println!(" \"node\": \"{node_did}\", \"ts\": \"{issued_at}\"}}");
println!();
if current_node_did == node_did {
println!(" Node DID matches current node. Signature is an Ed25519/base64url value.");
println!(" To verify offline, use the node's Ed25519 public key derived from:");
println!(" did:key → {node_did}");
} else {
println!(" WARNING: Certificate node DID ({node_did}) does not match");
println!(" current node DID ({current_node_did}).");
println!(" This certificate was issued by a different node.");
match &verdict {
Ok(()) => {
println!(
" VALID — Ed25519 signature verified against the key the certificate names ({node_did})"
);
}
Err(reason) => {
println!(" INVALID — {reason}");
}
}
println!();
println!(" Signature (base64url): {signature}");
// Contextual only — the verdict above stands on its own, so a node-info
// hiccup here must not turn a successfully displayed certificate into an
// error exit.
let current_node_did = match client.get("/").await {
Ok(resp) => resp
.json::<Value>()
.await
.ok()
.and_then(|info| info["did"].as_str().map(str::to_string)),
Err(_) => None,
};
match current_node_did.as_deref() {
Some(current) if current == node_did => {
println!(" Issuing node DID matches the node being queried.");
}
Some(current) => {
println!(" WARNING: Certificate node DID ({node_did}) does not match");
println!(" current node DID ({current}).");
println!(" This certificate was issued by a different node.");
}
None => {
println!(" NOTE: could not fetch current node info — skipping node-DID comparison.");
}
}
if require_valid {
if let Err(reason) = verdict {
anyhow::bail!("certificate signature did not verify: {reason}");
}
// A valid signature proves internal consistency only: the payload was
// signed by whatever key the certificate itself names. A hostile
// source can mint a keypair, put its DID in node_did, and self-sign.
// --verify therefore also anchors the issuer to a trusted DID:
// --expect-node when given, else the DID of the node being queried.
let expected = expect_node.as_deref().or(current_node_did.as_deref());
match expected {
Some(expected) if expected == node_did => {}
Some(expected) => anyhow::bail!(
"certificate is signed by {node_did}, but the expected issuer is {expected} — \
a valid signature alone proves internal consistency, not a trusted issuer"
),
None => anyhow::bail!(
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
),
}
}
Ok(())
}
/// Rebuild the node's canonical signing payload (field order must match
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
/// certificate's Ed25519 signature against the key embedded in `node_did`.
#[allow(clippy::too_many_arguments)]
fn verify_signature(
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher: &str,
node_did: &str,
issued_at: &str,
signature_b64: &str,
) -> std::result::Result<(), String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use std::str::FromStr;
let payload = serde_json::json!({
"repo_id": repo_id,
"ref": ref_name,
"old": old_sha,
"new": new_sha,
"pusher": pusher,
"node": node_did,
"ts": issued_at,
});
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?;
let did =
gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?;
let verifying_key = did
.to_verifying_key()
.map_err(|e| format!("cannot derive a public key from {node_did}: {e}"))?;
let sig_vec = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|e| format!("signature is not valid base64url: {e}"))?;
let sig_bytes: [u8; 64] = sig_vec
.try_into()
.map_err(|_| "signature is not 64 bytes".to_string())?;
gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes)
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
}
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
if id.len() >= 36 {
return Ok(id.to_string());
@@ -209,3 +313,88 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
_ => anyhow::bail!("certificate prefix {id} matches multiple certificates"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Pins gl's payload reconstruction to the frozen canonical byte form the
/// node signs (default serde_json maps = alphabetically ordered keys). If
/// serialization drifts — a field added, or a preserve_order feature
/// landing anywhere in the workspace (feature unification flips every
/// crate at once) — this literal stops matching and the test fails,
/// instead of every real certificate silently rendering INVALID.
#[test]
fn payload_serialization_matches_frozen_canonical_form() {
let payload = serde_json::json!({
"repo_id": "repo-1",
"ref": "refs/heads/main",
"old": "oldsha",
"new": "newsha",
"pusher": "did:key:z6MkPusher",
"node": "did:key:z6MkNode",
"ts": "2026-07-22T00:00:00+00:00",
});
let frozen = concat!(
r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#,
r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#,
r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#,
);
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
}
/// Signing exactly as the node does must round-trip through
/// verify_signature; any field tampering must fail it.
#[test]
fn verify_signature_round_trip_and_tamper() {
let kp = gitlawb_core::identity::Keypair::generate();
let node_did = kp.did().as_str().to_string();
let payload = serde_json::json!({
"repo_id": "repo-1",
"ref": "refs/heads/main",
"old": "0".repeat(40),
"new": "a".repeat(40),
"pusher": "did:key:z6MkPusher",
"node": node_did,
"ts": "2026-07-22T00:00:00+00:00",
});
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
let ok = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
&sig,
);
assert!(ok.is_ok(), "expected valid signature, got: {ok:?}");
let tampered = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"b".repeat(40), // new_sha changed after signing
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
&sig,
);
assert!(tampered.is_err(), "tampered payload must not verify");
let garbage = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
"not-base64url!!!",
);
assert!(garbage.is_err(), "malformed signature must not verify");
}
}
+238 -11
View File
@@ -16,6 +16,7 @@ use std::path::PathBuf;
use crate::http::NodeClient;
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
const GITHUB_API_BASE: &str = "https://api.github.com";
#[derive(Args)]
pub struct DoctorArgs {
@@ -141,18 +142,20 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
match std::env::var("GITLAWB_NODE") {
Ok(v) if !v.is_empty() && !v.contains("127.0.0.1") && !v.contains("localhost") => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => {
checks.push(Check::fail(
// A loopback host is a legitimate setup (self-hosted node, dev
// harness) — the connectivity check below fails loudly if it is not
// actually reachable, so don't red-flag the configuration itself.
Ok(v) if is_loopback_url(&v) => {
checks.push(Check::pass(
"GITLAWB_NODE",
format!(
"set to local address ({v}) — git push/clone will fail against remote nodes"
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
),
"export GITLAWB_NODE=https://node.gitlawb.com",
));
}
Ok(v) if !v.is_empty() => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
_ => {
checks.push(Check::fail(
"GITLAWB_NODE",
@@ -242,6 +245,15 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
));
}
// ── 5b. shell alias shadowing ─────────────────────────────────────────
// oh-my-zsh's default `git` plugin aliases gl='git pull', which silently
// shadows this binary in every interactive zsh — the classic symptom is
// `gl` printing "fatal: not a git repository". Aliases beat PATH, so no
// install method can fix it; the user's rc file has to unalias.
if let Some(check) = check_shell_alias_shadowing(dirs::home_dir()) {
checks.push(check);
}
// ── 6. git ────────────────────────────────────────────────────────────
match std::process::Command::new("git")
.arg("--version")
@@ -263,7 +275,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── 7. Version up to date ─────────────────────────────────────────────
let current = env!("CARGO_PKG_VERSION");
checks.push(check_version(current).await);
checks.push(check_version(current, GITHUB_API_BASE).await);
// ── Render ────────────────────────────────────────────────────────────
for check in &checks {
@@ -308,6 +320,83 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
/// Check if a binary name exists anywhere on PATH.
/// True when the rc file contains a real `unalias` command naming `gl` —
/// not a comment, and not a longer word like `unalias global`. Ordering
/// relative to oh-my-zsh loading is not modeled (an unalias placed before the
/// plugin loads would be ineffective); acceptable slack for a warning check.
fn rc_unaliases_gl(rc: &str) -> bool {
rc.lines().any(|line| {
let line = line.trim_start();
if line.starts_with('#') {
return false;
}
line.split([';', '&', '|']).any(|cmd| {
// A trailing comment must not count: `unalias foo # gl`
let cmd = cmd.split('#').next().unwrap_or("");
let mut tokens = cmd.split_whitespace();
tokens.next() == Some("unalias") && tokens.any(|t| t == "gl")
})
})
}
/// True when the URL's host is exactly a loopback address. Substring checks
/// misclassify hosts like `localhost.example` or URLs with "localhost" in the
/// path, so parse and compare the actual host.
fn is_loopback_url(value: &str) -> bool {
reqwest::Url::parse(value)
.ok()
.and_then(|u| {
u.host_str()
.map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]" || h == "::1")
})
.unwrap_or(false)
}
/// Detect interactive-shell setups where an alias shadows `gl` (aliases beat
/// PATH, so no install method can fix this). Best-effort heuristic over
/// ~/.zshrc: an explicit `alias gl=`, or oh-my-zsh's `git` plugin (which
/// ships gl='git pull'). Returns None when nothing suspicious is found or the
/// rc file already contains an `unalias gl`.
fn check_shell_alias_shadowing(home: Option<PathBuf>) -> Option<Check> {
let home = home?;
let rc = std::fs::read_to_string(home.join(".zshrc")).ok()?;
if rc_unaliases_gl(&rc) {
return None;
}
let explicit_alias = rc.lines().any(|l| l.trim_start().starts_with("alias gl="));
// Single-line `plugins=(git ...)` is the overwhelmingly common form; a
// multi-line plugins array slips past this heuristic, which is acceptable
// for a warning-level check.
let omz_git_plugin = home.join(".oh-my-zsh/plugins/git").exists()
&& rc.lines().any(|l| {
let l = l.trim_start();
l.starts_with("plugins=")
&& l.trim_start_matches("plugins=(")
.trim_end_matches(')')
.split_whitespace()
.any(|p| p == "git")
});
if explicit_alias || omz_git_plugin {
let source = if explicit_alias {
"~/.zshrc defines `alias gl=`"
} else {
"oh-my-zsh's git plugin aliases gl='git pull'"
};
Some(Check::warn(
"shell alias",
format!(
"{source} — interactive shells run that instead of this binary \
(symptom: `gl` prints \"fatal: not a git repository\")"
),
"echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc (must come after oh-my-zsh loads)",
))
} else {
None
}
}
fn which_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
@@ -319,8 +408,9 @@ fn which_in_path(name: &str) -> bool {
.unwrap_or(false)
}
/// Fetch the latest release tag from gitlawb/releases and compare to current version.
async fn check_version(current: &'static str) -> Check {
/// Fetch the latest release tag from Gitlawb/node (the actual release repo — see install.sh's
/// `GITLAWB_RELEASE_REPO` default) and compare to current version.
async fn check_version(current: &'static str, github_api_base: &str) -> Check {
let client = match reqwest::Client::builder()
.user_agent(format!("gl/{current}"))
.timeout(std::time::Duration::from_secs(5))
@@ -331,7 +421,9 @@ async fn check_version(current: &'static str) -> Check {
};
let resp = match client
.get("https://api.github.com/repos/gitlawb/releases/releases/latest")
.get(format!(
"{github_api_base}/repos/Gitlawb/node/releases/latest"
))
.send()
.await
{
@@ -339,6 +431,13 @@ async fn check_version(current: &'static str) -> Check {
Err(_) => return Check::pass("version", format!("v{current} (offline — could not check)")),
};
if !resp.status().is_success() {
return Check::pass(
"version",
format!("v{current} (GitHub API returned HTTP {})", resp.status()),
);
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Check::pass("version", format!("v{current} (invalid response)")),
@@ -382,6 +481,88 @@ fn is_newer(latest: &str, current: &str) -> bool {
mod tests {
use super::*;
fn fake_home(zshrc: Option<&str>, with_omz_git: bool) -> tempfile::TempDir {
let home = tempfile::TempDir::new().unwrap();
if let Some(rc) = zshrc {
std::fs::write(home.path().join(".zshrc"), rc).unwrap();
}
if with_omz_git {
std::fs::create_dir_all(home.path().join(".oh-my-zsh/plugins/git")).unwrap();
}
home
}
#[test]
fn alias_shadowing_flags_omz_git_plugin() {
let home = fake_home(Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\n"), true);
let check = check_shell_alias_shadowing(Some(home.path().to_path_buf()));
assert!(check.is_some(), "omz git plugin without unalias must warn");
}
#[test]
fn alias_shadowing_flags_explicit_alias() {
let home = fake_home(Some("alias gl='git pull'\n"), false);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some());
}
#[test]
fn alias_shadowing_silent_when_unaliased() {
let home = fake_home(
Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\nunalias gl 2>/dev/null\n"),
true,
);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
}
#[test]
fn alias_shadowing_ignores_fake_unaliases() {
// None of these actually free `gl` — the warning must survive them.
for rc in [
"plugins=(git)\n# unalias gl\n", // commented out
"plugins=(git)\nunalias global\n", // longer word
"plugins=(git)\nunalias foo # gl\n", // gl only in a comment
"plugins=(git)\necho unalias-gl-later\n", // not an unalias command
] {
let home = fake_home(Some(rc), true);
assert!(
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some(),
"must still warn for rc: {rc:?}"
);
}
// Real unalias forms that do free it — all must silence the warning.
for rc in [
"plugins=(git)\nunalias gl\n",
"plugins=(git)\nunalias gl 2>/dev/null\n",
"plugins=(git)\ntrue; unalias gl\n",
"plugins=(git)\nunalias glog gl gst\n",
] {
let home = fake_home(Some(rc), true);
assert!(
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none(),
"must be silent for rc: {rc:?}"
);
}
}
#[test]
fn loopback_url_detection_is_host_exact() {
assert!(is_loopback_url("http://127.0.0.1:7545"));
assert!(is_loopback_url("http://localhost:7545"));
assert!(is_loopback_url("http://[::1]:7545"));
assert!(!is_loopback_url("https://localhost.example"));
assert!(!is_loopback_url("https://node.gitlawb.com/localhost"));
assert!(!is_loopback_url("not a url"));
}
#[test]
fn alias_shadowing_silent_without_signals() {
let home = fake_home(Some("plugins=(z)\nexport EDITOR=vim\n"), false);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
let no_rc = fake_home(None, false);
assert!(check_shell_alias_shadowing(Some(no_rc.path().to_path_buf())).is_none());
assert!(check_shell_alias_shadowing(None).is_none());
}
#[test]
fn test_is_newer_minor_bump() {
assert!(is_newer("0.2.0", "0.1.0"));
@@ -417,4 +598,50 @@ mod tests {
fn test_which_in_path_missing_binary() {
assert!(!which_in_path("this-binary-does-not-exist-gl-test-12345"));
}
#[tokio::test]
async fn test_check_version_queries_gitlawb_node_releases() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name":"v9.9.9"}"#)
.create_async()
.await;
let check = check_version("0.1.0", &server.url()).await;
assert!(matches!(check.state, CheckState::Warn));
assert!(check.detail.contains("v9.9.9"));
}
#[tokio::test]
async fn test_check_version_up_to_date() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name":"v0.1.0"}"#)
.create_async()
.await;
let check = check_version("0.1.0", &server.url()).await;
assert!(matches!(check.state, CheckState::Ok));
assert!(check.detail.contains("up to date"));
}
#[tokio::test]
async fn test_check_version_http_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(403)
.create_async()
.await;
let check = check_version("0.1.0", &server.url()).await;
assert!(matches!(check.state, CheckState::Ok));
assert!(check.detail.contains("GitHub API returned HTTP 403"));
}
}
+403
View File
@@ -72,6 +72,23 @@ impl NodeClient {
req.send().await.with_context(|| format!("GET {url}"))
}
/// GET that signs when an identity keypair is present and falls back to an
/// anonymous GET otherwise — for read-visibility endpoints, where a public
/// repo is readable anonymously but a private repo requires the owner/reader
/// to be authenticated. Mirrors the conditional signing of post/put/delete.
pub async fn get_maybe_signed(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self.inner.get(&url);
if let Some(kp) = &self.keypair {
let signed = sign_request(kp, "GET", path, b"");
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
}
req.send().await.with_context(|| format!("GET {url}"))
}
/// POST with JSON body + RFC 9421 signing + transparent iCaptcha solve/retry.
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("POST", path, body).await
@@ -185,3 +202,389 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
.await
.context("iCaptcha solver task panicked")?
}
#[cfg(test)]
mod tests {
use super::*;
use gitlawb_core::identity::Keypair;
use mockito::Server;
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes the two integration tests that touch the process-global
/// `GITLAWB_ICAPTCHA_URL` / `GITLAWB_ICAPTCHA_INSECURE` env vars so they
/// never race.
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
fn test_keypair() -> Keypair {
Keypair::generate()
}
fn headers_from_pairs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
let mut h = reqwest::header::HeaderMap::new();
for (k, v) in pairs {
h.insert(
k.parse::<reqwest::header::HeaderName>().unwrap(),
v.parse::<reqwest::header::HeaderValue>().unwrap(),
);
}
h
}
// ── icaptcha_cfg ────────────────────────────────────────────────────
#[test]
fn icaptcha_cfg_returns_some_when_both_headers_present() {
let kp = test_keypair();
let client = NodeClient::new("http://localhost", Some(kp.clone()));
let headers = headers_from_pairs(&[
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
("x-icaptcha-level", "3"),
]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.did, kp.did().to_string());
assert_eq!(cfg.level, 3);
}
#[test]
fn icaptcha_cfg_defaults_level_when_only_url_present() {
let kp = test_keypair();
let client = NodeClient::new("http://localhost", Some(kp));
let headers = headers_from_pairs(&[("x-icaptcha-url", "https://icaptcha.gitlawb.com")]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
}
#[test]
fn icaptcha_cfg_defaults_url_when_only_level_present() {
let kp = test_keypair();
let client = NodeClient::new("http://localhost", Some(kp));
let headers = headers_from_pairs(&[("x-icaptcha-level", "5")]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.level, 5);
}
#[test]
fn icaptcha_cfg_returns_none_without_icaptcha_headers() {
let client = NodeClient::new("http://localhost", Some(test_keypair()));
let headers = reqwest::header::HeaderMap::new();
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
}
#[test]
fn icaptcha_cfg_returns_none_with_unrelated_headers() {
let client = NodeClient::new("http://localhost", Some(test_keypair()));
let headers = headers_from_pairs(&[("content-type", "application/json")]);
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
}
#[test]
fn icaptcha_cfg_errors_when_no_keypair() {
let client = NodeClient::new("http://localhost", None);
let headers = headers_from_pairs(&[("x-icaptcha-level", "3")]);
let err = client.icaptcha_cfg(&headers).unwrap_err();
assert!(err.to_string().contains("identity keypair"));
}
#[test]
fn icaptcha_cfg_ignores_unparseable_level() {
let client = NodeClient::new("http://localhost", Some(test_keypair()));
let headers = headers_from_pairs(&[
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
("x-icaptcha-level", "not-a-number"),
]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
}
// ── send_once ───────────────────────────────────────────────────────
#[tokio::test]
async fn send_once_attaches_proof_header_when_provided() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("x-icaptcha-proof", "test.proof.token")
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.send_once("POST", "/api/test", b"{}", Some("test.proof.token"))
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
#[tokio::test]
async fn send_once_omits_proof_header_when_not_provided() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("x-icaptcha-proof", mockito::Matcher::Missing)
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.send_once("POST", "/api/test", b"{}", None)
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
#[tokio::test]
async fn send_once_signs_request_when_keypair_present() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("Signature", mockito::Matcher::Any)
.match_header("Signature-Input", mockito::Matcher::Any)
.match_header("Content-Digest", mockito::Matcher::Any)
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_once("POST", "/api/test", b"{}", None)
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
#[tokio::test]
async fn send_once_does_not_sign_when_no_keypair() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("Signature", mockito::Matcher::Missing)
.match_header("Signature-Input", mockito::Matcher::Missing)
.match_header("Content-Digest", mockito::Matcher::Missing)
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.send_once("POST", "/api/test", b"{}", None)
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
// ── send_signed ─────────────────────────────────────────────────────
#[tokio::test]
async fn send_signed_returns_non_icaptcha_403_without_retry() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"forbidden"}"#)
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 403);
m.assert();
}
#[tokio::test]
async fn send_signed_returns_first_response_on_success() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"created"}"#)
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 201);
m.assert();
}
#[tokio::test]
async fn send_signed_handles_405_not_icaptcha() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.with_status(405)
.with_body(r#"{"error":"method not allowed"}"#)
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 405);
m.assert();
}
// ── send_signed iCaptcha retry (full integration) ────────────────────
/// Set GITLAWB_ICAPTCHA_URL and GITLAWB_ICAPTCHA_INSECURE so the iCaptcha
/// client trusts a local mockito HTTP server, restoring any prior values on
/// drop so a test run launched with those variables keeps working.
/// Holds [`ICAPTCHA_ENV_LOCK`] for its lifetime so concurrent tests don't
/// race on the process-global env vars.
struct IcaptchaEnv {
_lock: MutexGuard<'static, ()>,
prev_url: Option<OsString>,
prev_insecure: Option<OsString>,
}
impl IcaptchaEnv {
fn new(url: &str) -> Self {
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev_url = std::env::var_os("GITLAWB_ICAPTCHA_URL");
let prev_insecure = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::set_var("GITLAWB_ICAPTCHA_URL", url);
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
IcaptchaEnv {
_lock: lock,
prev_url,
prev_insecure,
}
}
}
impl Drop for IcaptchaEnv {
fn drop(&mut self) {
match self.prev_url.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
}
match self.prev_insecure.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
}
}
}
/// Set up a mock iCaptcha server that responds to challenge + answer.
/// `hits` sets the expected call count for both endpoints so the test can
/// verify the solve loop was entered the correct number of times.
struct MockIcaptcha {
challenge: mockito::Mock,
answer: mockito::Mock,
_guard: IcaptchaEnv,
url: String,
}
impl MockIcaptcha {
async fn new(server: &mut mockito::ServerGuard, hits: usize) -> Self {
let url = server.url();
let guard = IcaptchaEnv::new(&url);
let challenge = server
.mock("POST", "/v1/challenge")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"challengeId":"c1","type":"arithmetic","difficulty":1,"prompt":"What is 1 + 1?","token":"tk1"}"#,
)
.expect(hits)
.create_async()
.await;
let answer = server
.mock("POST", "/v1/answer")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"passed","proof":"mock.proof"}"#)
.expect(hits)
.create_async()
.await;
Self {
challenge,
answer,
_guard: guard,
url,
}
}
}
#[tokio::test]
async fn send_signed_solves_icaptcha_and_retries_to_success() {
let mut node = Server::new_async().await;
let mut icaptcha = Server::new_async().await;
let ic = MockIcaptcha::new(&mut icaptcha, 1).await;
let n1 = node
.mock("POST", "/api/register")
.with_status(403)
.with_header("content-type", "application/json")
.with_header("x-icaptcha-url", &ic.url)
.with_header("x-icaptcha-level", "3")
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
.expect(1)
.create_async()
.await;
let n2 = node
.mock("POST", "/api/register")
.match_header("x-icaptcha-proof", "mock.proof")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"created"}"#)
.expect(1)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 201);
n1.assert();
n2.assert();
ic.challenge.assert();
ic.answer.assert();
}
#[tokio::test]
async fn send_signed_returns_403_after_icaptcha_retries_exhausted() {
let mut node = Server::new_async().await;
let mut icaptcha = Server::new_async().await;
// MAX_ICAPTCHA_RETRIES = 2, so with every call returning 403 with
// iCaptcha headers the solve loop runs twice (2 challenge + 2 answer).
let ic = MockIcaptcha::new(&mut icaptcha, 2).await;
// The original + 2 retries = 3 node calls before the loop gives up.
let n = node
.mock("POST", "/api/register")
.with_status(403)
.with_header("content-type", "application/json")
.with_header("x-icaptcha-url", &ic.url)
.with_header("x-icaptcha-level", "3")
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
.expect(3)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 403);
n.assert();
ic.challenge.assert();
ic.answer.assert();
}
}
+52 -4
View File
@@ -38,13 +38,23 @@ pub async fn run(args: InitArgs) -> Result<()> {
let git_dir = cwd.join(".git");
if !git_dir.exists() {
println!("Initializing git repository...");
// -b main: the push flow targets `main`; without it a fresh repo uses
// the user's init.defaultBranch (often `master`). Older git (<2.28)
// lacks the flag, so fall back to a plain init.
let status = std::process::Command::new("git")
.args(["init"])
.args(["init", "-b", "main"])
.current_dir(&cwd)
.status()
.context("failed to run git init")?;
if !status.success() {
anyhow::bail!("git init failed");
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&cwd)
.status()
.context("failed to run git init")?;
if !status.success() {
anyhow::bail!("git init failed");
}
}
} else {
println!("Git repository detected.");
@@ -165,9 +175,47 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
}
// The hint must match the repo's actual state: with no commits yet, a bare
// `git push` fails with "src refspec ... does not match any" — exactly the
// trap a zero-to-push command must not set.
let has_commits = std::process::Command::new("git")
.args(["rev-parse", "--verify", "HEAD"])
.current_dir(&cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
// symbolic-ref succeeds on a branch (including an unborn one) and fails
// on a detached HEAD — where guessing "main" could push the wrong ref.
let branch = std::process::Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&cwd)
.stderr(std::process::Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
println!();
println!("Ready! Push with:");
println!(" git push gitlawb main");
match (branch, has_commits) {
(Some(branch), true) => {
println!("Ready! Push with:");
println!(" git push gitlawb {branch}");
}
(Some(branch), false) => {
println!("Ready! Nothing is committed yet — commit, then push:");
println!(" git add -A && git commit -m \"initial commit\"");
println!(" git push gitlawb {branch}");
}
(None, _) => {
println!("Ready! HEAD is detached — create or switch to a branch, then push:");
println!(" git switch -c main");
println!(" git push gitlawb main");
}
}
Ok(())
}
+207 -6
View File
@@ -932,13 +932,33 @@ async fn call_tool(
"webhook_list" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.json()
// Owner-gated route: an explicit `owner` arg wins, otherwise default to
// the signing keypair's short DID (NOT the node DID). The request is
// signed by this keypair, so the path owner must match it or the node
// returns 403/404.
let owner = if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
o.to_string()
} else {
let kp = keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
};
// Owner-gated route: must be signed (get_signed), not a plain get().
let resp = client
.get_signed(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?;
Ok(serde_json::to_string_pretty(&resp)?)
// Check the HTTP status before deserializing: a 401/403/404 JSON error
// body (missing identity, wrong owner, private/deleted repo) must fail
// the tool call, not be returned as a successful result.
let status = resp.status();
let body: Value = resp.json().await?;
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("webhook_list failed ({status}): {msg}");
}
Ok(serde_json::to_string_pretty(&body)?)
}
"webhook_delete" => {
@@ -1825,6 +1845,187 @@ mod tests {
assert_eq!(parsed["body"], "looks good");
}
#[tokio::test]
async fn test_webhook_list_via_mcp_signs_the_request() {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
// /hooks is owner-gated, so the MCP webhook_list tool must send a SIGNED
// request (get_signed). Requiring the header catches a regression to get().
// Passing `owner` in args makes resolve_owner skip the node-root lookup.
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[],"count":0}"#)
.create_async()
.await;
let result = call_tool(
"webhook_list",
json!({"owner": "alice", "repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
assert!(result.contains("webhooks"), "got: {result}");
}
#[tokio::test]
async fn test_webhook_list_default_owner_is_keypair_not_node_did() {
// When `owner` is omitted, the owner segment must come from the signing
// keypair's short DID, NOT the node root DID. The "/" mock returns a
// different DID; the /hooks mock only matches the keypair's short DID,
// so a regression to the node DID would leave the request unmatched.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _root = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"did":"did:key:z6MkNodeRootDid"}"#)
.create_async()
.await;
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"/api/v1/repos/{short}/myrepo/hooks$")),
)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[],"count":0}"#)
.expect(1)
.create_async()
.await;
let result = call_tool(
"webhook_list",
json!({"repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
assert!(result.contains("webhooks"), "got: {result}");
_m.assert_async().await;
}
#[tokio::test]
async fn test_webhook_list_explicit_owner_overrides_keypair() {
// An explicit `owner` arg must win over the keypair default and the node
// DID. The path must use the supplied owner; "/" must not even be hit.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/repos/someoneelse/myrepo/hooks$".to_string()),
)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[],"count":0}"#)
.expect(1)
.create_async()
.await;
let result = call_tool(
"webhook_list",
json!({"repo": "myrepo", "owner": "someoneelse"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
assert!(result.contains("webhooks"), "got: {result}");
_m.assert_async().await;
}
#[tokio::test]
async fn test_webhook_list_non_success_status_errors() {
// A 403 (or any non-2xx) JSON error body for a missing identity, wrong
// owner, or private/deleted repo must fail the tool call, NOT be returned
// as a successful result. Mirrors the `gl webhook list` status check.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"only the repo owner can perform this action"}"#)
.create_async()
.await;
let err = call_tool(
"webhook_list",
json!({"owner": "alice", "repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.expect_err("non-2xx must error, not return the error body as success");
let msg = err.to_string();
assert!(msg.contains("webhook_list failed (403"), "got: {msg}");
assert!(
msg.contains("only the repo owner"),
"must surface the node message, got: {msg}"
);
}
#[tokio::test]
async fn test_webhook_list_without_keypair_errors() {
// No identity loaded and no explicit owner → the tool must error rather
// than fall back to the node DID and issue an unsigned, mis-scoped GET.
let server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty: no identity.pem
let err = call_tool(
"webhook_list",
json!({"repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.expect_err("must error without an identity");
assert!(err.to_string().contains("no identity found"), "got: {err}");
}
#[test]
fn test_tool_count_is_42() {
let tools = tool_definitions();
+9 -2
View File
@@ -154,10 +154,12 @@ async fn cmd_remove(
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_owner_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, None);
// Read-visibility-gated on the node: public repos list anonymously, private
// repos need the owner's signature. Sign when an identity is available.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let resp = client
.get(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.await
.context("failed to connect to node")?;
@@ -307,6 +309,9 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":[],"count":0}"#)
@@ -338,6 +343,8 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
+431 -34
View File
@@ -146,6 +146,8 @@ pub enum RepoCmd {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
}
@@ -203,7 +205,7 @@ pub async fn run(args: RepoArgs) -> Result<()> {
RepoCmd::ReplicaUnregister { repo, node, dir } => {
cmd_replica_unregister(repo, node, dir).await
}
RepoCmd::Replicas { repo, node } => cmd_replicas(repo, node).await,
RepoCmd::Replicas { repo, node, dir } => cmd_replicas(repo, node, dir).await,
}
}
@@ -329,7 +331,10 @@ async fn cmd_clone(name: String, node: String, dir: Option<PathBuf>) -> Result<(
}
async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let client = NodeClient::new(&node, None);
// Sign when an identity is available so the read-visibility-gated replica
// sub-fetch below resolves for a private repo's owner (public repos still
// work anonymously).
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let (owner, name) = if repo.contains('/') {
let (o, n) = repo.split_once('/').unwrap();
@@ -339,7 +344,10 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
(short, repo)
};
let resp = client.get(&format!("/api/v1/repos/{owner}/{name}")).await?;
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
.await
.context("failed to connect to node")?;
// A non-existent (or unreadable/quarantined) repo is a real 404 from the
// node — surface it plainly instead of printing a stub card with `?` fields
// and a placeholder owner DID.
@@ -379,7 +387,7 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
// Replica count — failure to fetch is non-fatal (older nodes don't expose this).
if let Ok(resp) = client
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
{
if resp.status().is_success() {
@@ -459,15 +467,17 @@ async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>
Ok(())
}
async fn cmd_replicas(repo: String, node: String) -> Result<()> {
async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let client = NodeClient::new(&node, None);
// Read-visibility-gated: public repos list anonymously, private repos need
// the owner's signature. Sign when an identity is available.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let resp = client
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
.context("failed to connect to node")?;
let status = resp.status();
@@ -585,7 +595,7 @@ async fn cmd_fork(
}
/// Returns (owner, repo_name) — if repo contains '/', splits on it; otherwise uses caller's DID.
async fn resolve_owner_repo_pair(
pub(crate) async fn resolve_owner_repo_pair(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
@@ -658,16 +668,23 @@ async fn cmd_label_remove(
async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
// Read-visibility-gated like the sibling read surfaces (replicas, protected
// branches): sign when an identity is available so a private-repo owner can
// read their own labels, while public repos stay anonymously listable.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let resp: Value = client
.get_authed(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await?
.json()
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await
.context("invalid JSON")?;
.context("failed to connect to node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("list labels failed ({status}): {msg}");
}
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
let labels = body["labels"].as_array().cloned().unwrap_or_default();
if labels.is_empty() {
println!("No labels on {owner}/{name}");
} else {
@@ -679,17 +696,39 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
Ok(())
}
/// Parse the protected-branch list from the node's `GET /branches/protected`
/// response. The endpoint returns `{"protected_branches": [...], "count": N}`
/// (see `crates/gitlawb-node/src/api/protect.rs`); each entry may be a plain
/// branch-name string or an object carrying a `name` field.
fn parse_protected_branches(val: &Value) -> Vec<String> {
val["protected_branches"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|b| {
b.as_str()
.map(String::from)
.or_else(|| b["name"].as_str().map(String::from))
})
.collect()
})
.unwrap_or_default()
}
async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: bool) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let my_did = keypair.did().to_string();
let my_short = my_did.split(':').next_back().unwrap_or(&my_did).to_string();
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, None);
// Sign with the loaded identity so the read-visibility-gated protected-branch
// fetch below works on the owner's own private repos.
let client = NodeClient::new(&node, Some(keypair));
// Fetch repo info
// Fetch repo info (get_repo is read-visibility-gated; sign so the owner can
// inspect their own private repo).
let resp = client
.get(&format!("/api/v1/repos/{owner}/{name}"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
.await
.context("failed to connect to node")?;
if !resp.status().is_success() {
@@ -704,25 +743,14 @@ async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: b
.to_string();
let is_owner = my_did == owner_did || my_short == owner_short;
// Fetch protected branches
// Fetch protected branches (read-visibility-gated; signed via the client).
let protected: Vec<String> = match client
.get(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.await
{
Ok(resp) if resp.status().is_success() => {
let val: Value = resp.json().await.unwrap_or_default();
val.as_array()
.or_else(|| val["branches"].as_array())
.map(|arr| {
arr.iter()
.filter_map(|b| {
b.as_str()
.map(String::from)
.or_else(|| b["name"].as_str().map(String::from))
})
.collect()
})
.unwrap_or_default()
parse_protected_branches(&val)
}
_ => Vec::new(),
};
@@ -760,6 +788,20 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_protected_branches_reads_node_shape() {
use serde_json::json;
assert_eq!(
parse_protected_branches(&json!({"protected_branches":["main","release"],"count":2})),
vec!["main".to_string(), "release".to_string()]
);
assert!(parse_protected_branches(&json!({"count":0})).is_empty());
assert_eq!(
parse_protected_branches(&json!({"protected_branches":[{"name":"main"}]})),
vec!["main".to_string()]
);
}
fn write_identity(dir: &TempDir) {
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
@@ -1097,6 +1139,10 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
)
// Identity is loaded, so get_maybe_signed must sign — requiring the
// header guards against a regression back to bare get().
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"labels":[]}"#)
@@ -1123,6 +1169,10 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
)
// Identity is loaded, so get_maybe_signed must sign — requiring the
// header guards against a regression back to bare get().
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"labels":["language:rust","topic:defi"]}"#)
@@ -1138,6 +1188,97 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn test_cmd_label_list_anonymous_when_no_identity() {
// Empty dir → no identity → get_maybe_signed must fall back to an
// anonymous GET (the public-repo path). Assert NO signature header.
let dir = TempDir::new().unwrap();
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/labels$".to_string()))
.match_header("signature", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"labels":[]}"#)
.create_async()
.await;
cmd_label_list(
"owner/pubrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_label_list_404_errors() {
// A non-reader of a private repo gets 404 from the now-gated endpoint.
// The client must surface that as an error, not print "No labels" and
// exit 0 (the error body has no `labels` key).
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.create_async()
.await;
let err = cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("list labels failed (404")
&& err.to_string().contains("repo not found"),
"got: {err}"
);
}
#[tokio::test]
async fn test_cmd_label_list_non_json_error_surfaces_status() {
// A non-2xx response with a non-JSON body (e.g. a proxy 502) must still
// surface the HTTP status, not collapse to a bare JSON-parse error.
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
)
.with_status(502)
.with_header("content-type", "text/html")
.with_body("<html>502 Bad Gateway</html>")
.create_async()
.await;
let err = cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("list labels failed (502"),
"got: {err}"
);
}
#[tokio::test]
async fn test_cmd_owner_is_owner() {
let dir = TempDir::new().unwrap();
@@ -1148,11 +1289,15 @@ mod tests {
let short = did.split(':').next_back().unwrap().to_string();
let mut server = mockito::Server::new_async().await;
// Both fetches are read-visibility-gated; with an identity loaded they
// must be signed. Requiring the header guards a regression to get().
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/api/v1/repos/{short}/myrepo$")),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
@@ -1167,9 +1312,11 @@ mod tests {
r"^/api/v1/repos/{short}/myrepo/branches/protected$"
)),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"["main"]"#)
.with_body(r#"{"protected_branches":["main"],"count":1}"#)
.create_async()
.await;
@@ -1210,7 +1357,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"["main","release"]"#)
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
.create_async()
.await;
@@ -1251,7 +1398,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"["main"]"#)
.with_body(r#"{"protected_branches":["main"],"count":1}"#)
.create_async()
.await;
@@ -1292,4 +1439,254 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
#[tokio::test]
async fn test_cmd_replicas_signs_when_identity_present() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// Read-visibility-gated: with an identity the request must be signed.
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repo":"owner/myrepo","replica_count":0,"replicas":[]}"#)
.create_async()
.await;
cmd_replicas(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_replicas_anonymous_when_no_identity() {
// Empty dir → no identity → get_maybe_signed must fall back to an
// anonymous GET (the public-repo path). Assert NO signature header.
let dir = TempDir::new().unwrap();
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
.match_header("signature", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repo":"owner/pubrepo","replica_count":0,"replicas":[]}"#)
.create_async()
.await;
cmd_replicas(
"owner/pubrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_info_signs_repo_and_replica_fetches() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// Both the primary repo fetch and the replica sub-fetch are
// read-visibility-gated; with an identity loaded both must be signed.
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"id":"r1","name":"myrepo","owner_did":"did:key:zOwner","is_public":false,"default_branch":"main"}"#,
)
.create_async()
.await;
let _replicas = server
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"replica_count":0,"replicas":[]}"#)
.create_async()
.await;
cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_info_404_surfaces_error_not_fake_repo() {
// A non-reader of a private repo gets a JSON 404 from the now-gated
// GET /api/v1/repos/{owner}/{name}. The command must surface that as an
// error, not parse the error body and print a fabricated repo summary
// (owner / "?" / public=true / main) before exiting 0.
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.create_async()
.await;
let err = cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
// The merged cmd_info maps a 404 to a generic "not found" (it does not
// echo the server's message body on 404), which still surfaces the error
// rather than fabricating a repo card, and avoids leaking any
// server-provided detail on a private-repo denial.
assert!(
err.to_string()
.contains("repository 'owner/myrepo' not found"),
"got: {err}"
);
}
#[tokio::test]
async fn test_cmd_info_non_json_error_surfaces_status() {
// A non-2xx response with a non-JSON body (e.g. a proxy 502) must still
// surface the HTTP status, not collapse to a bare JSON-parse error.
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.with_status(502)
.with_header("content-type", "text/html")
.with_body("<html>502 Bad Gateway</html>")
.create_async()
.await;
let err = cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("repo info failed (502"),
"got: {err}"
);
}
}
// Drives the three deny/negative arms that the merge reconciliation changed but
// that no existing test executed (they were only reasoned): resolve_owner_did
// with no identity, cmd_info on an authenticated-but-denied 403, and
// get_maybe_signed with no keypair. Each asserts the must-not case.
#[cfg(test)]
mod vet_merge_deny_arms {
use super::*;
use tempfile::TempDir;
fn write_identity(dir: &TempDir) {
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
}
// No local identity must hard-error, never fall back to the node's own DID.
// (Load-bearing: #113's pre-merge resolve_owner_did returned Ok via a node
// fetch here; a reintroduced fallback makes this go red.)
#[tokio::test]
async fn resolve_owner_did_no_identity_errors_not_fallback() {
let empty = TempDir::new().unwrap(); // no identity.pem written
let err = resolve_owner_did("http://unused.invalid", Some(empty.path()))
.await
.unwrap_err();
assert!(
err.to_string()
.contains("no local identity to resolve the repo owner"),
"got: {err}"
);
}
// An authenticated caller DENIED with a 403 must surface an error, never
// render a repo card (deny-as-success is the INV-8 client-contract break).
#[tokio::test]
async fn cmd_info_403_denied_surfaces_error_not_card() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"forbidden"}"#)
.create_async()
.await;
let err = cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("repo info failed (403"),
"got: {err}"
);
}
// No keypair must send an unsigned GET; the mock only matches when the
// Signature header is absent, so a wrongly-signed request fails to match.
#[tokio::test]
async fn get_maybe_signed_no_keypair_sends_unsigned() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/repos/owner/pub")
.match_header("signature", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("{}")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.get_maybe_signed("/api/v1/repos/owner/pub")
.await
.unwrap();
assert!(
resp.status().is_success(),
"unsigned GET should match the Missing-signature mock; got {}",
resp.status()
);
}
}
+52 -13
View File
@@ -59,7 +59,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
println!(" repo {short_did}/{repo}");
}
None => {
println!(" repo (not in a gitlawb repo — no gitlawb:// origin)");
println!(" repo (not in a gitlawb repo — no gitlawb:// remote)");
}
}
@@ -143,29 +143,68 @@ fn trust_bar(score: f64) -> String {
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}
/// Parse `gitlawb://<did>/<repo>` from the git origin remote.
/// Find a `gitlawb://<did>/<repo>` remote in the current repo.
///
/// Checks `origin` first (clones), then `gitlawb` (the name `gl init` adds),
/// then any other remote with a gitlawb:// URL.
fn detect_gitlawb_remote() -> Option<(String, String)> {
let out = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.args(["remote"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() {
return None;
let names: Vec<String> = String::from_utf8(out.stdout)
.ok()?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
let preferred = ["origin", "gitlawb"];
let ordered = preferred
.iter()
.filter(|p| names.iter().any(|n| n == *p))
.map(|p| p.to_string())
.chain(
names
.iter()
.filter(|n| !preferred.contains(&n.as_str()))
.cloned(),
);
for name in ordered {
// A remote can carry several URLs, and the gitlawb:// one may be a
// push URL behind an https fetch URL — check every fetch and push URL.
for extra in [&["--all"][..], &["--push", "--all"][..]] {
let mut args = vec!["remote", "get-url"];
args.extend_from_slice(extra);
args.push(&name);
let Ok(out) = std::process::Command::new("git")
.args(&args)
.stderr(std::process::Stdio::null())
.output()
else {
continue;
};
if !out.status.success() {
continue;
}
let Ok(urls) = String::from_utf8(out.stdout) else {
continue;
};
if let Some(parsed) = urls.lines().find_map(parse_gitlawb_url) {
return Some(parsed);
}
}
}
Some((did, repo))
None
}
/// Parse a gitlawb:// URL string into (did, repo) — extracted for testing.
#[cfg(test)]
/// Parse a gitlawb:// URL string into (did, repo).
fn parse_gitlawb_url(url: &str) -> Option<(String, String)> {
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
+11 -13
View File
@@ -132,23 +132,11 @@ async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
/// `char::is_control` does not cover — they can reorder the displayed line).
fn sanitize_node_msg(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && !is_bidi_format(*c))
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
.take(200)
.collect()
}
/// Unicode bidirectional and directional-isolate format characters (category
/// `Cf`). These are not `char::is_control()` (that is category `Cc` only), but a
/// right-to-left override or isolate can visually reorder a terminal line to
/// spoof the error text, so they are stripped alongside the control bytes.
fn is_bidi_format(c: char) -> bool {
matches!(c,
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -293,6 +281,16 @@ mod tests {
assert_eq!(sanitize_node_msg(&long).chars().count(), 200);
}
#[test]
fn sanitize_preserves_legitimate_and_rtl_text() {
// Must not over-strip: a plain word, a genuine RTL SCRIPT letter (Arabic
// U+0627, category Lo — NOT a format char), and ZWJ (U+200D, a legitimate
// Cf char, e.g. emoji sequences) all survive. Guards the shared predicate
// against being widened into a blanket Cf stripper.
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
assert_eq!(out, "ok \u{0627}\u{200D}b");
}
#[tokio::test]
async fn trigger_handles_oversized_error_body_without_unbounded_output() {
// A hostile/broken node returns a 2 MB error body. The command must still
+169 -26
View File
@@ -42,6 +42,8 @@ pub enum WebhookCmd {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Delete a webhook
Delete {
@@ -66,7 +68,7 @@ pub async fn run(args: WebhookArgs) -> Result<()> {
node,
dir,
} => cmd_create(repo, url, events, secret, node, dir).await,
WebhookCmd::List { repo, node } => cmd_list(repo, node).await,
WebhookCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
WebhookCmd::Delete {
repo,
id,
@@ -76,15 +78,6 @@ pub async fn run(args: WebhookArgs) -> Result<()> {
}
}
async fn resolve_owner(client: &NodeClient) -> Result<String> {
let info: Value = client.get("/").await?.json().await?;
let did = info["did"]
.as_str()
.context("node missing DID")?
.to_string();
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
}
async fn cmd_create(
repo: String,
url: String,
@@ -94,8 +87,8 @@ async fn cmd_create(
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let owner = resolve_owner(&client).await?;
let event_list: Vec<&str> = events.split(',').map(str::trim).collect();
@@ -106,7 +99,7 @@ async fn cmd_create(
}))?;
let resp = client
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &payload)
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
.await
.context("failed to connect to node")?;
let status = resp.status();
@@ -143,18 +136,31 @@ async fn cmd_create(
Ok(())
}
async fn cmd_list(repo: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let owner = resolve_owner(&client).await?;
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
// The node owner-gates GET /hooks (callback URLs are owner-secret), so the
// list request must be signed — anonymous callers get 401.
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.json()
.await
.context("invalid JSON")?;
// get_signed (not get) attaches the RFC 9421 signature — plain get() never
// signs, and the node owner-gates this route, so an unsigned GET 401s.
let resp = client
.get_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
.await?;
let status = resp.status();
let body: Value = resp.json().await.context("invalid JSON")?;
let hooks = resp["webhooks"].as_array().cloned().unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("list webhooks failed ({status}): {msg}");
}
let hooks = body
.get("webhooks")
.and_then(Value::as_array)
.cloned()
.context("node response missing webhooks array")?;
if hooks.is_empty() {
println!("No webhooks for {repo}");
return Ok(());
@@ -185,13 +191,13 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
async fn cmd_delete(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let owner = resolve_owner(&client).await?;
let payload = serde_json::to_vec(&serde_json::json!({}))?;
let resp = client
.delete(
&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"),
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
&payload,
)
.await
@@ -337,33 +343,170 @@ mod tests {
#[tokio::test]
async fn test_list_webhooks_empty() {
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
// The route is owner-gated, so the list request must be signed.
// Requiring the header here is what catches a regression to plain get().
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[]}"#)
.create_async()
.await;
cmd_list("my-repo".to_string(), server.url()).await.unwrap();
cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_list_webhooks_with_items() {
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[{"id":"h1","url":"https://a.com","events":["push"],"active":true},{"id":"h2","url":"https://b.com","events":["*"],"active":false}]}"#)
.create_async()
.await;
cmd_list("my-repo".to_string(), server.url()).await.unwrap();
cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_list_owner_comes_from_keypair_not_node_did() {
// The owner segment must be derived from the caller's keypair (loaded
// from --dir), NOT the node root DID. mock_root returns a different DID;
// if the code regressed to using it, this path mock would not match.
let mut server = mockito::Server::new_async().await;
let (dir, kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"/api/v1/repos/{short}/my-repo/hooks$")),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[]}"#)
.expect(1)
.create_async()
.await;
cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
_m.assert_async().await;
}
#[tokio::test]
async fn test_list_honors_owner_repo_arg() {
// An explicit `owner/repo` argument is split and used verbatim.
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/repos/someoneelse/their-repo/hooks$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[]}"#)
.expect(1)
.create_async()
.await;
cmd_list(
"someoneelse/their-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
_m.assert_async().await;
}
#[tokio::test]
async fn test_list_webhooks_non_success_status_errors() {
// A 403 (or any non-2xx) JSON body has no `webhooks` field; cmd_list must
// surface the failure, not print "No webhooks" and exit 0.
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not the owner"}"#)
.create_async()
.await;
let err = cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(err.to_string().contains("not the owner"));
}
#[tokio::test]
async fn test_list_webhooks_2xx_missing_array_errors() {
// A 2xx body without a `webhooks` array signals a node/API contract
// regression. cmd_list must error rather than print "No webhooks".
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.create_async()
.await;
let err = cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(err.to_string().contains("missing webhooks array"));
}
// ── delete ───────────────────────────────────────────────────────
+4
View File
@@ -15,3 +15,7 @@ serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
mockito = "1"
+167 -20
View File
@@ -19,6 +19,7 @@ use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use serde_json::json;
pub mod pow;
pub mod solvers;
/// Default iCaptcha service base URL (used when the node doesn't advertise one).
@@ -63,6 +64,12 @@ fn sanitize_excerpt(s: &str) -> String {
}
continue;
}
if gitlawb_core::sanitize::is_bidi_format(ch) {
// Drop Cf bidi/format controls (INV-6, #183) without setting
// pending_space: they are not whitespace, so stripping leaves no
// phantom gap.
continue;
}
if ch.is_whitespace() {
pending_space = true;
continue;
@@ -81,18 +88,42 @@ fn sanitize_excerpt(s: &str) -> String {
out
}
/// Whether `u` parses as an `https` URL.
/// Whether `u` parses as a trusted URL.
///
/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a
/// runtime escape hatch for integration tests) the function also trusts
/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path
/// can be exercised against a local mockito server.
fn is_https(u: &str) -> bool {
reqwest::Url::parse(u)
.map(|p| p.scheme() == "https")
.unwrap_or(false)
let Ok(parsed) = reqwest::Url::parse(u) else {
return false;
};
if parsed.scheme() == "https" {
return true;
}
if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
&& parsed.scheme() == "http"
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
{
return true;
}
false
}
/// Lowercased host of a URL, if it parses and has one.
fn host_of(u: &str) -> Option<String> {
reqwest::Url::parse(u)
.ok()
.and_then(|p| p.host_str().map(|h| h.to_ascii_lowercase()))
/// Lowercased origin string (`scheme://host[:port]`) of a URL.
///
/// Includes the effective port so that `http://localhost:3000` and
/// `http://localhost:9000` are treated as distinct origins — a hostile node
/// cannot redirect the iCaptcha solve to a different loopback port.
fn origin_key(u: &str) -> Option<String> {
let parsed = reqwest::Url::parse(u).ok()?;
let scheme = parsed.scheme();
let host = parsed.host_str()?.to_ascii_lowercase();
let port = parsed
.port_or_known_default()
.map(|p| format!(":{p}"))
.unwrap_or_default();
Some(format!("{scheme}://{host}{port}"))
}
/// Decide which iCaptcha origin to actually talk to, and whether it is trusted
@@ -109,12 +140,13 @@ fn host_of(u: &str) -> Option<String> {
/// exfiltrated to an origin the operator did not choose.
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
let operator = operator.filter(|u| is_https(u));
let operator_host = operator.and_then(host_of);
let default_host = host_of(DEFAULT_URL);
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
let default_origin = origin_key(DEFAULT_URL);
let allowed = |host: &Option<String>| -> bool {
host.as_ref()
.map(|h| Some(h) == default_host.as_ref() || Some(h) == operator_host.as_ref())
let allowed = |origin: &Option<String>| -> bool {
origin
.as_ref()
.map(|o| Some(o) == default_origin.as_ref() || Some(o) == operator_origin.as_ref())
.unwrap_or(false)
};
@@ -125,7 +157,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
};
let chosen = match advertised {
Some(a) if is_https(a) && allowed(&host_of(a)) => a.to_string(),
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
Some(a) => {
tracing::warn!(
advertised = %a,
@@ -137,7 +169,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
};
// Key goes only to the operator's own configured origin.
let key_trusted = operator_host.is_some() && host_of(&chosen) == operator_host;
let key_trusted = operator_origin.is_some() && origin_key(&chosen) == operator_origin;
(chosen, key_trusted)
}
@@ -201,6 +233,10 @@ pub struct Challenge {
pub difficulty: u32,
pub prompt: String,
pub token: String,
/// Proof-of-work to solve and echo back as `powNonce` on the answer. Present
/// only when the service has PoW enabled; absent/None means no PoW required.
#[serde(default)]
pub pow: Option<pow::PowChallenge>,
}
#[derive(Debug, Deserialize)]
@@ -237,7 +273,28 @@ pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result<String
)
})?;
match submit_answer(&client, cfg, &challenge.token, &answer)? {
// Solve the proof-of-work bound to this challenge, if the service
// requires one. A required-but-unsolvable PoW (unknown algorithm or a
// difficulty above our cap) is a hard error — submitting without it
// would just be rejected.
let pow_nonce = match &challenge.pow {
Some(p) => Some(pow::solve(p).ok_or_else(|| {
anyhow!(
"cannot solve iCaptcha proof-of-work (algorithm '{}', difficulty {})",
p.algorithm,
p.difficulty
)
})?),
None => None,
};
match submit_answer(
&client,
cfg,
&challenge.token,
&answer,
pow_nonce.as_deref(),
)? {
AnswerResult::Passed { proof } => return Ok(proof),
AnswerResult::Continue { challenge: next } => challenge = next,
AnswerResult::Failed { reason } => {
@@ -273,11 +330,14 @@ fn submit_answer(
cfg: &IcaptchaCfg,
token: &str,
answer: &str,
pow_nonce: Option<&str>,
) -> Result<AnswerResult> {
let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/'));
let mut req = client
.post(&url)
.json(&json!({ "token": token, "answer": answer }));
let mut body = json!({ "token": token, "answer": answer });
if let Some(nonce) = pow_nonce {
body["powNonce"] = json!(nonce);
}
let mut req = client.post(&url).json(&body);
if let Some(key) = &cfg.api_key {
req = req.bearer_auth(key);
}
@@ -320,6 +380,37 @@ fn interactive_prompt(challenge: &Challenge) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes tests that touch the process-global
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
/// prior value on drop.
struct InsecureEnv {
_lock: MutexGuard<'static, ()>,
prev: Option<OsString>,
}
impl InsecureEnv {
fn new() -> Self {
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
InsecureEnv { _lock: lock, prev }
}
}
impl Drop for InsecureEnv {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
}
}
}
// ── P1: hostile error bodies must not reach the terminal raw ──────────
@@ -336,6 +427,34 @@ mod tests {
assert!(clean.contains("owned") && clean.contains("done"));
}
#[test]
fn sanitize_strips_bidi_format_controls() {
// The Cc-only test above is vacuous for Cf: it passes whether or not bidi
// controls survive, which is why this gap shipped (#183). Here every Cf
// bidi class is stripped — override (RLO), embedding + pop (LRE/PDF),
// isolate (LRI/PDI), marks (LRM/ALM) — asserted per code point, and the
// visible words JOIN with no phantom space (a bidi char is dropped like a
// control byte, but unlike a newline/tab it is not whitespace). The leading
// bidi char (index 0) also exercises the empty-out path.
let out = sanitize_excerpt("\u{202e}err\u{202a}\u{202c}\u{2066}\u{2069}\u{200e}\u{061c}ok");
for c in [
'\u{202e}', '\u{202a}', '\u{202c}', '\u{2066}', '\u{2069}', '\u{200e}', '\u{061c}',
] {
assert!(!out.contains(c), "bidi U+{:04X} leaked: {out:?}", c as u32);
}
assert_eq!(out, "errok");
}
#[test]
fn sanitize_preserves_legitimate_and_rtl_text() {
// Must not over-strip: plain text, a genuine RTL SCRIPT letter (Arabic
// U+0627, category Lo), and ZWJ (U+200D, a legitimate Cf char) survive.
assert_eq!(
sanitize_excerpt("ok \u{0627}\u{200D}b"),
"ok \u{0627}\u{200D}b"
);
}
#[test]
fn sanitize_collapses_whitespace_and_caps_length() {
let collapsed = sanitize_excerpt("a\n\n\t b");
@@ -344,6 +463,18 @@ mod tests {
assert_eq!(sanitize_excerpt(&long).chars().count(), MAX_ERR_CHARS);
}
#[test]
fn sanitize_bidi_chars_do_not_consume_the_length_cap() {
// A dropped bidi char must not count toward MAX_ERR_CHARS: it is dropped
// before `kept` increments, so interleaving bidi controls cannot truncate
// the visible output early. MAX_ERR_CHARS + 100 visible 'x', each trailed by
// a bidi override, must still yield exactly MAX_ERR_CHARS visible 'x' with
// no phantom spaces. (Would regress to fewer 'x' if bidi consumed budget, or
// to spaces if the drop branch wrongly set pending_space.)
let body = "x\u{202e}".repeat(MAX_ERR_CHARS + 100);
assert_eq!(sanitize_excerpt(&body), "x".repeat(MAX_ERR_CHARS));
}
// ── P2: never talk to (or hand a key to) an untrusted node-chosen URL ──
#[test]
@@ -403,4 +534,20 @@ mod tests {
assert_eq!(url, DEFAULT_URL);
assert!(!key_trusted);
}
#[test]
fn rejects_insecure_advertised_url_on_different_port() {
// With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different
// ports must be treated as distinct origins so a hostile node cannot
// redirect the bearer key to a different listener on localhost.
let _env = InsecureEnv::new();
let op = "http://localhost:3000";
let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op));
assert_eq!(
url, op,
"must fall back to operator origin, not use different port"
);
assert!(key_trusted, "key stays trusted with operator origin");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Proof-of-work solver for the iCaptcha answer step.
//!
//! iCaptcha requires a small proof-of-work with each answer so that minting a
//! proof costs CPU — a per-proof cost a distributed abuser cannot dodge by
//! rotating IPs. The work is bound to the per-challenge id: find a `nonce` such
//! that `sha256("{challenge}:{nonce}")` has at least `difficulty` leading zero
//! bits. This mirrors `icaptcha/src/pow.ts`.
//!
//! At the service default (~20 bits) this is well under a second for a single
//! solve; we still cap the search so a hostile/misconfigured difficulty can
//! never hang the client.
use sha2::{Digest, Sha256};
/// PoW parameters advertised by the service in a challenge (mirrors the
/// service's `PowChallenge`).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PowChallenge {
/// Only `sha256-leading-zero-bits` is understood; other algorithms are
/// rejected by [`solve`] rather than silently mis-solved.
pub algorithm: String,
/// The string to hash against (the per-challenge id).
pub challenge: String,
/// Required leading zero bits.
pub difficulty: u32,
}
/// Hard cap on nonce search iterations. 2^26 ≈ 67M keeps a ~20-bit target (~1M
/// expected hashes) comfortably solvable while bounding worst-case work if the
/// service ever advertises an unexpectedly high difficulty.
const MAX_ITERS: u64 = 1 << 26;
const ALGORITHM: &str = "sha256-leading-zero-bits";
/// Leading zero bits of a byte slice.
fn leading_zero_bits(bytes: &[u8]) -> u32 {
let mut bits = 0;
for &b in bytes {
if b == 0 {
bits += 8;
} else {
bits += b.leading_zeros();
break;
}
}
bits
}
/// sha256(`{challenge}:{nonce}`).
fn pow_hash(challenge: &str, nonce: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(challenge.as_bytes());
hasher.update(b":");
hasher.update(nonce.as_bytes());
hasher.finalize().into()
}
/// Solve the PoW, returning a nonce whose hash meets the difficulty. Returns
/// `None` for an unknown algorithm or if no nonce is found within the cap (a
/// difficulty far above what the service uses in practice).
pub fn solve(pow: &PowChallenge) -> Option<String> {
if pow.algorithm != ALGORITHM {
tracing::warn!(algorithm = %pow.algorithm, "unknown iCaptcha PoW algorithm; cannot solve");
return None;
}
if pow.difficulty == 0 {
return Some("0".to_string());
}
for i in 0..MAX_ITERS {
let nonce = format!("{i:x}");
if leading_zero_bits(&pow_hash(&pow.challenge, &nonce)) >= pow.difficulty {
return Some(nonce);
}
}
tracing::warn!(
difficulty = pow.difficulty,
max_iters = MAX_ITERS,
"iCaptcha PoW not solved within iteration cap"
);
None
}
#[cfg(test)]
mod tests {
use super::*;
fn pow(challenge: &str, difficulty: u32) -> PowChallenge {
PowChallenge {
algorithm: ALGORITHM.to_string(),
challenge: challenge.to_string(),
difficulty,
}
}
#[test]
fn leading_zero_bits_counts_across_bytes() {
assert_eq!(leading_zero_bits(&[0xff]), 0);
assert_eq!(leading_zero_bits(&[0x7f]), 1);
assert_eq!(leading_zero_bits(&[0x01]), 7);
assert_eq!(leading_zero_bits(&[0x00, 0xff]), 8);
assert_eq!(leading_zero_bits(&[0x00, 0x01]), 15);
}
#[test]
fn solves_and_the_solution_verifies() {
let p = pow("challenge-abc", 12);
let nonce = solve(&p).expect("should solve a 12-bit target");
assert!(leading_zero_bits(&pow_hash(&p.challenge, &nonce)) >= 12);
}
#[test]
fn solution_is_bound_to_the_challenge() {
// A nonce solving one challenge should (overwhelmingly) not solve another
// at the same difficulty — the work is challenge-specific.
let nonce = solve(&pow("challenge-A", 12)).unwrap();
assert!(leading_zero_bits(&pow_hash("challenge-B", &nonce)) < 12);
}
#[test]
fn rejects_unknown_algorithm() {
let mut p = pow("c", 8);
p.algorithm = "scrypt".to_string();
assert!(solve(&p).is_none());
}
#[test]
fn zero_difficulty_is_trivially_solved() {
assert_eq!(solve(&pow("c", 0)).as_deref(), Some("0"));
}
}
@@ -0,0 +1,94 @@
//! INV-19 guard: the icaptcha flow must actually call the service (consume the
//! mock), not short-circuit or make a live call. If a future change let
//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which
//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go
//! RED because the mocked endpoints were never hit.
//!
//! The mock-consumed assertions alone only prove the mock was hit; they cannot
//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or
//! any other origin (a different host the mock never observes). To make the
//! "no live call" half of the guard load-bearing, the test blackholes every
//! non-loopback destination: `NO_PROXY` lets the loopback mock through while a
//! closed proxy port routes any other host nowhere, so the client contacting
//! anything but the injected mock fails the request instead of silently
//! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED.
//!
//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific
//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so
//! a runner with a working `HTTPS_PROXY` in its environment would route an https
//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback
//! mock is still consumed and this test stayed green. So we blackhole the
//! scheme-specific variables too. No restore is needed: this file has a single
//! `#[test]`, so it runs in its own process and the override never races a
//! sibling test.
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
#[test]
fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
// Blackhole every non-loopback origin: the loopback mock bypasses the proxy
// (NO_PROXY); any other host is forced through a closed port and fails.
// reqwest reads these at client-build time, and obtain_proof builds its
// client fresh, so this bounds the transport for the whole flow.
//
// Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest
// gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so
// an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a
// runner whose environment already has a working HTTPS_PROXY.
let blackhole = "http://127.0.0.1:1";
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
std::env::set_var("no_proxy", "127.0.0.1,localhost");
for var in [
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
] {
std::env::set_var(var, blackhole);
}
let mut server = mockito::Server::new();
// The two endpoints the flow hits, each expected exactly once.
let challenge = server
.mock("POST", "/v1/challenge")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"challengeId":"c1","type":"anagram","difficulty":1,"prompt":"listen","token":"tok-1"}"#,
)
.expect(1)
.create();
let answer = server
.mock("POST", "/v1/answer")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"passed","proof":"PROOF-XYZ"}"#)
.expect(1)
.create();
let cfg = IcaptchaCfg {
url: server.url(),
did: "did:key:zTEST".to_string(),
level: 1,
api_key: None,
};
// "anagram" is not a built-in solvable type, so the flow consults this
// solver callback. The answer body itself does not matter: the mocked
// /v1/answer returns "passed" regardless of what is submitted.
let solve = |_c: &Challenge| Some("silent".to_string());
let solver: &dyn Fn(&Challenge) -> Option<String> = &solve;
let proof = obtain_proof(&cfg, Some(solver))
.expect("obtain_proof should complete against the mocked service");
assert_eq!(proof, "PROOF-XYZ");
// INV-19: prove the client actually called the mocked endpoints. Had it made
// a live call or skipped the network, these would fail (mock never hit).
challenge.assert();
answer.assert();
}
+14
View File
@@ -193,5 +193,19 @@ else
echo " gl quickstart"
fi
# oh-my-zsh's default git plugin aliases gl='git pull', which silently shadows
# the gl binary in every interactive zsh (aliases beat PATH). Detect the common
# setup and say so now, at install time, instead of letting the first `gl`
# print git's baffling "fatal: not a git repository".
if [ -f "$HOME/.zshrc" ] && [ -d "$HOME/.oh-my-zsh/plugins/git" ] \
&& grep -Eq '^[[:space:]]*plugins=\(([^)]*[[:space:]])?git([[:space:]][^)]*)?\)' "$HOME/.zshrc" \
&& ! grep -Eq '^([^#]*[[:space:];&|])?unalias[[:space:]]+([^;&|#]*[[:space:]])?gl([[:space:]]|$)' "$HOME/.zshrc"; then
echo ""
echo "NOTE: oh-my-zsh's git plugin aliases gl='git pull', which will shadow"
echo "the gl command in interactive shells. To use gl by name, run:"
echo ""
echo " echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc"
fi
echo ""
echo "Docs: https://docs.gitlawb.com"