Compare commits

...
Author SHA1 Message Date
beardthelion 26cdedc74d test(node): clear CodeQL alert and restore gossip readiness tests
Two findings from the 2026-09-08 review on head 988e8ea:

1. CodeQL rust/cleartext-logging on assert.rs:118. The panic sink
   interpolated redact_withheld(&reason, withheld), but CodeQL's taint
   analysis traces withheld tokens through redact_withheld without
   recognizing it as a sanitizer. Replace the interpolated panic with a
   fixed string so no tainted data reaches the sink. redact_withheld is
   still called (keeping it live) and its unit tests still pass; only the
   panic message changes. This is a test-only fixture false positive: no
   production path logs secrets.

2. Four gossip readiness tests dropped in the lib+bin split. Merge-base
   main.rs had 7 ping_peer_* tests; head had 3 renamed ping_peer_health_*
   tests. Restore the 4 missing: the /ready 200 direct happy path, the
   /ready 503 + /health 200 must-not-be-ready case, the legacy fallback
   with explicit expect-count, and the shared-deadline probe. No
   production code change; the function signatures are unchanged.

Verified: cargo fmt --check, cargo clippy -- -D warnings, 9 gossip
tests pass, 53 deny_harness tests pass (including assert unit tests).
2026-09-09 13:32:18 -05:00
beardthelion efdd3ce45b fix(node): tighten identity-key parent-dir reload mask and add owner-push socket coverage
The reload fast-path checked only write bits (0o022) while the publish
helper checked all group/world bits (0o077), so a 0755 parent survived
reload but was tightened on publish. Align the reload mask to 0o077 so
the owner-only 0700 invariant holds across restarts, not just first
publish. Adds a regression test proving a 0755 parent is tightened on
reload (RED with the old mask, GREEN after).

Adds real socket coverage for enforce_owner_push on an unprotected
branch, isolating the owner-push gate from branch protection. The
existing protected-branch test could not distinguish which gate fired
the 403.

Updates sweep_failure_tolerated to reflect the tightened reload path: a
0555 parent is now tightened to 0700 before the sweep, so the marker is
removed rather than surviving behind a read-only directory.
2026-09-07 23:07:43 -05:00
beardthelion d0ce3176d1 test(node): restructure deny-harness fixtures to clear CodeQL alerts
Route the anonymous withheld-CID GET through an anon_get helper (matching
the unflagged signed_request pattern) so CodeQL no longer traces the
secret into a single inline HTTP expression. Redact withheld tokens from
the assert_denied panic witness so a leak failure does not itself leak
the secret to stderr. Both are test-only changes; no production code moved.
2026-09-07 22:26:50 -05:00
beardthelion e23a4a0c97 fix(node): close remaining #194 gaps (CI lib, key parent, gossip shutdown)
Run join_or_abort lib tests in the deny-harness CI step, repair
group/world-writable key parents on existing-key load, abort gossip on
HTTP shutdown with per-peer shutdown polling, and log list_peers errors.
2026-08-30 18:22:31 -05:00
beardthelion f00d30c235 fix(node): restore lib/bin boot regressions dropped in the deny-harness extraction
Re-wire Config::validate(), the advisory-lock pool, and gossip peer-reachability
hysteresis on the library boot path, and narrow CodeQL suppressions on fixture-only
harness assertions.
2026-08-30 16:57:23 -05:00
beardthelion be61f69c84 test(node): clear CodeQL false positives on deny harness fixtures
Reuse the signed anon probe path for the loopback GET so the cleartext-
transmission query does not flag secret_cid in the URL builder, and mark
the assert_denied panic as an intentional leak witness for cleartext-logging.
2026-08-29 19:26:12 -05:00
beardthelion 21d4711dac ci(codeql): exclude test paths and drop secret tokens from panic text
CodeQL flagged intentional deny-harness fixtures (HTTP secret_cid probe and
pack listing assertion). Ignore tests and test_support for analysis, remove
an unused import, and keep the allow-unbounded-git marker on the harness
git spawn line.
2026-08-29 19:09:47 -05:00
beardthelion c39f0d2bb1 fix(node): close deny harness gaps after main integration
Tighten identity key parent dirs only on publish, extend INV-8 header
scanning, add inline claimant orphan guard, and align gossip ping tests
with the /ready-first readiness probe.
2026-08-29 17:19:00 -05:00
beardthelion df55f66e86 fix(node): apply review fixes for identity key dir and deny harness
Tighten identity key parent directories to 0700 before load/publish,
create fallback publish markers with 0600, and extend U5b /ipfs withhold
coverage with signed non-reader denial and allowlisted-reader grant legs.
Add OwnerGate/ReadGate registry probe-shape tests.
2026-08-29 15:18:31 -05:00
beardthelion 8a4d5a42c1 fix(node): integrate main and repair deny harness after lib+bin split
Merge origin/main (0.7.1 baseline) onto feat/real-node-deny-harness, restore
the thin binary entry and test-harness AppState fields, add --locked to the
deny-harness CI step, and seed pinned_cids with production CIDs from raw
object bytes so /ipfs withhold probes match get_by_cid verification.
2026-08-29 14:32:55 -05:00
beardthelion 306d8efdae fix(node): do not block on a FIFO planted at the identity key path
The non-regular-file refusal added in the previous commit sits after the open,
and opening a FIFO read-only waits for a writer to connect. A named pipe at the
key path therefore hung startup indefinitely instead of being refused, reachable
by the same actor the symlink case assumes. The directory case did not catch it,
since opening a directory fails immediately and does reach the refusal.

Opening with O_NONBLOCK as well makes the open return so the file-type check can
run. It has no effect on a regular file, and a non-regular descriptor never gets
past the check, so nothing needs to clear it afterwards.
2026-08-04 20:54:45 -05:00
beardthelion 8806208ea4 fix(node): open the identity key without following symlinks, and close the temp before removing it
Two findings from the last review.

Loading an existing key ran metadata, set_permissions, a mode verify, and the
read as four separate path lookups, all of which follow symlinks. A key path
inside a writable directory could therefore be pointed at any service-readable
file, and a privileged start would chmod that target to 0600 before it ever
looked at the contents. On Unix the load now opens the path once with O_NOFOLLOW
and does the mode check, the tightening, and the read through that one
descriptor, which refuses a symlink outright and closes the window between the
check and the use. A non-regular file is refused for the same reason. A regular
key with loose bits is still narrowed rather than rejected, since rejecting it
would break existing deployments.

The failed-write path called the cleanup helper while the temp file handle was
still open. Removing an open file fails on Windows, so a write or fsync failure
left the temp behind, and the temp names are deterministic per process id with
64 of them, so a service restarting under a reused pid could exhaust the set and
stay stuck after storage recovered. The handle is now closed first, with fsync
still ahead of it since it needs the live descriptor. A removal that fails is
logged, because it is the precursor to that exhaustion, while the write error
stays the one surfaced to the caller.
2026-08-04 20:28:30 -05:00
beardthelion f12be9f9a9 fix(ci): stub the gitlawb-node lib target in the Docker dependency cache
This branch gives gitlawb-node a lib target alongside its binary, and the
dependency-cache layer only wrote a main.rs stub for it. Cargo resolves every
declared target before it fetches anything, so the layer aborted on the missing
src/lib.rs and took the whole image build with it.

Adds the matching lib.rs stub. The layer deliberately has no `|| true`, so this
was a hard failure rather than a silently cold cache, which is the behavior that
block's comment asks for.
2026-08-04 20:28:30 -05:00
beardthelion 5feda900e5 Merge remote-tracking branch 'origin/main' into feat/real-node-deny-harness 2026-08-03 21:18:16 -05:00
beardthelion 62563fb97b Merge origin/main into feat/real-node-deny-harness (reintegrate current base) 2026-07-22 15:43:11 -05:00
beardthelion 558039021f test(node): close two vacuity holes in the shutdown-completeness guard
An adversarial review flagged that the new every_spawn_node_test_shuts_the_
node_down guard could itself pass vacuously - the exact class it exists to
prevent (INV-21 on the guard).

- A spawn_node test whose only `node.shutdown(` occurrence was a comment
  (`// node.shutdown().await`) satisfied the raw substring check while still
  returning via Drop.
- An unbalanced `{` inside a string literal in a test body could over-extend
  the brace-matched body slice and swallow a later test's shutdown, clearing
  the earlier one (latent: no current test triggers it).

Both are closed by sanitizing the source through a new `code_only` pass
(blanks comment and string/raw-string content, length-preserving) before the
scan sees it, and the offender check is extracted to a pure
spawn_node_tests_missing_shutdown so it can be driven with synthetic sources.
Three adversarial unit tests pin both holes shut; stubbing code_only to a
no-op turns the comment and brace tests RED (verified), so the sanitizer is
load-bearing.

deny_harness suite green (43 tests); clippy clean.
2026-07-22 05:52:59 -05:00
beardthelion e52629b5a1 test(node): harden deny-harness so its guarantees can't pass vacuously
Resolve three review findings on the real-node deny harness, all test-only.

- assert_denied no longer folds a body-read error into an empty body: an
  unreadable denial (a mid-body reset/truncation) now fails loud instead of
  being certified leak-free by a withheld-token scan over "".

- The three sqlx::test cases that returned via TestNode's async Drop
  (signed_stranger_protected_branch_push_is_forbidden,
  get_pr_diff_withheld_path_is_denied,
  deny_bearing_registry_denies_hostile_and_admits_authorized) now end with
  node.shutdown().await, releasing the serve task's pool clones before sqlx's
  synchronous DROP DATABASE cleanup instead of racing it.

- A source-scrape guard, every_spawn_node_test_shuts_the_node_down, makes that
  contract load-bearing for every spawn_node test, with a closed allowlist for
  the two Drop-regression tests that deliberately skip shutdown. Removing any
  one shutdown() turns it RED (verified).

- The eight fixture seeders in support/probe.rs use the bounded client, so a
  hung setup route fails fast rather than running to the CI job timeout.

Full deny_harness suite green (40 tests).
2026-07-22 00:25:10 -05:00
beardthelion 1568694a16 test(node): drive the #113 read/owner gates in the deny-harness fence
After merging main, the completeness fence flagged three repo-scoped list
GETs that #113 gated but the harness still treated as ungated public reads:
list_webhooks (authorize_repo_read + require_repo_owner), list_replicas and
list_protected_branches (authorize_repo_read). The runtime marker scan caught
its own hand-maintained PUBLIC_REPO_GETS list drifting stale.

Drive each real deny instead of exempting it:

- list_replicas and list_protected_branches take Option auth, so they slot in
  as standard ReadGate rows (anon and signed-non-reader both 404, owner 2xx,
  no-leak body).
- list_webhooks is doubly gated and 401s a headerless caller before any
  lookup, so a vanilla ReadGate row's hardcoded anon->404 leg does not fit.
  Add a per-Row anon_read signal (Deny404 default, Deny401 for auth-required
  reads) so its anon leg expects 401 while the signed-non-reader still drives
  the existence-hiding 404. Its owner layer is a sibling OwnerGate row on the
  public repo (signed stranger -> 403). The two share the /hooks path, so the
  consistency dedup key moves from (method, path) to (method, path, gate),
  which still catches an accidental same-gate double-registration.
- Empty PUBLIC_REPO_GETS: the three are now driven rows, classified via the
  driven-path set rather than declared public. The mechanism stays for the
  next genuinely-public repo-GET.

Each new row proven load-bearing: reverting the production gate turns the
matching hostile probe red (403->200 owner, 404->403/200 read), and flipping
list_webhooks' anon_read to Deny404 reddens its anon probe (got 401). Full
deny_harness green (39/39). No production changes.
2026-07-21 21:50:51 -05:00
beardthelion 5a05bae436 Merge remote-tracking branch 'origin/main' into feat/real-node-deny-harness 2026-07-21 21:18:55 -05:00
beardthelion ee140ea181 fix(node): order supersede before consume, retire quarantine forensics, key the waiter on absence
Both Won arms now supersede sibling quarantines before consuming the
chosen one, so a crash in the gap cannot leave an outcompeted key
adoptable. Plain quarantine forensics get the same 24h retention sweep
as superseded files, bounding growth on long-lived key directories. The
demotion waiter drops age logic entirely: it returns Ok only when every
claim file is gone and fails the publish closed while any claim
survives the bound, young or aged, so a heartbeat failure on a hostile
volume can no longer convert a live round into orphan residue mid-wait.
Doc prose reconciled with the shipped wait contract.
2026-07-21 20:51:26 -05:00
beardthelion 524182f0c2 fix(node): supersede on every Lost arm, heartbeat live claims, retire old forensics
Complete the supersede rule across its mirror arms: a lost adoption and
a restore-Won round now both demote every outcompeted adoptable
quarantine, so no arm leaves bytes the fresh-DID procedure could
resurrect. Live recovery rounds now heartbeat their claim mtimes every
quarter of the age floor under an RAII stop-and-join guard, making
age-as-orphan sound: an aged claim provably lost its recoverer, and a
stalled-but-live round on a slow volume can no longer be mistaken for
residue by the waiter, the sweep, or a competing recoverer. Superseded
forensic files are retention-swept after 24 hours, bounding growth on
long-lived key directories.
2026-07-21 20:51:26 -05:00
beardthelion 4fff552809 fix(node): sweep every healthy return, supersede outcompeted quarantines, fail closed on unsettled rounds
Every arm that returns a loaded, adopted, or reloaded key now funnels
through one sweep-then-return closure, so stale markers cannot outlive
any healthy boot path. Quarantines that provably lost their round (a
Lost restore, or siblings of an adopted quarantine) move to an inert
superseded class: bytes preserved as forensics, never adoptable, so the
delete-identity-for-fresh-DID operator procedure can no longer
resurrect an outcompeted key. The demotion waiter fails closed when its
bound expires while a live young claim persists: an unsettled round now
surfaces as a loud failed publish instead of resolving a key that disk
may be about to contradict.
2026-07-21 20:51:26 -05:00
beardthelion 14db9c31d0 fix(node): bound quarantine adoption, expire orphan claims, align the waiter
Adoption of a stranded quarantined identity is now bounded by the claim
age floor, so a current round's completed key is preserved while a
historical forensic quarantine can never resurrect an old DID or defeat
the delete-identity-for-fresh-DID operator procedure. A healthy boot
also schedules one delayed re-sweep after the age floor passes, so
claims spared as possibly-live cannot linger as aged residue that would
let later real corruption of a good final masquerade as a crash state
and silently regenerate. The demotion waiter now shares the sweep's
liveness rule: it waits out claims younger than the age floor rather
than a flat race deadline, and skips aged orphans quickly.
2026-07-21 20:51:26 -05:00
beardthelion 80ffc40774 fix(node): adopt stranded quarantined identities and age-gate claim stealing
A completed key stranded in quarantine by a failed or crash-interrupted
restore is no longer abandoned: the generate arm first scans quarantines
newest-first and republishes the first parseable one through the atomic
publisher, preserving identity continuity instead of silently minting a
fresh DID; unparseable quarantines stay as forensics. Recovering claims
become claimable only past the same age floor the sweep uses, so a
second starter can never steal a live recoverer's round mid-flight; a
claim-crash state recovers after the bounded age floor passes. The
crash-signature persistence doc now states the slow-mount trade-off
plainly: a pre-write window exceeding the floor yields churn that
converges, never divergence.
2026-07-21 20:51:26 -05:00
beardthelion 752dfaa9e6 fix(node): key recovery claims by process nonce and harden the freshness stamp
Two PID-1 containers on a shared identity volume derive identical claim
names, and rename replaces an existing destination, so one recoverer's
release could unlink the other's live claim. Claim names now carry a
per-process nonce between pid and counter, so the release set can never
alias another process's claims. The claim-freshness stamp gains a write
fallback (a one-byte append bumps mtime where set_modified is
unsupported), and its failure warning names the real hazard. Also lock
the vanished-final re-parse arm with a dedicated regression test.
2026-07-21 20:51:26 -05:00
beardthelion 8c5613a2ec fix(node): hand post-write publish failures to boot recovery, stamp claim freshness
A failed write or fsync on the fallback path now removes nothing: the
partial final and its marker stay in place as exactly the crash state
boot recovery claims, quarantines, and regenerates from. That deletes
the stat-then-unlink cleanup whose race could destroy a recoverer's
republished key, and can no longer strand a marker-less partial final
when an unlink fails. Pre-write failures keep removal and disposal;
they complete inside the crash-signature persistence window. Claim
renames also stamp the claim's mtime to now, because rename preserves
the source mtime and an aged stale marker otherwise births a live claim
the healthy-boot sweep would treat as orphaned mid-round.
2026-07-21 20:51:26 -05:00
beardthelion 8f98dad632 fix(node): keep no-clobber on the quarantine restore and scope cleanup to owned inodes
The restore now republishes the quarantined bytes through the atomic
publisher, so every tier refuses an existing final and a concurrent
winner survives; a lost restore keeps the quarantine as forensics and
loads the winner. The fallback's post-write error arms remove the final
only after proving the name still refers to the inode this publish
created, so a failing publisher whose round was claimed away can no
longer unlink the recoverer's republished key. Pre-write arms keep
by-name removal: they complete inside the crash-signature persistence
window, before any recovery can have claimed the round.
2026-07-21 20:51:26 -05:00
beardthelion c0fd330f5b fix(node): protect live recoveries from the sweep and restore hijacked keys
Second-model review findings. The healthy-boot sweep now age-gates
recovering claims (2x the race deadline; a live claim is always
younger), because sweeping a live recovery's claim cancelled a demoted
publisher's clearance wait and reopened two-sided divergence; publishing
markers keep the unconditional sweep since stealing one only forces a
safe demotion. The post-claim re-parse quarantines only on content-class
failures (a transient error there releases the claims and stays loud),
and a key completed inside the re-parse-to-quarantine window is detected
by parsing the quarantined bytes and restored via no-clobber link
instead of being discarded. The crash signature must now persist for
250ms of consecutive observations, above realistic mid-write latency on
shared volumes, so concurrent starts stop hijacking live publishes.
2026-07-21 20:51:26 -05:00
beardthelion 4f761c8bb2 test(node): cover the quarantine-exhaustion arm and pin recovery latency
Add the missing coverage on recover_crashed_publish's degraded arms:
quarantine-name exhaustion must fall back to removing the bad final and
still regenerate, and a blocked removal must surface the chained error
loudly, with the leftover claim proven claimable on the next boot. Route
all marker-class name construction through publishing_prefix and
recovering_prefix as the single source of truth, state the dir-fsync
degradation caveat in publish_key_atomically's guarantee prose, and pin
the crash-signature short-circuit with a timing assertion so recovery
never silently regresses to riding the full race deadline.
2026-07-21 20:51:26 -05:00
beardthelion 37a025b716 fix(node): make recovery claims complete, releasable, and themselves recoverable
Three review findings against the marker protocol. Recovery now claims
every publishing marker and stale recovering claim (per-item bounded
claim names), so a live publisher's own-marker commit check can no
longer falsely succeed while its round is stolen through a stale
marker; a post-claim re-parse aborts to reload when a publisher
completed in the window. Claims count toward the crash signature and
are claimable by rename, so a recoverer crash between claim and
quarantine no longer recreates the permanent wedge. Claims are released
on every exit including publish failures, closing the leak that made
demoted publishers ride the full race deadline on a dead claim. The
crash signature also needs two consecutive observations, giving a live
mid-write publisher one poll of grace before a reader can classify its
round as a crash.
2026-07-21 20:51:26 -05:00
beardthelion ea2db24f24 fix(node): sweep stale key-publish markers on every healthy boot
A marker orphaned between its durability fsync and create_new(final),
or a claim left by a crashed recovery, must not linger to misclassify a
much-later corruption of a good key as an interrupted first write. Both
healthy-boot arms (successful load, and a Won publish on the generate
path) now sweep stale .publishing and .recovering entries, but only
after the sweeper itself has made the final durable: a loadable final
is not necessarily synced, and removing a live winner's marker before
its sync_all would recreate the marker-less partial-final wedge on
power loss. Any durability failure skips the sweep and leaves the
markers in place.
2026-07-21 20:51:26 -05:00
beardthelion 5c82361c91 fix(node): make the fallback marker removal a commit check for stalled winners
A fallback winner stalled past the race deadline can have its round
claimed by a recovering peer: the peer renames the marker, quarantines
the in-progress final, and republishes. The winner's own-marker removal
now decides the round: removal succeeding linearizes it ahead of any
recovery, while any failure demotes it to Lost, after waiting out live
or stale .recovering claims so the follow-up load cannot observe the
pre-quarantine key. Demotion is always safe: absent interference the
Lost path re-loads the winner's own key.
2026-07-21 20:51:26 -05:00
beardthelion d3877d2cc4 fix(node): recover a crash-interrupted fallback key publish at boot
A marker plus a content-failed final (empty or unparseable PEM) is now
provably an interrupted first write: boot claims one marker by atomic
rename to a .recovering file, quarantines the final preserving its
bytes, sweeps the remaining markers, and regenerates, at most once per
start. Every other failure keeps today's loud fail bit-for-bit: a bad
final without a marker, any non-content load failure (transient EIO,
mode, stat), and a second-iteration failure never touch the key. The
error class is carried as a typed root error so recovery cannot key on
message text.
2026-07-21 20:51:26 -05:00
beardthelion 063ea75384 fix(node): bracket the fallback identity-key publish with a durable marker
The hard-link fallback exposes the final key name before the PEM is
complete, so a crash in that window used to leave a partial final that
wedged every later start. Create a bounded-name .publishing marker and
fsync its name durable before create_new(final), dispose of it on every
exit with the final's removal ordered first, and remove it only once the
final is complete and durable. The marker dir fsync is warn-and-continue
so dir-fsync-hostile mounts degrade to reduced power-loss protection
instead of never provisioning.
2026-07-21 20:51:26 -05:00
beardthelion 428ced31dc test(node): invariant deny-prober (driven registry + completeness cross-check) (#195)
* test(node): U1 deny-bearing route registry (owner-gate + signature tranches)

Foundation for the invariant deny-prober (plan 002): a declarative registry of
ONLY the routes that carry a runtime deny (owner-gate 403 / read-gate 404 /
signature 401), each classified by reading its handler and recording the fn
name. Owner-gate (12 rows: merge/close_pr, close_issue, webhooks, labels,
protect, visibility set/remove/list incl the GET-is-403 correction) and
signature-required (git-receive-pack) tranches are verified; read-gate tranche
stubbed with a TODO (per-handler 404-vs-filter verification pending). A
consistency self-test asserts no dup method+path, every row names its handler,
and every read-gate row carries a positive-twin Reach. Decoupled from the #194
primitives (U1 uses none), so classification is not hostage to harness churn.

* test(node): U2 fixture state matrix + per-gate-class probe generators

Two-repo fixture (public owner-gate substrate + private read-gate substrate)
plus probes_for(): each deny-bearing row expands to a hostile probe asserting
the exact deny status and a positive twin (owner-reachability for owner-gate,
authorized read for read-gate) so a deny for the wrong reason cannot false-pass.
Three self-checks cover the owner-gate, read-gate, and signature classes.

* test(node): U1 read-gate tranche (14 repo-scoped 404-deny reads)

Each row's handler was read to confirm it gates on "/" via
authorize_repo_read / visibility_check and returns RepoNotFound (404) to a
non-reader of a private repo, with the owner re-read as the ReaderReads
positive twin. Sub-entity gets (get_issue/get_pr/get_cert/get_bounty and the
comment/review lists) are deferred to the fixture expansion that seeds them;
global list-filter surfaces and the KNOWN_UNGATED reads are excluded with
reasons. Runtime behavior is asserted by U3.

* test(node): U3 drive the deny-bearing registry over a real node

Boots a real node, seeds the two-repo fixture (plus a PR and an issue so the
author-or-owner close gates are reached, not 404'd on an absent entity), then
walks every deny-bearing route: the hostile probe must return the exact deny
status and leak nothing, and the positive twin must reach the handler (owner:
not 403; read: 2xx). A terminal invariant asserts one hostile probe per row
was driven and the count equals the registry, so a row that produced no probe
fails loud rather than passing vacuously. Adds seed_pr/seed_issue to TestNode.

Mutation-verified load-bearing: breaking did_matches drove the merge_pr
owner-gate hostile to 500 (RED, named); neutering authorize_repo_read's deny
drove the get_repo read-gate hostile to 200 (RED, named); both reverted green.

* test(node): U4 completeness cross-check keeps the registry from drifting

A pure source scrape of server.rs (multiline .route parser, floor 90) that the
runtime sweep cannot give: no deny-bearing row points at a route that no longer
mounts (anti-stale), and no handler carrying an unambiguous owner-gate marker
(require_repo_owner / require_owner) escapes the registry (orphan). The api dir
is read at test time so a new module is covered. Complements authz_guard (which
proves handlers are gated) by proving the deny-bearing ones are actually driven,
and reaches the non-API git mounts authz_guard never sees.

Mutation-verified: a bogus row path tripped anti-stale (named the row); removing
the merge_pr row tripped the orphan guard (named merge_pr); both reverted green.

* test(node): close vacuous-green gaps in the deny prober's own assertions

Vetting the harness against itself surfaced branches that ran green but were
never exercised adversarially:
- Ok2xx twin dropped its unused token Option; the arm now asserts the authorized
  read returns a NON-EMPTY 2xx, so an empty-200 denial-rendered-as-success is
  caught (executed by every read twin).
- U4 orphan guard gains a floor on the owner-marker scan (>=6), so a marker-scan
  regression to zero fails loud instead of passing by checking nothing.
- Added probe unit tests that execute the previously-uncovered probes_for arms:
  the SiblingPublic path-scoped twin, and the Reach::None fail-loud panic.

All 22 harness tests green. Every send_probe/probes_for branch is now executed;
the Deny(401), Not403, and Ok2xx-status arms were additionally mutation-verified
RED (signature-expect flip; did_matches->false; authorize_repo_read->deny) and
both U4 floors RED via threshold flips, all reverted.

* test(node): close 3 completeness gaps in the deny prober's own guards (#195)

jatmn's review flagged three ways the prober's self-guards stay green when
the protection they check is removed:

- F1: the U4 completeness check enforced owner-gate orphans but not
  read-gate ones, so dropping authorize_repo_read on get_issue/get_cert/
  get_bounty/etc. left the sweep green. Add a symmetric read-gate orphan
  guard that scans src/api for authorize_repo_read/visibility_check handlers
  and requires each to be a driven ReadGate row or an enumerated
  READ_GATE_NOT_DRIVEN entry with a reason, replacing the free-text prose.
  A staleness check keeps the allowlist from carrying dead exemptions.

- F2: the U3 registry sweep called assert_denied with an empty withheld
  list, proving status only. Seed the private fixture with a distinctive
  secret and its blob OID, carry them on the read-gate hostile probes, and
  pass them through so a denial body echoing withheld data fails.

- F3: the owner-gate orphan scan matched only require_owner/require_repo_owner
  and missed the inline did_matches(caller, &record.owner_did) idiom that
  protect_branch/unprotect_branch use. Fold in a has_owner_did_matches
  discriminator that catches the owner form without false-matching the
  signer-self (register_replica) or author (close_pr) did_matches forms,
  pinned by unit tests on the exact bodies.

* test(node): make the deny-prober guards load-bearing (#195, F1-F4)

Resolves jatmn's four INV-21 completeness findings — guards that were green but
not load-bearing:

F1: every ReadGate hostile probe was anonymous, so the signed-non-reader branch of
each private read was untested — a regression treating any valid signature as
authorized would leak while the sweep stayed green. Add a Signer::Stranger 404/
no-leak probe per read row, with a dedicated readgate_stranger counter so a dropped
probe fails loudly. RED: authorize any signed caller -> the stranger probe leaks
(200) while the anon probe stays green.

F2: get_star_status, get_icaptcha_proof, and replicate_encrypted_blobs were
source-only exemptions (a marker scan, never driven). Drive them as real ReadGate
rows and remove the allowlist entries. RED: a runtime bypass that keeps the
authorize_repo_read marker leaks -> the driven probe RED, the source scan green.

F3: git_receive_pack's protected-branch owner 403 was never driven (its registry
row is the unsigned-401 path). Add signed_stranger_protected_branch_push_is_forbidden
(a signed non-owner push to a protected branch -> 403, owner control -> not-403),
plus a seed_protected_branch harness helper. RED: invert the gate -> the probe RED.

F4: the read-gate no-leak assertion omitted the private repo id (seed discarded it).
Preserve it on the Fixture and withhold it; a synthetic 404 body carrying the UUID
must fail check_denied. RED: a UUID-bearing body passed before the token was added.

Each guard proven load-bearing by reverting the exact production auth-line (reverts
not shipped). deny_harness suite 30 passed, fmt + clippy clean.

* test(node): drive every arm of the multi-principal close/dispute gates

close_pr, close_issue (owner-or-author) and dispute_bounty (creator-or-claimant)
were each driven with a single owner/creator twin, so reverting the untested arm
stayed green. Model gates by their authorizing-principal set (MultiPrincipalGate
+ Principal), seed distinct author/creator/claimant identities, and emit one
Not403 twin per arm plus the stranger 403. registry_internal_consistency now
invokes probes_for and asserts a twin per declared arm, so a future multi-arm
handler cannot register with an untested arm.

Each arm proven load-bearing: reverting is_owner/is_author on close_pr/close_issue
and is_creator/is_claimant on dispute_bounty turns that arm's twin RED.

* test(node): drive the deferred private reads with per-read leak assertions

The deferred reads (get_issue, get_pr, get_pr_diff, list_issue_comments,
list_reviews, list_comments, get_cert, get_bounty, path-scoped get_tree) sat in
READ_GATE_NOT_DRIVEN, checked only for an authorize_repo_read marker, so an
ignored result or early return stayed green. Seed each sub-entity in the private
repo (issues/PRs via the real create handlers, a cert via a signed owner push,
a private-repo bounty), thread id-keyed paths through IdSource, and drive each as
a ReadGate row: anon/signed-stranger 404, owner 2xx.

Each seeded entity carries a distinctive marker added to the read-gate withheld
set, so a 404 that leaks the entity's own content fails (not just a status
check). get_pr_diff exercises both its repo-root gate and its per-path
visibility_check loop. get_encrypted_blob stays deferred (owner twin needs live
IPFS; reclassified structurally next).

Load-bearing: an ignored-result bypass on each read turns its hostile probe RED;
a 404 leaking a seeded marker fails the body assertion.

* test(node): close the prose-excuse hatch with a mounted-route completeness gate

READ_GATE_NOT_DRIVEN allowed a free-text reason to park a read-gated handler out
of the runtime sweep. Replace the reasons with a closed NotDrivenReason enum
(ReadGatingMutation, GitSmartHttpRead, ContentAddressedRead, GlobalListFilter,
ExternalDependencyUnavailable); get_encrypted_blob moves to the last. A drivable
404-deny GET no longer type-checks a reason and is caught by a driven-not-
relabeled assertion.

Add a completeness gate that derives the required read-gate set from the mounted
route table (scrape_mounts, INV-21b), not only the source-marker scan: every
mounted repo-scoped GET must be a driven ReadGate row, a deny-bearing GET,
structurally excused, or a declared public GET — else it fails. Catches a read
that gates via a helper the marker scan does not recognize.

Load-bearing: re-parking a driven read trips the relabel assert; removing a
driven ReadGate row trips the mounts cross-check.

* test(review): harden multi-principal twin check against signer collision

Add a distinct-signer assertion to multi_principal_rows_emit_one_twin_per_arm:
the per-arm any() check is satisfied by a single twin when two Principals map to
the same Signer, so a signer_for_principal collision would leave one arm silently
untested. Assert the emitted twins carry principals.len() distinct signers
(proven RED by colliding Author->Owner). Also correct the get_cert / priv_markers
comments, which claimed a withheld cert-content marker that is not seeded — no
cert marker is needed because get_cert 404s at the repo-root gate before the cert
is fetched, so cert fields cannot reach the deny body.

* style(test): apply rustfmt to the deny-prober additions

* test(node): drive the bounty mutation gates and pin fixture object format (#195)

- submit/approve/cancel_bounty become MultiPrincipalGate registry rows
  with status-appropriate bounty fixtures (claimed/submitted/open): their
  status check fires before the auth gate, so each row seeds its own
  bounty through the real API and the arm twins consume the status
- pin --object-format=sha1 in both fixture worktrees so a sha256 default
  object format cannot break the OID equality with the served sha1 repos

* test(node): drive the caller-self task/register gates in the deny-prober (#195)

Resolve the three review findings on head 07abcc6.

Bound the two new real-node probes (protected-branch push, PR-diff) on a
timeout-bearing client, so a wedged git subprocess fails the suite rather than
hanging it until CI kills the job.

Withhold the pushed secret blob's OID (full and [..12] short form) in the PR-diff
denial. The old branch was dead code: the OID was never in the fixture seed map,
so build_branch_push_body now returns the pushed blob OIDs.

Drive every caller-self runtime deny the registry claimed but only source-marker
checked. create_task, claim_task, and register are a new SignerSelfGate class (a
body DID field bound to the authenticated signer); complete_task and fail_task
are a MultiPrincipalGate assignee arm (the caller must equal the task's stored
assignee). Each runs hostile-then-authorized against a real node. A completeness
guard, derived from a scan of src/api, now requires every did_matches(caller, X)
handler to be a driven registry row, and the module doc is reconciled to the REST
surface (the parallel GraphQL mutation gates are fenced separately, #219).

Every gate is mutation-verified load-bearing: reverting a production did_matches
line drives its hostile probe RED, and the completeness guard goes RED when a
driven row is dropped or the classifier is starved. No production code changed.
2026-07-22 08:30:38 +08:00
beardthelion 692d8ad7af fix(node): recover identity-key fallback fsync failure and abort wedged serve task
F1: the hard-link fallback wrote the PEM directly to the final path then
fsync'd it separately; an fsync failure left a durable name over non-durable
content, so a later crash wedged startup (load_or_create_keypair parses the
existing truncated file instead of regenerating). Fold the file fsync into
write_key_or_cleanup so the final is removed on fsync failure, matching the
hard-link temp policy; the next start regenerates. Parent-dir fsync still
keeps-on-failure.

F2: TestNode::shutdown moved the serve JoinHandle into tokio::time::timeout and,
on elapse, the panicking .expect dropped it, detaching the still-running task
and leaking the test DB across DROP DATABASE. Retain the handle (&mut), abort
and reap it on timeout before panicking. Also: honor a task that finishes in the
timeout race as graceful (not a false wedge), propagate a real serve-task panic,
and close the pool on every path (including the wedged arm) before returning or
panicking so the leak is closed on the path that needs it most.
2026-07-20 16:05:30 -05:00
beardthelion 605a63eb37 test(node): close review gaps on the key-publish fallback and Drop abort (#194)
Round-five review follow-ups, no production behavior change beyond a
pure fsync_parent_dir extraction:

- Recondition the KEY_RACE_DEADLINE and publish_key_atomically rustdocs:
  a present key is complete-on-first-read only on the hard-link path; on
  the fallback the final name is visible mid-write (the deadline covers
  it) and the fallback also weakens durability (d), failing loud at next
  start, never a silently used key.
- Cover the fallback create_new open-error arm via a new fault hook,
  asserting the returned error chains the original link error.
- Make the Drop abort load-bearing (INV-21): a test-only flag suppresses
  the graceful signal so handle.abort() carries teardown alone; removing
  the abort turns the new test RED while the existing one stays green.
- Observe the crash-residue contract: empty/garbage finals fail loudly.
- Extract fsync_parent_dir, shared by the Won and fallback arms.
- Split the permissive-umask test so a 0000 regression can't hide behind
  0077.
2026-07-19 16:52:59 -05:00
beardthelion 2f1db8b17d docs(node): state the real shutdown-grace contract and lock it with a real-socket test (#194)
GITLAWB_SHUTDOWN_GRACE_SECS promised a 503 to in-flight clients at
expiry; drive_serve_with_grace (correctly) abandons them instead, and a
retroactive 503 on an in-flight response is not constructible under
hyper. The config prose now states the abandon contract, and a new test
drives a real listener with a slow handler through the driver at a
250ms grace, asserting the client never sees a 503 status line and the
driver returns bounded with grace_expired set.

This commit changes only a doc comment and a test, so it is typed docs()
rather than fix() to avoid a spurious release-please patch bump.
2026-07-19 16:52:46 -05:00
beardthelion 74bd05c56c fix(node): verify key file mode before writing PEM bytes at both creation sites (#194)
Both creation sites (the temp on the hard-link path, the final on the
fallback path) now run the loader's tighten-then-verify pair right after
create_new and before any private bytes are written: set_permissions
repairs a umask-narrowed mode (0277 landed 0400 and previously shipped
that way), and on mode-ignoring mounts where the tighten silently no-ops
the verify fails closed with the empty file removed so retries are not
wedged. A verified temp implies a verified hard-linked final (shared
inode). Both call sites are mutation-vetted; umask cases run in child
processes because umask is process-global.
2026-07-19 16:18:45 -05:00
beardthelion 5a4bee9368 test(node): surface axum::serve's result through TestNode::shutdown (#194)
The spawned serve task previously discarded its io::Result; shutdown()
now joins and checks it. On axum 0.8.8 the Err arm is unreachable
(graceful shutdown hard-codes Ok and accept errors retry forever), so
this is forward-compat insurance documented as such inline; the Ok path
is exercised by every harness test.
2026-07-19 16:03:06 -05:00
beardthelion 5c72cf41d7 chore: sync Cargo.lock with the 0.5.1 workspace manifests 2026-07-19 15:59:54 -05:00
beardthelion eb34ed2982 test(node): abort the server task in TestNode::drop (#194)
A test that returns without calling shutdown() previously relied on the
watch signal alone to unwind the server before sqlx's DROP DATABASE.
Drop now also aborts any remaining serve handle, so teardown holds even
when the graceful chain is broken; signal and abort are independently
sufficient (verified by a four-quadrant neuter matrix in the new
drop_without_shutdown_unblocks_database test, which observes leaked
sessions via a standalone connection with sqlx pool reaping disabled).
2026-07-19 15:59:42 -05:00
beardthelion 2e8ed29f7e fix(node): fall back to a direct create_new publish when hard_link fails (#194)
On a non-AlreadyExists hard_link error, publish the identity key by
creating the final path directly with create_new(0600): no-clobber is
preserved via the exclusive create (AlreadyExists routes to the lost-race
load), write failures remove the partial final, and fsync failures keep
the complete final on disk since a concurrent loser may already have
loaded it (the Won-arm policy). The fallback chains the original link
error and the rustdoc now scopes the complete-only-visibility guarantee
to the hard-link path. Link step is injectable via compile-time fault
hooks; six new tests drive both fallback arms, the lost race, the
concurrent convergence, and both failure phases.
2026-07-19 15:59:42 -05:00
beardthelion 3fa786d439 test(node): let the partial-final reader wait out the fsynced publish (#194)
The added temp-file and directory fsyncs can push the hard_link past the
reader's old 400ms window under parallel-suite disk load, flaking
publish_never_exposes_a_partial_final (observed ~1 in 5 local runs). The
reader now polls a 5s deadline and stops at the first complete
observation; the never-see-partial assertion still runs before the
break, so the exposure mutation still fails the test.

Also moves the allow-unbounded-git marker in seed_bare_repo directly
above the spawn line, where the pre-push gate looks for it.
2026-07-15 19:44:21 -05:00
beardthelion e16ec0729a fix(node): recover key publish from stale temps, make it durable; join TestNode teardown (#194)
- pick the temp key name with a bounded per-call retry instead of a
  process-global counter: a crashed start's leftover temp no longer wedges
  a PID-stable restart, and cross-container publishers stop colliding
- fsync the temp PEM before it can be linked and the key directory after
  publication, so a power crash cannot leave a durable-but-truncated
  identity.pem that permanently blocks startup
- TestNode::shutdown() joins the serve task and closes the pool before
  sqlx's DROP DATABASE cleanup runs; every deny-harness test now ends
  with it instead of relying on drop-only teardown
2026-07-15 19:27:46 -05:00
beardthelion dd62b4bec1 fix(node): publish the identity key atomically and wait a real deadline
The create path did create_new(final)+write_all, so the final path appeared as
an empty inode before the PEM was flushed, and a losing/fast-path start only
retried ~100ms (50x2ms). On a slow or stalled filesystem the winner's write can
exceed that window, so every other start failed boot with 'invalid PEM key'
(#194). Publish atomically instead: write the full PEM to a sibling temp, then
hard_link it into place. hard_link is atomic and fails if the target exists, so
the final path only ever appears COMPLETE (no partial-read window), a lost race
never clobbers the winner, and a crashed writer leaves only a temp rather than a
partial final that would wedge later starts. load_racing_key now polls on a
wall-clock KEY_RACE_DEADLINE (5s) instead of a fixed 100ms count. Hoisted
load_existing_key/load_racing_key to module level for testability.

Adversarial RED->GREEN: a 250ms-slow winner is waited out (RED at the old 100ms);
a reader watching the final never sees a partial file (RED with create_new+write);
a losing publish does not clobber the winner; 500 tests pass.
2026-07-14 23:18:51 -05:00
beardthelion 9f88dc0bf3 fix(node): remove a failed first-write key file; verify key mode after tightening (#194)
F1 (P1): create_new(true) closed the permission window, but a write_all failure
after the file was created left an empty/partial PEM behind. Every later start
then took the key_path.exists() branch, re-parsed that corrupt file in load_racing,
and exited 'invalid PEM key' instead of regenerating — a transient ENOSPC/EIO on
first boot permanently wedged the node. Extract write_key_or_cleanup, which removes
the just-created file on write failure, and wire it into both the unix and non-unix
create branches.

F2 (P2): the load path tightened a loose existing key to 0600 but discarded the
set_permissions result. A chmod that failed (read-only mount, ownership/ACL
mismatch) left a world/group-readable private key in use while logging a normal
'loaded existing identity'. Surface the tighten failure (propagate it) and add
ensure_key_mode_0600, which fails closed if the key is not 0600 after the attempt.

RED->GREEN: failed_write_removes_the_partial_key_file (a failed write leaves no
file; RED without the remove_file). loose_key_mode_is_rejected_not_used (a 0644 key
is rejected; RED without the mode check). Existing created_key_is_mode_0600,
existing_key_is_loaded_and_tightened, and concurrent_starts_converge_on_one_identity
stay green. Full node lib+bin suite 497 passed, fmt + clippy clean.
2026-07-13 20:35:17 -05:00
beardthelion 26421e7a39 Address PR review feedback (#194)
- Create the node identity key atomically with create_new + mode 0600, closing
  the umask-derived 0644 disclosure window and the exists()->write overwrite
  race; on AlreadyExists load the winner's key (bounded retry so a loser can't
  read a half-written PEM) and tighten looser perms on load.
- Enforce the configured shutdown grace: bound the axum drain by grace measured
  from the signal (extracted as drive_serve_with_grace), abandoning in-flight
  requests once it expires instead of waiting indefinitely. Removes the
  discarded grace value.
- Route the deny-harness reqwest clients through a shared bounded_client (30s
  timeout) so a wedged node path fails fast instead of hanging to the CI limit.

Tests: 0600-on-create, load-tighten, concurrent-start convergence (create race),
and grace-race abandon / normal-drain / signal-gated-clock. All RED-then-GREEN
by execution.
2026-07-12 22:27:07 -05:00
beardthelion febe42c14d style(node): cargo fmt the deny-harness sources 2026-07-12 17:48:48 -05:00
beardthelion 9d8cec399a test(node): full-stack owner-gate deny cases for 5 high-value mutations
Fan-out of U6 to the security-sensitive owner-gated mutations that had only the
source-level authz-table guard and no runtime deny test: protect_branch,
unprotect_branch, create_webhook, delete_webhook, remove_visibility. Each
rejects a validly-signed non-owner with 403 and lets the owner reach the
handler (not 403). Mutation-verified load-bearing on their shared root gate:
did_matches forced true opens all five (non-owner protect_branch returns 201)
and the test flips RED.

Replica register/unregister were intentionally excluded: they are signer-self
(you register your own node), not owner-gated, so there is no owner-deny to
assert.
2026-07-12 17:44:48 -05:00
beardthelion 29b9918ec6 test(node): cover the deny-assertion guard branches (U4)
Add unit tests for the two remaining check_denied branches: a non-4xx expected
status is rejected as a test bug, and an empty withheld token is skipped rather
than matching every body. Closes the last unexecuted branches in the deny
assertion.
2026-07-12 17:33:39 -05:00
beardthelion 4d21cdb8e2 ci(node): run the real-node deny harness on every PR
cargo test --workspace skips the harness because it lives behind the
test-harness feature (kept off the production binary). Add an explicit step
that runs it with the feature and the same Postgres service, so the INV-1/
INV-2/INV-8 trust-boundary regression cases execute on every PR instead of
only when run by hand.
2026-07-12 17:17:36 -05:00
beardthelion 22a584a290 test(node): INV-2 replication-path deny case over git-upload-pack (U8)
Drives the git-upload-pack POST directly (v0 stateless-RPC: want HEAD, flush,
done) via a bounded reqwest client rather than a vanilla `git clone` (which
negotiates protocol v2 and deadlocks against the node's v0 server, and would
otherwise wedge the suite). The served pack is indexed with git index-pack and
its objects listed with verify-pack -v: a packfile-aware assertion, since a raw
byte scan cannot see an OID inside the zlib-compressed stream.

A public repo with a /secret/** withhold rule must serve a pack that omits the
withheld blob's object while keeping the sibling public blob. Mutation-verified
load-bearing: forcing visibility_check to allow puts the withheld blob back in
the pack and flips the test RED.

Completes the harness (8 units, 11 integration tests). Prod build (no feature)
and the 488-test node suite stay green.
2026-07-12 17:01:03 -05:00
beardthelion 13653f55aa test(node): INV-2 read + content-addressed withhold deny cases (U5b, U7)
Adds seed_bare_repo (shells git to build a real bare repo at the served path,
sha1 or sha256 object format) and two INV-2 deny cases over the real stack:

- U7: a public repo with a /secret/** withhold rule denies an anonymous blob
  read of the withheld path (404) with no content/OID leak, while the sibling
  public path is served (path-scoped, not blanket).
- U5b: the same withhold denies an anonymous /ipfs/{cid} read of the withheld
  blob's content-addressed id (404, no leak), while the public blob's CID is
  served. Completes U5 (INV-8) alongside U5a.

Both mutation-verified load-bearing: forcing visibility_check to allow leaks
the secret at 200 and the INV-8 assertion flips each test RED.
2026-07-12 16:35:29 -05:00
beardthelion 52b8714e64 test(node): INV-1 owner-gate deny case over the real stack (U6)
A validly signed non-owner PUT /visibility is rejected 403 by require_owner
(no x-ucan, so require_ucan_chain passes through to the gate); the owner's
signed PUT reaches the handler (reachability proof, guards against a 404/415
masquerading as a pass). Adds seed_repo/withhold_path seeding helpers to the
test harness. Mutation-verified load-bearing: with require_owner forced Ok the
non-owner PUT returns 201 and the INV-8 assertion flips the test RED.
2026-07-12 16:26:19 -05:00
beardthelion f656d4859a test(node): real-node deny harness rig + INV-8 unsigned-push case (U2-U4, U5a)
Add a feature-gated (test-harness) spawn surface (src/test_harness.rs) that
boots a real node on 127.0.0.1:0 over an ephemeral #[sqlx::test] pool through
the production axum::serve stack with connect-info, and an integration crate
(tests/deny_harness.rs) that drives deny paths with a real reqwest client:

- U2 signing client: wraps gitlawb_core::http_sig::sign_request for reqwest;
  self-checks that a valid signature clears require_signature and a tampered
  body is rejected (400 content_digest_mismatch).
- U3 spawn_node: real socket, p2p disabled, per-test DB, shutdown-on-drop.
- U4 assert_denied: 4xx AND body-no-leak AND not-empty-200 (INV-8); pure core
  unit-tested for clean-403 / empty-200 / leaking-403 / wrong-status.
- U5a INV-8: unsigned git-receive-pack is denied 401 with no leak.

Widens the three cfg(test) test builders (Db/RepoStore::for_testing,
run_migrations) to also compile under the feature. No production behavior
change: prod build (no feature) excludes test_harness; node suite stays green
(488) and the 7 integration tests pass.
2026-07-12 16:09:44 -05:00
beardthelion e9cc86748d refactor(node): split gitlawb-node into lib+bin so integration tests can spawn a real node
Move the module tree and boot logic from main.rs into a new lib.rs crate
root exposing the boot surface (build_router, AppState, Config, migrations)
as pub; main.rs becomes a thin #[tokio::main] shim over run(). No behavior
change: both targets build and the full node suite (488 tests) stays green.

Prerequisite for the real-node deny harness (U1).
2026-07-12 15:54:55 -05:00
16 changed files with 15064 additions and 1838 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
name: "Gitlawb CodeQL config"
paths-ignore:
- "**/tests/**"
- "**/test_support/**"
+10
View File
@@ -116,6 +116,16 @@ jobs:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
# The real-node deny harness is behind the `test-harness` feature (so its
# spawn surface never compiles into the production binary), which means
# `--workspace` above skips it. Run it explicitly so the trust-boundary
# regression cases (INV-1/INV-2/INV-8) execute on every PR. Uses the same
# Postgres service; `#[sqlx::test]` provisions an isolated DB per test.
- name: cargo test (real-node deny harness)
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked -p gitlawb-node --features test-harness --lib --test deny_harness
build-release:
name: build --release
runs-on: ubuntu-latest
+4
View File
@@ -26,11 +26,15 @@ COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
# gitlawb-node declares BOTH a [[bin]] and a [lib], so it needs both stubs:
# cargo resolves every declared target before it fetches anything, and a
# missing src/lib.rs aborts the layer with a target resolution error.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-node/src/lib.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
+26 -2
View File
@@ -6,10 +6,28 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lib]
name = "gitlawb_node"
path = "src/lib.rs"
[[bin]]
name = "gitlawb-node"
path = "src/main.rs"
# Exposes the `test_harness` spawn surface (src/test_harness.rs) to the
# real-node deny-harness integration crate. Off by default so the production
# binary never compiles test-only boot code. Enable with
# `cargo test -p gitlawb-node --features test-harness`.
[features]
test-harness = []
# Only builds when `test-harness` is enabled; otherwise skipped (not an error),
# so `cargo test --workspace` without the feature stays green.
[[test]]
name = "deny_harness"
path = "tests/deny_harness.rs"
required-features = ["test-harness"]
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
ed25519-dalek = { workspace = true }
@@ -77,6 +95,12 @@ libp2p-dns = { version = "0.44.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
tempfile = "3"
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
# on which timer fired instead of on wall clock.
# test-util enables tokio::test(start_paused): the delayed claim re-sweep
# test drives the real CLAIM_SWEEP_MIN_AGE-plus-margin sleep on paused time.
tokio = { workspace = true, features = ["test-util"] }
# Used by the deny-harness integration crate (tests/deny_harness.rs): the
# #[sqlx::test] macro for an ephemeral per-test pool, and reqwest as the real
# HTTP client that drives deny paths over the socket.
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "macros", "migrate"] }
reqwest = { workspace = true }
hex = "0.4"
+4 -2
View File
@@ -205,8 +205,10 @@ pub struct Config {
pub metrics_addr: String,
/// Maximum time to wait for in-flight requests to drain on shutdown, in
/// seconds. After this elapses, the server returns 503 to anything still
/// in flight and exits. Default: 30s.
/// seconds. On the shutdown signal the server stops accepting new
/// connections and gives in-flight requests this long to complete; at
/// expiry it abandons whatever remains (clients observe a closed
/// connection, not an error response) and exits. Default: 30s.
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
pub shutdown_grace_secs: u64,
+5 -5
View File
@@ -270,7 +270,7 @@ impl Db {
&self.pool
}
#[cfg(test)]
#[cfg(any(test, feature = "test-harness"))]
pub fn for_testing(pool: PgPool) -> Self {
Self { pool }
}
@@ -279,7 +279,7 @@ impl Db {
/// provisions an empty per-test database, so DB-backed tests must run this
/// before seeding. Reuses the production `migrate()` path (the advisory lock
/// is harmless on an isolated test DB and migrations are idempotent).
#[cfg(test)]
#[cfg(any(test, feature = "test-harness"))]
pub(crate) async fn run_migrations(&self) -> Result<()> {
self.migrate().await
}
@@ -8278,8 +8278,8 @@ mod peer_authority_tests {
/// | `prune_non_public_peers` (db/mod.rs) | a delete keyed on a computed bad-DID array; cannot repoint; boot-only caller in main.rs |
/// | `seed_local_peer` (sync.rs) | excluded by test-module location: a deliberate `upsert_peer` bypass for `file://` fixtures, which the public-URL gate rejects |
/// | `a_legacy_row_can_still_refresh_its_liveness` (db/mod.rs) | test-only. Seeds a PRE-GATE row by raw SQL on purpose: `upsert_peer` cannot create one, since the gate it is testing refuses exactly that DID. The fixture models what a deployed table already holds |
/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (main.rs) | test-only fixture seed. Raw SQL because the test drives the readiness HYSTERESIS, which needs a row already at `last_ping_ok = TRUE` before the round runs; it never exercises the announce gate |
/// | `manual_ping_uses_readiness_without_mutating_federation_gate` (api/peers.rs) | test-only fixture seed, same shape and same reason: the row under test must pre-exist so the assertion is about what the ping does NOT rewrite |
/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (lib.rs) | test-only fixture seed for the gossip hysteresis round; the row must pre-exist so the assertion is about when `mark_peer_ping` fires |
///
/// And the `upsert_peer` CALL-SITE authority table, which the ledger above
/// structurally cannot hold, because the bootstrap site issues no SQL of its own
@@ -8366,11 +8366,11 @@ mod peers_table_writer_guard {
const LEDGER: &[(&str, usize)] = &[
("a_legacy_row_can_still_refresh_its_liveness", 1),
(
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
"manual_ping_uses_readiness_without_mutating_federation_gate",
1,
),
(
"manual_ping_uses_readiness_without_mutating_federation_gate",
"gossip_ping_round_requires_two_failures_before_persisting_unreachable",
1,
),
("mark_peer_ping", 1),
+1 -1
View File
@@ -71,7 +71,7 @@ impl RepoStore {
/// Derives its own lock pool from `pool`, so callers that only have the main
/// pool (tests, `for_testing` sites in other modules) still get the
/// `after_release` semantics `acquire_write` depends on.
#[cfg(test)]
#[cfg(any(test, feature = "test-harness"))]
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
Self::new(
repos_dir,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+586
View File
@@ -0,0 +1,586 @@
//! Test-only spawn surface for the real-node deny harness (see
//! `tests/deny_harness.rs`). Feature-gated behind `test-harness` so it never
//! compiles into the production binary. It exposes a single boot constructor so
//! the out-of-crate integration test can bring up a real node over a bound TCP
//! socket without the integration crate needing access to `graphql`,
//! `rate_limit`, `repo_store`, or the other internals `AppState` is built from.
//!
//! The node is built the same way `test_support::build_state` builds it (p2p
//! disabled, real migrated pool), but bound on `127.0.0.1:0` and served through
//! the real `axum::serve` stack with connect-info, so the middleware order,
//! body limits, and per-IP rate limiters all run exactly as in production.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use clap::Parser;
use sqlx::PgPool;
use tokio::net::TcpListener;
use tokio::sync::watch;
use uuid::Uuid;
use gitlawb_core::identity::Keypair;
use crate::config::Config;
use crate::db::{Db, PullRequest, RepoRecord, VisibilityMode};
use crate::rate_limit::{RateLimiter, TrustedProxy};
use crate::state::AppState;
/// A running node bound to an ephemeral port. End every test with
/// [`TestNode::shutdown`]: it joins the serve task and closes the pool, so
/// `#[sqlx::test]`'s `DROP DATABASE` cleanup (which runs the moment the test
/// future returns) never races the server's still-open connections. Dropping
/// without it signals shutdown, aborts any remaining serve task, and removes
/// the temp repository directory, a fallback for tests that return or panic
/// before reaching teardown.
pub struct TestNode {
/// Base URL of the running node, e.g. `http://127.0.0.1:54321`.
pub base_url: String,
/// The node's own DID (for building requests that reference it).
pub node_did: String,
shutdown_tx: watch::Sender<bool>,
repos_dir: PathBuf,
/// Seeding handle over the same pool the node serves from. Kept private so
/// the integration crate seeds through the methods below rather than
/// naming `Db`/`RepoRecord` directly.
db: Arc<Db>,
/// The detached `axum::serve` task, joined by [`Self::shutdown`]. `None`
/// once taken, so the `Drop` fallback never overlaps a completed teardown.
/// Carries `serve`'s own `io::Result<()>` so `shutdown()` can observe it
/// rather than discard it (see the coverage note there).
server: Option<tokio::task::JoinHandle<std::io::Result<()>>>,
/// A clone of the served pool, kept so [`Self::shutdown`] can close it
/// (sqlx pool clones share one inner, so closing this closes the clones
/// inside `AppState`/`Db` too).
pool: PgPool,
/// Test-only: when set, `Drop` skips the graceful watch signal so ONLY
/// `handle.abort()` tears the serve task down. Defaults false, so every
/// other test keeps both redundant teardown mechanisms. Set via
/// [`Self::force_abort_only_teardown`] to fence that the abort alone
/// releases the database when the graceful chain is broken.
suppress_graceful_shutdown_on_drop: bool,
}
impl Drop for TestNode {
fn drop(&mut self) {
// Fallback for tests that return or panic before reaching
// [`TestNode::shutdown`]: flip the shared shutdown signal so the serve
// task exits gracefully, abort whatever is left of it, and remove the
// temp repos dir. Drop cannot await, so it cannot join the server or
// close the pool; a test that already failed should not panic again in
// teardown. All three actions are idempotent or no-ops after
// `shutdown()` (which takes the handle), so running afterwards is
// harmless.
//
// The abort matters when the graceful chain does not run to completion
// before sqlx's `DROP DATABASE` cleanup: `JoinHandle::abort()` is a
// synchronous cancellation request callable from non-async Drop, and
// the aborted task's future (with the `PgPool` clones inside its
// router/state) is dropped on a subsequent scheduler tick, which sqlx
// cleanup's own awaits provide before the drop statement runs. The
// race is closed by those scheduling points, not by the abort alone.
//
// `suppress_graceful_shutdown_on_drop` (test-only) forces the abort to
// carry teardown by itself, so a test can fence that the abort line is
// load-bearing; it is false everywhere else.
if !self.suppress_graceful_shutdown_on_drop {
let _ = self.shutdown_tx.send(true);
}
if let Some(handle) = self.server.take() {
handle.abort();
}
let _ = std::fs::remove_dir_all(&self.repos_dir);
}
}
/// Outcome of joining the serve task during teardown, split so the wedged-task
/// path is observable to a test (which injects a short timeout) without waiting
/// the production teardown window.
enum ServeTeardown {
/// The task returned (carrying `serve`'s `io::Result<()>`) — either within
/// the timeout, or in the race between the timeout firing and the abort
/// landing. A task that actually finished is never a wedge.
Graceful(std::io::Result<()>),
/// The task did not finish within the timeout and was aborted (cancelled)
/// and reaped.
TimedOutAborted,
}
/// Join `handle` within `timeout`; on expiry `abort()` it and await the aborted
/// handle so the serve task (and the `PgPool` clones inside its router/state) is
/// reaped BEFORE the caller returns and `#[sqlx::test]` runs `DROP DATABASE`.
/// Takes `&mut` (a `JoinHandle` is `Unpin`) so ownership is retained across the
/// timeout and the handle can be aborted on expiry — the previous code moved the
/// handle into `timeout` and, on elapse, dropped it, which DETACHES the still
/// running task rather than aborting it, leaking the test database (#194 F2).
async fn join_or_abort(
handle: &mut tokio::task::JoinHandle<std::io::Result<()>>,
timeout: Duration,
) -> ServeTeardown {
match tokio::time::timeout(timeout, &mut *handle).await {
Ok(join_result) => ServeTeardown::Graceful(join_result.expect("serve task must not panic")),
Err(_elapsed) => {
handle.abort();
match handle.await {
// The task finished in the race between the timeout firing and
// the abort landing — honor its result, not a false wedge.
Ok(result) => ServeTeardown::Graceful(result),
Err(e) if e.is_cancelled() => ServeTeardown::TimedOutAborted,
// A genuine panic in the serve task surfaces here, mirroring the
// in-time arm's `.expect("serve task must not panic")`.
Err(e) => std::panic::resume_unwind(e.into_panic()),
}
}
}
}
/// Allocate a process-unique temp directory for a spawned node's repositories
/// without pulling in the `tempfile` dev-dependency (which is unavailable to
/// the library crate under `--features test-harness`).
fn unique_repos_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("gitlawb-deny-harness-{}-{}", std::process::id(), n))
}
/// Build an [`AppState`] over the given migrated pool, mirroring
/// `test_support::build_state` but with a real on-disk repository directory so
/// git smart-HTTP routes can serve. P2P is always disabled.
fn build_state(db: Arc<crate::db::Db>, pool: PgPool, repos_dir: PathBuf) -> AppState {
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let node_did = keypair.did();
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let schema = Arc::new(crate::graphql::build_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
));
AppState {
config: Arc::new(Config::parse_from(["gitlawb-node"])),
db,
node_did,
node_keypair: Arc::new(keypair),
p2p: None,
http_client: Arc::new(reqwest::Client::new()),
ref_update_tx: ref_tx,
task_event_tx: task_tx,
graphql_schema: schema,
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(repos_dir, pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
ipfs_max_legacy_scan_rows: crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
shutdown_tx: watch::channel(false).0,
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
encrypt_inflight: crate::state::EncryptInflight::new(),
repo_write_leases: crate::state::RepoWriteLeases::new(8),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(
8,
),
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(
16,
),
git_bin: "git".to_string(),
}
}
/// Spawn a real node bound to `127.0.0.1:0` over the given (already-created,
/// empty) test pool. Runs the schema migrations, builds the router, and serves
/// it on a background task through the production `axum::serve` stack with
/// connect-info so per-IP layers key on the real peer. Returns once the socket
/// is bound and accepting connections.
pub async fn spawn_node(pool: PgPool) -> TestNode {
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
db.run_migrations()
.await
.expect("test schema migrations should apply");
let repos_dir = unique_repos_dir();
std::fs::create_dir_all(&repos_dir).expect("create temp repos dir");
let state = build_state(db.clone(), pool.clone(), repos_dir.clone());
let node_did = state.node_did.to_string();
let shutdown_tx = state.shutdown_tx.clone();
let mut shutdown_rx = shutdown_tx.subscribe();
let router = crate::server::build_router(state);
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind ephemeral port");
let addr = listener.local_addr().expect("read bound addr");
let server = tokio::spawn(async move {
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.changed().await;
})
.await
});
TestNode {
base_url: format!("http://{addr}"),
node_did,
shutdown_tx,
repos_dir,
db,
server: Some(server),
pool,
suppress_graceful_shutdown_on_drop: false,
}
}
impl TestNode {
/// Test-only: make `Drop` tear the serve task down through `handle.abort()`
/// alone by suppressing the graceful watch signal. Used to prove the abort
/// is load-bearing (that the database is released even when the graceful
/// chain is broken); no production or normal-teardown path calls this.
pub fn force_abort_only_teardown(&mut self) {
self.suppress_graceful_shutdown_on_drop = true;
}
/// Graceful async teardown: signal shutdown, join the serve task, close
/// the pool, remove the temp repos dir. Call this at the end of every
/// test. `#[sqlx::test]` issues `DROP DATABASE` as soon as the test
/// future returns, so the server (which owns pool clones inside
/// `AppState`/`Db`) must be gone and the pool closed before then, or the
/// cleanup fails and leaks per-test databases. A serve task that does not
/// exit within 10s panics loudly: a wedged graceful shutdown must fail
/// the test, not hang CI.
///
/// When `self` drops on return, `Drop` re-sends the signal and re-removes
/// the dir; both are idempotent, and the serve handle was already taken,
/// so the overlap is harmless.
pub async fn shutdown(self) {
self.shutdown_with_timeout(Duration::from_secs(10)).await;
}
/// Body of [`Self::shutdown`] with an injectable teardown timeout so a test
/// can drive the wedged-shutdown path without waiting the production 10s.
/// Production always calls this with 10s.
async fn shutdown_with_timeout(mut self, timeout: Duration) {
let _ = self.shutdown_tx.send(true);
let outcome = match self.server.take() {
Some(mut handle) => Some(join_or_abort(&mut handle, timeout).await),
None => None,
};
// Release the pool and temp dir on EVERY path before returning OR
// panicking. A wedged graceful shutdown is exactly when in-flight
// per-connection tasks still hold `PgPool` clones; aborting the serve
// future alone does not reliably drop them, so the explicit
// `pool.close()` (which closes the shared inner) is what frees them
// before `#[sqlx::test]` runs `DROP DATABASE`. Panicking before this
// (the previous behavior) skipped the close on the one path that needs
// it most (#194 F2).
self.pool.close().await;
let _ = std::fs::remove_dir_all(&self.repos_dir);
match outcome {
// Honest coverage contract: on axum 0.8.8,
// `WithGracefulShutdown::into_future` always resolves `Ok(())` (the
// shutdown signal is treated as success and `accept()` errors are
// retried forever rather than returned), so this `Err` arm is
// unreachable today - every existing harness test exercises only the
// `Ok` path here. Forward-compat insurance for a future axum where
// `serve` can fail: that should surface as a clear panic, not be
// silently discarded.
Some(ServeTeardown::Graceful(result)) => {
result.expect("axum::serve exited with an error");
}
Some(ServeTeardown::TimedOutAborted) => {
panic!(
"serve task did not exit within {timeout:?} of the shutdown \
signal (wedged graceful shutdown)"
);
}
None => {}
}
}
/// Insert a repo owned by `owner_did` and return its repo id. Mirrors
/// `test_support::seed_repo` (the `disk_path` field is unused by the
/// integration path — `RepoStore` computes the on-disk path from
/// `repos_dir`/owner/name, see [`Self::seed_bare_repo`]).
pub async fn seed_repo(&self, owner_did: &str, name: &str, is_public: bool) -> String {
let now = Utc::now();
let record = RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
};
self.db.create_repo(&record).await.expect("seed repo");
record.id
}
/// Mark `branch` as protected on `repo_id`, so a non-owner push to it is
/// rejected by the branch-protection gate (used by the deny-prober's
/// protected-push probe — #195, F3).
pub async fn seed_protected_branch(&self, repo_id: &str, branch: &str, created_by: &str) {
self.db
.protect_branch(repo_id, branch, created_by)
.await
.expect("seed protected branch");
}
/// Seed an open PR `number` in `repo_id`, authored by `author_did`. The
/// author-or-owner close gate (`close_pr`) loads the PR *before* it runs, so a
/// deny-prober row for `.../pulls/{number}/close` needs the PR to exist or a
/// stranger gets a 404 (absent entity) instead of the 403 the gate emits.
pub async fn seed_pr(&self, repo_id: &str, number: i64, author_did: &str) {
let now = Utc::now().to_rfc3339();
let pr = PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
number,
title: "seed pr".to_string(),
body: None,
author_did: author_did.to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
};
self.db.create_pr(&pr).await.expect("seed pr");
}
/// Seed issue `issue_id` (a git-ref JSON blob authored by `author_did`) in the
/// repo's bare on-disk store. Like [`Self::seed_pr`], the `close_issue`
/// owner-or-author gate loads the issue before it runs, so the deny-prober row
/// for `.../issues/{id}/close` needs the issue present to reach the 403.
pub fn seed_issue(&self, owner_did: &str, name: &str, issue_id: &str, author_did: &str) {
let slug = owner_did.replace([':', '/'], "_");
let bare = self.repos_dir.join(&slug).join(format!("{name}.git"));
let json = format!(r#"{{"author":"{author_did}"}}"#);
crate::git::issues::create_issue(&bare, issue_id, &json).expect("seed issue");
}
/// Add a path-scoped visibility rule restricting `path_glob` to
/// `reader_dids` (empty = only the owner). Mode B keeps object SHAs intact
/// and withholds blob content, which is what the read/replication gates
/// enforce.
pub async fn withhold_path(
&self,
repo_id: &str,
path_glob: &str,
reader_dids: &[String],
created_by: &str,
) {
self.db
.set_visibility_rule(
repo_id,
path_glob,
VisibilityMode::B,
reader_dids,
created_by,
)
.await
.expect("set visibility rule");
}
/// Record `pinned_cids` rows for every blob OID returned by
/// [`Self::seed_bare_repo`] so `/ipfs/{cid}` can resolve via provenance on
/// current main (the table is the primary lookup path). CIDs are computed
/// from raw object bytes exactly as the production pin path does
/// (`Cid::from_git_object_bytes`), not from the git oid hash.
/// Returns each seeded path's canonical CID for use in `GET /ipfs/{cid}`.
pub async fn seed_pinned_cids_for_repo(
&self,
owner_did: &str,
repo_name: &str,
repo_id: &str,
oids: &std::collections::HashMap<String, String>,
) -> std::collections::HashMap<String, String> {
let slug = owner_did.replace([':', '/'], "_");
let bare = self.repos_dir.join(&slug).join(format!("{repo_name}.git"));
let mut cids = std::collections::HashMap::new();
for (path, oid_hex) in oids {
if path == "HEAD" {
continue;
}
let (_ty, raw) = crate::git::store::read_object(&bare, oid_hex)
.expect("read seeded object")
.expect("seeded object exists in bare repo");
let preliminary = gitlawb_core::cid::Cid::from_git_object_bytes(&raw).to_string();
let cid = preliminary
.parse::<cid::CidGeneric<64>>()
.expect("parse production pin cid")
.to_string();
self.db
.record_pinned_cid_with_source(oid_hex, &cid, repo_id)
.await
.expect("seed pinned_cids row");
let roundtrip = self
.db
.oids_for_cid(&cid)
.await
.expect("oids_for_cid after seed");
assert!(
!roundtrip.is_empty(),
"pinned_cids seed for {path} did not round-trip cid {cid}"
);
cids.insert(path.clone(), cid);
}
cids
}
/// Create a real bare git repository on disk at the exact path
/// `RepoStore::acquire` reads (`<repos_dir>/<owner-slug>/<name>.git`), with
/// one commit on `main` containing `files` (`(path, contents)`). Returns the
/// blob OID of each path so callers can assert a withheld OID never appears
/// in served bytes (U8). Shells out to `git`, mirroring
/// `test_support`'s served-content seam.
/// `object_format` is `"sha1"` for the git smart-HTTP surfaces and
/// `"sha256"` for the content-addressed `/ipfs/{cid}` surface (whose CIDs
/// are the sha2-256 object ids).
pub fn seed_bare_repo(
&self,
owner_did: &str,
name: &str,
files: &[(&str, &str)],
object_format: &str,
) -> std::collections::HashMap<String, String> {
let run = |args: &[&str], cwd: &std::path::Path| {
// Test-harness-only fixture seeding, feature-gated out of the production
// binary; runs local git against a tempdir inside a test's own lifetime.
// allow-unbounded-git: test-only seeding, never on a request path holding a permit
let out = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output() // allow-unbounded-git: test-harness fixture seeding only
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
// Build a source work tree, then bare-clone it into the served path.
let src = self.repos_dir.join(format!("src-{name}"));
std::fs::create_dir_all(&src).expect("create src dir");
let fmt_arg = format!("--object-format={object_format}");
run(&["init", "-q", "-b", "main", &fmt_arg], &src);
run(&["config", "user.email", "t@t"], &src);
run(&["config", "user.name", "t"], &src);
for (path, contents) in files {
let full = src.join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("create file parent dir");
}
std::fs::write(&full, contents).expect("write seed file");
run(&["add", path], &src);
}
run(&["commit", "-q", "-m", "seed"], &src);
let mut oids = std::collections::HashMap::new();
for (path, _) in files {
let oid = run(&["rev-parse", &format!("HEAD:{path}")], &src);
oids.insert((*path).to_string(), oid);
}
// The HEAD commit oid, used as the `want` when driving upload-pack.
oids.insert("HEAD".to_string(), run(&["rev-parse", "HEAD"], &src));
let slug = owner_did.replace([':', '/'], "_");
let bare = self.repos_dir.join(&slug).join(format!("{name}.git"));
std::fs::create_dir_all(bare.parent().unwrap()).expect("create bare parent");
run(
&[
"clone",
"--bare",
"-q",
src.to_str().unwrap(),
bare.to_str().unwrap(),
],
&self.repos_dir,
);
oids
}
}
#[cfg(test)]
mod tests {
use super::{join_or_abort, ServeTeardown};
use std::time::Duration;
/// A wedged serve task (never returns within the window) must be ABORTED and
/// reaped on timeout, not detached. The previous teardown moved the
/// `JoinHandle` into `timeout` and dropped it on elapse, which detaches the
/// still-running task and leaks the test DB across `DROP DATABASE` (#194 F2).
/// Load-bearing: neutralize the abort in `join_or_abort` (return without
/// abort+await) and this flips RED — `handle.is_finished()` stays false
/// because the task was left running.
#[tokio::test]
async fn join_or_abort_aborts_a_wedged_task_on_timeout() {
let mut handle: tokio::task::JoinHandle<std::io::Result<()>> = tokio::spawn(async {
loop {
tokio::time::sleep(Duration::from_secs(3600)).await;
}
});
assert!(
matches!(
join_or_abort(&mut handle, Duration::from_millis(50)).await,
ServeTeardown::TimedOutAborted
),
"a task that never returns must be reported timed-out, not graceful"
);
assert!(
handle.is_finished(),
"the wedged serve task must be aborted and reaped, not detached"
);
}
/// A task that returns within the window is reported graceful, carrying its
/// `io::Result` — the normal teardown path.
#[tokio::test]
async fn join_or_abort_reports_graceful_when_task_exits_in_time() {
let mut handle: tokio::task::JoinHandle<std::io::Result<()>> =
tokio::spawn(async { Ok(()) });
assert!(
matches!(
join_or_abort(&mut handle, Duration::from_secs(5)).await,
ServeTeardown::Graceful(Ok(()))
),
"a prompt clean exit must be reported graceful"
);
}
}
File diff suppressed because it is too large. Load diff
+237
View File
@@ -0,0 +1,237 @@
//! INV-8 deny assertion. A denial must be an actual refusal (4xx) whose body
//! and headers carry none of the data it is withholding, and must never be a 2xx
//! (an empty-200 rendered as success is the denial-as-success bug this guards).
//!
//! The check is split into a pure `check_denied` (unit-tested below with the
//! self-check scenarios) and an async `assert_denied` wrapper that reads a real
//! `reqwest::Response`.
/// Pure deny check. Returns `Err(reason)` when the response is not a clean
/// denial. `withheld` are exact tokens (OIDs, paths, private slugs) that must
/// not appear anywhere in the body; register short-OID prefixes and encoded
/// path forms as separate tokens at the call site (a raw substring scan cannot
/// normalize them).
pub fn check_denied(
status: u16,
body: &str,
expected: u16,
withheld: &[&str],
) -> Result<(), String> {
// The harness only ever expects denials; a non-4xx expectation is a
// programming error in the test, not a node behavior under test.
if !(400..500).contains(&expected) {
return Err(format!(
"test bug: assert_denied expected status {expected} is not a 4xx"
));
}
// INV-8: a denial rendered as success is the headline bug. Catch it
// explicitly so the failure message names it, rather than only failing the
// status-equality check below.
if (200..300).contains(&status) {
return Err(format!(
"denial rendered as success: got {status}, expected denial {expected}. \
body={body:?}"
));
}
if status != expected {
return Err(format!(
"expected denial status {expected}, got {status}. body={body:?}"
));
}
for token in withheld {
if token.is_empty() {
continue;
}
if body.contains(token) {
return Err(format!(
"withheld token {token:?} leaked in denial body: {body:?}"
));
}
}
Ok(())
}
/// Like [`check_denied`] but also scans serialized response headers for withheld
/// tokens. Call sites that only have a body should keep using `check_denied`.
pub fn check_denied_with_headers(
status: u16,
body: &str,
header_text: &str,
expected: u16,
withheld: &[&str],
) -> Result<(), String> {
if let Err(e) = check_denied(status, body, expected, withheld) {
return Err(e);
}
for token in withheld {
if token.is_empty() {
continue;
}
if header_text.contains(token) {
return Err(format!(
"withheld token {token:?} leaked in denial headers: {header_text:?}"
));
}
}
Ok(())
}
/// Replace each withheld token in `s` with `[REDACTED]` so a test-failure
/// witness does not itself leak the secret it caught.
fn redact_withheld(s: &str, withheld: &[&str]) -> String {
let mut result = s.to_string();
for token in withheld {
if !token.is_empty() {
result = result.replace(token, "[REDACTED]");
}
}
result
}
/// Drive a real response through the deny check and panic on failure. Reads the
/// full body once.
pub async fn assert_denied(resp: reqwest::Response, expected: u16, withheld: &[&str]) {
let status = resp.status().as_u16();
let header_text = resp
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|v| format!("{}: {}", name.as_str(), v))
})
.collect::<Vec<_>>()
.join("\n");
// A body-read failure must NOT fold to "" — an empty body passes the withheld
// scan vacuously, so an unreadable denial (a mid-body reset/truncation) would be
// certified leak-free without ever inspecting the bytes. Fail loud instead. A
// legitimately empty body is `Ok("")`, not `Err`, so the honest empty-401 denials
// still pass.
let body = resp
.text()
.await
.expect("read denial body: an unreadable denial cannot be certified leak-free");
if let Err(reason) = check_denied_with_headers(status, &body, &header_text, expected, withheld)
{
// Panic with a fixed string: CodeQL's taint analysis traces withheld
// tokens from check_denied through redact_withheld into the panic sink,
// even though redact_withheld strips them at runtime. A fixed message
// keeps no tainted data on the sink path. The test name identifies
// which probe failed; check_denied's unit tests cover the error detail.
let _ = redact_withheld(&reason, withheld);
panic!("deny assertion failed: status/body/header leak check");
}
}
#[cfg(test)]
mod tests {
use super::{check_denied, check_denied_with_headers, redact_withheld};
#[test]
fn clean_403_with_no_leak_passes() {
let r = check_denied(
403,
r#"{"error":"only the repo owner can do this"}"#,
403,
&["a1b2c3"],
);
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn empty_200_is_flagged_as_denial_rendered_as_success() {
let r = check_denied(200, "", 403, &[]);
assert!(r.is_err());
assert!(r.unwrap_err().contains("rendered as success"));
}
#[test]
fn leaking_403_fails_and_names_the_token() {
let withheld = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let body = format!("not found near object {withheld}");
let r = check_denied(403, &body, 403, &[withheld]);
assert!(r.is_err());
assert!(r.unwrap_err().contains(withheld));
}
#[test]
fn wrong_status_fails() {
// A 404 when the owner gate's 403 was expected must not pass: it means
// an earlier layer denied, so the gate under test was never exercised.
let r = check_denied(404, "not found", 403, &[]);
assert!(r.is_err());
assert!(r.unwrap_err().contains("expected denial status 403"));
}
#[test]
fn non_4xx_expected_is_a_test_bug() {
// Guards the harness itself: expecting a non-denial status is a test
// authoring error, not a node behavior, and must be rejected.
let r = check_denied(403, "denied", 200, &[]);
assert!(r.is_err());
assert!(r.unwrap_err().contains("not a 4xx"));
}
#[test]
fn empty_withheld_token_never_false_positives() {
// An empty token must be skipped, never treated as "contained in every
// body" — otherwise every denial would spuriously fail as a leak.
let r = check_denied(403, "any body", 403, &[""]);
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn leaking_header_fails_and_names_the_token() {
let withheld = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let headers = format!("X-Debug: object {withheld}");
let r = check_denied_with_headers(
404,
r#"{"error":"repository not found"}"#,
&headers,
404,
&[withheld],
);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("leaked in denial headers"));
assert!(err.contains(withheld));
}
#[test]
fn clean_headers_with_clean_body_passes() {
let withheld = "a1b2c3";
let r = check_denied_with_headers(
404,
r#"{"error":"repository not found"}"#,
"Content-Type: application/json",
404,
&[withheld],
);
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn redact_withheld_replaces_secrets() {
let withheld = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let msg = format!("withheld token {withheld:?} leaked in body: near {withheld}");
let redacted = redact_withheld(&msg, &[withheld]);
assert!(
!redacted.contains(withheld),
"token must be redacted: {redacted}"
);
assert!(
redacted.contains("[REDACTED]"),
"redaction marker must appear: {redacted}"
);
}
#[test]
fn redact_withheld_leaves_clean_strings() {
let result = redact_withheld(
"no secrets here",
&["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"],
);
assert_eq!(result, "no secrets here");
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Shared support for the real-node deny harness: the deny assertion (U4), the
//! RFC-9421 signing client (U2), and (via `gitlawb_node::test_harness`) the
//! node spawner (U3).
pub mod assert;
// U2 fixture state matrix + per-gate-class probe generators; driven by U3.
#[allow(dead_code)]
pub mod probe;
// U1 deny-bearing route registry; fields consumed by U2/U3 as they land.
#[allow(dead_code)]
pub mod routes;
pub mod signing;
/// A reqwest client with a bounded request timeout, so a wedged git subprocess or
/// route fails the suite rather than hanging it until CI kills the job (#195, F1).
/// The two real-node probes that drive git subprocesses use this; the inline
/// twins at the upload-pack and registry-sweep sites use the same pattern.
#[allow(dead_code)]
pub fn bounded_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap()
}
/// Build an anonymous (unsigned) GET request for `path` under `base_url`.
/// Extracted from inline call sites so URL construction goes through a
/// function boundary, matching the `signed_request` pattern in `support::signing`.
#[allow(dead_code)]
pub fn anon_get(client: &reqwest::Client, base_url: &str, path: &str) -> reqwest::RequestBuilder {
let url = format!("{base_url}{path}");
client.get(url)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,32 @@
//! Real RFC-9421 request signing for the deny harness (U2). Wraps
//! `gitlawb_core::http_sig::sign_request` (the same entry point the
//! git-remote-gitlawb helper uses in production) so a test can present a valid
//! signature for any identity, reaching the authenticated-but-wrong-owner path
//! that an injected-DID shortcut cannot.
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
/// Build a reqwest request carrying a valid RFC-9421 signature for `keypair`.
///
/// `path` is both appended to `base_url` to form the request URL and signed as
/// the `@path` component, so the value the server reconstructs and the value
/// that was signed are guaranteed identical. `body` is signed via its
/// content-digest and sent as the request body.
pub fn signed_request(
client: &reqwest::Client,
method: reqwest::Method,
base_url: &str,
path: &str,
body: Vec<u8>,
keypair: &Keypair,
) -> reqwest::RequestBuilder {
let sig = sign_request(keypair, method.as_str(), path, &body);
let url = format!("{base_url}{path}");
client
.request(method, url)
.header("content-digest", sig.content_digest)
.header("signature-input", sig.signature_input)
.header("signature", sig.signature)
.body(body)
}