Compare commits

..
Author SHA1 Message Date
beardthelion 6c91652633 fix(node): db_bounded returns Elapsed without polling when the deadline is spent
tokio::time::timeout polls the future before it checks the timer, so a zero
budget still started the work and could return a value. A spent deadline now
yields Elapsed at once. Makes discovery_record_elapsed_leaves_neither_row
deterministic.

Refs #585
2026-11-12 21:24:53 -06:00
beardthelion 6f990197ea feat(node): per-IP write-surface rate brake + purge-spam admin tool (#196)
* feat(node): add per-IP write-surface rate brake and apply to write_routes

Introduces a dedicated write_rate_limiter bucket (GITLAWB_WRITE_RATE_LIMIT,
default 600/hr, 0 disables), separate from the creation brake so a write flood
cannot drain the creation budget (KTD-1). Attaches rate_limit_by_ip to
write_routes, mirroring creation_routes. Per-DID deliberately not paired (a DID
farm never trips it).

Tests (route-level, execution-verified against Postgres): a write_routes sink
(PUT /star) is 429'd before auth; an exhausted write bucket does not throttle
repo creation (separate-bucket property).

* feat(node): extend the per-IP write brake to the remaining write sinks

Attaches rate_limit_by_ip (write bucket) to task_write_routes,
issue_write_routes, bounty_write_routes, profile_write_routes, and the /graphql
POST/GET surface (KTD-5 — an HTTP-layer brake counts every /graphql request;
/graphql/ws subscriptions stay unbraked). write_ip_limiter is now built once and
shared. Every non-git, non-creation authenticated write group is braked.

Tests (execution-verified against Postgres): /graphql POST is 429'd, and a
representative REST write sink (issue comment) is 429'd, by the write brake.

* test(node): adversarial TrustedProxy verification through the write brake

Middleware-level tests complementing the client_key unit coverage: a spoofer
varying the client-controlled leftmost X-Forwarded-For hop cannot rotate its
bucket key (keyed on the trusted rightmost hop); distinct trusted client hops
get distinct buckets (no cross-starvation); and a request with no trusted header
in a proxy mode falls back to the socket-peer key — the documented collapse,
asserted by name so any change is deliberate.

Operator-warning on missing-header deferred deliberately: a per-request warning
is a log-noise risk and the security-load-bearing fallback (peer key, never
skip) is what these tests lock down.

* feat(node): purge-spam admin subcommand for the empty-burst cleanup

Adds a purge-spam subcommand (clap #[command(subcommand)] split; no-subcommand
path still runs the node unchanged). Candidate selection is signature-based, not
per-DID: a repo qualifies only if owned by the named burst DID AND verified empty
(zero git refs) per repo. A hard exclusion gate runs BEFORE the empty check, so
an empty repo owned by the content-bearing user or the mirror-bot DID is never a
candidate. Dry-run is the default; deletion needs an explicit --execute flag and
operates per-repo, never 'all repos of DID X'.

Tests (RED->GREEN, exclusion gate confirmed load-bearing by targeting an excluded
DID directly): empty target listed; target-with-refs spared; empty excluded/
intern repos spared (must-assert); dry-run deletes nothing; execute removes only
the empty target on disk.

* fix(review): make purge-spam empty-check reject git-discovery reads

P1 from code review: on_disk_ref_count shelled 'git for-each-ref' with only the
repo path as cwd and no --git-dir, so a path that exists but is not a bare repo
let git discover a parent .git (repos_dir may sit inside the operator's checkout)
and read a DIFFERENT repo's refs — possibly 0, deleting a real repo. Now require
the bare-repo markers (HEAD file + objects/ dir) before trusting any count;
anything else fails closed (skipped).

Tests: a non-git dir under a git ancestor (zero-ref) now fails closed — verified
RED without the guard (read the ancestor's 0 refs); target-DID-never-excluded
invariant pinned; write_flood test anchored to a genuinely-drained bucket so its
assert_ne can't pass vacuously.

* fix(review): normalization-consistent purge, brake helper, CLI + coverage

Addresses the remaining code-review findings:
- purge-spam DID matching now uses did:key normalization on both sides
  (list_repos_by_owner_did via OWNER_KEY_CASE_SQL, is_excluded and the target
  scope via normalize_owner_key), so short/full form is consistent and an
  excluded identity is protected in either form. Adds a short-form exclusion test.
- Extract the per-IP write brake into a chainable WriteBraked helper so a future
  write group can't silently ship unbraked (kept separate from add_auth_layers
  by design; documented why).
- Mark --repos-dir / --database-url global so admin subcommands accept them after
  the subcommand (was an 'unexpected argument' parse error); verified.
- Document GITLAWB_WRITE_RATE_LIMIT in .env.example incl. the per-IP NAT-aggregate
  caveat so operators can size it.
- Coverage: adoption-floor (under-limit write passes), write-limit=0 disables,
  task/bounty/profile route-level 429s, /graphql/ws stays unbraked.

Full suite 927 pass / 0 fail; fmt + clippy -D warnings clean.

* test(review): close the reasoned-not-run gaps by execution

- purge-spam execute now re-verifies emptiness immediately before delete (TOCTOU:
  a push between selection and delete no longer risks deleting a now-non-empty
  repo) and removes the on-disk bare repo dir so DB and disk stay consistent; a
  per-repo delete failure warns and continues rather than aborting the batch.
- Tests (RED-verified where they cover new behavior): partition_for_delete skips
  a repo no longer empty; execute removes the on-disk dir (RED without the
  removal); list_repos_by_owner_did finds a short-form burst row (RED without the
  SQL normalization); every braked write group passes under-limit (per-group
  grant path, not just the star representative).

* fix(node): reject path-traversal repo names in purge-spam before remove_dir_all

A peer-mirror row skips API name validation, so a burst-owned repo can carry
a '../' name; repo_disk_path joined it verbatim and the emptiness marker check
passed on the traversed target, so purge-spam selected and remove_dir_all'd a
repo OUTSIDE repos_dir. Validate the name via repo_store::validate_repo_name at
selection (fail closed -> non-candidate) and assert canonical containment inside
repos_dir immediately before the destructive remove. Adversarial RED->GREEN: a
victim bare repo reachable via '../../victim' is deleted before, survives after;
symlink-escape covered by path_within.

* fix(node): delete child rows transactionally in delete_repo_by_id

delete_repo_by_id was a bare DELETE FROM repos with no cascade and the schema
has no FKs, so every child row (ref_certificates, pull_requests + pr_reviews/
pr_comments, webhooks, push_events, protected_branches, repo_stars, repo_replicas,
repo_labels, visibility_rules, encrypted_blobs, repo_icaptcha_proofs, agent_tasks,
and the slug-keyed branch_cids/sync_queue/received_ref_updates/arweave_anchors)
was orphaned and could re-join a later repo reusing the id. Wrap parent+child
deletes in one transaction, resolving the owner_short/name slug for the repo-TEXT
children. bounties (financial) and issue_comments (unmappable) are left intact by
design. RED->GREEN test seeds a repo_id child, a slug child, and a PR grandchild.

* fix(node): reap write_rate_limiter in the periodic cleanup loop

The PR's new write_rate_limiter was the one limiter omitted from main()'s
hand-maintained cleanup list, so its key map never shed expired entries until
the 200k cap. Move the sweep into AppState::cleanup_rate_limiters (co-located
with the limiter fields, single source of truth) and call it from the loop, so
a newly-added limiter is reaped rather than silently dropped. Guard test seeds a
reclaimable entry into write_rate_limiter and asserts the sweep reaps it — RED
if write_rate_limiter is dropped from cleanup_rate_limiters (verified).

* fix(node): hold the per-repo advisory lock across purge recheck+delete

purge-spam took no write lock, so a concurrent receive-pack could land a ref
between the emptiness recheck and the delete, silently losing a live push.
Add RepoStore::try_lock_repo — a lock-only, non-blocking counterpart to
acquire_write (no Tigris I/O) whose guard holds a dedicated pooled connection so
the advisory lock and its unlock run on the same session. purge now locks each
candidate, rechecks emptiness UNDER the lock, deletes, and releases; a repo held
by a live writer is skipped, not force-deleted. RED->GREEN: a locked empty repo
survives purge, then deletes once the writer releases (neuter-check confirms the
lock is load-bearing).

* fix(node): count purge-spam disk-removal failures separately from deletes

The Ok(n) arm incremented the deleted total before remove_dir_all, whose failure
only warn!'d, so the summary reported N deleted even when the on-disk dir
survived (DB/disk drift shown as clean success). run_purge_spam now returns a
PurgeSummary and counts a failed (or containment-refused) on-disk removal in
disk_failed, never folding it into deleted; the summary line surfaces
'M on-disk removal(s) failed'. RED->GREEN: a read-only parent dir forces the
removal to fail; deleted=1 AND disk_failed=1 with the dir surviving (neuter-check
confirms the counter is load-bearing).

* fix(node): warn on unparseable rate-limit env vars instead of silently defaulting

GITLAWB_CREATE/WRITE/PUSH_RATE_LIMIT parsed with .ok().and_then(parse).unwrap_or,
so a typo (e.g. 5O with a letter O) silently reverted to the default and left the
operator believing a stricter cap was in force. Route all three through
rate_limit_from_env, which distinguishes absent/empty (default, silent) from
present-but-unparseable (default, WARN). Pure resolve_rate_limit is unit-tested
across absent/empty/valid/zero/unparseable — 0 stays valid (its own ==0 warn
handles the disable case).

* docs(node): document GITLAWB_IPFS_API/TIGRIS_BUCKET/METRICS_ADDR/SHUTDOWN_GRACE_SECS in .env.example

Four config knobs read in config.rs were absent from .env.example, so operators
had no template for them. Add each in its matching section with the config
default (INV-24: operator docs match the config the code reads, in the same PR
that touches the file).

* test(node): exercise the dry-run guard with a real on-disk candidate present

dry_run_deletes_nothing ran against an empty repos_dir, so select_spam_candidates
found nothing and run_purge_spam hit the no-candidate early return — the
if !execute { return } guard was never exercised with candidates present (a
vacuous guard, INV-21). Materialize a real empty bare repo so a genuine candidate
exists, then assert dry-run leaves both the row and the on-disk dir. Verified
load-bearing: removing the !execute guard turns the test RED (dry-run deletes).

* style(node): cargo fmt for the #196 fix set

* fix(node): pin the writer's advisory-lock connection in RepoWriteGuard

acquire_write took the session-scoped advisory lock through the shared
pool and returned the lock-owning connection to the pool, so release
unlocked on a different connection (silent no-op) and a concurrent
try_lock_repo could be handed the idle lock-owning connection and
reentrantly re-grab the lock, defeating the purge tool's mutual
exclusion. Pin a dedicated PoolConnection for the guard's lifetime and
unlock on it, mirroring RepoLockGuard.

Load-bearing contention test (single-connection pool to force the
reentrant grab deterministic): RED (Ok(Some)) before, GREEN after.

* refactor(node): introduce ObjectStore trait seam over TigrisClient

Extract an async ObjectStore trait (exists/upload/download/delete) that
TigrisClient implements, and hold Option<Arc<dyn ObjectStore>> in
RepoStore instead of Option<TigrisClient>. A dyn async trait needs
async-trait (added as a direct dep; already transitively present), which
E0038 would otherwise reject. Pure refactor — no behavior change; full
suite stays green. Enables an in-test fake for the purge Tigris work (U3).

Cargo.lock also picks up the pre-existing 0.5.0->0.5.1 version sync
(#185: the committed lock was stale vs the manifests) that any cargo run
regenerates.

* fix(node): make purge-spam Tigris-authoritative and archive-deleting

purge-spam built its RepoStore with no object store, so on a Tigris
deployment it rechecked emptiness against possibly-stale local disk
(deleting a repo with live remote refs) and left the archive behind (to
be re-downloaded into a later same-owner/name repo). Give the admin
command the configured object store, refresh the local copy from the
authoritative archive under the per-repo lock before the recheck
(fail-closed on a store error via a new skipped_store_error), and delete
the archive on a successful purge (failures counted in a new
archive_failed, never folded into success). When no bucket is configured
the None path is byte-for-byte unchanged.

Three load-bearing guards verified RED->GREEN on the Tigris-blind purge:
remote-refs-not-deleted, archive-deleted, fail-closed-on-unreachable.

* fix(review): don't hold/leak the pool connection in acquire_write

Two regressions the connection-pinning fix (010c625) introduced, caught
in pre-push review:

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

Add acquire_write_guard_excludes_try_lock_across_connections: a
multi-connection test proving the exclusion holds across sessions (the
production topology), which also catches a pin-but-don't-lock regression
the single-connection test cannot.

* fix(node): make advisory-lock guards drop-safe and reorder acquire_write

RepoWriteGuard and RepoLockGuard now hold their pooled connection as an
Option: release() takes it (returns it to the pool) while a Drop impl closes
the connection when release() was never reached — an early ?, a panic, or the
handler future cancelled on client disconnect. Closing the connection ends the
Postgres session so the server frees the session-scoped advisory lock, instead
of returning a lock-holding connection to the pool where it would block every
later write to that repo until the connection is reaped (up to ~30min at the
pool's default max_lifetime).

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

Tests observe the leak by disabling the pool's ambient reaping (which otherwise
masks it ~1.8s after drop), so the lock stays held until the fix's own unlock.
RED before / GREEN after for both guards and the acquire-side cancel; each
protection line proven load-bearing by revert (INV-21).

* fix(node): complete a fully-received push after client disconnect

git_receive_pack now runs the acquire -> receive-pack -> release core in a
spawned task the handler awaits but whose cancellation it does not propagate.
The pack body is fully buffered before the handler runs, so the task is
self-contained: a client that disconnects mid-apply drops the handler future
without cancelling the task, and the push still applies, uploads, and releases
its lock in order. Drop-safety (the guard's close-on-drop) remains the backstop;
this closes the dominant window where a disconnect both bricked the lock and
abandoned a received push. The post-push bookkeeping tail stays in the
cancellable handler (a disconnect still forgoes it, as today).

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

Disconnect regression drives a real push with a sleeping pre-receive hook and
drops the handler mid-hook: RED on the inline handler (the git group is killed,
ref absent), GREEN once detached.

* fix(node): serialize archive uploads under the per-repo advisory lock

The lazy-migration upload in acquire(), init()'s background upload, and
release_after_write() (fork's upload) previously PUT to the object store with
no lock, so an in-flight upload could resurrect an archive purge-spam had just
deleted under the same lock, and init's background upload could clobber a
freshly-pushed archive. All three now go through upload_locked(): take the
per-repo lock, re-check the local dir exists under it (a purge removes the dir
under its lock, so a post-purge uploader finds it gone and skips), upload, then
release. The background migration/init uploads skip on contention and self-heal;
fork's foreground upload waits (bounded) instead, since a skipped fork upload
has no later retry.

Tests (recording object-store double): init upload skips under a held lock,
fork upload waits then PUTs once after release, a late upload after a
purge-style delete does not resurrect the archive, and an uncontended upload
PUTs once. Load-bearing per INV-21 — neutralizing the lock+dir-check turns the
three contention/resurrect tests RED.

* fix(node): let purge-spam reach repos that exist only as archives

On a multi-machine (object-store) deployment a spam repo can exist only as a
Tigris archive with no local copy. Selection filtered on a ref count that
returns non-empty for a missing local path, so such a repo was never a
candidate and the tool it was hardened for could not reach it. Selection now
distinguishes missing-local (Option::None) from a one-ref repo and admits a
missing-local row as a *remote-unverified* candidate only when an object store
is configured (via RepoStore::has_object_store); partition_for_delete passes
those through rather than re-dropping them on the local ref count. The execute
loop is unchanged — it already refreshes from the archive and rechecks emptiness
under the per-repo lock — so a remote-only empty archive is deleted, one with
refs is refreshed and skipped, and missing-both fails closed (refresh no-ops,
the local recheck reports non-empty). Storeless deployments keep the old
fail-closed behavior. Dry-run marks remote-only candidates distinctly and counts
them in a new PurgeSummary field.

Tests (recording object-store double): remote-only empty archive deleted,
remote-only archive-with-refs skipped, missing-both fail-closed, storeless
missing-local not a candidate. Load-bearing per INV-21 — reverting the
store-configured admission turns the three remote-reach tests RED.

* refactor(node): dedup limiter setup + write-brake test driver; fix .env wording

- .env.example: the write brake wraps the whole /graphql route, so its docs now
  say it covers all GraphQL HTTP requests (queries and the playground GET), not
  only mutations, with WebSocket subscriptions called out as excluded.
- main.rs: collapse the identical create/write/push per-IP limiter setup into
  build_ip_limiter (resolve-env, build bounded, warn-on-zero), preserving the
  exact limits/defaults and the present-but-unparseable warning in
  rate_limit_from_env; each limiter now also info-logs its configured value.
- api/repos.rs: the nine per-IP write-brake tests share a send_from helper
  mirroring rate_limit.rs's post_from/post_with, replacing the repeated
  request-build + ConnectInfo + oneshot + status boilerplate. Behavior identical.

* docs(node): drop the .env.example shutdown-grace entry #196 added (#196)

The GITLAWB_SHUTDOWN_GRACE_SECS field, its doc-comment, and main.rs's
grace handling pre-date this PR (#22); #196's diff only added the
.env.example entry, whose comment promised a 503-on-expiry the node does
not implement. Remove just that entry. PR #194 owns the real bounded
drain and the config prose in disjoint files.

* fix(node): give advisory-lock guards a dedicated pool and bound the release upload (#196)

Repo-write guards pinned a connection from the shared app pool for the
whole receive-pack plus an untimed archive upload, so ~20 concurrent
distinct-repo pushes starved unrelated DB handlers. Guards now draw from
a dedicated lock_pool (GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS, default 32,
sized to peak writers) separate from the app pool that serves handlers
and the purge delete, and release()'s Tigris upload is wrapped in a
timeout so the connection hold is bounded on every path. The guard still
locks and unlocks on its single held connection (no off-session unlock).

Db::pool() is now test-only since nothing in the bin reads the raw app
pool directly.

* fix(node): advertise a window-derived Retry-After on rate-limit 429s (#196)

Both 429 sites hardcoded Retry-After: 60 while every limiter uses a
one-hour window, so a compliant client retrying at 60s kept hitting 429
for nearly another hour. The limiter now returns the delay from its
oldest live timestamp (window - elapsed, clamped >= 1); a cap-full
rejection with no bucket advertises the full window. The check() -> bool
API is preserved as a wrapper so unrelated callers are untouched.

* fix(node): skip slug-scoped purge cascade when a mirror row shares the slug (#196)

delete_repo_by_id cascaded branch_cids/sync_queue/received_ref_updates/
arweave_anchors by the normalized owner/name slug, which is non-unique
across repos rows (INV-9): a did:key:zX row and a bare zX mirror of the
same name share the slug. Purging an empty did:key row therefore wiped a
surviving non-empty mirror's branch->CID index and Arweave anchors. The
cascade now runs only when no other repos row resolves to the same slug
(checked with the same OWNER_KEY_CASE_SQL the slug is built from, on the
delete's own transaction); the physical row and id-keyed children are
always removed. The concurrent colliding-sibling-create window stays an
accepted operator-scoped residual.

* fix(node): fail closed classifying the purge target directory (#196)

local_refs_on_disk returned None for a missing path, an existing
non-bare directory, AND an unreadable path alike; with a store
configured, None promotes the candidate to remote_unverified and the
archive refresh's remove_dir_all replaces whatever sits at the target.
It now returns None only for a verified NotFound (symlink_metadata +
ErrorKind::NotFound, so a dangling symlink counts as present); every
other state fails closed as present so a non-repo directory at the
target is never overwritten.

* fix(node): run the push completion tail in the disconnect-surviving task (#196)

The receive-pack refactor detached only acquire/apply/release; the whole
success tail (push+trust records, ref certificates, webhooks,
replication/pinning, ref-update broadcast, peer-notify) ran inline in
the cancellable request future. A client disconnect mid-apply committed
and uploaded the git state but dropped that tail, leaving accepted git
history with none of the metadata or fan-out every other push produces.
The tail now runs inside the detached task, gated on receive-pack Ok;
report-status still returns to a connected client early over a oneshot
(never awaited on the JoinHandle), and a dropped receiver no longer
aborts the tail. guard.release runs on every path.

* fix(node): tombstone bounties when their repo is purged (#196)

Bounties are keyed only on (repo_owner, repo_name) with no repo id, and
purge left them intact, so recreating the same name re-attached the old
open bounty and claim_bounty succeeded against it. Purge now transitions
the repo's non-terminal bounties (open/claimed/submitted) to a dead
'purged' status on the delete's own transaction, preserving the
financial record. 'purged' is inert against every mutating bounty
endpoint, including dispute (which would otherwise reset claimed/
submitted back to open).

* fix(node): serialize create_repo against the purge advisory lock (#196)

Purge holds a per-owner/name advisory lock across delete-row +
remove-dir, but create_repo took no synchronous lock, so a concurrent
same-key create slipped into the row-deleted-but-dir-not-removed window
and left a repos row pointing at storage the purge then removed.
create_repo now acquires the same lock (lock_repo_blocking -> the guard
the purge holds, keyed identically) around its existence-check, init,
and insert, releasing on every path. Serializes a same-key create
against a purge; the INV-9 colliding-sibling case stays the accepted
KTD4 residual.

* test(node): prove purge deletes at MAX_CONNECTIONS=1 with the split pools (#196)

The split pool from the earlier fix already resolves the one-connection
self-deadlock (the guard holds a lock_pool connection while the delete
runs on the app pool), so this locks that wiring in: a purge at
app-pool MAX_CONNECTIONS=1 deletes the target row. The test is
load-bearing for the wiring - a shared-pool store (guard and delete on
one pool) deadlocks the delete's begin() on the held connection and
deletes nothing. No production change; U2 did the wiring.

* fix(node): fail the push when the durable upload times out, don't 200 (#196)

The bounded release() upload (from the lock-pool fix) abandoned a slow
PUT, freed the lock, and returned no failure signal, so receive-pack
reported 200 while the archive never reached object storage — and the
next acquire_write downloaded the stale pre-push archive over local
disk, reverting the pushed refs. A timed-out or errored durable upload
now returns upload_ok=false; the handler sends a 5xx and skips the
success tail so the client re-pushes idempotently instead of trusting a
lost commit. The lock still frees on every path and the connection-hold
bound is unchanged.

* fix(node): tombstone bounties under both owner forms on purge (#196)

The tombstone matched only the repo's full did:key owner, but
create_bounty stores repo_owner verbatim from the URL path — commonly
the bare short form. A short-owner-form bounty therefore stayed open and
re-attached to a recreated repo, the exact hole the tombstone closes for
the full form. The UPDATE now matches repo_owner IN (full, normalized
short) so both forms are tombstoned. Adds short-form + submitted-status
fixtures and asserts dispute cannot reopen a purged bounty.

* fix(node): fail closed on a symlink at the purge target (#196)

local_refs_on_disk used symlink_metadata only to detect NotFound, then
the HEAD/objects marker checks followed a symlink — so a symlink to a
real empty bare repo outside repos_dir was admitted as an empty
purgeable candidate, and the DB row was deleted before path_within
refused the dir removal. The symlink is now classified present/skipped
before the marker checks. Adds outside-empty-bare and dangling-symlink
tests.

* fix(node): retry-after on the push-advert 429, and create_repo locks on the app pool (#196)

Two review follow-ups:

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

- create_repo's transient serialization lock drew from the write
  lock_pool, so a burst that pins lock_pool starved repo creation.
  Postgres advisory locks are database-global, so create now takes its
  lock from the app pool (via lock_repo_blocking_on) and still
  serializes against a purge holding the same key from lock_pool.

* fix(node): correct the create-pool and bounty-tombstone remediations (#196)

A re-run adversarial panel caught three issues in the prior round's own
fixes:

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

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

Tests: create-at-1-app-connection (no self-deadlock), bare-row full-form
tombstone, and sibling-survives-keeps-bounty-open, each RED before / GREEN
after.

* fix(node): close the durable-upload data-loss class on every write path (#196)

The earlier fix that fails a push when its Tigris upload times out was
wired only into git_receive_pack; the merge, issue-create, and
issue-close write paths still ignored release()'s result and returned
2xx after a failed upload, so the next acquire_write reverted their
change from the stale archive. release() is now #[must_use], which
surfaces every caller: the production upload paths fail with a 5xx on a
false return (so the client retries), and the internal/test callers are
explicitly justified. create_repo's lock-acquire failure now returns a
retryable 503 (new AppError::Unavailable) instead of a 500, matching its
own contract.

* refactor(node): route the per-DID 429 through the shared too_many_requests helper

* fix(node): init's background upload waits out the creator's advisory lock (#196)

create_repo holds the per-repo lock across init, so the wait=false spawn
try-locked against its creator and died permanently, leaving a new repo
with no object-store archive until its first later access. RED observed:
init_upload_waits_out_creators_lock_then_uploads failed (PUT count 0)
against the old code; the bounded-skip negative is retained as
init_upload_gives_up_after_bounded_lock_wait.

* fix(node): roll back the local issue ref before unlock when the durable upload fails (#196)

The failed-upload 500 previously left refs/gitlawb/issues/{id} on disk,
so a client retry minted a second issue once the store recovered. The
delete now runs via RepoWriteGuard::release_with_failure_cleanup while
the advisory lock is still held, closing the unlock-to-delete window a
concurrent write could have archived the orphan ref through. RED
observed on both new tests (orphan ref survived; retry yielded 2
issues); the retry test pins the store double's exists()=false contract
so it cannot pass via a stale-archive revert.

* fix(node): fork publishes only after a durable upload, serialized on the target-namespace lock (#196)

fork_repo now takes the per-repo advisory lock before its conflict check
and holds it through clone, upload, and row insert, 503ing on contention
exactly as create_repo does. The upload runs under the held guard via
upload_under_guard, PUT bounded by release_upload_timeout, returning
false only for an attempted upload that failed or timed out (no-store
nodes fork unchanged); on failure the mirror is removed and the fork
500s before any row is built. release_after_write is retired; its tests
migrated onto the new method. RED observed on the serialization,
durability, and contention tests; defect injection (row insert reordered
above the upload) turned the durability test RED and was reverted.

* fix(node): read-path downloads publish under the purge lock, without pinning it across the network (#196)

acquire's cache-miss and every acquire_fresh refresh now coalesce on a
per-repo download mutex, download to a temp sibling with no advisory
lock held, then try-lock, re-check the archive exists under the lock,
and swap - so an in-flight read can no longer resurrect a repository
purge just deleted (RED observed on both the cache-miss and
refresh-over-existing repros), a post-purge reader degrades instead of
publishing, and concurrent cold reads download once. The hit path stays
lock-free (deterministic held-lock fence) and no lock-pool connection is
held while a download is parked.

* fix(node): bound every under-lock object-store call and dedup the upload path (#196)

Round-two review found the durability fixes reintroduced the
unbounded-work-under-lock class they were meant to close: upload_locked's
PUT (now reachable under the lock via init's wait=true), the read-path
download, and the under-lock exists() re-check all ran with no timeout
while pinning the advisory lock and its lock-pool connection. All three
now wrap the call in timeout(release_upload_timeout) and fail closed on
elapse, matching RepoWriteGuard::release's existing INV-22 pattern.
upload_under_guard and upload_locked's duplicated resolve/recheck/upload
body is extracted into upload_dir_locked, threading the load-bearing
dir-gone signal (fork needs success, lazy migration needs
skip-and-retry) through a parameter rather than aligning it. RED
observed: upload_locked_put_stall_is_bounded failed (lock never freed)
against the un-timeout'd PUT.

* fix(node): harden the read-path download against cancellation, stale swaps, and lock contention (#196)

Four read-path gaps the review surfaced. A TempDownloadDir RAII guard
now cleans the temp download dir if the handler future is cancelled on
client disconnect (the read handlers have no spawn shield, and a
post-download disconnect leaked the full populated dir), plus an
entry-time sweep of stale siblings. A cold or refresh reader that
downloaded while a concurrent writer published fresher on-disk state now
discards its stale temp and serves the writer's copy instead of swapping
over it (detected by presence for the cold path, mtime for the refresh
path). The takeover path re-registers its download-mutex entry so late
readers coalesce instead of starting a duplicate download. And the
try_lock Ok(None) degrade arm - the KTD-3 invariant that a reader
degrades rather than resurrecting under a live writer/purge - is now
tested by holding the advisory lock via an out-of-pool observer. RED
observed on both stale-swap tests.

* fix(node): keep the fork's unbounded work out of the target-lock span (#196)

The fork tail held the target-namespace advisory lock across two
unbounded operations. The source acquire, which on a cold source
triggers a full object-store download plus a nested lock-pool acquire on
the same writer-sized pool, now runs before the target lock is taken (it
is read-only on a different namespace and nothing under the lock depends
on it) so a cold-source fork can no longer demand two connections from a
one-per-writer pool at once. The git clone --mirror now runs via
tokio::process with kill_on_drop under a timeout(git_service_timeout_secs)
ceiling, cleaning the partial mirror and releasing the lock on elapse.
RED observed by executing the pool-size-1 cold-source fork with the
source acquire moved back under the lock: PoolTimedOut, source never
publishes, clone fails.

* refactor(node): move read-path download coordination into a repo_store submodule (#196)

repo_store.rs had grown past 2800 lines. The download-coordination code
(DownloadOutcome, TempDownloadDir, download_published,
download_and_publish, remove_download_entry) moves to a private
repo_store/download.rs child module, which reaches the parent's private
fields and methods without widening visibility beyond pub(super) on the
two items the parent names. Pure move: no behavior change, the same 44
git::repo_store tests pass unchanged.

* fix(node): bound acquire_write's download and free the download-map entry on cancel (#196)

Two gaps a second-model adversarial pass caught that the in-family
review missed. acquire_write's under-lock exists()/download() ran with
no timeout, so a stalled Tigris GET pinned the write lock and its
lock-pool connection forever (the local-copy fallback only fires on an
error, never a hang) - both are now wrapped in
timeout(release_upload_timeout), a timeout mapping to the existing
download-error fallback, which also makes the prior 'bound every
under-lock call' claim true. The download_locks map entry was removed
only on normal return, not on a cancelled cold read, so a disconnecting
caller could grow the map across public repos; a DownloadEntryGuard now
prunes it in Drop (outer map moved to std::sync::Mutex, held only for
map ops, never across an await). RED observed on both new tests,
including a real mid-download cancellation.

* fix(node): roll back the fork mirror and archive on late clone/insert failures (#196)

The bounded-clone rework cleaned up the mirror on the timeout and spawn
arms but not on a clone that creates the destination then exits nonzero,
so a retry hit 'destination already exists' and could never complete
that fork name. And because upload_under_guard is now required and runs
before the row insert, a create_repo failure reliably orphaned the
durable archive too, not just the local dir. Both failure arms now
remove the mirror, delete the fork's archive (no-op on a store-less
node), release the lock, and return. RED observed on both new tests (a
non-empty-dest clone-128 repro and a decoy-row insert-failure repro).

* fix(node): serialize repo purge cascade with mirror ingest via advisory lock

delete_repo_by_id ran a point-in-time sibling check then cascaded slug-scoped
deletes; upsert_mirror_repo (peer sync) did not serialize against that window,
so a colliding mirror could land mid-purge and get half-wiped. Both now take a
transaction-scoped pg_advisory_xact_lock keyed on the normalized slug. (F1, #196)

* fix(node): roll back local ref on failed durable upload (close/merge/receive-pack)

close_issue, merge_pr, and the receive-pack task applied a local ref write then
released with an empty failure cleanup, so a failed Tigris upload left stale
state that the local-fast-path read served despite the client getting a 5xx.
Each now captures the prior ref and restores it via release_with_failure_cleanup
when the upload fails, matching create_issue. (F2, #196)

* fix(node): tie cancelled cold-download cleanup to blocking extraction

On a download timeout the TempDownloadDir guard dropped while the detached
spawn_blocking extraction kept running and could rename its output back into
the just-removed temp path; that dir was only reclaimed on a later same-repo
request, so cold reads across distinct repos accumulated extracted repos on
disk. The timeout arm now hands the owned download future to a detached janitor
that awaits extraction completion before removing the temp dir, off the
critical path so waiters are still freed immediately. (F3, #196)

* chore(node): fmt + clippy cleanup for the #196 fixes

Auto-deref (&mut tx) on the lock_repo_slug calls and rustfmt of the new test
lines. No behavior change.

* fix(node): never let a failed ref snapshot become a delete-all rollback; cover merge and push rollbacks

* fix(node): slug-lock every peer-ingest writer and drive the real ingest in the purge-race test

* fix(node): own cold-download cleanup in a spawned task so handler cancellation cannot resurrect temp dirs

* refactor(node): delegate issue ref helpers to the store ref primitives

* fix(node): slug-lock arweave anchor writes; fail closed when the pre-push ref snapshot fails

* fix(node): close review residuals: coalesce cold downloads to one in-flight task, compensate a timed-out upload with the rolled-back tree, aggregate partial ref restores, race-test the anchor lock, fail-closed snapshot test

* fix(node): re-apply create/fork purge serialization and restore brake coverage

The merge-forward kept main's durable-write machinery but dropped two
feature pieces the tests now prove are load-bearing:

- create_repo/fork_repo take the per-owner/name advisory lock
  (lock_repo_blocking) across their existence-check -> write -> row-insert
  span, so neither can land inside a same-key purge's delete-row +
  remove-dir window. Contention maps to RepoBusy (503). fork_repo also
  materializes the source before the target lock and bounds the mirror
  clone, clearing the partial dest on non-zero exit, spawn failure, or
  timeout.
- The per-IP write-brake coverage is restored against the merged router:
  per-group 429s, write/creation bucket separation, /graphql POST braked
  while /graphql/ws keeps its DID layer outside the brake, under-limit
  floors, and the zero-disable knob. The purge serialization tests drive
  try_lock_repo directly, and the fork durable-write tests run on the
  shared S3 mock: an ambiguous upload keeps the clone for reconciliation,
  and a row-insert failure compensates mirror and attempt-stamped archive.

* fix(node): close review gaps in purge, locks, and fork cleanup

- try_lock_repo_on routes the acquire through LockProbe so cancellation
  cannot return a lock-owning connection to the pool
- run_pending_migrations runs on the connection holding the advisory
  lock, so a max_connections=1 purge cannot deadlock at startup
- purge-spam canonicalizes repos_dir once; the ./repos default's CurDir
  component made validated_repo_disk_path reject every candidate
- delete_archive fences on the generation verified empty; a PUT landing
  after the check makes the delete refuse instead of erasing it
- refresh and archive delete run under transfer_bound while the repo
  lock is held, so a stalled transfer cannot pin the lock
- init_handle returns the archive upload JoinHandle; create_repo awaits
  it before releasing the creation lock so a purge or retry cannot find
  the row with the PUT still in flight
- ForkCloneGuard is created before the clone and claim_fork_disk_path
  creates the owner dir for the stamp; every clone failure, timeout,
  and cancellation path now cleans only what this attempt owns

* fix(node): subscriptions-only ws schema and unsettled-work purge guard

- /graphql/ws serves a schema with no mutation root, so the socket can
  no longer execute task mutations outside the write-brake bucket;
  queries and subscriptions still run, and the schema carries the same
  Db, broadcast, and cursor-key data the resolvers consume
- purge-spam skips a repo that still has unsettled receive-pack
  requests or pending ref transitions (checked under the repo lock,
  fail-closed) instead of stranding that work mid-effect

* fix(node): wire close_issue rollback, close purge symlink gap, correct ws doc

- close_issue snapshots the pre-close JSON and uses release_compensating,
  so a failed durable upload restores the ref instead of leaving the
  local repo closed while the client saw a 5xx (mirroring create_issue)
- ref_count_on_disk rejects a symlink at the repo path, matching the
  selection-side fail-closed rule so the execute recheck cannot follow
  a link to an empty bare repo outside repos_dir
- build_ws_schema rustdoc now states mutations are disabled via
  EmptyMutation while queries still run on QueryRoot

* fix(node): close purge/upload lifecycle gaps from review

- purge-spam execute: the object-store archive delete now runs on every
  successful row delete; a refused or failed local dir removal no longer
  strands the archive (P1)
- purge-spam summary: skipped_unsettled is now printed, and a dedicated
  skipped_unsettled_probe counter records fail-closed skips when the
  unsettled-work probe itself errors (P2)
- close_issue: rollback compensation uses create_issue_bounded, the same
  deadline-bounded git strategy as create_issue's compensator, so a hung
  git cannot stall the write guard past git_service_timeout (P2)
- create_repo: the initial archive upload's outcome now gates the 201; a
  failed or fenced upload rolls back the row and disk dir and surfaces
  an error instead of reporting success (P2)
- purge-spam: tigris_bucket is clap-global so --tigris-bucket parses
  after the subcommand (P3)

* fix(node): merge rollback, honest create rollback, purge row recheck

- merge_pr snapshots the pre-merge target tip and uses
  release_compensating to rewind it via bounded update-ref on a
  refused publish, matching the close_issue pattern; regression proves
  the ref returns to its prior OID after a refused upload (P1)
- create_repo rollback names residual state instead of claiming a full
  rollback when the row delete fails or dir removal fails (P2)
- purge execute re-checks row absence via get_repo_by_id immediately
  before remove_dir_all and refuses the disk delete when a row
  reappeared after commit, closing the post-commit mirror-ingest
  window (P2)
- sweep_evicts_expired_keys_from_every_limiter now seeds and overrides
  the write_rate_limiter TTL (P3)

* fix(node): probe purge slug liveness, gate issue write brake behind the read gate (#196)

Three items from review round 6:

- merge_pr rollback test seeded commits with git commit-tree under no
  configured identity, which fails on CI runners. The helper now passes
  -c user.name/-c user.email per invocation.
- run_purge_spam's post-delete guard probed the deleted primary key, but
  a concurrent upsert_mirror_repo lands a NEW row for the same slug with
  a mirror id, and the archive delete ran unconditionally. The probe now
  reads slug liveness via get_repo(owner, name) and gates BOTH the disk
  and archive deletes, counted as skipped_slug_live.
- issue_write_routes carried the outer per-IP write brake, so an
  exhausted bucket answered 429 before authorize_repo_read could return
  the opaque 404 a hidden repo owes the caller. The brake is now debited
  inside close_issue and create_issue_comment, immediately after the
  read gate; the route group carries no outer brake.

* fix(node): close-issue rollback uses resolved ref, drop router brake test, tag fixtures get -m (#196)

- close_issue's compensating rollback now rewrites the resolved full issue
  id; a close-by-prefix refused at publish left the full ref closed while
  the client saw 5xx (P2, regression test included)
- issue_comment_is_rate_limited_by_ip asserted an outer 429 the issue
  write routes deliberately lack (P1); issues.rs owns the ordering
- git tag fixtures pass -m so dumb CI terminals do not hit "EDITOR unset"

* fix(node): close_issue spends neither bucket when either cap refuses, complete write-brake operator copy (#196)

close_issue chained two consuming limiter checks, so a close refused by its
own cap still debited the shared write bucket. Probe both with is_throttled
first and consume only when both have room. Rewrite the write-brake consumer
list in .env.example, lib.rs and state.rs to cover every surface on the bucket.

* fix(node): refund the write slot when the close cap refuses, send Retry-After on write-bucket 429s (#196)

close_issue debited the write bucket and then the close bucket, so a
concurrent close taking the last close slot between the probe and the
check left the write slot spent with no close done. Drop the probes:
debit write, then close, and refund the write slot when close refuses.

The in-handler write-bucket refusals in close_issue and
create_issue_comment now return AppError::RateLimited, which maps to 429
with a window-derived Retry-After like the router middleware's 429s. The
close-cap-only refusal keeps its plain 429.
2026-11-13 01:52:04 +00:00
beardthelionandanandh8x 5a7576b682 feat: add push, fetch, and sync latency metrics (#580)
* feat: add push fetch and sync latency metrics

* Merge remote-tracking branch 'origin/main' into pr-362

* fix(node): sample sync lag at mirror visibility, clamp untrusted origin ages

- the lag observation moves to the upsert point, before the
  encrypted-blob tail and mark_sync_done, so the metric reads
  push-to-visible rather than push-to-fully-replicated
- a quarantined mirror is admitted but withheld from readers and now
  reports no visibility lag
- MAX_SYNC_LAG_OBSERVATION (24h) discards absurd sender-supplied origin
  ages that would park fabricated values in the tail buckets; the
  timestamp is unauthenticated peer input
- the migration lands at v47 after main's slot moves; the test
  initializer gains origin_timestamp

* fix(node): split sync-lag origin from gossip publish freshness

The single origin_timestamp captured after the durability wait was reused
as the signed RefUpdateEvent timestamp, so a tail that published more
than 600s later minted an already-stale event that verified ingest drops
in check_freshness, silently disabling gossip auto-sync on mirrors.

- Capture the lag origin at push landing, before the durability wait, and
  carry it on a new optional signed field RefUpdateEvent::lag_origin
  (serde default, skipped when absent, so events from older peers still
  parse and the null-optional signing shape is byte-identical).
- Sign the publish instant in event.timestamp again, matching merge-base,
  so check_freshness sees the time the event actually went on the wire.
- Ingest now feeds enqueue_sync lag_origin with a timestamp fallback, so
  sync-lag still measures push-to-visible instead of notify-to-visible.
- Re-pin the all-Some GOLDEN_SIGNING_BYTES for the new field; the
  all-None production golden is unchanged. Round-trip test asserts the
  field survives serde.

Also restore the v18 pinned-cid upgrade oracle: the test's rollback
delete targeted schema_migrations version 47 instead of 18, so v18 never
re-applied and the index_exists assertion failed on stable and beta.

* fix(node): version-gate lag_origin, read persisted quarantine, keep push-land instant

- lag_origin ships under v=1: CURRENT_REF_UPDATE_VERSION bumped, ingest
  rejects a v0 event carrying the v1 field, goldens re-pinned for the
  v1 publish shape, and the legacy-parse test now pins the fleet rule
  (v0 still parses, v1 refuses loudly on the v key)
- the sync-lag observation gate reads is_repo_quarantined instead of
  the attempt-local flag, so a re-sync of a quarantined mirror no
  longer samples visibility readers still deny; fail-closed on lookup
- post_receive_replication_tail takes push_landed_at so a deferred
  drain reports the request row's landing instant rather than its own
  drain time; live callers pass the acceptance instant, tests pass None

* fix(node): tighten the two new lag-observation edges

- deferred tails source push_landed_at from the accepted transitions'
  applied_at (the instant refs actually moved), falling back to the
  request row's created_at rather than drain time
- the visibility gate confirms the mirror's ref tip matches the queued
  outcome before sampling, so a row superseded by a newer origin
  update (or a deletion the mirror absorbed) never records lag

* fix(node): split notify publish/land clocks and unify live push-land instant

- HTTP sync/notify now stamps timestamp with send time (publish
  semantics, matching what gossip ingest stores on the feed row) and
  carries the land instant in a new optional lag_origin field; the
  receiver plumbs lag_origin to sync_queue.origin_timestamp with a
  timestamp fallback for older senders
- both live replication-tail spawns pass the request row's acceptance
  instant (the same clock the deferred drain's created_at fallback
  uses) instead of minting a fresh clock at spawn time

* fix(node): use the deferred land-instant rule on live replication tails

Both live post_receive_replication_tail spawns now resolve
push_landed_at through db.request_push_landed_at: the latest
applied_at across this request's applied transitions, falling back to
the request row's created_at, the same rule the deferred drain uses.
The pre-receive-pack acceptance stamp no longer flows into gossip
lag_origin, the HTTP notify land field, or sync_queue.origin_timestamp
on the live path, so live and deferred pushes report one lag origin.
Regression: request_push_landed_at_returns_max_applied_or_none covers
the max-applied arm and both fallback cases.

* fix(node): align notify land surfaces and resolve land instant in-task

- the multi-ref notify regression now matches lag_origin for the
  supplied land instant instead of timestamp, which is a live send
  instant after the publish/land split; the tail comment now names the
  notify lag_origin field rather than timestamp
- both live replication-tail spawns resolve push_landed_at inside the
  spawned task so the handler no longer awaits the lookup while
  holding the reclaimed write lease or leaving the tail un-owned on
  disconnect

* test(node): realign the inv22 tail-spawn gate and metrics help text (#580)

- inv22_replication_tail_spawns_at_the_durability_boundary matched the
  literal tokio::spawn(post_receive_replication_tail( shape, which went
  blind when production wrapped the tail in tokio::spawn(async move {
  ... }). The gate now matches the tail call itself (filtering the fn
  definition) while keeping the disposition.spawn_tail gate, brace
  depth, no-second-spawn, landed-set, and ordering assertions.
- metrics help for gitlawb_push_duration_seconds described receive-pack
  subprocess time but observes the whole successful handler interval;
  gitlawb_sync_lag_seconds described the origin ref-update timestamp but
  samples lag_origin (the land instant). Both strings and the module
  bullet now name what is measured.

* fix(node): tri-state mirror tip probe, full-interval push duration (#580)

- mirror_ref_tip returns a TipProbe (Tip/Absent/Unknown): a failed
  rev-parse is no longer a confirmed-absent ref, so a delete row only
  samples sync lag on proven absence (regression test included)
- observe_push_duration now fires at the response paths (claim-loser and
  post-effects), so the sample covers the whole handler interval the
  client waits on rather than stopping before inline effects

* fix(node): tighten inv22 landed-ref gate, align push-duration help (#580)

- inv22_gates U5: match the third call argument against landed/landed_refs
  instead of contains("landed"), which push_landed_at also satisfied and
  made the proven-landed check vacuous.
- metrics: gitlawb_push_duration_seconds help now describes handler wall
  time through the response including claim-loser returns.
- sync: log mirror-repo registration failures instead of silently
  discarding them.

---------

Co-authored-by: anandh8x <[email protected]>
2026-11-12 23:17:40 +00:00
f426fe8399 docs(node): document the two-tier post-push pinning model (#576)
* docs: document the two-tier storage and pinning model

* docs: clarify pin de-duplication is best-effort, not atomic

* docs: address review — fix table/no-op/peer/budget claims, link from README

* docs: name the hot tier as the unbounded side of the parked-list memory

Also record the two wording residuals from the same round: dedup isolation
is one-directional (warm row presence suppresses a later hot pin), and an
unset Pinata JWT disables uploads but not the tail's permit + walk cost.

* docs(node): narrow the storage doc to its pinning charter, fix the Durability bullet

- opening now says what the page is: optional post-push pinning tiers in
  ipfs_pin.rs and pinata.rs, with one pointer to bare-repo storage so the
  page does not read as the answer to "where is my data?"
- the "Running both" Durability bullet now carries the warm-first caveat
  the dedup paragraph already documents: an object warm-pinned before
  Kubo was configured keeps only the warm copy
- README links the doc from the pin configuration row, where operators
  configuring pins actually look; the original staking-list link stays

* docs(pinning): keep the config table contiguous, document local_ipfs_provenance

- README: the duplicate GITLAWB_MAX_CONCURRENT_PIN_TASKS row and the
  free-standing paragraph it introduced broke the table; the single row
  now carries the STORAGE-AND-PINNING cross-link inline
- dedup: the hot tier skips on has_ipfs_cid (local_ipfs_provenance =
  TRUE), not row presence; a Pinata-only row is local_ipfs_provenance =
  FALSE, so it enters the hot path on later pushes once Kubo is
  configured instead of being permanently suppressed
- the "Running both" durability bullet now agrees: enabling Kubo
  backfills local copies for warm-only objects

* docs(pinning): qualify pin admission, per-copy durability, and loop sizing

- the opening now names the admission gates: publish-durability
  confirmation plus root replication; private and non-replicating repos
  and withheld objects are out of "every push pins" wording
- warm-table and durability copy drop the Filecoin backing claim the v3
  API does not document
- the JWT-unset paragraph now says the permit and re-derivation walk
  only run when the push was admitted, and an empty IPFS_API can still
  briefly touch the pin pool on a non-empty list
- the semaphore section and README row state the two-loops-per-push
  fan-out so the default of eight reads as roughly four dual-tier
  pushes
- the backfill and running-both wording is conditional on object
  selection and per-sink success, naming the reconciliation sweep

* docs(pinning): drop unsupported Filecoin claim, normalize punctuation (#576)

* docs(pinning): qualify warm admission and loop count for coalesced pushes (#576)

A push that coalesces onto an in-flight encrypt/pin task never runs its
own withheld walk, yet still spawns the warm Pinata worker, which
re-derives the withheld set inside its pin permit. Scope "up to two
loops per push" to pushes that start their own hot task; a coalesced
push adds at most one warm loop.

---------

Co-authored-by: andreolf <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-12 22:20:39 +00:00
beardthelion 55ca89bfff test(node): real-node deny harness for trust-boundary regressions (owner-gated mutations, path-scoped reads, client-surfaced denials) (#194)
* 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).

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* style(node): cargo fmt the deny-harness sources

* 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.

* 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.

* 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.

* 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

* 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.

* 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.

* 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).

* chore: sync Cargo.lock with the 0.5.1 workspace manifests

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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).

* test(node): refresh deny harness for post-July main drift

Main's quarantine read pre-checks and task visibility layering landed
after the registry froze, so the merged table needed three classes of
refresh:

- scan hygiene: mask string/comment contents and strip cfg(test) mods
  before the marker scans, so fixture text (the resolver_body helper)
  cannot fake a decl boundary or inherit markers
- registry refresh: allowlist the read-gating mutations that gained
  authorize_repo_read (merge_pr, visibility/protect/webhook/label/
  bounty/close/replica writes, git_receive_pack), drop the task_claimable
  helper and create_task's dedicated-test-driven conditional owner gate,
  and class list_visibility (owner-driven GET) and list_anchors
  (query-scoped read) explicitly
- sweep substrate: scope the visibility twins to /swept-scope so
  set_visibility cannot 404 later strangers on the shared repo, and seed
  completable/failable tasks on the public repo so strangers reach the
  assignee 403 through get_visible_task

* test(node): restore main's lock-pool-direct for_testing semantics

The merge reconstruction derived an 8-connection lock pool inside
for_testing, so callers passing a pool observed nothing checked out.
Main's contract is that the argument IS the lock pool, which the
exhaustion, deadline, backend-reuse, and waiter-isolation tests observe
directly. Route through Self::new like for_testing_with_tigris so the
cfg(test) fields stay gated.

* fix(node): close review leaks in the deny harness and the grace knob doc

- config.rs: GITLAWB_SHUTDOWN_GRACE_SECS documented an enforced drain
  bound that production never applies; the value is only logged and axum
  drains in-flight requests unbounded. Say so.
- test_harness.rs: join_or_abort unwound a serve-task panic before
  pool.close() ran, leaking the test database on exactly the path where
  teardown matters. Carry the payload as ServeTeardown::Panicked and
  resume unwinding only after the pool and repos dir are released.
- probe.rs: the cert-push workdir was keyed on pid+server_main_tip, which
  two parallel fixtures seeding within one second share; one fixture's
  cleanup could delete another's in-progress tree. Use a tempfile
  TempDir, which is unique and also cleans up on the panic path.

* test(node): point the inv26 purge gate at lib.rs after the lib/bin split

The queue-lifecycle purge boot wiring moved from main.rs to lib.rs in the
crate split, but the source gate still scanned main.rs, so
inv26_step4_queue_lifecycle_purge_is_wired panicked in CI while the
purge itself stayed correctly wired. Scan lib.rs and update the doc and
assertion strings; the purge contract is unchanged.
2026-11-11 20:55:39 +00:00
7ae4707c48 docs: language-agnostic PROTOCOL.md wire spec (#578)
* docs: add initial language-agnostic PROTOCOL.md wire specification

* docs: add language-agnostic PROTOCOL.md wire spec

* docs: scope PROTOCOL.md to shipped alpha behavior

Address all five review findings: did:key-only resolution (did:web /
did:gitlawb marked planned), SHA-1 object format with IPNS demoted to
planned and the REST refs CID mapping described instead, the receive-pack
route and the 404-fetch vs 401-push auth split documented, iCaptcha wire
shape corrected (client sends only x-icaptcha-proof; url/level are 403
response headers; ICAPTCHA_MODE defaults off over a small endpoint set),
and the core gitlawb/ref-update/v1 schema spelled out field-for-field
with node push receipts explicitly split off as a different document.

* docs(protocol): close the outstanding review findings on the wire spec

- @path binds path plus query, not RFC 9421's path-only value; a second
  implementation signing info/refs without ?service= fails verification
- content-digest is a mandatory covered component; a body-less request
  signs the SHA-256 of the empty body, also accepted when absent
- TLS transport stated in the signature section with a pointer from
  smart-HTTP
- the ref-update certificate section is marked planned: the schema is
  defined in code but no shipped endpoint serves it, and its 64-hex
  hash fields reserve the SHA-256 width while repos are sha1 today
- smart-HTTP documents the mandatory ?service= values, pkt-line
  framing, and the upload-pack/receive-pack result content types

* docs(protocol): correct digest, transport, path, and icaptcha claims

- Content-Digest is required on every signed request: the node refuses
  a signed request without it (400 missing_content_digest); a body-less
  request still sends the SHA-256 of the empty body, never omits it
- transport: deployments should run TLS in front, but the node verifies
  signatures on whatever transport reaches it, no scheme gate
- path normalization documented: one trailing .git stripped, owner may
  be full did:key or bare multibase, and @path is the literal
  path_and_query bytes; the signing example is now signable as written
- icaptcha enforce with no loaded verifier key stays inert, so the 403
  reads as enforce-plus-loaded-key, not enforce alone

* docs(protocol): stop requiring TLS on signed smart-HTTP in §5

§5 still told readers signed git-receive-pack traffic follows §2
"including TLS transport" after §2 was corrected to state the verifier
has no scheme check. A second implementation could infer TLS is part of
the signed push contract and reject cleartext requests the node accepts.
Point the path at §2's RFC 9421 header rules and defer transport policy
to §2's non-normative deployment note.

---------

Co-authored-by: tatulatu700-lang <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-11 17:40:47 +00:00
72c31ddeca gl: add --icaptcha-proof flag to register command (#579)
* gl: add --icaptcha-proof flag to register command

This allows users to provide a pre-obtained iCaptcha proof token when
registering with a node, sent as the x-icaptcha-proof header on the
initial POST /api/register request.

Usage:
  gl register --icaptcha-proof "<token>"
  GITLAWB_ICAPTCHA_PROOF="<token>" gl register

The existing automatic iCaptcha retry flow (403 -> solve -> retry) remains
unchanged and is still available if no initial proof is provided.

* gl: disable redirects, add proof test, improve error context

- Disable HTTP redirects in NodeClient to avoid leaking proof/signature headers
- Add send_signed_does_not_follow_redirect test
- Use specific error context for proof-bearing registration requests
- Add register_forwards_icaptcha_proof_header test

* gl: disable redirects for signed/proof requests, add end-to-end test

* fix(gl): land the outstanding review asks on the icaptcha-proof flag

- help text on --icaptcha-proof so Clap renders what the flag accepts
- register_forwards_icaptcha_proof_header pins the supplied proof onto
  the register request as x-icaptcha-proof
- revert the 186 file-mode flips the contributor commits carried
  (644->755 noise unrelated to the feature)
- the redirect-forwarding concern is already covered on main: the
  client runs a same-origin-only redirect policy, so the proof header
  can never ride a redirect to a different origin; the cross-origin
  and proof-header tests already exist in http.rs

---------

Co-authored-by: root <root@Rocinante>
Co-authored-by: jilt <[email protected]>
Co-authored-by: jilt <[email protected]>
2026-11-11 16:00:56 +00:00
beardthelionandAyush7614 4fc041a399 fix(node): encrypt webhook HMAC secrets at rest and redact from Debug (#575)
* fix(node): encrypt webhook HMAC secrets at rest and redact from Debug

Webhook secrets are signing keys and were stored in cleartext, exposed by the
Webhook struct's derived Debug, and reachable through JSON serialization.

- Introduce a WebhookSecret newtype whose Debug and Serialize redact to "***",
  so the secret never reaches a log line or an API body.
- Seal the secret at rest with a node-held key (HKDF-derived from the identity,
  domain-separated as gitlawb/webhook-secret/v1) via XChaCha20-Poly1305; the
  column holds enc:v1:<nonce||ciphertext>.
- Open the secret only at delivery-signing time; legacy plaintext rows still
  sign and are re-encrypted once at startup by Db::reencrypt_legacy_webhook_secrets.

Closes #506

* test(node): derive webhook test keys from the OS CSPRNG

CodeQL flagged the hard-coded [0x42u8; 32] / [0x43u8; 32] helper keys
as a hard-coded cryptographic value. Derive them with OsRng.fill_bytes
instead, matching gitlawb-core's scan_token::new_key, so no hard-coded
key material sits in the tree. A wrong key still fails
XChaCha20-Poly1305 authentication with overwhelming probability.

* test(node): draw webhook test keys via OsRng.gen to clear CodeQL flag

CodeQL's 'Hard-coded cryptographic value' query treats the [0u8; 32]
zero-init flowed through OsRng.fill_bytes as a hard-coded key, so the
CSPRNG fix still tripped the gate. Generate the key array directly with
OsRng.gen() so no constant byte array reaches the key sink.

* fix(node): withhold unsigned webhook deliveries, classify sealed rows by open()

- delivery path now decides via SigningOutcome: a hook whose stored
  secret cannot be opened (foreign or rotated key, truncated
  ciphertext) is withheld with a warn and the secret_unopenable metric
  label, instead of posting unsigned and marking the event sent; a
  hook with no secret still delivers unsigned as before
- reencrypt_legacy_webhook_secrets treats "already sealed" as "opens
  under the node key", not "has the enc:v1: prefix": a plaintext secret
  that literally begins with the prefix, or a value sealed elsewhere,
  is re-sealed raw instead of being skipped and wedged. The UPDATE is
  compare-and-set on the value read so a concurrent secret change is
  not clobbered, and prefixed-but-unopenable rows log a warning
- WEBHOOK_SECRET_KEY_INFO doc now says a version bump renders every
  sealed row unopenable until a re-seal path exists, rather than
  advertising rotation the code cannot do
- shared hkdf_sha256 helper in state.rs: derive_scan_token_key and
  derive_webhook_secret_key were byte-identical HKDF bodies
- new pins: signing_decision across all four outcomes, reveal on a
  sealed value, the prefixed-plaintext re-encryption wedge

* fix(node): leave foreign ciphertext alone in webhook re-encrypt, count CAS outcome

- has_seal_shape splits "prefixed plaintext or corrupt blob" from
  "well-formed ciphertext": a sealed value that fails open() under the
  node key is left unchanged so delivery keeps withholding, instead of
  re-wrapping the outer blob and signing with it as a fake plaintext
- rewritten counts rows_affected, not iterations; a lost CAS race is
  logged and not counted
- derive_webhook_secret_key rustdoc now agrees with
  WEBHOOK_SECRET_KEY_INFO: a version bump orphans sealed secrets, it is
  not a rotation
- the signing_decision test key comes from OsRng.gen() like the other
  webhook test keys, clearing the hard-coded-key CodeQL alert
- pins: foreign ciphertext survives the pass untouched and reveal still
  returns None for it

* chore: re-trigger CodeQL check evaluation

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-11 03:22:39 +00:00
beardthelionandVasanthdev2004 3a0d91a801 feat(node)!: anchor the UCAN proof chain and honour delegated git/push (#567)
* feat(core)!: verify_chain returns the proof chain's root issuer

verify_chain returned Result<()>, so a caller learned that a chain was
internally consistent and nothing else. `did:key` is self-certifying: anyone
can mint a keypair, self-issue `*` on `*`, and produce a token that verifies.
That is correct UCAN behaviour rather than a defect — a token with no proofs is
its own root — and it still verifies after this change. But it does mean "the
chain verifies" is not an authorization answer on its own, and without the root
identity no caller could ask the question that actually decides a push: does
this chain rest on someone this repository trusts?

Returning the root means a caller cannot accept a chain without being handed
the identity it rests on. Callers that legitimately do not care — the node
middleware checking that a bootstrap token is well-formed, for one — discard it
explicitly. The doc comment now states outright that this establishes internal
consistency and not trust, and names what a caller has to compare the root
against.

Multi-proof chains are refused: more than one proof means more than one root,
and nothing says which root authorized a given capability, so returning any
single one would be unsound. The old loop followed every proof but returned
nothing, so a two-proof chain verified silently; the new test pinned that by
observing the pre-fix code accept one. Ucan::delegate only ever writes one
proof, so no token this codebase produces is affected.

BREAKING CHANGE: Ucan::verify_chain returns Result<Did> instead of Result<()>.

* fix(node): gate two Unix-only tests so the crate compiles on Windows

Two tests build a fake git as a `/bin/sh` script, mark it executable through
`std::os::unix::fs::PermissionsExt`, and reap the hung `rev-list` with
`libc::kill(SIGKILL)`. None of that exists on Windows, and neither test was
cfg-gated, so `cargo test -p gitlawb-node` failed to compile there with six
errors before running anything. A Windows checkout could not run a single test
in the crate, including the ones that are platform-independent.

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

Refs #228

* feat(node): carry the verified UCAN and its root into the request

require_ucan_chain validated a presented token and discarded it, so no handler
could read the result and Ucan::can had no call site anywhere in the node. A
UCAN could only ever fail a request, never authorize one.

The middleware's own behaviour is unchanged — an absent header passes through,
an invalid chain is still 401. It now also parks the verified token and the root
issuer its chain rests on in request extensions, so an authorization decision
downstream can use them. The root is stored rather than recomputed so the chain
is walked once per request.

Holding a VerifiedUcan is deliberately not an authorization decision. did:key is
self-certifying, so a chain that verifies proves only internal consistency; the
caller has to compare the root against an identity it trusts for the resource
being touched. The next commit adds that comparison for git/push.

* feat(node): add ucan_grants_push, anchored at the repo owner

The predicate that decides whether a delegated capability authorizes a push.
The chain must root at the repo owner — data the node holds independently of
the token, which is what a self-minted chain cannot forge — and the leaf must
carry git/push for this repo.

Only the leaf is examined for the capability. verify_chain has already
established that each leaf capability is attenuated by its proof, transitively
to the root, so a surviving leaf capability is no broader than what the root
granted; re-walking the chain would duplicate that guarantee.

Resource matching is structural rather than a string compare because owner_did
is stored full on canonical rows and bare on mirror rows; a literal match would
deny valid delegations for every mirror, which is a defect that would have
looked like a permissions bug rather than a parsing one.

A capability carrying nb authorizes nothing while constraints stay
uninterpreted, so an owner who writes a ref restriction never accidentally
grants repo-wide push.

Not yet wired into the push path — the next commit does that — so a non-test
build still reports VerifiedUcan::ucan as unread.

* feat(node): honour a delegated git/push capability on the push path

caller_authorized_to_push becomes owner || delegated, exactly the Phase 2 its
own doc comment described. The owner check is unconditional and runs first, so
the UCAN path can only ever turn a 403 into a 200, never the reverse. A push
carrying no X-Ucan reaches the same owner-only decision it did before.

git_receive_pack takes the verified token as Option<Extension<VerifiedUcan>>;
axum extracts an absent extension as None rather than rejecting, so the optional
header stays optional.

The denial message is deliberately identical whether a delegation was absent,
expired, or named a different repository. A behavioural test asserts the two
bodies are byte-identical, because a difference would turn the refusal into an
oracle for which capabilities exist.

The behavioural test drives both auth layers with a real RFC 9421 signature and
a real invocation, and discriminates on status: 500 means the request passed
require_signature, passed require_ucan_chain, cleared the owner gate, and
reached git on a repo with no disk backing. A bare `!= 403` would let a 401
regression through. It needs no fake-git shim, so unlike the rest of the push
path it is not cfg(unix) and runs on every platform.

Both new tests were verified to fail: with the delegation branch removed the
behavioural test reports 403 where it wants 500, and the unit test's assertion
fires. A test written after its implementation proves nothing until it has been
watched failing.

* feat(gl): gl ucan import stores a delegation for the push helper

The git remote helper needs a delegation on disk in a location it can derive
from a gitlawb:// URL alone. Files key on the bare base58 owner key because
did:key contains a colon, which Windows will not accept in a filename, and the
same identity appears in both full and bare form across this codebase — storing
under one and looking up by the other would silently miss.

Import decodes the token so a malformed delegation fails here, where the error
is actionable, rather than surfacing as an unexplained 403 in the middle of a
git push. A delegation naming no repository is rejected with the resources it
did carry, since that is almost always a wrong --cap argument.

* feat(git-remote): send a delegated push capability as X-Ucan

Wraps a stored delegation into an invocation (iss=agent, aud=node,
prf=[delegation]) and attaches it to the receive-pack POST, which is what lets a
CI or delegated key push once owner-only push is enforced. Only on the push: a
fetch is gated by read visibility, not by git/push.

Capabilities are copied from the delegation unchanged, so an invocation can
never be broader than what was delegated. No expiry is set on the invocation
itself — the delegation`s own exp still bounds the chain because verify_chain
checks each proof`s expiry as it recurses. A test proves that rather than
asserting it: an already-expired delegation still fails the chain after
wrapping, so an expired grant cannot be laundered into an open-ended one.

The whole path is best-effort. A missing delegation, an unreachable node, or an
unreadable stored token all send the request without the header. The node
decides whether one was required, and a node denial has to reach the user
instead of being pre-empted by a local guess.

delegation_path duplicates gl`s six-line version because gl is not a dependency
of this crate; both carry a pointer to the other.

* test(git-remote): cover pack-POST URL splitting

split_pack_post_url decides which repo a delegation is looked up for, so a
parsing slip silently means no delegation was found, surfacing as a confusing
403 rather than a visible error. Covers the optional .git suffix, a repo
genuinely named x.git, and the malformed shapes that must not parse.

* build: sync Cargo.lock for the git-remote-gitlawb dependencies

Adding serde_json and chrono to crates/git-remote-gitlawb/Cargo.toml updated
Cargo.lock locally, but the lockfile lives at the repo root and was never
staged. Every CI job builds with --locked, so all seven compiling jobs failed
immediately with "cannot update the lock file ... because --locked was passed".

* fix(core,gl,git-remote): close two review findings on delegated push

Both are mine, both were found in review, and both are load-bearing.

1. Stripping `nb` mid-chain escalated a constrained delegation.

`Capability::is_attenuated_by` compared only `with` and `can`, so the holder of
a capability constrained by `nb` could re-delegate the same resource and action
with the constraints removed and `verify_chain` still accepted the chain. The
node's push gate refuses constrained capabilities at the LEAF, so it then saw an
unconstrained one and granted repo-wide push — guarding the leaf guarded the
wrong end of the chain.

Constraints now participate in attenuation: an unconstrained parent permits
anything, an identical child is attenuated, and both a differing child and a
child that drops the constraints are refused. `nb` has no interpreted semantics
yet, so "different" cannot be shown to be narrower and is refused with the
stripping case. Tests cover the forged strip, the legal unchanged case, and
adding constraints under an unconstrained parent.

2. `gl ucan import` wrote to a path taken from an untrusted token.

`repo_from_resource` split the resource with `rsplit_once('/')`, so the owner
half could carry separators, `..`, or an absolute prefix. `Path::join` with an
absolute component discards the base entirely, so a resource of
`gitlawb://repos/C:/Windows/System32/x` did not merely climb out of the
delegations directory — it replaced it. The value then reached `std::fs::write`.

The resource must now be exactly `gitlawb://repos/<owner>/<repo>`, with each
half an allow-listed component: alphanumerics plus `.`, `-`, `_` and `:` (a DID
carries colons), never `.` or `..` or anything containing `..`. An allow-list
rather than a deny-list of separators, which would miss whichever ones the next
platform introduces.

The same shape existed in the helper's `split_pack_post_url`. There the value
comes from the remote URL and the path is only read, but a traversing owner
would read an arbitrary file and send its contents to the node as `X-Ucan`, so
it carries the same guard.

Also: the node-DID probe now uses a 5s timeout instead of inheriting the shared
client's 300s. It is best-effort metadata, and a stalled node should not delay
every delegated push by five minutes before falling back to sending no header.

* fix(core,node,gl,git-remote): close the second review round on delegated push

Each finding was reproduced against the code before being changed; two were
confirmed with the mutation the reviewer described.

A delegation could be perpetual
--------------------------------
`exp` is optional, `is_expired` reports false when it is absent, `gl ucan
delegate` defaulted to no expiry, and there is no revocation path. So the
default flow minted a permanent push grant: once the token leaked, the owner's
only remedy was rotating the DID the repo is keyed on. The PR body claimed the
damage window was the token's `exp`, which was simply untrue when `exp` was
`None`.

`Ucan::chain_lifetime_is_bounded` walks every link, and `ucan_grants_push`
requires it. It is deliberately NOT enforced inside `verify_chain`: an
unbounded token is well-formed and may suit a read-only capability; whether an
unbounded grant is acceptable is the consumer's policy, not the format's.
`gl ucan delegate` now defaults to 720 hours with an explicit `--no-expiry`
opt-out, and the helper carries the delegation's expiry onto the invocation so
the leaf is bounded too.

The recursion to the root was untested
---------------------------------------
Every chain in the suite was depth two, where the immediate proof IS the root,
so nothing distinguished walking to the true root from returning the proof's
issuer. Confirmed by mutation: keeping full recursive validation but returning
`proof.payload.iss` left gitlawb-core at 92 passed, the node's UCAN tests at 17,
and the e2e green. A three-link owner -> lead -> agent test now pins it, with
`assert_ne!` against the middle issuer as well as `assert_eq!` against the root
— without the former, returning the middle would still pass.

A path-prefixed node base broke delegated push entirely
--------------------------------------------------------
`GITLAWB_NODE` may carry a path (`https://host/gitlawb` behind a proxy), and
`repo_base` passes it through verbatim. `split_pack_post_url` read the FIRST two
segments as owner/repo, so the prefix became the owner: the delegation lookup
missed, the DID probe hit the wrong URL, no `X-Ucan` was sent, and a valid
delegate got a 403 — silently, because every failure on that path is
best-effort. It now strips the known trailing `<owner>/<repo>/<service>` instead,
which is correct at any prefix depth, and prefix segments carry the same
allow-list so a `..` cannot redirect the probe.

A wildcard delegation grew without limit
-----------------------------------------
`with: "*"` covered every repo the owner created AFTER signing, a scope nobody
chose. `build_invocation` now narrows to the concrete repo, which
`is_attenuated_by` accepts under a `*` parent, so a captured invocation is worth
one repo rather than all of them. Constraints are copied from the covering
capability rather than dropped, since dropping them is a widening.

Branch protection and the owner gate now disagree, on purpose
--------------------------------------------------------------
A delegate clears the owner gate and is still refused on a protected branch. That
is the intended policy: a protected branch is the owner's explicit marker that
even routine writes should stop, and if a delegation overrode it, issuing any
capability would weaken every protection already set. The comment claiming
non-owners never reach that loop is corrected, and
`delegated_push_is_still_refused_on_a_protected_branch` pins it — asserting the
body names the branch, so the refusal is provably branch protection rather than
the owner gate.

Smaller items
-------------
The denial body no longer says "only the repo owner may push", which stopped
being true once a delegation could authorize one. It stays a single unconditional
message: varying it by whether a delegation was presented, expired, or named
another repo would turn the refusal into an oracle for which capabilities exist.

`gl ucan import` writes the delegation 0600, matching the sibling identity key.
The token alone cannot push — the node requires `iss` to equal the request
signer — but it discloses the delegation graph.

`docs/RUN-A-NODE.md` told operators not to enable owner-push until every pusher
was the owner, which is the workflow this change adds. It now documents the
delegation flow, the four requirements the node enforces and why, that a
delegation does not override branch protection, and that withdrawal is by expiry
only. `README.md` no longer describes UCAN as a future workflow.

* fix(gl): restrict the delegations directory, and state the contract

The imported delegation was written 0600 on Unix and left to inherit the
directory ACL elsewhere. Tightening only that one file would have been theatre:
identity.pem sits in the same directory under exactly the same assumption, and
its disclosure is strictly worse.

The directory itself is now 0700 on Unix, and the comment states the contract
that already governed the private key — gitlawb_dir accepts any path, std::fs
has no portable ACL API, so on other targets the caller must supply a
user-private directory, which a per-user profile provides by default.

* fix(gl,git-remote): make a delegation issued the documented way actually work

Two silent failures, both of which end as a 403 telling the delegate to obtain
the delegation they are already holding.

The narrowing fix broke the documented issuing form
----------------------------------------------------
`build_invocation` built `gitlawb://repos/{owner}/{repo}` from the push URL and
compared it to the delegation's `with` with `==`. The URL always carries the BARE
owner, since `parse_gitlawb_url` takes the last colon-delimited segment, while
`docs/RUN-A-NODE.md` tells the owner to issue
`--cap gitlawb://repos/<owner-did>/<repo>` — the full DID. Those strings never
match, `find` returns None, and because every failure in `delegation_header` is
best-effort the push goes out with no `X-Ucan`.

This was introduced by the previous round: copying `att` through unchanged worked
because the node normalizes both forms in `did_matches`. Narrowing to a
URL-derived string did not.

The owner segment is now compared on the bare key, and the parent's `with` is
kept VERBATIM whenever it already names this repo — `is_attenuated_by` compares
`with` by exact equality, so re-emitting the bare form under a full-DID parent
would fail attenuation at the node and trade one silent refusal for another. Only
a `*` parent uses the URL-derived resource, which is the case narrowing exists
for. Both combinations are now tested; neither side exercised them before.

The two halves disagreed about where delegations live
------------------------------------------------------
`git-remote-gitlawb` resolves its store from `resolve_key_path().parent()`, which
honors `GITLAWB_KEY`. `gl ucan import` wrote under `gitlawb_dir(None)`, which was
always `~/.gitlawb`. With `GITLAWB_KEY=/data/keys/identity.pem` — the shape
`.env.example` documents — the import stored the token in one directory and the
helper read another, empty one.

`gitlawb_dir` now falls back to the parent of `GITLAWB_KEY` before `~/.gitlawb`,
so both halves derive the store from the same setting.

Also: `.env.example` still said a push from a non-owner DID is rejected, which is
the behaviour this branch removes.

* fix(gl): refuse a relative or non-UTF-8 GITLAWB_KEY when deriving the store

Two defects in the previous round's fix, both of which put the delegation
somewhere the helper does not look and end as an unexplained 403.

A relative GITLAWB_KEY resolved differently in each half. `gl ucan import` and
`git-remote-gitlawb` do not share a working directory, so `keys/identity.pem`
sends the import to one `delegations` directory and the lookup to another. A
one-component value is worse: `parent()` yields "", so the store becomes
`./delegations` relative to whatever directory happened to be current. It is now
refused with a message that says why, rather than silently resolved.

`std::env::var` returns Err for a non-UTF-8 value, which the code treated as
unset — indistinguishable from having no GITLAWB_KEY at all, and silently
selecting ~/.gitlawb instead of the operator's real key directory. `var_os`
keeps the OsString, so a valid non-UTF-8 path now works and an empty one is
still treated as unset.

* fix(identity): one resolver for GITLAWB_KEY across gl and the helper

`gl` and `git-remote-gitlawb` each carried their own answer to "where is my
identity?", and so did five call sites inside `gl` itself. The round-three fix
hardened one of them — `gitlawb_dir` — and left the rest, which is why the same
misconfiguration kept surfacing in a new place each round.

The rules now live in `gitlawb-core::identity_path`, the crate both binaries
already depend on:

  identity_key_path()  $GITLAWB_KEY, else ~/.gitlawb/identity.pem
  identity_dir()       its parent — the delegation store

read through `var_os` (so a non-UTF-8 path is refused rather than folded into
"unset" by `var`), with `~/` expanded on the first path component, a relative
value refused, and a bare `~`, `~/`, or `/` refused rather than resolved to a
directory whose parent is not where anything lives.

Both take the home directory as an argument rather than looking it up: core is
held to an explicit dependency allowlist and both callers already carry `dirs`,
so the rules can be shared without widening core's tree. It also lets every
case be tested against a fixed home instead of the machine's.

Call sites moved onto it:

  gl   identity::gitlawb_dir            delegates to identity_dir
  gl   identity::load_keypair_from_dir  was ~/.gitlawb even after `gl identity
                                        new` wrote elsewhere, so `gl ucan
                                        delegate` signed with a stale DID — or
                                        found nothing — for exactly the
                                        operators who moved their key. Every
                                        `load_keypair_from_dir(None)` caller
                                        (register, repo, pr, clone, mcp) is
                                        fixed with it.
  gl   doctor::run                      the one command whose job is to explain
                                        a broken setup was reporting on a
                                        directory the setup does not use
  gl   init (ucan.json, generate_identity)
  gl   mcp ucan_show
  gl   ucan_cmd::cmd_show
  helper resolve_key_path / the delegation store behind delegation_header

The helper's version was the loosest of the set: `env::var`, a literal `"~/"`
prefix, `HOME` falling back to `"."` (never set on Windows), and no absolute
check at all.

Also in this commit:

- `gl ucan import` creates the store and the token at their final mode.
  `create_dir_all` then chmod leaves 0755 under the usual umask, and
  `fs::write` then chmod leaves 0644, both readable by any local user until the
  second call lands. `DirBuilder::mode` and `OpenOptions::mode` close the
  window; the trailing `set_permissions` now only matters for a store an older
  `gl` left behind.

- `gl ucan import` refuses a delegation the push path cannot use. It filtered on
  resource shape only, so a `pr/open` token printed "Stored delegation for
  owner/repo" and was then dropped by `build_invocation` behind a
  `tracing::warn`, surfacing as a 403 with nothing connecting it to the earlier
  success. Import now applies the same push-class filter the helper does.

- README's write-authorization limitation is narrowed to what is actually
  missing (revocation, `nb` interpretation, non-push capabilities) rather than
  claiming delegated push is not implemented.

Every new guard was verified by disabling it and watching the matching test go
red: the absolute-path check, the bare-`~` refusal, the `load_keypair_from_dir`
routing, and the import action filter each own a failing test.

* test(git-remote): cover delegation_header end to end

`delegation_header` was the one piece of the delegated push path with no test.
Its parts each had one — `split_pack_post_url`, `build_invocation`,
`delegation_path` — but nothing checked they compose, and this is the function
where a regression is silent by construction: every failure inside it returns
`None` and the push goes out without `X-Ucan`, so a break surfaces as a 403 at
the node with nothing locally to connect it to.

Five cases, driven against a mockito node with the store seeded where
`gl ucan import` would have left it:

- the delegated push: a stored token becomes an invocation issued by the agent,
  addressed to the node's DID, rooted at the repo owner, with every link bounded
- the owner's own push: no store read, no node round-trip. The comparison has to
  survive the form mismatch — the keypair holds `did:key:z…` while the URL
  carries the bare key — so the mock asserts zero hits
- no usable delegation: an empty store, a token that does not decode, and a
  `git/fetch` capability that carries nothing to wrap. All three yield no header
  rather than an error
- an unreadable node DID: a 500, a JSON body with no `did`, a proxy's HTML error
  page, and a `did` that does not parse. The push loses its header, never aborts
- a path-prefixed node base: the DID probe must go to `/gitlawb`, not `/`.
  `split_pack_post_url` is unit-tested for the prefix, but nothing checked the
  probe followed it; the mock on `/` asserts zero hits

Each case was watched failing before it was kept, against five separate
mutations: the owner short-circuit removed, the prefix dropped from the node
base, `build_invocation`'s push-class filter widened, the node-DID lookup given
a fallback, and the invocation addressed to the agent instead of the node.

* fix(gl): finish the identity-path sweep on the write side

The previous commit unified where `gl` READS the identity from and stopped
there. Four resolvers were left holding their own `~/.gitlawb`, and one of them
is a write:

  register.rs   ucan_path        the bootstrap UCAN from POST /api/register
  quickstart.rs the wizard dir   identity generation AND the UCAN it stores
  name.rs       identity_dir     reads identity.pem
  node_stake.rs load_did         reads identity.pem

`register.rs` is the one that actually breaks a working setup. With
`GITLAWB_KEY=/data/keys/identity.pem` — the shape `.env.example` documents —
`gl register` loaded the key from `/data/keys/identity.pem` and then wrote
`ucan.json` to `~/.gitlawb/`. Registration reported success; `gl doctor`,
`gl ucan show`, `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the
key's directory, found nothing, and reported an unregistered identity. Split
storage, no error anywhere.

`quickstart` had the same shape end to end: it generates the identity and stores
the bootstrap token, so an operator who ran the wizard under `GITLAWB_KEY` got
both in a directory nothing else consults.

`name.rs` and `node_stake.rs` only read, but both fell back to `PathBuf::from(".")`
when the home directory could not be determined, which makes the identity path
depend on the working directory — the same defect the helper's `resolve_key_path`
carried before it moved to the shared resolver.

All four now go through `crate::identity::gitlawb_dir`. Afterwards the only
`dirs::home_dir()` calls left in `gl` are the one inside `gitlawb_dir` itself,
one test expectation, and `doctor`'s shell-rc scan, which is unrelated to the
identity directory.

`gl register` also no longer prints a path it did not use: the success line was
hardcoded to `~/.gitlawb/ucan.json`, so an operator with `GITLAWB_KEY` set was
told to look somewhere the file was not. It now prints the real destination, and
says so plainly when the node returned no token at all.

The `GITLAWB_KEY` test guard moves to `identity::test_env` so the resolver's own
cases and the new register case share one lock. Two suites each holding their
own mutex over the same process-global variable would not serialise against each
other.

Verified by reverting `ucan_path` to the hardcoded form and watching
`register_saves_the_bootstrap_ucan_beside_the_key` fail on exactly that
assertion.

* fix(gl,core): close round five — lazy home, envelope reads, honest register

Five findings from the round-five review, four of them introduced by round four's
own centralisation.

resolve_key_path and resolve_identity_dir opened with `home_dir()?`, but
identity_key_path only needs a home when the key is unset, empty, or `~/`-prefixed.
On a host where `dirs::home_dir()` returns None — no HOME and no passwd entry, an
ordinary container shape — a perfectly valid absolute GITLAWB_KEY was discarded and
the push went out unsigned, blaming the home directory for a setting the operator
had got right. The pre-round code resolved an absolute key without consulting home
at all, so this was a regression, not an inherited gap. The core resolvers now take
`Option<&Path>` and demand a home only where the value being resolved needs one.

`gl ucan show` called Ucan::decode on the whole of ucan.json, but register, init,
and quickstart all write an envelope — {"ucan", "node", "did", "saved_at"} — and
doctor and quickstart already read it as one. Ucan is {payload, s}, so show failed
with "missing field `payload`" immediately after a successful `gl register`. It now
reads the envelope like every other reader, and still accepts a bare token so files
written by an older gl stay readable. The predating mismatch belongs to this round
because this round rerouted cmd_show and claimed to unify the identity flow.

`gl register` printed "You are now a verified agent" even when the node returned no
bootstrap UCAN — the exact shape the previous commit message said it existed to
remove, one line below the fix. The closing line is now inside the branch that
actually stored a token.

The MCP `ucan_show` tool called gitlawb_dir(None) while every sibling tool in
call_tool honours the server's --dir, so one session read two identity directories.
The parameter was already in scope.

Six `--dir` help texts still promised "default: ~/.gitlawb" after the resolver
stopped having that default.

Both behavioural fixes were verified by mutation: demanding a home unconditionally
turns an_absolute_key_resolves_without_a_home_directory red, and dropping the
envelope branch turns the_register_envelope_decodes red.

* fix(node,gl,git-remote): enforce repo narrowing at the node, honour the key basename

Round seven. Two P1s, and the first changes an authorization outcome.

The node accepted a leaf resource of `*` for delegated push. Narrowing a wildcard
delegation to the concrete repository happened only in `git-remote-gitlawb`, which
is one client's behaviour and not a protocol boundary: a delegate can sign an
otherwise valid `agent -> node` invocation that keeps `with: "*"`, it passes
attenuation because its proof is `"*"` too, and the node then authorised a push to
any repository owned by the root DID — including repositories created after the
delegation was issued. `docs/RUN-A-NODE.md` promised exactly the opposite, and
named the growth it was preventing, while resting that promise on the helper.
`repo_capability_matches` now requires a concrete `gitlawb://repos/<owner>/<repo>`.
Wildcard delegations still work through the normal path, because the helper
narrows before signing; only a hand-built wildcard leaf is refused.

`honours_the_resource_wildcard_and_repo_admin` asserted the old behaviour, so it
is now `refuses_a_resource_wildcard_and_honours_repo_admin`, and it separates the
two axes: a `*` ACTION on a concrete repository is still fine, since attenuation
bounds it. `a_wildcard_delegation_cannot_reach_a_second_repository` covers the
attack directly.

`gl` discarded the basename of `GITLAWB_KEY`. The shared resolver returns the
configured PEM path, but `gl` kept only its parent and re-appended `identity.pem`,
while the helper opens the configured path. With
`GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI wrote and read
`/data/keys/identity.pem` while every push loaded `/data/keys/ci-agent.pem`, so
the two either disagreed on identity or the helper found no key — and both owner
enforcement and the delegation proof key off that identity. This was mine, from
last round's centralisation: a directory-level contract substituted where callers
needed a key-file-level one. `key_path_for` now serves both, and the seven
production sites that still hardcoded the basename go through it: `identity`
(load/new/backup/restore), `doctor`, `init`, `name` (x2), `node_stake`,
`quickstart` (x2). An explicit `--dir` keeps `<dir>/identity.pem`.

Also:

- `gl ucan import` binds the delegation's audience to the local identity and
  verifies its chain, expiry, and bounded lifetime before storing. It previously
  accepted a token addressed to a different agent, reported success, and left the
  push to fail on proof linkage with nothing pointing back.

- `build_invocation` selects only a capability the node can authorise. It took the
  first push-class capability, including a constrained one, while the node refuses
  constrained leaves outright — so a delegation holding both a constrained and an
  unconstrained grant succeeded or failed purely on their order in `att`.

- Delegation files are replaced atomically: written to a sibling temp at 0600,
  fsynced, then renamed. `truncate(true)` on the live path emptied a working
  delegation before the replacement was written, so an interruption left the
  helper with an unreadable token and pushes that silently drop `X-Ucan`.

- `gl quickstart` no longer reports "UCAN saved to ..." when the node returned no
  token. Both reviewers flagged this independently; it is the same false success
  fixed in `register` last round, one file over.

Every fix was verified by mutation: restoring the node's wildcard acceptance,
restoring `<parent>/identity.pem`, disabling the audience check, and dropping the
helper's constraint filter each turn the matching test red.

* fix(gl): align the MCP ucan_show tool with gl ucan show

The two surfaces answer the same question and answered it in different shapes:
`gl ucan show` decodes the envelope and prints fields, while the MCP tool returned
the file verbatim. That was the round-seven non-blocking note.

Aligning them removes a second problem the note did not name. The file contains
the bootstrap token, so the MCP tool handed the caller the credential itself —
and an MCP response travels further than a terminal does, into a model's context,
transcripts, and logs. Nothing needs the token to answer "am I registered, with
what, until when", so the tool now returns issuer, audience, version,
capabilities, expiry, expired, signature validity, and the path it read.

The tool description said "Show the saved bootstrap UCAN token"; it now says what
is returned and that the token is not.

Verified by mutation: restoring the raw-file return turns
`ucan_show_returns_decoded_fields_and_withholds_the_token` red on the assertion
that the token is absent.

* fix(gl): drop a needless borrow in quickstart's unix key write

`generate_identity` now takes `path: &Path` rather than deriving it from an owned
directory, so `set_permissions(&path, ..)` became a double borrow. Clippy refuses
it under `-D warnings`.

It sits inside `#[cfg(unix)]`, which is why the local gate passed: this machine is
Windows, so that block is never compiled or linted here. Only CI sees it. The
sibling `fs::write` on the line above was already corrected; this one was missed
because the same pass could not fail locally.

The other `&path` / `&dest` / `&tmp` borrows in the unix blocks this PR touches
are on owned `PathBuf`s, where the borrow is required — `quickstart` is the sole
place a parameter changed from owned to reference, and so the sole place the lint
applies.

* fix(node,gl,git-remote): fix a delegation's scope at issuance

Round eight. The round-seven wildcard fix closed the wrong half, and I confirmed
that by execution before changing anything:

    PROBE ucan_grants_push = true

An owner issues `with: "*"`, the delegate narrows it to a concrete leaf for a
repository created afterwards, `verify_chain` passes, and the node authorised the
push. Refusing a wildcard LEAF did nothing, because `is_attenuated_by` accepts a
concrete child under a `*` parent — and that narrowing is exactly what
`build_invocation` performed, so the first-party helper was the working mint path.
The round-seven tests never saw it: they injected a self-issued `*` leaf with no
`prf`, so they never touched a proof chain at all.

The contract is now that a delegation's scope is fixed when it is issued, which is
what both operator documents already promised:

- The node requires EVERY link to name the repository, not just the leaf
  (`proofs_name_repo` walks `prf` after `verify_chain` has established the chain).
- `git-remote-gitlawb` no longer selects or narrows a `*` capability, so a stored
  wildcard fails closed locally with a clear message instead of minting an
  invocation the node would refuse.
- `gl ucan delegate` refuses to issue a push-class wildcard at all.

Also, all from round eight:

- `gl quickstart` loads the exact key it selected. It resolved `pem_path` through
  `key_path_for` but then loaded through `Some(&dir)`, which re-derived the
  basename as `identity.pem` — so with `GITLAWB_KEY=/data/keys/ci-agent.pem` the
  existence check and the load disagreed, and the Err arm regenerated ONTO
  `ci-agent.pem`, destroying a working key and changing the DID that repository
  ownership, registrations, and delegations all key off.

- `gl ucan import` binds every capability's owner segment to the VERIFIED ROOT.
  `verify_chain` proves a chain is internally valid; it says nothing about which
  repository it applies to. Without this, any key holder could issue a valid
  bounded token naming `gitlawb://repos/<victim>/repo` and displace the working
  delegation for a repository they have no authority over. The whole set is
  validated before anything is written, so a later bad capability cannot leave a
  multi-repository import half applied.

- Import applies the same push-class rule the helper and node use, including
  `constraints.is_none()`. A constrained-only grant used to import cleanly and
  then authorise nothing.

- Delegation replacement stages to a per-call path (`create_new`, pid + counter)
  rather than a shared `.<name>.tmp`, so two concurrent refreshes cannot write the
  same inode and publish each other's bytes. The non-Unix path stages and renames
  too; it previously truncated the live file, so a failed refresh destroyed a
  working token on that platform.

Three tests that were weaker than they read, all mine:

- `a_refused_reimport_leaves_the_stored_delegation_intact` never reached
  `write_private_file` — it stopped at the audience check. Renamed to say what it
  covers, and `a_failed_write_leaves_the_stored_delegation_intact` now forces a
  real write failure against a read-only store.
- The MCP token-withholding assertion used `!out.contains(&token)`, which passes
  whether or not the token leaked: the token is itself JSON, so embedding it
  escapes every quote. It now asserts on the signature, a bare base64 string that
  survives escaping.
- `build_invocation_narrows_a_wildcard_to_the_pushed_repo` asserted the behaviour
  this commit removes; it is now `build_invocation_refuses_a_wildcard_delegation`.

* fix(gl): drop an unused import in the unix-only refresh tests

`refresh_atomicity_tests` moved from `token_for_agent` to `owned_token` when
import began requiring the resource owner to equal the verified root, and the
import was left behind. The module is `#[cfg(all(test, unix))]`, so it does not
compile on this Windows host and no local clippy run could see it.

Cross-linting is not available either: `cargo clippy --target
x86_64-unknown-linux-gnu` fails in ring's build script for want of a C
cross-compiler. CI is the only linter for cfg(unix) code from here, which is worth
recording rather than rediscovering.

The sibling `import_binding_tests` still uses both helpers; only the unix module's
import changed.

* fix(node): pass the UCAN extension at the last two receive-pack call sites

Two `git_receive_pack` calls in `#[cfg(unix)]` test blocks still supplied six
arguments. Like the rest of that class they do not compile on this Windows host,
so neither the local build nor clippy could see them; CI found them one at a time.

Rather than wait for a third round, every call site was audited by grep instead of
by compiler: 25 calls, 2 missing, both fixed, none others. The other signatures
this branch changed — `caller_authorized_to_push`, `identity_key_path`,
`identity_dir`, `key_path_for` — were audited the same way and are consistent.

Both sites push as a synthetic non-owner DID under an owner-push-disabled config,
so `None` is correct: no delegation is involved in what they test (write-lock
release on disconnect, and on push success).

* fix(core,node,gl,git-remote): one definition of a usable push capability

Round nine. Both reviewers named the same pattern independently: the rule for
"can this token push to this repository?" was reconstructed at every boundary —
issuance, import, invocation construction, node authorization — and each round
found a token one boundary accepted and the next refused. This round's own
instance: the node walked the whole proof chain, import checked only the leaf, so
a concrete leaf on a wildcard proof imported cleanly and 403'd on every push.

The rule now lives once, in `gitlawb_core::ucan::push`, the crate all four
already depend on:

  did_key_eq            the node's exact did:key collapse rule — bare and full
                        forms are one identity; a bare id never matches across
                        methods (`did:gitlawb:X` is not `X`)
  is_push_action        git/push, "*", repo/admin
  parse_repo_resource   gitlawb://repos/<owner>/<repo>, exactly two segments
  is_push_wildcard      a push-class action on a "*" resource
  Capability::grants_push_to(owner, repo)
                        push-class, unconstrained, names exactly this repo
  Ucan::chain_grants_push_to(owner, repo)
                        the leaf AND every proof behind it, depth-bounded,
                        failing closed on undecodable or multi proofs

and every boundary calls it:

  node       ucan_grants_push = root anchored && bounded && chain_grants_push_to.
             `repo_capability_matches` is a thin wrapper; `proofs_name_repo` and
             `push_class_names_repo` are gone. `did_matches` delegates to
             did_key_eq so there is one collapse rule, not two.
  import     the same walk, before any write. A leaf naming the repo on a "*"
             proof is refused with a message that names the proof. Everything is
             validated before anything is written.
  helper     `build_invocation` refuses a stored token whose chain does not grant
             push to this repo, with a message that says why, before selecting a
             leaf — instead of minting an invocation the node is certain to refuse.
  issuance   `gl ucan delegate` and the MCP `ucan_delegate` tool share
             is_push_wildcard. The MCP arm had no guard at all.

Two tests that failed CI, both invisible on this Windows host because they are
cfg(unix):

- `import_creates_the_store_and_token_owner_only` still issued against a
  synthetic `z6MkAbc` owner, so import's owner-root binding refused it before the
  mode assertions. Now built from `owned_token`, like its siblings.

- `a_failed_write_leaves_the_stored_delegation_intact` chmod'd the store to 0500
  and expected the staging create to fail. It did not: `create_private_dir`
  repairs the store to 0700 before every write, so the second import succeeded
  and the test failed on `is_err()` — and a privileged runner would have made the
  arrangement pass anyway. Failure is now injected through a test-only seam
  (`fault::FailStagingWrites`) that fires after the bytes are written and before
  anything is published, on every platform, and the test also asserts no staging
  file is left behind. The module is no longer unix-only.

Also: the node's rustdocs for `repo_capability_matches` and `ucan_grants_push`
described the pre-round-8 behaviour (helper narrowing; leaf-only coverage) and now
match the code. An orphaned docblock above `staging_path` — the old
`write_private_file` doc, left behind in round 8 and claiming "not create_new"
when the writer uses exactly that — is removed and the writer has a correct one.

Verified by mutation, each restored byte-for-byte afterwards:

  core walks no proofs        → core 3-link, node 2-link, node 3-link, import,
                                and helper wildcard tests all red
  core walks ONE proof only   → ONLY the two 3-link tests red; every 2-link test
                                stays green — the recursion is load-bearing
  MCP guard removed           → MCP deny test red, MCP named-resource test green
  staging seam disconnected   → atomicity test red
  import chain check removed  → import deny test red, delegate deny test green

fmt, clippy -D warnings, check --locked --workspace --all-targets clean.
gitlawb-core 122, gl 389, git-remote-gitlawb 65, node auth:: 23, and the DB-backed
delegated-push tests pass alongside main's owner-push test.

* fix(node): drop repo_capability_matches, orphaned by the shared push rule

Round nine routed the node's authorization through
Ucan::chain_grants_push_to, which parses the resource and compares
owners with did_key_eq itself. The thin wrapper left behind had no
callers, and CI's -D warnings build rejects dead code. Nothing else in
the crate referenced it.

* fix(core,gl): bound every proof-chain walk and make verify report the chain

Round ten of #331.

`verify_chain` and `chain_lifetime_is_bounded` recursed with no depth
guard while `chain_grants_push_to` carried one. The node verifies the
chain on the `X-Ucan` header of any signed request before the scope walk
runs, so the bound protected nothing: a deep chain met the unbounded
walk first. Both now stop at `MAX_CHAIN_DEPTH`, which moves to the
module root since all three walks share it, and a chain at the bound
still validates end to end.

The MCP `ucan_delegate` tool issued an unbounded token when
`expiry_hours` was omitted, which import and the node both refuse for
push. It now defaults to the CLI's 720 hours and refuses a non-positive
value.

`gl ucan verify` and the MCP `ucan_verify` tool answered from the leaf
alone, so a good signature on a broken proof chain reported valid and
then failed at import and at the node. Both report through one
`VerifyReport`, which walks the chain; `valid` requires it.

The non-Unix `write_private_file` removed the live delegation before
`rename`, on the belief that Windows refuses to replace an existing
destination. std maps `rename` to `MoveFileExW` with
`MOVEFILE_REPLACE_EXISTING`, so the remove only opened a window with no
delegation at all. Dropped; a refresh over an existing token is now
tested on every platform.

Also: `cmd_import` and `cmd_verify` share one token-argument reader that
reports an unreadable path as a file problem and caps the file size,
instead of falling through to a decode error on the path string; and
the MCP `ucan_delegate` description says import is CLI-only.

* docs: state the two gates a delegated push crosses, and finish the sweep

Round eleven of #331, documentation half.

A `git push` crosses two independent gates: read visibility on the
`info/refs` advertisement, and push authorization on the receive-pack
POST. This PR implements the second and left the first as it was, and a
UCAN grants no read access — so on a private repository a delegate must
also be a reader. Nothing said so. RUN-A-NODE now states the contract
once, in the owner-push section, and the delegation section carries a
"Private repositories" note with the `gl visibility set` invocation and
a "One repository per token" note matching what import enforces.

The rest is the drift the earlier doc edits left behind: the README
env-var table still said a `git/push` capability was "verified but not
yet honored", the known-limitations bullet still said enforcement
defaults to `false`, and the RUN-A-NODE section opened with a blanket
403 for every non-owner two paragraphs above the text that says a
delegate can push. All three now describe the same node.

* fix(gl): store one repository per imported delegation; pin the two-gate composition

Round eleven of #331, code half.

`gl ucan import` validated every capability up front and then wrote one
file per repository, and `write_private_file` is atomic per file, not
across files: a token naming two repositories could publish the first
delegation, fail on the second, and report failure for both. `gl ucan
delegate` and the MCP tool issue one capability per token, so such a
token is hand-built; import now refuses it with the supported shape in
the message. Several capabilities on one repository are still one file.

`delegated_push_on_a_private_repo_needs_read_visibility_too` pins the
composition the docs now describe, through the middleware production
gives each route: on a private repository a valid delegation is refused
at the `info/refs` advertisement (404) until the owner names the agent
as a reader, clears the receive-pack POST on its own, and a reader with
no delegation is still refused on the push. Making the visibility check
admit everyone reddens the advertisement assertion.

* fix(gl,core): sanitize token-derived output, prove the publish step, add MCP import

Round eleven of #331, second half.

Every string a token carries is chosen by whoever built it: `Did`
deserializes any string (only `FromStr` validates), and a capability
field is free text. `gl ucan verify`, `gl ucan show`, and the import
refusal messages printed them raw, so an ANSI or OSC sequence in `with`,
`can`, `iss` or `aud` — or in the error text `verify_chain` quotes them
into — reached the operator's terminal. The strip is now one definition
in core, `sanitize::strip_terminal_controls` (both halves of INV-6), and
`gl`'s existing `sanitize_node_msg` is that plus its length cap. In
`ucan_cmd`, `shown()` wraps every token-derived value on its way out.

The refresh-replacement test could not tell the current non-Unix writer
from the one that removed the live file before renaming, because a
rename that succeeds ends in the same state either way. A second seam,
`fault::FailPublish`, fails the rename after the staged bytes are
complete; the new test requires the old token to survive that, and
reinstating the remove reddens it on the platform that had it.

The MCP server could issue and verify a delegation but not store it
where `git-remote-gitlawb` looks. `cmd_import` is now a printer over
`import_delegation`, and the `ucan_import` tool is the same function
with a JSON answer: one set of checks, one store.

* test(gl): count the MCP tool list at 41 with ucan_import

`e3314c6` added the tool and left the count test at 40, so `test
(windows)` went red first. The test also carried "42" in its name while
asserting 40; it is now named for what it checks, and the names test
covers `ucan_import` alongside the other two UCAN tools.

* fix(node,gl): reader gate on receive-pack POST, GITLAWB_KEY in optional load, sanitize + bound UCAN surfaces (#331)

* test(gl): pin GITLAWB_KEY honouring in load_optional_keypair (#331)

* fix(gl): drop needless borrow caught by clippy

* fix(gl): doctor reports refused resolvers, sanitize MCP UCAN JSON, atomic key write (#567)

* fix(ucan): match push capabilities against the canonical repo name

A capability written gitlawb://repos/<owner>/<repo>.git passed import
and filed under the suffixed name, but the helper looks the store up by
the canonical name and the node compared the resource verbatim against
record.name, so the delegation missed at every hop and the push died at
the owner gate with a bare 403.

Introduce ucan::push::canonical_repo_name - the same at-most-one-.git
strip smart_http_repo_name applies - and run it everywhere a push
capability is issued (gl ucan delegate, MCP ucan_delegate), imported
(repo_from_resource now returns the canonical segment, so the file
lands where the helper looks), loaded (split_pack_post_url), and
matched (grants_push_to, hence chain_grants_push_to and the node gate).
At-most-once preserves the literal foo.git repo, reachable as
foo.git.git. Document the canonical form in RUN-A-NODE.md.

* fix(ucan): refuse key clobber on unreadable identity, fix doc gate sites and message escapes

- quickstart: bail on an existing-but-unreadable identity instead of
  regenerating over it, and write the fresh key with create_new so a
  file that appears mid-setup is not clobbered (same rule gl init uses)
- RUN-A-NODE: gate 1 read visibility applies on both GET info/refs and
  the git-receive-pack POST (same 404 for a client that skips the
  advertisement); visibility set example carries an explicit --mode
- ucan delegate: replace literal \n escapes in the wildcard refusal
  messages (CLI and MCP) with line continuations so the error prints as
  one sentence; MCP expiry bound uses the shared MAX_EXPIRY_HOURS
- MCP ucan_show: run the token version field through shown()
- helper tests: restore GITLAWB_KEY via a drop guard so a panicking case
  cannot leak a tempdir path, and run the advertisement signature test
  under the shared lock with a deterministic empty store

* docs(node): gate 1 allow-list semantics, fix comment-strip order in fn_body

- RUN-A-NODE: a `/` rule is an allow-list that replaces the public flag,
  so "public repo = open to everyone / reader step unnecessary" only
  holds when no `/` rule exists; gate-1 bullet, the both-gates summary,
  and the private-repositories section now tell that one story
- authz_guard fn_body strips // line comments before /* */ blocks so a
  line comment containing /* cannot open a range that swallows a gate

* fix(node,gl): fail closed on protection-lookup errors, surface push readiness in verify

- git-receive-pack: a non-owner whose is_branch_protected lookup errors
  is now refused instead of treated as unprotected; the owner path is
  unchanged (skip the check outright)
- VerifyReport gains bounded_lifetime and push_ready so `gl ucan verify`
  and MCP verify show what import and the node would do with an
  unbounded push chain instead of a bare valid:true
- RUN-A-NODE.md: the owner-push bullet names the delegation exception,
  and the delegation walkthrough now states the GITLAWB_KEY absolute-path
  prerequisite (import --dir must land in the same store the helper reads)
- pins: delegate push refused when the protection lookup errors; an
  unbounded push-shaped delegation verifies but is not push_ready

* fix(gl): judge push readiness with the shared push predicates in ucan verify

- push_class now uses the same is_push_action rule import and the node
  apply, so repo/admin and * actions classify as push-shaped instead of
  omitting the Push-ready line
- push_ready is a stored verdict, not just bounded+valid: every concrete
  gitlawb://repos/... push capability on the leaf must pass
  chain_grants_push_to, so a wildcard proof behind a concrete leaf
  verifies cryptographically yet reports not push-ready, matching what
  import and git-receive-pack would do
- pins: repo/admin and * actions are push-ready; a wildcard-proof chain
  verifies but is not push-ready

* fix(git-remote): gate delegation_header on import's push predicates and the file cap

- a stored delegation that is expired or has an unbounded link is now
  ignored instead of attached: the node would refuse it at middleware
  with a distinguishable invalid_ucan response, and omitting the header
  keeps the denial on the unified owner-push path
- the store file is size-checked against the same 1 MiB cap
  MAX_TOKEN_FILE_BYTES applies at import; an oversize entry is ignored
  like an unreadable one rather than read into memory on every push
- pins: an expired on-disk delegation produces no X-Ucan (and no node
  probe); an oversize store entry produces none either

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-11-11 02:49:14 +00:00
beardthelionandAyush7614 d15f194f7f fix(node): pin no-redirect and bound the body read on the iCaptcha public-key fetch (#574)
* fix(node): pin no-redirect and bound the body read on the iCaptcha public-key fetch

The node fetches the iCaptcha public key that anchors its proof gate, but built
its client with reqwest's default redirect policy and trusted `.json()` to
buffer the response. A redirect from the configured ICAPTCHA_URL could move the
trust anchor, and an oversized body was buffered without bound.

- Build the key client with `redirect::Policy::none()`, matching every other
  outbound client the node runs.
- Read the response body through a capped chunk loop (8 KiB) and parse from the
  received bytes instead of buffering unboundedly.
- Add mockito tests proving a valid JWKS still parses, a 302 to a host serving
  a real key is NOT followed, and an oversized body is refused.

Closes #507

* test(node): make iCaptcha oversized-body test use a valid padded JWKS

coderabbit flagged that the size-limit test fed fetch_key a body of
repeated x bytes, which is invalid JSON: even without a byte cap the
parse would return None, so the test passed regardless of the limit.
Pad jwks_body() with JSON whitespace past KEY_MAX_BODY_BYTES so an
unbounded read would still parse successfully, making the assertion
actually exercise the cap.

* chore(ci): re-trigger PR checks on the flaky test (beta) run

* fix(node): close the anchor paths the redirect pin left open, pin the boundary

- fetch_key refuses any non-2xx status before parsing the body: under
  Policy::none() a 3xx surfaces rather than being followed, but its own
  body could still carry a parseable JWKS and move the anchor anyway
- read_key_body accepts a body of exactly the cap (the loop now probes
  for end-of-body instead of exiting at the limit); over-cap is still
  refused, matching the documented "exceeds" contract
- the no-redirect test now points Location at the mocked /v1/pubkey so a
  redirect-following client would return a real key, making the pin
  load-bearing, and asserts the target is never contacted
- new pins: at-cap JWKS parses, a non-2xx with a valid JWKS body is
  refused, an unreachable endpoint is fail-safe None

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-10 20:38:29 +00:00
beardthelionandcarlitos973 f67688eae2 fix(gl): resolve webhook owner from caller identity, not node DID (#568)
* fix(gl): resolve webhook owner from caller identity, not node DID

resolve_owner() in webhook.rs unconditionally fetched the node's own
root DID via GET / and used it as the repo owner, ignoring both an
explicit owner/repo argument and the caller's own loaded identity.
This made gl webhook create/list/delete effectively unusable for any
repo not owned by the node operator itself -- every call resolved to
the wrong owner and 404d.

cert.rs's resolve_repo already has the correct pattern: check for an
explicit slash first, then prefer the caller's own keypair, falling
back to the node DID only if no local identity is available. This
applies that same pattern to webhook create/list/delete.

Verified against a live self-hosted node: webhook create/list/delete
all resolve the correct owner now. Existing webhook test suite passes
unchanged (8/8).

* fix(gl): print fully-qualified owner/repo in webhook create delete hint

Per CodeRabbit review on #159: the resolve_owner destructuring shadowed
repo with the bare name, so the printed delete hint lost the owner
qualification -- only correct by coincidence if the caller runs it
under the same identity used at create time. Print owner/repo
explicitly so the hint always works regardless of default identity.

* fix(gl): thread identity resolution and status checking through webhook list

Apply the same fix already applied to create/delete to the list
subcommand, per review feedback on this PR:

- gl webhook list now accepts --dir and resolves the caller identity
  the same way create/delete do, instead of always falling back to
  the node root DID for bare repo names
- cmd_list checks the HTTP status before parsing, so a non-2xx error
  (e.g. repo not found, not authorized) surfaces as an error instead
  of silently rendering as an empty webhook list
- list output now prints the fully-qualified owner/repo, matching the
  fix already applied to the create/delete hints

Also tightens resolve_owners doc comment: it falls back to the node
DID on any keypair-load failure, not only when no keypair exists.

* fix(gl): thread caller-identity repo resolution through MCP webhook tools (#159)

* test(gl): pin unsigned webhook_list node-denial fallback on MCP (#568)

---------

Co-authored-by: carlitos973 <[email protected]>
2026-11-10 18:53:03 +00:00
beardthelionandJaime Alfaro Salazar af6a759330 test(node): pin task list limit ceiling and default on the cursor handler (#572)
* Fix #399: GET /api/v1/tasks: clamp limit to match siblings; negative limit returns 500 with raw DB error

* test(api): test exact limit boundaries for negative and i64::MAX limit requests

* test(node): pin task list limit ceiling and default on the cursor handler

Port the #405 regression coverage onto the current cursor-paginated
handler: i64::MAX clamps to the 200 ceiling and an omitted limit falls
back to the 50-row default. Negative-limit behavior is already pinned
by negative_limit_returns_empty.

---------

Co-authored-by: Jaime Alfaro Salazar <[email protected]>
2026-11-10 17:12:41 +00:00
beardthelionandAyush7614 66fe646320 fix(node): close quarantine bypass on encrypted blobs, visibility routes, and sibling mutations (#566)
* fix(node): close quarantine bypass on encrypted blobs and visibility routes

Rebuilt per review: this branch now carries ONLY the quarantine fix
(dropped the unrelated task-claim and Windows fetch commits, which live
in #275/#312, and the author-local .cursor symlink). The ipfs.rs and
db-layer halves of the original commit were superseded by main's paged
scan, which already carries per-row quarantine flags; what remains is
routing encrypted-blob and visibility handlers through
authorize_repo_read so a quarantined repo is an opaque 404 there too,
plus the three regression tests for exactly that surface.

* fix(node): gate sibling mutation endpoints on quarantine, reconcile 404 contract in gl clone (#276)

* fix(node): release quarantined mirror fixtures, bound 404 body read in gl clone (#566)

* test(node): pin read-before-owner order on every dual-gated handler (#566)

* test(node): pin list_webhooks in the order loop, close-owner second assert, tighter release legs (#566)

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-10 15:29:23 +00:00
JATMNandClaude Opus 5.5 309fc8360d ci: run PR checks and CodeQL only for the surfaces a PR touches (#519)
* ci: run PR checks and CodeQL only for the surfaces a PR touches

Every PR ran every PR Checks job on the whole workspace, and CodeQL default
setup analyzed every language, including a ~13 min macOS Swift autobuild
(55 min when it hung on main) for an 8-file macos-app/ that 4 of the last
~280 commits touched.

scripts/ci-surfaces.sh maps a PR's changed paths to the jobs that validate
them, and scopes cargo test / build --release to the touched crates plus
their transitive reverse dependencies (derived from crates/*/Cargo.toml).
Root Cargo.toml/Cargo.lock/.cargo, the CI wiring itself, and unknown crate
dirs widen to the full suite. Push to main, merge_group, and the release
flow's workflow_dispatch always run everything on the whole workspace.

codeql.yml replaces default setup with the same scoping per language and a
full analysis on push to main and weekly. It needs default setup disabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: say which changes widen PR CI to the whole workspace

ci-surfaces.sh has two widening tiers. A root Cargo.toml/Cargo.lock/.cargo
change (or toolchain config, or an unknown crate dir) runs the Rust jobs on
--workspace, while the other jobs still follow their own inputs. Only the CI
wiring itself (pr-checks.yml, codeql.yml, ci-surfaces.sh) and non-PR events
run every surface. AGENTS.md and the script header described both tiers as
"everything" / "the full suite" and left "CI wiring" undefined, so an edit
to, say, release.yml read as if it ran the full PR Checks suite; it runs
none of those jobs and only CodeQL's Actions analysis.

Comment and docs only; the mapper's output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci: test gitlawb-node when bootstrap-peers.json changes

ci-surfaces.sh attributed Rust crates only by crates/<dir>/ paths, but
gitlawb-node also compiles in the repo-root bootstrap-peers.json through
include_str!. A PR that changed only that file therefore ran the Docker
smoke (docker build + --version, which embeds the JSON without parsing
it) and skipped cargo test, so embedded_seed_list_parses_successfully
("embedded bootstrap-peers.json must always parse") no longer guarded it.

Add a path -> crate table for files outside crates/ that a crate
compiles in, routed through the same check as crates/<dir>/ paths (an
unknown crate still widens to --workspace). bootstrap-peers.json now
yields rust=true with -p gitlawb-node and keeps its Docker trigger; every
other mapper output is unchanged.

The header also said anything the script cannot attribute precisely
widens the run. That holds only inside the Rust surface; a path no
surface lists (docs, README, infra/, other workflows) runs no PR Checks
job, as AGENTS.md says. Reword it to the two tiers the script implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci: feed ci-surfaces NUL-delimited paths and pin its mapping in a test

The mapper read newline-delimited `git diff --name-only` output. In that
mode git C-quotes any path with non-ASCII, tab, double-quote or newline
characters, so crates/gl/src/é.rs arrived as "crates/gl/src/\303\251.rs",
matched no surface, and a PR whose only Rust change was such a file ran no
Rust job (Café.swift likewise skipped Swift CodeQL). Newline-delimited
input also cannot carry a path that contains a newline.

Both workflows now pass `git diff -z` output and the mapper reads it with
`read -r -d ''`; input that is not NUL-terminated is rejected (exit 2)
instead of being misread. Mapping output for every ASCII path is
unchanged (checked against the last 43 commit ranges).

scripts/test-ci-surfaces.sh pins one must-not-skip case per surface row,
the crate fan-out and widening rules, --all, the wiring rows, quoted
names, and rejection of newline-delimited input. PR Checks runs it in the
`changed surfaces` job before mapping, on every event, so a broken mapper
turns the run red rather than silently narrowing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-10 00:41:03 +00:00
f68d0dd3ff fix(node): durable post-receive outbox for receive-pack (#26 split 1/4) (#558)
* fix(node): durable post-receive outbox at the DB layer (#26 split 1/4)

Reviewer 2 closed PR #224 on 2026-08-28 with a directive: split the
work into four narrow PRs. This is Split PR 1 (durable post-receive
lifecycle) at the DB layer; the handler refactor in
crates/gitlawb-node/src/api/repos.rs:2007 (git_receive_pack) lands in
the next slice so the test can drive the failure injection end-to-end.

The pre-outbox crash window the reviewer flagged: receive_pack can
apply a ref to disk and return Ok, and a process exit, a dropped
future, or a DB failure before the bookkeeping at
crates/gitlawb-node/src/api/repos.rs:2361 (push event + cert + webhook)
loses the recovery record. Startup drain enumerates only sources
written from that bookkeeping, so it cannot reconstruct the missing
work. The partial fallback that re-derives from a row present in the
bookkeeping substitutes did:key:recovered and an empty attestation,
which is not equivalent to the original authenticated push.

This commit adds the durable boundary the handler will lean on.

NEW TABLE pending_ref_transitions (migration v27):
  - Written by the handler BEFORE smart_http::receive_pack, in state
    'prepared', carrying the verified pusher DID, the raw RFC 9421
    signature header, signature-input, and content-digest that
    authorized the push, the request id, and the parsed ref update.
  - The handler transitions the row to 'applied' on receive_pack Ok
    or 'cancelled' on Err. The drain reads only 'applied'.
  - A failed or cancelled receive-pack therefore leaves the row in
    'prepared' or 'cancelled', which the drain never promotes. This
    is what closes the reviewer's second proof ("a failed or cancelled
    receive-pack does not turn a prepared intent into completed
    accounting or anchoring").

NEW TABLE anchor_jobs (migration v27, owned by PR 1, consumed by PR 2):
  - One row per (repo_id, ref_name, old_sha, new_sha) transition.
    PR 1 inserts it on 'applied'; PR 2 reads it and updates claimed_at.
  - ON CONFLICT (id) DO NOTHING makes the insert idempotent on the
    deterministic id, so a recovery re-pass cannot create a second
    upload request. This is the handoff boundary; the bundler call
    itself is PR 2.

NEW DB METHODS on Db:
  - insert_pending_ref_transitions: writes one 'prepared' row per
    ref update, returns the persisted rows.
  - mark_pending_ref_transitions_applied / _cancelled: state flip,
    gated on the FROM state, idempotent.
  - list_pending_ref_transitions_applied: drain query, oldest first.
  - delete_pending_ref_transition: called by recovery after the
    artifacts land; a third pass is a no-op.
  - record_push_with_id: ON CONFLICT (id) DO NOTHING on the
    deterministic id.
  - insert_ref_certificate_idempotent: ON CONFLICT (repo_id, ref_name)
    DO NOTHING, returns None if a live-path cert already exists.
  - insert_anchor_job_idempotent: ON CONFLICT (id) DO NOTHING on the
    deterministic per-transition id.

NEW HELPERS in db/mod.rs:
  - deterministic_id: SHA-256 hex with an ASCII Unit Separator
    between fields so two distinct tuples never collide on prefix
    overlap.
  - push_event_id_for, ref_cert_id_for, anchor_job_id_for: the
    derived ids above, one helper per artifact so a caller cannot
    derive a wrong id by mistake.

NEW STRUCTS:
  - PendingRefTransition: the row shape.
  - AnchorJob: the handoff row shape.
  - pending_state: const strings ('prepared' / 'applied' /
    'cancelled') shared by tests, the producer, and the drain so a
    typo on one side cannot silently mismatch the other.

NEW TESTS in db::pending_ref_transition_tests (8 tests, all green):
  - insert_then_mark_applied_flips_state_for_every_ref: producer
    contract.
  - mark_applied_is_idempotent_on_repeat: re-fire is a no-op.
  - cancelled_rows_are_not_returned_by_the_drain: reviewer's second
    proof at the DB layer.
  - prepared_rows_are_not_returned_by_the_drain: same proof for the
    pre-flip state (handler crashed before reaching post-Ok).
  - mark_cancelled_is_idempotent_on_repeat: counterpart.
  - drain_then_re_derive_is_idempotent: reviewer's first proof at
    the DB layer. Inserts a row in 'applied' state directly via
    insert_pending_ref_transition_for_test, drains it, derives the
    artifact ids twice, exercises record_push_with_id and
    insert_anchor_job_idempotent directly, asserts exactly one push
    event row and exactly one anchor job row regardless of how many
    times the drain runs.
  - deterministic_id_avoids_prefix_overlap_collisions: the
    separator regression test.
  - push_event_id_for_is_stable: derived ids match across calls and
    differ on each varied input.

OTHER:
  - Make RefUpdate and its fields pub(crate) so the DB methods can
    iterate the parsed ref updates. No public API change.

NOT IN THIS SLICE (the handler refactor, next commit):
  - The receive-pack handler does not yet call insert_pending_ref_
    transitions before the receive_pack call, nor mark_applied /
    mark_cancelled after. The DB layer is in place for it; the
    handler will call these methods and the startup drain will be
    wired in main.rs.
  - The startup drain in main.rs is not yet called; it will iterate
    list_pending_ref_transitions_applied, re-derive the artifacts,
    and delete the row.
  - The cert/push event issuance in cert.rs and the bookkeeping in
    api/repos.rs:2361 are not yet changed to use the deterministic
    ids. The helper functions exist and are tested; the callers
    follow.

Compiles clean, clippy clean under -D warnings, fmt clean.

* fix(node): wire durable outbox into the receive-pack handler (#26 split 1/4)

This is the handler-level half of Split PR 1. The previous commit
added the migration and the DB methods; this one threads them
through crates/gitlawb-node/src/api/repos.rs:2007 (git_receive_pack),
the cert issuer, and the startup drain.

CHANGES IN THE HANDLER
======================

In git_receive_pack, AT THE LAST POSSIBLE MOMENT before the
smart_http::receive_pack call, the handler now:

  1. Generates a per-handler request_id (UUID).
  2. Captures the raw Signature, Signature-Input, and Content-Digest
     headers from the request.
  3. Calls db.insert_pending_ref_transitions(request_id, ...) which
     writes one row per ref update in state 'prepared'.

The receive_pack call runs as before. After it returns:

  4. On Ok: db.mark_pending_ref_transitions_applied(request_id) —
     the row is the ONLY thing that promotes a 'prepared' row to
     'applied', and the drain reads only 'applied' rows. A process
     crash before this call leaves the row in 'prepared', which the
     drain never promotes.
  5. On Err: db.mark_pending_ref_transitions_cancelled(request_id) —
     a failed receive_pack leaves the row in 'cancelled', which the
     drain never promotes.

This is what closes the reviewer's two proofs:

  Proof 1 (crash window): if the process dies after
  mark_pending_ref_transitions_applied but before the bookkeeping
  writes, the row is in 'applied' and the next startup drain
  re-derives the push event, the per-ref certificate (carrying the
  ORIGINAL pusher DID, not a placeholder), and the anchor handoff.
  The drain uses the persisted authentic pusher DID and signature
  header, not a recovered placeholder.

  Proof 2 (failed receive-pack): the row is only ever flipped to
  'applied' in the explicit Ok branch above. A 'prepared' or
  'cancelled' row is invisible to the drain, so a failed or dropped
  receive_pack cannot turn a prepared intent into completed
  accounting or anchoring.

BOOKKEEPING IS NOW DETERMINISTIC-ID
===================================

The post-Ok bookkeeping at api/repos.rs:2448 now uses:

  - record_push_with_id with push_event_id_for(request_id, first_ref)
    — ON CONFLICT (id) DO NOTHING, so a recovery re-pass is a no-op.
  - issue_ref_certificate_idempotent with
    ref_cert_id_for(request_id, ref_name) — ON CONFLICT (repo_id,
    ref_name) DO NOTHING, returns None if a live-path cert already
    exists.
  - insert_anchor_job_idempotent with
    anchor_job_id_for(repo_id, ref_name, old_sha, new_sha) — the
    per-transition tuple key, so two pushes to the same ref produce
    one anchor upload per landed state.

The legacy entry points (record_push, issue_ref_certificate,
insert_ref_certificate) remain for callers that prefer a fresh UUID
per cert; they are #[allow(dead_code)] for the PR 3 cert/CLI compat
pass to decide whether to keep or remove.

STARTUP DRAIN
=============

crates/gitlawb-node/src/main.rs calls
durable_outbox::drain_pending_ref_transitions(state, 1000) ONCE
before serving, after migrations and after the existing peer /
quarantine prunes. Non-fatal: a transient drain failure logs and
leaves the rows for the next startup.

durable_outbox::drain_pending_ref_transitions reads every 'applied'
row, calls derive_one (which re-derives the three artifacts using the
persisted authentic pusher DID and signature header), then deletes
the row. A second drain pass is a no-op for both the artifacts
(idempotent inserts) and the row (gone after the first pass).

NEW END-TO-END TESTS
====================

crates/gitlawb-node/src/durable_outbox.rs adds three end-to-end
tests in drain_tests, complementing the eight DB-layer tests in
db::pending_ref_transition_tests:

  - drain_re_derives_all_three_artifacts_for_an_applied_row: the
    reviewer's first proof. Inserts a row in 'applied' state (the
    crash window), drains, asserts exactly one push event row,
    exactly one cert row carrying the original pusher DID (not a
    placeholder), and exactly one anchor job row. Asserts the
    deterministic cert id matches. Asserts a second drain pass is a
    no-op.
  - cancelled_row_produces_no_artifacts: the reviewer's second proof
    for the cancelled state. A row in 'cancelled' (receive_pack
    returned Err) is invisible to the drain.
  - prepared_row_produces_no_artifacts: the reviewer's second proof
    for the prepared state. A row in 'prepared' (handler crashed
    between insert_prepared and the post-Ok branch) is invisible to
    the drain.

Each test names the invariant it pins and the production line it
covers. Reverting that line turns the named assertion red.

Compiles clean, 1099 tests pass with 0 regressions, clippy clean
under -D warnings, fmt clean.

Cross-PR overlap (declared in the PR description):

  - #134 (anchors auth): composes. The /arweave/anchors route
    already requires auth; this PR does not change the route.
  - #285 (advisory-lock session affinity): composes. The durable
    intent is written inside the same handler that holds the lock
    from #285; no changes to the lock layer.
  - #306 (Content-Digest on signed requests): composes. PR 1
    persists the Content-Digest header that #306 makes mandatory.
  - #314 (small-order Ed25519): independent. PR 1's tests use strong
    keys.
  - #324 (libp2p keypair persistence): independent. PR 1 does not
    touch p2p identity.
  - #325 (gossip ref-update auth): independent. PR 1's signed
    envelope is the HTTP-side equivalent, not the gossip-side.
  - #382 (replication withheld-subtree trees): independent. PR 1
    does not touch replication or pin selection.

* fix(node): address four reviewer findings on #26 split 1/4

- P1-A: add startup reconcile step that promotes `prepared` rows to
  `applied` when the on-disk ref matches the row's `new_sha`. The
  recovery drain (which only reads `applied` rows) can now pick up
  a ref that landed when the live handler's
  `mark_pending_ref_transitions_applied` call errored or was
  interrupted. Strict SHA equality is the load-bearing check — a
  `prepared` row whose target did NOT actually land stays
  `prepared`.
- P1-B: route the live handler's cert issuance through
  `cert::issue_ref_certificate` (the upsert) instead of
  `issue_ref_certificate_idempotent` (DO NOTHING). A re-push to the
  same ref now updates the cert's `old_sha` / `new_sha` /
  `pusher_did` / `issued_at` / `signature` to the new transition
  while preserving the deterministic `cert_id`. The recovery drain
  keeps the idempotent variant; both paths collapse to one row.
- P2-A: refactor the drain into a `drain_pending_ref_transitions_with`
  testable seam that does per-row log-and-continue, and add
  `drain_pending_ref_transitions_all` that loops
  `DRAIN_PER_PASS_LIMIT=1000` rows for `DRAIN_MAX_PASSES=10` passes.
  A failing row no longer stalls the batch; a backlog above 1000
  rows is fully processed across passes.
- P2-B: add a `first_ref_name` column to `pending_ref_transitions`
  via migration v28. The live handler hoists a `first_ref_name`
  local and persists it on every row of the same `request_id`. The
  drain's `derive_one` keys the push event id on
  `row.first_ref_name` instead of `row.ref_name`, so live and
  recovery produce the same id and `ON CONFLICT (id) DO NOTHING`
  collapses a multi-ref push to one push event row (and one trust-
  score bump). Cert and anchor ids stay per-ref / per-transition.

* fix(node): address four reviewer findings on #26 split 1/4 (round 2)

* fix(node): address reviewer round-3 findings on #26 split 1/4

- P1: Delete outbox rows after live durable effects complete so they
  don't replay on every restart
- P1: Parse git report-status for per-ref ok/ng results; mark only
  proven rejections as cancelled, uncertain outcomes as recoverable
- P1: Introduce 'uncertain' state for receive-pack errors where some
  refs may have landed; reconcile checks these against disk at startup
- P2: Promote deletion transitions during reconcile (new_sha == ZERO_SHA
  with absent ref = successful deletion)
- P2: Loop reconcile across multiple passes so backlogs beyond the first
  page are processed in the same startup

Closes review round 3 findings from reviewer-1 and reviewer-2.

* fix(node): fix CI failures from round-3 changes

- Add COMMENT ON TABLE to v29 migration so migration_bodies_are_non_empty passes
- Return error on non-zero receive-pack exit (preserving backward compat
  with tests that expect Err(AppError::Git(_))) while still parsing
  report-status for outbox row handling

* fix(node): update inv22 gate tests for receive_pack_raw refactor

- F3 gate: scan for smart_http::receive_pack_raw( instead of receive_pack(
- U5 gate: scan for all_refs_ok instead of receive_result.is_ok()

* fix(node): require reflog proof before the reconcile promotes a row (#26 split 1/4)

Closes the second half of the reviewer's round-3 [P1]: the startup
reconcile still promoted on `disk_refs.get(ref) == row.new_sha`, which
also matches a coincidental current tip (old=B, new=A while the ref is
already A). That is not an exotic case — it is the ordinary shape of a
REJECTED push, since git refuses an update whose expected old value does
not match the ref. Both the SHA check and the MAX_RECONCILE_AGE window
pass, the push never happened, and the drain then writes a push event, a
signed certificate and an anchor for it.

The live path proves landings from git's report-status body; that body is
gone by the time the reconcile runs, so the proof is re-derived from the
repository. `git::store::ref_reflog_entries` reads `logs/<ref>` and a row
is promoted only when an entry carries its exact `old -> new` pair,
stamped at or after the row was written (REFLOG_CLOCK_SKEW absorbs git's
whole-second truncation against the row's sub-second `created_at`). The
timestamp floor is what separates a landing from a later push that
re-introduced the same pair: proof must postdate the intent it proves.

Deletions stay exempt from the reflog half and keep the absence-plus-age
rule already in place — git removes a ref's reflog along with the ref, so
no other evidence can exist for one.

`init_bare` now sets `core.logAllRefUpdates`, which bare repos default
off; a repo with no reflog yields no proof and its rows stay put for
human-attended recovery rather than becoming accounting the node cannot
substantiate.

Tests: reconcile_refuses_a_coincidental_tip_with_no_reflog_proof,
reconcile_promotes_a_row_the_reflog_proves,
reconcile_leaves_a_row_prepared_when_the_repo_keeps_no_reflog,
reconcile_refuses_a_reflog_entry_older_than_the_row,
reconcile_still_promotes_a_landed_deletion_which_can_have_no_reflog,
init_bare_keeps_reflogs_so_a_landing_can_be_proven,
ref_reflog_entries_is_none_when_the_repo_kept_no_log.

No migrations added or renumbered.

* fix(node): walk the reconcile backlog on a keyset cursor (#26 split 1/4)

The multi-pass reconcile looped but never advanced. Each pass re-issued
the same cursor-less `LIMIT n` query, and unlike the drain — which
deletes every row it finishes, so its next page is always new work — the
reconcile leaves every row it cannot promote exactly where it was. One
unprovable row at the head of the ordering therefore pinned page one on
every pass, and the backlog behind it was never examined at all, which
is the condition the multi-pass loop was added to remove. Rows keep
ageing toward MAX_RECONCILE_AGE while they wait, so "picked up next
restart" can mean "never recovered".

Pages now resume strictly after the `(created_at, id)` of the last row
EXAMINED, via `list_pending_ref_transitions_prepared_or_uncertain_after`.
A keyset cursor rather than an OFFSET because rows leave the set
underneath the walk when they are promoted: an absent row simply does
not appear on a later page, and it can never shift an unvisited row into
a page that was already read. Pass budget, per-pass limit, short-page
exit and the residual-backlog warning are unchanged.

This matters more with the reflog gate in place: a landing in a repo
that keeps no reflog is PERMANENTLY unprovable, so before this it would
jam page one on every startup rather than once.

Tests: reconcile_all_walks_the_backlog_past_the_first_page,
reconcile_all_advances_past_an_unprovable_row (whose blocker is a
no-reflog row, the class the gate introduces).

* fix(node): bound the reflog read to a recent tail (#26 split 1/4)

`ref_reflog_entries` slurped the whole reflog. A reflog grows one line
(~150 bytes) per ref update and how often a ref is updated is
PUSHER-controlled, so that was an attacker-sized allocation, taken once
per stranded row, at startup — the moment the node can least absorb it.
Every other attacker-influenced read in this crate is bounded
(read_body_capped, MAX_SIGNATURE_ENTRIES, the pin/walk budgets); this one
should be too.

The bound costs nothing in proving power, which is why it is a ceiling
rather than a tradeoff: the landing gate only ever accepts an entry
stamped at or after `created_at - REFLOG_CLOCK_SKEW`, and reflogs are
append-ordered oldest-first, so every entry that could possibly qualify
is at the tail. The read now seeks to the last REFLOG_TAIL_BYTES (256KB,
~2000 recent updates to one ref) and `take`s that many bytes, so the
allocation stays bounded even if the file grows between the stat and the
read.

A window into the middle of a file starts mid-record, so the bytes before
its first newline are discarded: without that, a boundary landing inside
a record's old SHA leaves a suffix that still tokenizes and yields a
truncated SHA as a bogus `old -> new` pair — and landing proof is an
exact pair match, so a bogus pair is a fabricated proof. An entry older
than the window reads as absent, which is the safe direction: the caller
treats "no matching entry" as unproven and leaves the row where it is.
`Ok(None)` for a missing file, and the absence-of-evidence contract, are
unchanged.

Tests: ref_reflog_entries_reads_whole_records_from_the_recent_tail (lays
the file out so the boundary lands 20 bytes into a record's old SHA — the
alignment that would otherwise parse), an_entry_older_than_the_reflog_tail_reads_as_unproven.

* fix(node): read the report through both side-band frames

The report-status is framed twice whenever the client negotiates
side-band-64k, which `git push` over smart HTTP does: the outer
side-band envelope carries a stream that is itself pkt-line encoded.
One strip pass left the first line as `000eunpack ok`, so the unpack
check failed and parse_report_status returned None — read by the caller
as "no report", which keeps every declared ref. The per-ref landing
gate was therefore inert for exactly the pushes it exists to filter.

The regression test is a byte-for-byte capture from git 2.50.1
rejecting one ref of a two-ref push, because a hand-written
single-framed fixture parses under the old code and hides the case.

* fix(node): address round-4 reviewer findings on #26 split 1/4

* fix(node): rustfmt round-4 reviewer fixes

* fix(node): remove empty line after doc comment

* fix(node): drop redundant reflog gate; implicit-ok on exit-zero no-report

* fix(node): address round-5 reviewer findings on #26 split 1/4

* fix(db): v30 migration — add receive_pack_requests table and ordinal column

The reviewer's round-5 finding: the push event identity was encoded
into a mutable per-ref column (first_ref_name) whose correct value is
knowable only after git, and the correction was not committed
atomically with the per-ref outcomes. A crash between git updating
a later ref and the rewrite left the durable rows naming the
rejected ref, and derive_one's row.ref_name == row.first_ref_name
guard then meant no push event ever landed for the accepted child.

Per the state-transition model at
.gravirei/plans/state-model-durable-post-receive.md, this migration
introduces the request-level record that owns the push event and the
trust score, and extends the per-ref child with an ordinal column
the drain and the effect executor walk together. first_ref_name is
dropped; the push event id is keyed on
(request_id, accepted_ordinal) and lives on the request row.

The next commit (handler rewrite) wires the live path and the
recovery drain against the new tables.

BREAKING CHANGE: pending_ref_transitions.first_ref_name is dropped.
Callers that read or write that column must move to
receive_pack_requests.accepted_ordinal.

* fix(node): rewrite receive-pack handler against the request-level model (#26 split 1/4 step 2)

The v30 migration added receive_pack_requests and dropped first_ref_name,
but no Rust code used either. The handler still ran a four-branch per-ref
state flip plus a first_ref_name rewrite after git returned, which is the
mixed-outcome bug a push with the first ref rejected exposes: the live
path and the drain computed different push-event ids.

- Insert a receive_pack_requests row in state `received` BEFORE git runs
  (insert_receive_pack_request), carrying the request bytes and the
  SHA-256 of the body. Crash between intent and git return is now
  recoverable via the reconcile step.
- Drop the first_ref_name rewrite at api/repos.rs:2454. The push event
  identity is now (request_id, accepted_ordinal); accepted_ordinal is
  computed once at the per-ref state flip.
- Re-key push_event_id_for and ref_cert_id_for on (request_id, ordinal).
  Anchor job id stays per-transition.
- Transition the request row to outcomes_committed (with parsed_report
  and accepted_ordinal) or rejected_at_git (no report + non-zero exit)
  in the same handler tail as the per-ref state flip. The drain (step 3)
  picks up outcomes_committed rows; today the live path also runs the
  per-ref effects inline.
- Stub the step-3 surface (mark_request_effects_pending, complete,
  list_receive_pack_requests_due, update_request_attempt) with
  #[allow(dead_code)] + contract-pin comments so the rewrite is
  bisectable: reverting this commit restores the pre-rewrite live path.

The U5 gate (replication tail spawn inside push_succeeded, before
guard.release) is preserved; inv22_replication_tail_spawns_at_the_
durability_boundary stays green.

* fix(node): use raw bytes for request_bytes_hash (#26 split 1/4 step 2)

The v30 migration defined request_bytes_hash as BYTEA but the handler
was binding a hex-encoded String. Postgres rejected the insert with
"column request_bytes_hash is of type bytea but expression is of type
text" and the handler shed the push with a 503.

Switch the handler to bind the raw 32-byte digest and update the
ReceivePackRequest struct field type to Vec<u8> to match. The
drain-side test fixture already used Vec<u8> and needs no change.

Pinned by the bin test suite going from 24 failures (all the
receive-pack-cap tests that send a 4-byte body) to 0 regressions
on the new model.

* fix(node): factor out apply_request_effects; drain walks receive_pack_requests (#26 split 1/4 step 3)

The step-2 commit moved the request row to be the unit of work but
left the per-ref effects fan-out inline in the handler and the
drain walking pending_ref_transitions per-ref. Step 3 factors the
effects fan-out into apply_request_effects(state, request_id) and
rewrites the drain to walk receive_pack_requests.

- apply_request_effects lives in durable_outbox.rs; the live handler
  in api/repos.rs and the drain both call it. Idempotent on
  (request_id, accepted_ordinal). Returns EffectsOutcome::{Done,
  Nothing, Retry}.
- Drain switches from list_pending_ref_transitions_applied +
  derive_one to list_receive_pack_requests_due +
  apply_request_effects. derive_one and the per-ref drain entry
  points are deleted.
- The step-3 stubs in db/mod.rs (mark_request_effects_pending,
  mark_request_complete, list_receive_pack_requests_due) lose
  their #[allow(dead_code)] annotations; every stub has a caller
  in this PR.
- Reconcile (reconcile_prepared_from_disk_all) is unchanged.
  The crash window between intent durable and outcomes commit
  still routes through per-ref reflog proof.
- Drain tests rewritten to stage receive_pack_requests rows; the
  per-ref fixtures (make_row, lookup_accepted_ordinal) are gone.
- New inv26_step3_live_and_drain_share_apply_request_effects
  assertion pins the live/drain sharing and the per-request
  drain walk.

* fix(node): bounded retirement purge for terminal request rows (#26 split 1/4 step 4)

The v30 partial index idx_receive_pack_requests_completed_at
exists but no code reads it. Step 4 wires a periodic purge task
that deletes terminal `complete` and `rejected_at_git` rows
older than the retention window, along with their per-ref
children. `quarantined` (a step-5 state) is never purged by the
timer.

- `purge_completed_receive_pack_requests(older_than, limit)` —
  deletes parent requests using the v30 partial index.
- `purge_completed_pending_ref_transitions(older_than, limit)` —
  deletes children of purged parents, gated on the child's own
  applied_at / cancelled_at.
- `purge_request_queue(db, retention_days, limit)` — orchestrator
  in durable_outbox.rs that calls both and returns the totals.
  The deletion order (parents first, then children) is the
  contract; a crash mid-purge leaves orphaned children that the
  next pass will pick up.
- `spawn_queue_lifecycle_sweep` in main.rs — periodic task on
  the same detached pattern as `spawn_legacy_cid_sweep`, 24-hour
  interval, shutdown-aware. The interval matches the spec's "one
  per cluster per day" target.
- Config knobs `queue_retention_days` (default 7, range 1..=365)
  and `queue_purge_batch` (default 1000, matches
  DRAIN_PER_PASS_LIMIT).
- 3 new `durable_outbox::drain_tests::purge_*` tests pin the
  contract: only old terminal rows are deleted, the second pass
  is a no-op, and a row inside the retention window survives.
- New `inv26_step4_queue_lifecycle_purge_is_wired` gate asserts
  the wiring (main.rs calls the purge, the DB helpers exist, the
  Config knobs exist).

* fix(node): reference-transaction marker + quarantined state + failure matrix (#26 split 1/4 step 5)

Steps 2-4 gave the request row the unit of work, the shared
executor, and the bounded retirement policy. Step 5 closes the
evidence gap: the reconcile now requires a per-request marker
ref (refs/gitlawb/requests/<id>) whose value matches
request_bytes_hash. A missing or mismatched marker quarantines
the request; an operator reclassifies it.

- v31 migration: adds `quarantined` to the state vocabulary and a
  partial index for operator queries.
- Handler writes the marker ref before git-receive-pack; the
  marker is causally bound by being in the same async task as
  the receive-pack call. The marker's value is content-addressed
  (git hash-object of the request bytes), so the gate compares
  consistent SHAs on both sides.
- git::store::read_ref reads a single ref's value, returning
  Ok(None) for absent refs. Used by the marker gate.
- git::store::marker_value_for computes the content-addressed
  marker value; both the live handler and the reconcile use it
  so the write and the read agree.
- Reconcile gains a marker gate between the age check and the
  reflog proof. Mismatch or absent ⇒ mark_request_quarantined +
  mark_children_rejected_for_quarantined_parent.
- effects_max_attempts bound (config knob, default 8) flips
  retry-stuck requests to `quarantined` after N attempts,
  closing the infinite-retry DoS window.
- New failure_matrix_tests submodule covers the spec's outcome ×
  ref-kind × exit-point × recovery-scenario matrix (6 cells).
- New inv26_step5_marker_quarantine_and_bound_are_wired gate
  asserts the marker gate, the bound check, the handler's
  pre-receive-pack ordering, and every load-bearing helper.
- Existing 7 reconcile tests updated to stage a marker ref via
  the new `stage_marker` test helper.

* fix(node): converge durable post-receive to singular request-level lifecycle

Unify producer, evidence, aggregate outcome, executor, retry, and
retirement on the request row: atomic intent and outcome commits,
synthetic normalized reports for implicit-ok/reconciled paths,
reconcile parent promotion with fail-closed deletions and
competing-claimant guard, retry progression from both executable
states with backoff/quarantine, terminal-parent-gated purge with
marker cleanup and hidden refs, minimal digest-only intent plus
request-level RFC9421 proof (v32).

* fix(node): harden request occurrence lifecycle, proof, retry, and retirement

Reject incomplete report framing and omitted refs as indeterminate,
preserve uncertain children on completion, terminalize all-rejected,
bind reconcile with landing history, durable proof with ack gate,
occurrence-keyed anchors, background due loop, deletion quarantine
with operator resolve, children-before-parent purge with marker
tombstones, recovery prereq upgrade check, and bounded marker runner.

* fix(node): keep recovery prereq upgrade best-effort on push path

Refusing pushes when git config upgrade fails broke fake-git and
non-repo disk-path tests with 503. Reconcile already fails closed
on missing reflogs, so downgrade to warn-and-proceed.

* fix(node): close reviewer round on outcome authority and lifecycle ownership

Gate effects on unpack_ok with cleared ordinal and terminal no-effects,
intersect executor with applied children, treat absent report-status as
indeterminate regardless of exit, require landing history before child
delete, claim due requests with recoverable leases, dedupe webhooks by
occurrence ledger, ack-gate proof retention with terminal disposition,
fair marker tombstones with dead-letter, kill_on_drop marker bounds,
and loud migration version collision guard.

* fix(node): load-bearing coverage for intent, guards, and delivery ledger

Handler intent test plus inv22 gate so disabling the durable insert
turns red; unpack-false, mixed ok/ng, divergent cancelled, partial
sibling, and proof-ack tests pin each executor guard; webhook
deliveries record sent_at with stale-pending reclaim; stale
parsed_report comment corrected.

* fix(node): centralize live retry, bound outcome commit, close retention orphans

Route live Retry/Err through schedule_request_retry_or_quarantine so
attempt progression matches the drain; retry post-git outcome commits
3x before restart repair; block terminal purge while non-terminal
children exist; add v27 upgrade-path test.

* fix(node): document attended-restart commit policy and pin executor retry arms

Track outcome-commit success, skip metrics/touch on failure, correct
the lease-expiry comment to startup reconcile; add restart-repair test
for stuck received parents plus RED tests for landing-history and
proof-ack failure paths.

* fix(node): correct marker prereq comment to warn-and-proceed

* fix(node): single partial-completion model with disposition gate

Split apply_request_effects into phase-1 bundle (push, cert, anchor,
history, webhook, proof ack before child deletion) and phase-2 gate
that retries while non-cancelled siblings remain; Nothing only when
nothing owed. Gate tail, Tigris, and effects on PostGitDisposition
with documented replication carve-out; add partial-sibling webhook
and disposition tests; fix stale comments.

* fix(node): close review round on outbox ordering, marker visibility, and repair reachability

Address the P1/P2 review findings: await the webhook delivery claim
before accepted-child deletion; exempt the marker namespace from the
fail-closed ref peeling guard so visibility walks tolerate blob-pointing
request markers; keep request marker deletion bounded in the purge path;
promote cross-page siblings via idempotent report superset; run
stuck-parent repair when the prepared queue is empty; bound the durable
intent write while admission permits are held; remove dead proof
verification scaffolding.

* fix(node): close review round on pre-git refusal lifecycle, namespace gate, and promotion merge

* fix(node): close review round on namespace gate, intent lifecycle, and promotion atomicity

Namespace gate hardening:
- Refuse non-UTF-8 ref-update lines (git accepts bytes ≥0x80; gate never sees them)
- Catch bare 'refs/gitlawb' name (starts_with missed it)
- Add deny test for both holes plus refusal wiring verification

Intent lifecycle guards:
- Add IntentDropGuard: terminalize stranded intents on dropped futures
- Bound refuse_receive_pack_before_git to 10s (no statement_timeout on pool)
- Guard mirror fetch: hideRefs blocks non-exempt refs/gitlawb/* from +refs/*:refs/*

Promotion merge atomicity:
- Take row under SELECT FOR UPDATE so merge is atomic with state read
- Prevents racing due worker from stranding newly applied children outside report

* fix(test): correct inv22 namespace gate assertions to match actual implementation

The test was looking for the wrong patterns:
- UTF-8 check: error message string, not pre-loop condition
- Namespace check: `*r == "refs/gitlawb"`, not `ref_name ==`
- Visibility exemption: `&&` logic (both false to exclude), not `||`

* chore: cargo fmt

* fix(node): phase-aware intent guard, mirror import prune, terminal-state merge

* fix(node): guard cleanup retry with fates, fail-closed mirror prune, refusal coverage

* fix(node): bound guard attempts, sibling-wait outcome, fail-closed prune enumeration

* fix(node): sibling-wait outcome, no-report path, bounded prune and guard retry

* fix(node): no-report durability, cert landing order, bounded reconcile git, flush checks

* fix(node): gate trust-score bump on a new push event row

* chore: update rustls 0.23.37 → 0.23.45 to resolve RUSTSEC-2026-0285

* fix(node): surface git's report-status when a stdin EPIPE follows child exit

* fix(node): cap receive-pack ref-update count before per-ref work and intent

* fix(node): cap ref-update parsing in-loop and dedupe identical commands

* docs(node): correct round-4 comment claims on client outcome and gate cost

* fix(node): single replication tail, proven marker taxonomy, claimed webhook delivery (#558)

- One post_receive_replication_tail per push: the carve-out spawn and the
  durability-bound spawn are mutually exclusive positions on the same
  proven-landed ref set; inv22 now fails on any unfiltered or ungated spawn.
- Marker reconcile: bounded-read errors (git Err/deadline) stay prepared
  and retry instead of quarantining as 'missing marker ref'; only a
  successfully read marker proves missing or mismatch. Err-injection test
  pins no-quarantine on unproven reads.
- Claimed webhook delivery: the synchronous claim phase returns the
  claimed Webhook rows so the send task never re-lists; a list failure
  after claim can no longer drop claimed deliveries. Source gate pins it.

* docs(node): name claimed-hooks variant in fire_event_occurrence doc

* fix(node): order pending_ref_transitions create before outbox alters, fill v29 (#558)

* fix(node): proven-missing marker taxonomy, v38 upgrade test, report-ok tail fixtures (#558)

* fix(node): read marker refs via rev-parse so present refs return their oid (#558)

---------

Co-authored-by: Gravirei <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-09 23:40:38 +00:00
beardthelionandcarlitos973 3c1ee27b60 fix(node): make creation-route rate limit configurable; fix(gl): surface real errors on pr create failure (#560)
- gitlawb-node: rate limiter was hardcoded to 10 req/hour per DID across all
  creation routes (repo/issue/PR create, register, fork). Adds
  GITLAWB_RATE_LIMIT_MAX and GITLAWB_RATE_LIMIT_WINDOW_SECS config fields
  (clap env-var pattern matching the rest of Config), defaults unchanged.
  Lets node operators tune this for real agent/bot usage without recompiling.

- gl pr create: previously called resp.json() before checking response
  status, so any non-2xx response with an empty or non-JSON body crashed
  with a generic "invalid JSON" error instead of surfacing the real cause.
  Now reads the raw response body first and includes it in the error message
  on failure.

Co-authored-by: carlitos973 <[email protected]>
2026-11-09 23:02:05 +00:00
beardthelionandKRATOS 209618a2fe feat(node): add scheduled inbound mirror worker (#555)
* feat(node): persist upstream mirror transition state

Origin-Session: local-d6a143 | Codex | 12 prompts

* fix(node): reject null mirror transition state

Origin-Session: local-d6a143 | Codex | 14 prompts

* fix(node): address mirror state review findings

Origin-Session: local-d6a143 | Codex | 16 prompts

* fix(node): close mirror review blockers

Origin-Session: local-d6a143 | Codex | 20 prompts

* fix(node): align peer URL validation message

Origin-Session: local-d6a143 | Codex | 20 prompts

* fix(node): close remaining mirror review findings

Origin-Session: local-d6a143 | Codex | 21 prompts

* feat(node): add scheduled inbound mirror worker

Origin-Session: local-d6a143 | Codex | 18 prompts

* fix(node): harden scheduled mirror fetches

Origin-Session: local-d6a143 | Codex | 24 prompts

* fix(node): close inbound mirror review blockers

Origin-Session: local-d6a143 | Codex | 24 prompts

* fix(node): reject special-use upstream literals at configure time (#555)

validate_mirror_upstream_url used only the peer public-host classifier, so
an IP literal the fetch worker's stricter permit denies (192.0.2/24,
198.18/15, 2001:db8::/32, ...) could be stored and then hard-fail on every
scheduled scan. The IANA special-use permit moves to peers::
mirror_upstream_ip_permitted, shared by configure validation and the
resolve-path check; the negative test set now covers the denied literals.

* test(node): scrub inherited git env in mirror fixture spawns (#555)

The repository_local_* fixture tests ran plain Command::new("git") and
inherited any GIT_DIR/GIT_CONFIG_* state from an outer checkout, so a
fixture spawn could act on the wrong repository. A shared fixture_git
helper now sets the cwd and removes git environment vars; all five spawn
sites use it.

* fix(node): bound mirror preflight probes separately, align operator docs (#555)

* chore(node): retrigger checks after code-scanning alert triage (#555)

* chore(node): retrigger checks after code-scanning alert triage (#555)

---------

Co-authored-by: KRATOS <[email protected]>
2026-11-09 22:48:47 +00:00
beardthelion a00d7d5c48 fix(gl): pin secret-file modes at creation instead of chmod after (#354) (#557)
* fix(gl): pin secret-file modes at creation instead of chmod after (#354)

identity.pem and ucan.json were written with fs::write and then chmod'd,
leaving the private key world-readable between the two syscalls, and
ucan.json was never chmod'd at all. A shared helper now opens with
mode(0o600) so no permissive window exists, adds O_NOFOLLOW so a
pre-planted symlink is refused rather than written through, and re-pins
the mode after write so a pre-existing loose file is tightened. The
containing directories are created 0700 via DirBuilder and re-pinned the
same way. gitlawb-node's own key write gets the same treatment.

* fix(gl): tighten secret handling before, not after, replacing contents (#459)

Address PR review feedback:

- secret_file::write and the node key write now open without truncate,
  pin mode 0600 on the descriptor, then set_len(0) and write, so a
  pre-existing permissive file never holds new secret contents while
  still group/world-readable.
- Directory re-pins skip symlinked paths instead of chmodding through
  them to the link target.
- The node's existing-key read opens with O_NOFOLLOW and reads from the
  descriptor instead of read_to_string on the path.
- An existing permissive key parent dir is re-pinned to 0700.

* fix(gl): adopt writable --dir leaves the user cannot chmod (#557)

create_dir re-pinned every existing final directory to 0700. On a leaf the
caller can write but does not own (group 0775, ACL grant, a system dir like
/tmp), fchmod fails EPERM and the whole command aborted, a regression over
create_dir_all's no-op. The re-pin now applies only when the euid owns the
opened directory; residual EPERM on an owned leaf adopts rather than aborts.
Regression test proves /tmp is adopted with its mode unchanged.
2026-11-09 20:13:51 +00:00
4cd0a6a306 fix(node)!: gate agent-task reads behind visibility rules (#553)
* fix(node): gate agent-task reads behind the same visibility rules as repo data

list_tasks and get_task had no authorization at all: any anonymous caller
could enumerate every task on the node, including another party's
repo-less task, its ucan_token, and its payload (#268). Add task_visible,
mirroring the repo read-visibility gate already used by the ref-updates
feed: the delegator and assignee can always read their own task, a
repo-scoped task follows that repo's normal visibility rules, and a task
naming no repo (or a repo this node doesn't host) is visible only to its
delegator/assignee. Both REST and GraphQL now route through the same
collect_visible_tasks/get_visible_task collectors so the two surfaces
cannot drift, and neither read path echoes ucan_token back, since the
holder already received it via the create/claim response.

Fixes #268

* fix(node): query the task limit-clamp test as the delegator

tasks_limit_ceiling_clamped_to_200 seeded 201 repo-less tasks and read
them back anonymously, expecting all 200. That read is exactly the
enumeration #268 closes, so the new visibility gate correctly returns
none of them and the test went red. The clamp ceiling is what this test
pins, not the gate, so query as the tasks' delegator, who can legitimately
see all 201 rows.

Refs #268

* fix(node): scope task visibility lookups to the page's repos

collect_visible_tasks loaded every repo on the node and every visibility
rule in order to gate at most 200 tasks, so an anonymous request paid for
the whole node's repo and rule set. Narrow both lookups to the repo ids the
fetched page actually names, and skip them when no task names a repo.

The deduped repo snapshot stays the source of truth for resolving a
repo_id: it collapses mirror and canonical pairs and omits quarantined
repos, and an id missing from it has to keep failing closed. Resolving ids
straight from the repos table would surface exactly those withheld rows.

Add GraphQL denial tests as well. Nothing pinned that the task resolvers
delegate to the shared collectors, so a resolver that queried the database
directly would not have gone red.

Refs #268

* Preserve authorized task reads across shipped clients.

Refs #268

* Bound denied task history scans.

Refs #268

* Signal incomplete task scans with recoverable cursors and map read errors to AppError.

Refs #268

* Stop disclosing denied task rows in the scan-wall cursor and add GraphQL pagination state.

Refs #268

* Normalize task cursors and gate mutation endpoints behind visibility.

Canonicalize RFC 3339 timestamps in parse_after_cursor to handle URL-decoded spaces, reject mixed cursor alias families, gate complete_task and fail_task behind get_visible_task so unreadable tasks 404 instead of leaking existence with 403, and only flag incomplete when hitting candidate ceilings on full SQL batches.

Refs #268

* Update complete_task test in test_support to exercise both 404 on unreadable and 403 on non-assignee tasks.

Refs #268

* Bind task to repo id in complete_task test in test_support.

Refs #268

* Preserve timestamp fractional width and anchor continuation tests on legitimate cursors.

Refs #268

* Match on Value::Object to extract task ID in GraphQL continuation test.

Refs #268

* Remove unused mut binding on visible_newer in GraphQL query test.

Refs #268

* Hide unauthorized claim and keep assigned tasks from being stolen

Gate REST and GraphQL claim behind the same visibility check as complete and fail, refuse claim when another assignee already holds the task, and only broadcast publicly visible task events. Treat a full list page as incomplete when more candidates remain. Surface HTTP errors from CLI and MCP claim and complete helpers.

Refs #327

Co-Authored-By: cairn-code <[email protected]>

* Keep task-list incomplete for the scan ceiling only.

A full visible page was flagged incomplete whenever the SQL batch was full, so the first page of any list with more than 200 candidates looked stalled. Align the GraphQL claim test with the visibility gate's not-found message.

Refs #268

* fix(node): pin claim guards and map mutation errors through AppError

Review required tests that go red if the pre-assigned claim predicate or
the anonymous announce gate is deleted, and incomplete must not stay
true when the candidate stream is exhausted at the scan ceiling. Route
claim, complete, and fail through AppError so closed-pool outages stay
503 and 404s match the read envelope.

Refs #268

* fix(node): match bare and did:key assignee forms in claim and list

create_task stores the supplied assignee unchanged, so a raw SQL
equality check drops a designated assignee who presents the other
did:key form. Compare the normalized key so claim and filtered list
agree with did_matches.

Refs #268

* fix(node): check task creation status and add assignee expression index

- Add error_for_status() to cmd_create and task_create MCP tool
- Update test_create_task_server_error to assert failure on 500
- Add migration v18 creating expression index idx_agent_tasks_assignee_key matching ASSIGNEE_DID_CASE_SQL
- Add did:web:z6Mkfoo single-residual shape to parity boundary matrix

Refs #327

# Conflicts:
#	crates/gitlawb-node/src/db/mod.rs

* fix(node)!: Replace task cursors with opaque continuation tokens

The task read path treated visibility, pagination, and error vocabulary as
separate edits, so each one broke where they met. Rework them as one contract.

A raw (created_at, id) cursor forced a choice between two broken options: it
could name the last visible row, and then a denied window longer than the
1,000-candidate scan budget was unpageable forever; or it could name the last
examined row, and then a denied read leaked the id and timestamp of a task
GET /tasks/{id} otherwise 404s. Continuation tokens remove the choice. They
carry the last examined candidate, so paging always advances a full scan budget
per request, and they are encrypted and authenticated under a node-derived key,
so the caller learns nothing from one and cannot forge one naming a row of
their choosing. Encryption is a synthetic-IV construction over the hmac/sha2
pair already used for webhook signatures, so it adds no dependency and needs no
randomness source.

Making the token the only accepted cursor also gives the ordering key one
domain. agent_tasks.created_at is TEXT and compared as TEXT, so a caller-typed
'...Z' and '...+00:00' denote one instant but sort differently, and a client
could silently skip or repeat same-time rows. The token carries the stored
string verbatim, so the value compared is always one the server wrote. The raw
after_*/cursor_* pairs are removed rather than kept alongside it, since a second
domain is the bug.

Separate the two facts the old single incomplete flag conflated: has_more says
candidates remain, incomplete says this page is short only because the
authorization scan hit its ceiling. Both REST and GraphQL now return has_more,
incomplete, and next_cursor from the shared collector, and REST echoes the limit
it actually applied so a clamped request is visible as clamped.

Have gl task list and MCP task_list follow next_cursor instead of issuing one
request: --limit 500 returned a successful but silently truncated 200 rows.
Following is bounded by a page cap and a no-progress guard, and a run stopped by
either reports an explicit incomplete result with a resume cursor.

Route claimTask, completeTask, and failTask through the same task_write_conflict
classifier the REST handlers use, via curated helpers in the graphql module so
the map_err source guard still holds. A claim race or stale finish reached
GraphQL clients as a generic database error while REST clients got an actionable
conflict; genuine sqlx faults stay opaque on both.

Refs #327

* fix(node): End the candidate stream only after the batch is examined

A short SQL batch means no rows exist past it, not that every row in it was
examined. When the page filled mid-batch the collector treated the two as the
same, marked the stream ended, and suppressed the continuation, so every row
after the one that filled the page was unreachable. The equal-timestamp paging
tests caught it: three rows with a limit of one returned only the first.

Track how much of each batch was consumed and end the stream only when the
whole of a short batch has been examined. Otherwise leave `has_more` to the
probe row, which resumes from the last examined candidate.

Refs #327

* fix(gl): Reject a non-positive task-list limit and fault the write under test

A `--limit 0` reached the node, which clamped it to zero and answered with
an empty page marked complete, so an invalid request read as proof that no
tasks exist. Reject a non-positive limit in `fetch_tasks()`, the helper the
CLI and MCP share, so the guard cannot drift between the two surfaces.

`task_write_sql_faults_stay_opaque` did not exercise what it named. Dropping
`updated_at` also broke the SELECT in `get_task()`, so the fault surfaced
from the `get_visible_task()` pre-check through `graphql_app_err` and never
reached `graphql_claim_conflict`. A `BEFORE UPDATE` trigger keeps every read
valid and faults only inside `Db::claim_task`, and the test now also asserts
that a write-time fault is not reclassified as a claim race.

Refs #268

* fix(node): Bind task cursors to the caller and brake the task read routes

A continuation token names the last candidate a scan examined, not the last
row it returned, so it encodes how far that scan got under one caller's
visibility. The MAC bound the page filter but not the presenting identity,
so resuming a token as a different caller started the scan past rows that
caller was entitled to read and dropped them from the answer with nothing
to signal the loss. Bind the caller's normalized DID into the MAC, with
anonymous flagged absent rather than encoded as empty. Normalization goes
through normalize_owner_key so the two spellings of one did:key identity
bind identically, matching did_matches on the read path: a caller who
presents the other form of their own DID keeps their own page. A mismatched
token renders the existing single rejection message, so this adds no oracle.

GET /api/v1/tasks and GET /api/v1/tasks/{id} are anonymously reachable, and
the visibility gate costs a task lookup plus deduped-repo and
visibility-rule queries before it can return the opaque 404. An
unauthenticated prober therefore pays nothing while the node pays per
request, whether or not the id exists. Attach the per-IP limiter already
used on /ipfs/{cid}, configurable through GITLAWB_TASK_READ_RATE_LIMIT and
swept by the periodic task like every other per-key limiter.

Refs #268

* fix(node): Brake anonymous GraphQL task reads on the same per-IP bucket

The per-IP brake added for the task read routes covered only
/api/v1/tasks*, so an anonymous caller reached the same
collect_visible_tasks and get_visible_task gate over /graphql with no
bucket at all. The fence had an open lane beside it.

Carry the brake as GraphQL request data and debit it in the tasks and
task resolvers rather than layering rate_limit_by_ip onto the GraphQL
router: /graphql is one endpoint for every operation, so a router layer
would charge unrelated queries and every mutation against the task-read
bucket. Debiting per resolved field also prices an aliased query
honestly, since ten aliased tasks fields run the gate ten times.

Extract RATE_LIMIT_MESSAGE so the GraphQL surface, which cannot return a
429 status inside a 200 envelope, refuses with the same text the REST
routes use.

/graphql/ws serves the query root as well and stays unbraked; closing it
needs a WebSocketUpgrade handler and is left for a follow-up.

Refs #268
Refs #327

* fix(gl,node): parse typed task pages, track cursor cycles, and normalize assignee filter MAC

* fix(node): Cap aliased GraphQL task reads per request and derive has_more from visible rows

- Cap aliased GraphQL task read fields per request using an atomic counter
  on TaskReadBrake (MAX_GRAPHQL_TASK_READS_PER_REQUEST = 5).
- Derive has_more in collect_visible_tasks by scanning for bounded_limit + 1
  visible rows, eliminating the un-gated keyset probe that could leak the
  presence of trailing denied tasks.
- Add regression tests covering aliased GraphQL capping and trailing denied
  task has_more privacy.

Refs #327

* fix(node): Check database continuation when scan budget ends on exact batch boundary

When candidate scanning reaches MAX_TASK_SCAN_CANDIDATES without finding
a target_visible row and the final batch was full, probe the database for
rows beyond the scan position so an exhausted candidate stream is not
erroneously marked incomplete.

Refs #327

* fix(node): Document and pin the bound on the task scan-ceiling probe

The scan-ceiling branch of collect_visible_tasks settles has_more with an
un-gated LIMIT 1 probe, so a caller can learn whether any row - readable
or not - trails the position the scan stopped at. Withholding the probe
does not remove that bit: enumeration past a denied window longer than
one scan budget requires handing back a continuation, and following that
continuation returns the same terminal page one round trip later.

State what the probe discloses (one bit, only at server-chosen positions
a full scan budget apart, reachable only through a MAC'd cursor, never a
denied row's id, payload or ucan_token) and pin it end to end. Also
correct the comment above the branch, which claimed has_more never comes
from an un-gated probe while the code below it did exactly that.

Refs #327

* Index task keyset pages so the scan ceiling bounds database work.

Optional IS NULL predicates kept the planner from using a created_at/id order, so every list_tasks_keyset batch could sort a growing match set before LIMIT. Dedicated per-domain SQL plus v28 indexes make the candidate ceiling a database bound.

Refs #327

* Reject oversized task pages so list never returns more than the requested limit.

fetch_tasks asked for the remaining total, then appended every row on a valid-shaped page. A remote that sent more tasks than want could make gl and MCP expose more than --limit. Treat that page as protocol-invalid before any extra row is kept.

Refs #327

* Enforce task page limits on legacy responses and drop redundant single-column indexes.

Refs #327

* Address review feedback on task-read authorization gating and client safety.

- Wire TaskReadBrake into /graphql/ws subscriptions and verify per-request field caps and per-IP rate limiting over WebSocket connections.
- Define open-claim eligibility separately from read visibility so unassigned tasks on readable/unscoped domains can be claimed without making task bodies enumerable.
- Propagate identity errors on explicit key directories in CLI and MCP instead of silently falling back to anonymous mode.
- Enforce response byte limits before deserializing task pages, validate row schema and page-local uniqueness before row commit, and sanitize continuation cursors in terminal diagnostics.
- Add GraphQL denial assertions for repo-less tasks and token isolation.

Refs #268
Refs #327

* Address review feedback on task-read authorization gating, WebSocket budgets, and test framing.

Refs #395

* Address review feedback on quarantined task claims, WebSocket auth, and MCP limit parsing.

Refs #395

* Reject forged signatures during WebSocket authentication upgrade.

Refs #395

* Accumulate forged WebSocket handshake response until headers terminate.

Refs #395

* Withhold quarantined-repo tasks on all read surfaces before party checks.

Refs #395

* docs: Align task-read documentation with enforced visibility and rate limits.

Refs #395

* docs: Distinguish task list and get behavior in rate-limit guidance.

Refs #395

* fix(node): Batch task quarantine checks to bound read costs.

Refs #395

* docs(node): clarify the shared task-read budget

Document that GraphQL task queries over HTTP and WebSocket debit the same per-IP hourly budget as REST task reads.

Refs #395

* fix(node,gl): bound scoped repo lookup and validate MCP cursor

Bound repository access in list_repos_deduped_by_ids to requested groups via idx_repos_owner_key_name, leaving dedup_cte unscoped. Strictly validate MCP task_list cursor argument before network dispatch.

Refs #396

* test(db): verify scoped lookup work in prepared plans

Measure discarded rows and verify actual custom and generic prepared-plan counters, including the previous full-index-scan regression.

Fixup for a0ea4c78e4.
Refs #396

* docs(db): remove stray dedup comment escapes

Restore readable root-path notation in the deduplicated listing documentation.

Fixup for a0ea4c78e4.
Refs #396

* fix(node): address #464 review findings

- Hold migration 27/28 numbering: reservation comment now names the
  competing Gitlawb/node#384 range (27-35) and states the numbering is
  on hold pending #384. No renumber.
- Restore crates/gl/src/identity.rs to LF endings so the diff shows
  only the added load_optional_keypair function.
- Drop the dead single-column idx_agent_tasks_assignee_key build from
  v27 (v28 builds the keyset-ordered variants directly); adjust the
  v27 test to assert the raw index is dropped and the expression
  index is never built.
- Align task_claimable with claim_task's conditional write: drop the
  delegator short-circuit and correct the docstring.
- Pin error_for_status on the gl task list path with a 403 mock that
  serves a well-formed empty page, so the test goes red if the call
  is removed.

* test(node): pin the delegator deny on a pre-assigned claim

- Add claim_task_delegator_cannot_claim_preassigned_task: pins opaque 404
  for delegator claiming a pre-assigned task (regression guard for P2)
- Fix test_list_tasks_server_error_surfaces regex: /api/v1/tasks\? ->
  /api/v1/tasks\? (escaped ? for literal question mark)
- Assert actual reqwest StatusCode::FORBIDDEN instead of matching "403"
  in error string (avoids spurious pass from random mock port)

* test(node): exercise streamed byte-budget guard with chunked body

Use with_chunked_body instead of with_body so mockito sends no
Content-Length, pinning the no-length streamed accumulation guard.
Assert the "exceeded" message to distinguish from the declared-length
check.

* fix(node): pin declared-length byte-budget arm in oversized content-length test

Addresses beardthelion review finding (2026-09-26): test_read_task_page_json_oversized_content_length now asserts on the "(declared" arm the way the chunked twin asserts "(exceeded", so the Content-Length pre-check at crates/gl/src/task.rs:211 is genuinely pinned. Verified the pin by neutering the pre-check (test goes red), then restored. fmt clean.

* fix(node): fail closed on claim for unresolvable repo

task_claimable returned true for an unassigned task whose repo_id is
slash-form (mirror row) or names a repo this node does not host, while
task_visible fails closed on the same rows. A signed stranger could claim
such a task even though reads 404. Return false for a Some(repo_id) the
node cannot resolve to a locally-hosted repo, matching task_visible's
fail-closed check; keep the None arm open (repo-less tasks claimable by
design). Adds a regression test asserting a stranger's claim 404s and
leaves the assignee unchanged for both row classes.

* fix(node): pad sealed task cursor to fixed width

The scan-ceiling token seals the last examined row's (created_at, id) as
variable-length JSON under a length-preserving keystream, so token length
leaked the withheld row's field widths: a 32-char to_rfc3339() rendering
mints a 148-char token where a 35-char one mints 161 chars. Pad the
serialized payload with trailing JSON whitespace to a fixed 128-byte
bucket before the SIV tag is computed; decode tolerates the padding
(serde_json ignores trailing whitespace) and tokens minted before this
change still verify. REST and GraphQL share task_cursor::encode, so one
fix covers both.

Adds token_length_does_not_vary_with_position_width: asserts identical
token length across narrow/wide timestamp positions and that the padded
token still decodes to the exact stored position.

* fix(node): address beardthelion round-six P3 findings on task cursor

- Pin the encode docstring compat claim: add unpadded_legacy_token_still_verifies
  test minting a token via the pre-padding recipe (siv + apply_keystream with
  no padding) and asserting decode returns the position.
- Pin the PAYLOAD_BUCKET round-up claim: add oversized_payload_rounds_up_to_next_bucket
  test with a 140-char id crossing the 128-byte bucket, asserting round-trip
  at two buckets.
- Correct timestamp-width figures: to_rfc3339() renders 25 chars with no
  fractional seconds (not 32); 32 is the microsecond width.

* fix(node): address beardthelion round-seven P3 findings on task cursor

- Correct wire-form docstring to v1.<iv>.<body>
- Fix payload width figures: 101 bytes total, to_rfc3339() precision ladder
- Tighten oversized-payload test to assert exactly 2*PAYLOAD_BUCKET
- Pin legacy back-compat test with a frozen v1 token fixture

* test(node): address beardthelion round-eight P3 findings on task cursor

- Pin the one-bucket width: round_trips_position_verbatim now asserts the
  decoded body of a normal token is exactly PAYLOAD_BUCKET, catching a pad
  that skips next-multiple or a drifted bucket constant. Every other width
  assertion here is bucket-relative, so only an absolute check goes red.

- Assert the frozen fixture is the unpadded shape:
  unpadded_legacy_token_still_verifies decodes the body segment and asserts
  raw.len() < PAYLOAD_BUCKET, so a literal re-minted under the padded
  recipe would retire the back-compat pin loudly instead of passing
  silently.

- Narrow the recipe-pin claim: the docstring now names what the frozen
  literal actually binds (domains, MAC input, keystream, segment layout,
  field names) instead of claiming padding and serialization changes break
  the test. The padding-width claim stays with
  oversized_payload_rounds_up_to_next_bucket.

* fix(node): trim stored assignee on every task gate; align SECURITY.md task-read wording

* test(node): pin mirror-row list-vs-get asymmetry; fix plan-test bind (#553)

- EXPLAIN helper binds normalize_owner_key(assignee) to match the
  production list_tasks_keyset bind.
- Pin the deliberate asymmetry jatmn asked about: a delegator sees a
  slash-form (mirror-hosted) task in a list, while get fails closed for
  every caller because the row has no local repo backing. Notes in
  get_visible_task and a regression test keep it from drifting.
- Repair test-mod drift from the main merge: build_schema gains its
  TaskCursorKey arg at three sites and list_tasks calls move to
  list_tasks_keyset.

* test(node): reconcile tasks complexity contract with TaskPageType (#553)

The four GraphQL task/brake tests failed on this branch: contract tests
still queried the flat tasks { id } shape that TaskPageType replaced, and
the alias-brake tests ran default-size lists whose six aliases exceed
GRAPHQL_MAX_COMPLEXITY before the brake can debit.

- Contract tests now select through items { ... } with refreshed cost
  arithmetic.
- Brake tests use tasks(limit: 1), keeping six aliases under the budget
  so the 6th field reaches TaskReadBrake and fails RATE_LIMIT_MESSAGE.
  Complexity stays the outer gate (#552); the brake stays the per-request
  field-count gate.

* docs: note mirror-hosted task list-vs-get asymmetry (#553)

Option 3 for slash-form mirror repo_id rows was pinned in code and test at
6c0926de; this adds the operator-facing half jatmn asked for. README and
SECURITY task-read bullets now state that mirror rows list for party readers
while get-by-id and GraphQL task fail closed for all callers.

* ci: retrigger codeql scan after alert 86 dismissal

---------

Co-authored-by: euxarist <[email protected]>
Co-authored-by: cairn-code <[email protected]>
Co-authored-by: cairn-intern <[email protected]>
Co-authored-by: Marlowe <[email protected]>
2026-11-09 19:02:54 +00:00
0919cf7c62 fix(core): ignore invalid signatures in satisfies_threshold (#349) (#554)
* fix(core): ignore invalid signatures in satisfies_threshold (#349)

RefUpdateCert::satisfies_threshold called verify_all, which fails closed
on the first malformed or invalid signature entry. Signature entries live
outside the signed body, so any third party can append a junk entry with
no key material — appending one denied an otherwise valid certificate, a
DoS on the threshold check.

Add a lenient valid_signers() helper that skips entries failing to parse,
decode, or verify, and have satisfies_threshold count distinct valid
signers from it. verify_all stays strict (fail-closed) so its API and the
tampered_signature_fails_verify_all test are unchanged. A forged entry
naming a real maintainer DID with a bad signature is skipped, so it cannot
inflate the count either.

Adds tests: appended junk entries do not deny a valid 2-of-2 cert, and a
forged maintainer signature is not counted toward the threshold.

Closes #349

* fix(core): bound the tolerant signature scan before cryptographic work

satisfies_threshold verified every entry, so a peer could append
arbitrarily many syntactically valid entries with bogus signatures and
buy attacker-controlled CPU and allocation on every threshold check.
Bound each stage ahead of the loop: reject a list over
MAX_SIGNATURE_ENTRIES loudly (a prefix scan would let junk starve real
signatures out of the window), refuse to base64-decode any encoded
signature that is not exactly the 86 chars of a 64-byte Ed25519
signature, skip non-maintainer and already-counted signers on a string
compare before any curve work, and return as soon as the threshold is
met.

* docs(cert): correct ENCODED_SIG_LEN length-gate comment (#554)

The comment claimed anything longer cannot decode to a valid signature.
The same 64 bytes appear in padded or non-url-safe encodings; the gate is a
canonical-form DoS bound for the tolerant scan, not a crypto impossibility.

---------

Co-authored-by: andreolf <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-09 17:42:13 +00:00
beardthelionandRapyBus S.A.C. 05afd68e41 bootstrap-peers: add moonwake-ledger with a dialable p2p multiaddr (#559)
Co-authored-by: RapyBus S.A.C. <[email protected]>
2026-11-09 17:21:54 +00:00
beardthelion 6c971e7ea0 ci(audit): ignore RUSTSEC-2026-0459, unlinked libsqlite3-sys advisory (#564)
The advisory covers bundled SQLite in libsqlite3-sys 0.30.1, which sits in
Cargo.lock only because sqlx resolves sqlx-sqlite unconditionally. sqlx builds
postgres-only, so the bundled C source is never compiled or linked. The fix
needs libsqlite3-sys >= 0.39 but sqlx-sqlite 0.8.6 pins ^0.30.1; the waiver is
documented to retire if the sqlite feature ever enters the build.
2026-11-09 16:54:05 +00:00
94 changed files with 51831 additions and 5363 deletions

No files matched your search

+67 -4
View File
@@ -6,8 +6,16 @@
# Path to the node's Ed25519 keypair PEM file. Must name a key file, not a
# directory: a trailing slash, a final `.` or `..`, and `..` traversal are
# refused so the path cannot retarget a parent. A `~/` value must stay under
# home after expansion. Bare `identity.pem` is still legal.
# home after expansion, and the path must be absolute: its parent is also
# the delegation store that `gl ucan import` writes and `git-remote-gitlawb`
# reads, and the two do not share a working directory, so a relative path
# sends them to different places. Bare `identity.pem` under the default
# config dir is still legal.
# Generate with: gl identity new
# Leaving it unset uses ~/.gitlawb/identity.pem, which is the shared default
# for both `gl` and `git-remote-gitlawb`. The bare-filename form is for the
# node's own service config; client tooling should inherit the default or use
# an absolute path.
GITLAWB_KEY=/data/keys/identity.pem
# Path to the node's persistent libp2p identity key file. Must include a
@@ -152,9 +160,10 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# push to every repository, private ones included. On by default.
#
# Set to false only for a rolling upgrade whose pushers are not yet the repo
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
# verified but not yet honored for authorization, so they cannot push while this
# is on. See docs/RUN-A-NODE.md.
# owner. A holder of an owner-rooted, time-bounded git/push UCAN naming that
# repository can push while this is on — see docs/RUN-A-NODE.md for issuing and
# importing one. A CI key with no such delegation counts as a non-owner and
# cannot push.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Comma-separated libp2p multiaddrs.
@@ -326,6 +335,19 @@ GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
# from the concurrency caps above). 0 disables. Default 600.
GITLAWB_IPFS_RATE_LIMIT=600
# Per-client-IP rate limit for the anonymous task read routes
# (GET /api/v1/tasks, GET /api/v1/tasks/{id}), in requests per hour.
# Both are publicly reachable (optional_signature). GET /api/v1/tasks runs
# collect_visible_tasks and returns a visibility-filtered page;
# GET /api/v1/tasks/{id} runs get_visible_task and returns an opaque 404 when
# the task is hidden or missing. These reads can require task, repository,
# and visibility-rule queries even when no task is returned, so the brake
# bounds the cost of anonymous probes. Keyed on the resolved client IP via
# GITLAWB_TRUSTED_PROXY. 0 disables. Default: 1200 (a list page
# followed by per-task reads is a normal client pattern, so this sits above
# the /ipfs budget). The GraphQL/WS task-read brake shares this budget.
GITLAWB_TASK_READ_RATE_LIMIT=1200
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
@@ -334,6 +356,32 @@ GITLAWB_IPFS_RATE_LIMIT=600
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
# the client IP. 0 disables. Default 120.
GITLAWB_CREATE_RATE_LIMIT=120
# Per-DID creation requests allowed inside the window below (repo create,
# issue create, PR create, register, fork). 0 is rejected at startup: it would
# silently disable the limiter. Default 10.
GITLAWB_RATE_LIMIT_MAX=10
# Window the per-DID creation count above applies over, in seconds. 0 is
# rejected at startup: every timestamp would prune immediately, disabling the
# limiter. Default 3600.
GITLAWB_RATE_LIMIT_WINDOW_SECS=3600
# ── Write rate limiting (non-creation authenticated writes) ───────────────
# Max requests per client IP per hour on the shared write bucket. It is debited
# by: issue and PR comments, issue close, labels, stars, merges, protect and
# unprotect, replicas, webhook create/delete, visibility (including the GET on
# that group), tasks, bounties, profile, and every HTTP request to /graphql.
# The /graphql brake wraps the whole route, so queries and the playground GET
# consume this bucket too, not only mutations (GraphQL WebSocket subscriptions
# are excluded). Issue comment and issue close debit it inside the handler,
# after the read gate, rather than in router middleware. Issue close also draws
# on its own GITLAWB_CLOSE_ISSUE_RATE_LIMIT bucket, and a close refused by
# either cap spends nothing from the other. Separate from the creation and push
# brakes. Uses GITLAWB_TRUSTED_PROXY to resolve the client IP.
# NOTE: this is a per-IP aggregate across ALL those write actions, so behind a
# shared NAT/egress IP (or with GITLAWB_TRUSTED_PROXY unset) many users collapse
# onto one bucket — raise this for automation-heavy or multi-user single-IP
# deployments. 0 disables. Default 600.
GITLAWB_WRITE_RATE_LIMIT=600
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
@@ -359,6 +407,21 @@ GITLAWB_TRUSTED_PROXY=
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false
# ── External upstream mirroring ───────────────────────────────────────────
# Default-off scheduled fetch worker for repositories configured as INBOUND.
# Enabling requires GITLAWB_ENFORCE_OWNER_PUSH=true; the node refuses to start
# otherwise. The worker updates branches and tags atomically while preserving
# Gitlawb-owned review/internal refs. Enabling also requires Git >= 2.37 on
# the host for http.curloptResolve DNS pinning; the node refuses to start
# when the installed Git is older.
GITLAWB_UPSTREAM_MIRROR_ENABLED=false
# Pause after each complete scan. Must be 5..=86400 seconds.
GITLAWB_UPSTREAM_MIRROR_INTERVAL_SECS=300
# Deadline for the external fetch child only; the preflight probes run on
# their own short bound. Must be 1..=3153600000 seconds.
GITLAWB_UPSTREAM_MIRROR_FETCH_TIMEOUT_SECS=600
# Keyset page size; every page is processed before the next scan delay.
GITLAWB_UPSTREAM_MIRROR_PAGE_SIZE=100
# ── Reconciliation sweep ─────────────────────────────────────────────────
# Periodic durability sweep: re-derives the public pin set and the withheld-blob
# recovery set each hour and fills gaps so a dropped replication job never means
+4
View File
@@ -0,0 +1,4 @@
name: "Gitlawb CodeQL config"
paths-ignore:
- "**/tests/**"
- "**/test_support/**"
+100
View File
@@ -0,0 +1,100 @@
name: CodeQL
# Advanced-setup replacement for CodeQL default setup. Default setup analyzes
# every detected language on every PR, including Swift (macos-app/), which
# autobuilds on a macOS runner for ~13 minutes per PR and has hung for 55 on
# main; that single job was the longest check on most PRs. Here a PR analyzes
# only the languages it touches (scripts/ci-surfaces.sh), while push to main
# and the weekly schedule analyze every language, so main's code-scanning
# baseline stays complete.
#
# Requires CodeQL default setup to be DISABLED (Settings -> Code security ->
# Code scanning): GitHub rejects advanced-setup uploads while it is enabled.
#
# A PR that skips a language may show "configuration not found" on the CodeQL
# check for that language; that is informational and expected.
on:
pull_request:
branches: [main]
push:
branches: [main]
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
name: changed languages
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
codeql: ${{ steps.surfaces.outputs.codeql }}
matrix: ${{ steps.surfaces.outputs.codeql_matrix }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
fetch-depth: 2
- name: Map changed paths to languages
id: surfaces
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
# -z: literal paths; plain --name-only C-quotes unusual names.
git diff -z --name-only --no-renames HEAD^1 HEAD > changed-paths
tr '\0' '\n' < changed-paths >&2
scripts/ci-surfaces.sh < changed-paths > surfaces.out
else
scripts/ci-surfaces.sh --all > surfaces.out
fi
cat surfaces.out
grep -E '^(codeql|codeql_matrix)=' surfaces.out >> "$GITHUB_OUTPUT"
analyze:
name: Analyze (${{ matrix.language }})
needs: changes
if: needs.changes.outputs.codeql == 'true'
runs-on: ${{ matrix.runner }}
# Normal runs take <=14 min (Swift autobuild is the longest); cap a hung
# autobuild instead of letting it hold a runner for an hour.
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
packages: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Autobuild
if: matrix.build-mode == 'autobuild'
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
+108 -3
View File
@@ -30,8 +30,62 @@ env:
RUST_BACKTRACE: 1
jobs:
# Decide which checks this event needs. On pull_request, each job below runs
# only when the PR touches the surface it validates, and the cargo test /
# release-build jobs are scoped to the touched crates plus their reverse
# dependencies (scripts/ci-surfaces.sh). Every other event (push to main,
# merge_group, the release flow's workflow_dispatch) runs the full suite, so
# main and the merge queue keep whole-workspace coverage and main-red-alert
# still sees a complete run. A skipped job reports as "skipped", not green.
changes:
name: changed surfaces
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
rust: ${{ steps.surfaces.outputs.rust }}
rust_packages: ${{ steps.surfaces.outputs.rust_packages }}
windows: ${{ steps.surfaces.outputs.windows }}
windows_packages: ${{ steps.surfaces.outputs.windows_packages }}
docker: ${{ steps.surfaces.outputs.docker }}
audit: ${{ steps.surfaces.outputs.audit }}
core_deps: ${{ steps.surfaces.outputs.core_deps }}
release_versioning: ${{ steps.surfaces.outputs.release_versioning }}
release_tag: ${{ steps.surfaces.outputs.release_tag }}
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# The pull_request checkout is the test-merge commit; depth 2 brings
# its first parent (the base tip) so the diff is exactly this PR.
fetch-depth: 2
# Every job's gate comes from the mapper, so pin its mapping first; a
# failure here fails this job and the run is red, not silently narrowed.
- name: Test the surface mapper
run: scripts/test-ci-surfaces.sh
- name: Map changed paths to surfaces
id: surfaces
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
# -z: literal paths; plain --name-only C-quotes unusual names.
git diff -z --name-only --no-renames HEAD^1 HEAD > changed-paths
tr '\0' '\n' < changed-paths >&2
scripts/ci-surfaces.sh < changed-paths > surfaces.out
else
scripts/ci-surfaces.sh --all > surfaces.out
fi
cat surfaces.out
cat surfaces.out >> "$GITHUB_OUTPUT"
fmt-clippy:
name: fmt + clippy
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -57,6 +111,8 @@ jobs:
release-tag-validation:
name: release tag validation
needs: changes
if: needs.changes.outputs.release_tag == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -86,6 +142,8 @@ jobs:
test:
name: test (${{ matrix.toolchain }})
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
@@ -130,10 +188,27 @@ jobs:
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
# `--workspace`, or `-p <crate>...` for the crates a PR touches plus
# every crate that depends on them.
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo test --locked $PACKAGES
# 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
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
@@ -153,10 +228,20 @@ jobs:
key: release
- name: cargo build --release
run: cargo build --locked --release --workspace
env:
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo build --locked --release $PACKAGES
audit:
name: cargo audit
# cargo audit reads only Cargo.lock (+ .cargo/audit.toml), so a PR that
# leaves both unchanged cannot change its verdict; a newly published
# advisory against main is caught by the push-to-main run and the weekly
# Scheduled Audit instead of turning every unrelated PR red.
needs: changes
if: needs.changes.outputs.audit == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -216,6 +301,8 @@ jobs:
msrv:
name: MSRV (Rust 1.91)
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -239,6 +326,12 @@ jobs:
docker-build:
name: Docker build smoke test
# Runs when the image's own inputs change: the Dockerfile, the manifests
# its dependency layer copies, the lockfile, bootstrap-peers.json. A
# source-only Rust change is already compiled by `build --release` (stable)
# and `MSRV` (1.91, the image's toolchain); push to main still runs this.
needs: changes
if: needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -255,6 +348,8 @@ jobs:
test-windows:
name: test (windows, non-blocking)
needs: changes
if: needs.changes.outputs.windows == 'true'
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
@@ -282,7 +377,13 @@ jobs:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test --locked -p gl -p git-remote-gitlawb
shell: bash
env:
# `-p gl -p git-remote-gitlawb`, or whichever of the two a PR affects.
PACKAGES: ${{ needs.changes.outputs.windows_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo test --locked $PACKAGES
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
@@ -292,6 +393,8 @@ jobs:
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
needs: changes
if: needs.changes.outputs.core_deps == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -324,6 +427,8 @@ jobs:
# not its author knew this gate existed.
release-versioning:
name: release versioning coverage
needs: changes
if: needs.changes.outputs.release_versioning == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
+1 -1
View File
@@ -40,5 +40,5 @@ When a node denies a request, surface the denial to the user. Never render a den
## Toolchain and CI reality
- MSRV is Rust 1.91 (`rust-version` in the workspace `Cargo.toml`). The MSRV CI job runs `cargo check --workspace --all-targets` on exactly 1.91, so check that an API you rely on is stable there; the full test suite runs on stable.
- The PR workflow runs these jobs on every PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
- The PR workflow's jobs are `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. On a PR, `scripts/ci-surfaces.sh` runs each job only when the PR touches its inputs, and scopes `cargo test` and the release build to the touched crates plus every crate that depends on them. Root `Cargo.toml`/`Cargo.lock`/`.cargo/` changes run the Rust jobs (fmt, clippy, tests, release build, MSRV) on the whole workspace; every other job still runs only when its own inputs change. Edits to the CI wiring itself (`pr-checks.yml`, `codeql.yml`, `scripts/ci-surfaces.sh`), push to `main`, and the merge queue run every job on the whole workspace. Edits to any other workflow run none of these jobs on a PR; CodeQL only analyzes them as Actions code. Run `cargo test --workspace` locally regardless. The same script limits CodeQL (`codeql.yml`) to the languages a PR touches. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
- Conventional commit titles (`feat:`, `fix:`, `docs:`, ...) are required by convention and drive releases, but no CI job checks them; a bad title fails review, not the pipeline.
Generated
+14 -9
View File
@@ -2208,7 +2208,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2701,7 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@@ -3085,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3473,10 +3473,13 @@ name = "git-remote-gitlawb"
version = "0.7.1"
dependencies = [
"anyhow",
"chrono",
"dirs",
"gitlawb-core",
"libc",
"mockito",
"reqwest",
"serde_json",
"tempfile",
"tracing",
"tracing-subscriber",
@@ -3537,11 +3540,13 @@ dependencies = [
"async-compression",
"async-graphql",
"async-graphql-axum",
"async-trait",
"aws-config",
"aws-sdk-s3",
"axum",
"base64 0.22.1",
"bytes",
"chacha20poly1305",
"chrono",
"cid",
"clap",
@@ -3601,6 +3606,7 @@ dependencies = [
"ed25519-dalek 2.2.0",
"gitlawb-core",
"icaptcha-client",
"libc",
"mockito",
"percent-encoding",
"reqwest",
@@ -4978,11 +4984,10 @@ dependencies = [
[[package]]
name = "multihash"
version = "0.19.3"
version = "0.19.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d"
checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447"
dependencies = [
"core2",
"unsigned-varint",
]
@@ -6228,7 +6233,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7162,7 +7167,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7954,7 +7959,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+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 && \
+121
View File
@@ -0,0 +1,121 @@
# Gitlawb Wire Protocol Specification (v1, alpha)
This document describes the wire behavior a second implementation can interop
with **today**. Anything the node does not ship yet is explicitly marked
*planned*; nothing below is aspirational unless it says so.
## 1. Identity & DIDs
- **Key type:** Ed25519.
- **Method (alpha):** `did:key` only. Resolution is deterministic public-key
extraction from the multicodec prefix (`z6Mk…`, multicodec `0xed01`).
- Signed requests presenting any other DID method are rejected; the node's
auth layer answers with a hint that only `did:key` is supported in alpha.
- *Planned:* `did:web` and `did:gitlawb` resolution. Clients MUST NOT assume
either is accepted anywhere yet.
## 2. Authentication (RFC 9421)
- **Transport:** production deployments should run the node behind TLS
(typically a reverse proxy). The node itself has no scheme check: it verifies
RFC 9421 headers on whatever transport the request arrives over, so nothing
at the signature layer refuses a cleartext request.
- **HTTP signatures:** `Signature-Input` covers `@method`, `@path`, and
`content-digest`:
`sig1=("@method" "@path" "content-digest");keyid="did:key:…";alg="ed25519";created=<unix>`
All three components are mandatory: a `Signature-Input` missing one is
rejected as `incomplete_signature`.
- **`@path` value:** the node binds `@path` to the path *and* query string of
the request, not to the path-only value RFC 9421 defines for `@path`. A
second implementation must sign the request exactly as it goes on the
wire, e.g. `GET /zOwner/myrepo.git/info/refs?service=git-upload-pack`
including the `?service=…` part (see §5 for the owner and `.git`
normalization).
- **Signer identity:** `keyid` is the actor DID (`did:key` only, see §1).
- **Integrity:** `Content-Digest` is SHA-256 over the request body and is
verified against it. Every signed request MUST send the header: a signed
request without it is refused `400 missing_content_digest` before routing.
On a body-less request the header still carries the SHA-256 digest of the
empty body (`sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:`),
it is never omitted.
## 3. Proof of Intelligence (iCaptcha)
- **Mode:** controlled by `ICAPTCHA_MODE` = `off` | `shadow` | `enforce`,
**default `off`**. A node with the gate off never demands proof.
- **Scope:** the gate applies to a small set of write endpoints (agent
registration and repo create/fork), not to all writes.
- **Wire shape:** the client-supplied header is `x-icaptcha-proof` — that is
the only iCaptcha header a node reads from a request. When the gate is
`enforce` and a verifier public key is loaded, a missing or invalid proof
answers `403 icaptcha_proof_required` and sets `x-icaptcha-url` and
`x-icaptcha-level` **on the response** (also mirrored as JSON fields) so
the client can go solve a challenge and retry. Fail-safe: `enforce` with
no public key loaded (unset and unfetchable) stays inert and admits the
request rather than rejecting every request.
## 4. Ref-Update Certificate
*Planned:* the `gitlawb/ref-update/v1` schema below is defined in code, but no
shipped endpoint serves or verifies these certificates today; nothing in
sections 2-3 produces one on the wire. It is documented here so a second
implementation shares the type vocabulary ahead of the wire surface.
The hash fields reserve 64 hex characters for the SHA-256 object format; the
repositories the node serves are sha1 with 40-hex OIDs (see §5), so the field
width is a forward reservation, not what a current push produces.
Two distinct certificate-shaped payloads exist; this section documents the
**core signing schema**, which is what `gitlawb/ref-update/v1` names:
- **Type string:** `gitlawb/ref-update/v1`.
- **Body (canonical JSON, the signed bytes):** `type`, `repo` (the
repository DID), `ref_name`, `from` (previous hash, 64 hex chars,
all-zeros for a new ref), `to` (target hash, 64 hex chars), `seq`
(monotonically increasing, replay prevention), `timestamp` (RFC 3339),
`nonce`.
- **Signatures:** a list of `{signer: did:key, sig: base64url-unpadded
Ed25519}` entries appended outside the signed body; thresholds are counted
over distinct signers that verify.
The node's REST API separately serves **push receipts** — a different,
node-issued JSON payload attesting that a push was processed. Receipts are
not `gitlawb/ref-update/v1` documents and are not interchangeable with them;
they are documented with the node's HTTP API, not here.
## 5. Storage & Git Transport
- **Object format:** bare repositories are created with
`--object-format=sha1`; production OIDs are 40 hex chars. *Planned:*
SHA-256 object format.
- **Path normalization:** `{repo}` may carry one trailing `.git` suffix,
stripped before lookup (so `myrepo.git` resolves to `myrepo`; a repo
literally named `foo.git` addresses as `foo.git.git`). `{owner}` may be the
full `did:key:…` string or the bare multibase segment the `did:key:` prefix
implies; both resolve to the same owner. None of this changes `@path`:
sign the request path and query exactly as sent.
- **Smart-HTTP endpoints:**
- `GET /{owner}/{repo}/info/refs?service=git-upload-pack` — fetch
advertisement.
- `GET /{owner}/{repo}/info/refs?service=git-receive-pack` — push
advertisement.
The `?service=` parameter is mandatory and takes exactly those two
values; a missing parameter is a `400`, not an omitted-service default.
- `POST /{owner}/{repo}/git-upload-pack` — clone/fetch. Request and
response bodies are pkt-line framed; the result content type is
`application/x-git-upload-pack-result`.
- `POST /{owner}/{repo}/git-receive-pack` — push. **Authentication is
mandatory**: an unsigned push is refused with `401`. Same pkt-line
framing; the result content type is
`application/x-git-receive-pack-result`.
Signed requests on this path follow the RFC 9421 header rules in §2.
§2's transport note applies here too: the verifier has no scheme check,
so nothing at the signature layer requires this push to arrive over TLS.
- **Fetch vs push auth split:** fetch-side requests against a withheld or
private repo answer `404` (existence is not confirmed to unauthorized
callers), while the push path answers `401` for missing/invalid
signatures. A second implementation must not treat the two symmetrically.
- **Content addressing:** the node records IPFS CID metadata per branch and
serves it over REST (the repo `refs` endpoint maps branch heads to CIDs).
*Planned:* IPNS-backed branch pointers; IPNS is not part of the shipped
wire contract.
+9 -7
View File
@@ -67,10 +67,10 @@ Good today:
Known limitations:
- Repository write authorization is secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` only for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint a `did:key` and sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included.
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
- Repository write authorization is one switch: `GITLAWB_ENFORCE_OWNER_PUSH`, which defaults to `true`. With it off — rolling upgrades only — a valid HTTP Signature identifies a pusher but does not authorize one, so every signed caller may push everywhere.
- UCAN capabilities are consulted on the push path only. There, the chain's root issuer is anchored to the repository owner, so an owner-rooted, time-bounded delegation that names the repository grants scoped collaborator access for pushing to it, for the `git/push`, `*`, or `repo/admin` actions. A `*` *resource* is refused: a delegation must name the repository it applies to, so it cannot grow to cover repos created later. Push-only cuts the other way too: a delegation grants no read access, so on a private repository the delegate must also be a reader (`gl visibility set`) — the two gates a push crosses are described in [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). The rest is unchanged: there is no revocation path, `nb` constraints are refused rather than interpreted, and no other route — reads, pull requests, issues, agents — consults capabilities at all.
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Task list/get reads (REST and GraphQL) are repository/task-gated and omit `ucan_token`. Slash-form `repo_id` rows (mirror-hosted tasks) can appear on list for the delegator or trimmed assignee, but `GET /api/v1/tasks/{id}` and GraphQL `task` fail closed for every caller because there is no local repository backing to serve. Read visibility is not a blanket data-classification boundary: IPFS-pin and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
@@ -313,7 +313,7 @@ metadata local disk / optional S3
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
| UCAN | Delegation token for future capability-based workflows. |
| UCAN | Capability token. An owner delegates `git/push` on a repo to another DID; the node honors it when the proof chain roots at that owner. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
| Gossipsub | libp2p topic for ref-update events. |
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
@@ -401,8 +401,8 @@ Important node settings:
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_RECONCILIATION_SWEEP` | Enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
@@ -411,7 +411,7 @@ Important node settings:
| `GITLAWB_MAX_CONCURRENT_GIT_OPS` | Max concurrent served git READ ops (upload-pack and its `info/refs` advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER`. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
| `GITLAWB_MAX_CONCURRENT_GIT_PUSHES` | Max concurrent `git-receive-pack` POST operations, in a pool separate from the read pool. The anon receive-pack `info/refs` advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (`/8`, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
| `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER` | Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as `GITLAWB_TRUSTED_PROXY`: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Default 8. |
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Up to two loops run per push that starts its own encrypt/pin task when both IPFS and Pinata are configured; a push coalesced onto an in-flight task adds at most one warm loop. See [docs/STORAGE-AND-PINNING.md](docs/STORAGE-AND-PINNING.md) for the two-tier model, dedup, and parked-list memory. Default 8. |
| `GITLAWB_REPO_LEASE_MAX_WAITERS` | Max pushes parked at once waiting for the same repo's write lease. Each waiter pins its buffered pack body, so this bounds that memory for a hot repo; past the cap the newest push sheds a 503 + Retry-After instead of queueing. Pushes to other repos are unaffected, and the lease holder is not counted. Default 8. |
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
@@ -422,6 +422,7 @@ Important node settings:
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
| `GITLAWB_TASK_READ_RATE_LIMIT` | Max anonymous task-read requests (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`) per client IP per hour, sharing the budget with the GraphQL/WS task-read brake. Uses `GITLAWB_TRUSTED_PROXY` to resolve the client IP. 0 disables. Default 1200 (above the `/ipfs` budget to allow list pages followed by per-task reads). |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
@@ -477,6 +478,7 @@ Read:
- [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md)
- [`docs/ECONOMICS.md`](docs/ECONOMICS.md)
- [`docs/STORAGE-AND-PINNING.md`](docs/STORAGE-AND-PINNING.md)
Use a dedicated low-balance operator wallet. Do not use a treasury wallet as the heartbeat key.
+9 -9
View File
@@ -26,7 +26,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
**Content addressing**
- Every git object is content-addressed via CIDv1 (SHA-256)
- Tamper-evident by construction — a modified object changes its CID
- Tamper-evident by construction: a modified object changes its CID
**UCAN token validation**
- Bootstrap UCAN tokens are issued at registration.
@@ -35,8 +35,8 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
**Smart contracts (Base Sepolia testnet)**
- `GitlawbDIDRegistry` — on-chain DID → document registry
- `GitlawbNameRegistry` — human name → DID registry
- `GitlawbDIDRegistry`: on-chain DID → document registry
- `GitlawbNameRegistry`: human name → DID registry
- Both auditable on-chain, no admin keys
---
@@ -55,7 +55,7 @@ These are documented limitations of the current live release. They should be pri
### Repository write authorization defaults
- `git-receive-pack` verifies HTTP Signatures and, with the default configuration, also requires the pusher DID to be the repository owner: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`.
- **Impact:** With the default setting, a valid signature authenticates the pusher *and* the pusher must be the repository owner. Authentication is not authorization on its own — anyone can mint a `did:key` and sign — so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, including private ones.
- **Impact:** With the default setting, a valid signature authenticates the pusher, and the pusher must be the repository owner. Authentication is not authorization on its own, since anyone can mint a `did:key` and sign, so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, including private ones.
- **Mitigation:** `GITLAWB_ENFORCE_OWNER_PUSH=false` is the explicit rolling-upgrade compatibility override. Disable it only for the duration of an upgrade whose pushers are not yet the repo owner, then turn it back on. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on.
### UCAN delegation and revocation
@@ -73,7 +73,7 @@ These are documented limitations of the current live release. They should be pri
### Private repository reads
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
- Task list/get reads (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`, and GraphQL `tasks` / `task` over HTTP and WebSocket) are repository/task-gated through shared visibility collectors and omit `ucan_token`. Slash-form `repo_id` rows (mirror-hosted tasks) can appear on list for the delegator or trimmed assignee, but `GET /api/v1/tasks/{id}` and GraphQL `task` fail closed for every caller because there is no local repository backing to serve. All of those transports share the same per-IP `GITLAWB_TASK_READ_RATE_LIMIT` budget. `/api/v1/ipfs/pins` and `/api/v1/arweave/anchors` are not repository-gated; pin and anchor listings expose object and ref metadata.
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
@@ -119,10 +119,10 @@ These are documented limitations of the current live release. They should be pri
## Threat Model
Twigpine is designed to be secure against:
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Tampered git objects** — CIDv1 content addressing detects modification
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
- **Unauthenticated writes:** HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Tampered git objects:** CIDv1 content addressing detects modification
- **Identity spoofing:** DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown:** no single point of control; data on IPFS + Arweave
Twigpine is **not yet** designed to defend against:
- A compromised node operator (node operators are trusted for their own node)
+9 -1
View File
@@ -1,7 +1,7 @@
{
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-06-30",
"updated": "2026-10-02",
"peers": [
{
"name": "gitlawb",
@@ -42,6 +42,14 @@
"http_url": "https://rapybus-gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-05-16"
},
{
"name": "moonwake-ledger",
"operator": "Moonwake Ledger",
"did": "did:key:z6Mkh9AZ46Z3RkGZfFCom5vzX2535f54d6G2o2ZGkCqjwW8K",
"http_url": "https://node.moonwakeledger.com",
"p2p_multiaddr": null,
"added": "2026-10-02"
}
]
}
+9
View File
@@ -14,8 +14,17 @@ path = "src/main.rs"
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
anyhow = { workspace = true }
reqwest = { workspace = true }
# Reading the node's DID from `GET /` so a delegated push can address its
# invocation to the right executor.
serde_json = { workspace = true }
# The invocation carries the delegation's expiry, so an unbounded write capability
# is never minted; converting the stored i64 timestamp needs chrono.
chrono = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Home-directory lookup for the shared GITLAWB_KEY resolver in gitlawb-core,
# which takes `home` as an argument so core's dependency allowlist stays lean.
dirs = "5"
[dev-dependencies]
mockito = "1"
File diff suppressed because it is too large. Load diff
+228 -11
View File
@@ -41,6 +41,30 @@ use crate::{Error, Result};
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
/// Exact url-safe unpadded base64 length of a 64-byte Ed25519 signature.
/// The tolerant threshold scan requires this canonical unpadded length before
/// attempting decode: the length gate is a DoS bound that keeps an
/// attacker-sized sig string from costing allocation before the signature is
/// rejected. A longer string is not meaningless (the same 64 bytes can appear
/// in padded or non-url-safe encodings) but no in-tree signer produces one,
/// so the scan skips non-canonical entries rather than decoding them.
const ENCODED_SIG_LEN: usize = 86;
/// Hard ceiling on signature entries a certificate may carry into the
/// tolerant threshold scan. A legitimate certificate holds one entry per
/// maintainer (a re-sign after key rotation may briefly leave a second), so
/// this is far above any real signer set, while turning the scan's worst case
/// from "one curve verification per attacker-appended entry" into a small
/// constant. Rejection is loud (`Err`), not a silent prefix scan: scanning
/// only the first N entries would let junk placed ahead of the real
/// signatures starve them out of the window, which is the #349 denial again
/// in positional form. An attacker positioned to inflate the list past this
/// ceiling can already suppress the certificate outright. The loud
/// rejection does deny a legitimately over-long certificate; that is an
/// explicit, detectable denial above the cap rather than a silent window
/// junk placement could exploit.
const MAX_SIGNATURE_ENTRIES: usize = 64;
/// A signature on a ref-update certificate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CertSignature {
@@ -138,9 +162,15 @@ impl RefUpdateCert {
Ok(())
}
/// Verify all signatures on this certificate.
/// Verify all signatures on this certificate, failing closed.
///
/// Returns the list of DIDs whose signatures are valid.
/// On success returns the list of DIDs whose signatures are valid. This is
/// strict: it returns an error if *any* signature entry is malformed or
/// invalid. Because signature entries live outside the signed body (anyone
/// can append one without key material), do not use this to make a
/// threshold decision on an untrusted certificate — a single appended junk
/// entry would reject the whole cert. Use [`Self::satisfies_threshold`],
/// which ignores invalid entries and counts only the valid signers.
pub fn verify_all(&self) -> Result<Vec<Did>> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let signing_bytes = self.body.to_signing_bytes()?;
@@ -169,21 +199,65 @@ impl RefUpdateCert {
/// from the provided set of authorized maintainer DIDs.
///
/// Counts distinct signer DIDs, not signature entries: a repeated
/// signature from the same maintainer counts once.
/// signature from the same maintainer counts once. Malformed and invalid
/// signature entries are skipped, not fatal — entries live outside the
/// signed body, so any third party can append junk without key material,
/// and short-circuiting on the first bad one (as [`Self::verify_all`]
/// does) would let that junk deny an otherwise valid certificate (#349).
///
/// `threshold` must be at least 1. Zero is rejected instead of being
/// treated as "no signatures required": a caller that derives the
/// threshold from configuration and gets 0 would otherwise accept any
/// certificate, whether its signatures verify or not.
/// Tolerance must not become an unbounded verification path (#349 review
/// round 2): entries are attacker-extensible, so the work done for them is
/// bounded BEFORE the loop, not discovered inside it:
///
/// - a list over [`MAX_SIGNATURE_ENTRIES`] is rejected outright (see the
/// constant for why loud rejection beats a silent prefix scan);
/// - an encoded signature is length-gated before base64 work — a 64-byte
/// Ed25519 signature is exactly [`ENCODED_SIG_LEN`] characters in
/// url-safe unpadded base64, so an oversized blob never allocates;
/// - entries whose signer is not in `maintainers`, or is already counted,
/// are skipped on a string compare, before any decode or curve work —
/// so even within the cap, junk naming strangers costs no crypto;
/// - the scan returns as soon as the threshold is met.
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
if threshold == 0 {
return Err(Error::RefCert("threshold must be at least 1".to_string()));
}
if self.signatures.len() > MAX_SIGNATURE_ENTRIES {
return Err(Error::RefCert(format!(
"certificate carries {} signature entries (max {})",
self.signatures.len(),
MAX_SIGNATURE_ENTRIES
)));
}
let signing_bytes = self.body.to_signing_bytes()?;
let mut counted: HashSet<&Did> = HashSet::new();
let valid = self.verify_all()?;
let distinct_signers: HashSet<&Did> =
valid.iter().filter(|d| maintainers.contains(d)).collect();
Ok(distinct_signers.len() >= threshold)
for cert_sig in &self.signatures {
if counted.contains(&cert_sig.signer) || !maintainers.contains(&cert_sig.signer) {
continue;
}
if cert_sig.sig.len() != ENCODED_SIG_LEN {
continue;
}
let Ok(vk) = cert_sig.signer.to_verifying_key() else {
continue;
};
let Ok(sig_bytes_vec) = URL_SAFE_NO_PAD.decode(&cert_sig.sig) else {
continue;
};
let Ok(sig_bytes) = <[u8; 64]>::try_from(sig_bytes_vec) else {
continue;
};
if verify(&vk, &signing_bytes, &sig_bytes).is_ok() {
counted.insert(&cert_sig.signer);
if counted.len() >= threshold {
return Ok(true);
}
}
}
Ok(counted.len() >= threshold)
}
/// Validate the certificate structure (not signatures).
@@ -416,6 +490,149 @@ mod tests {
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
}
/// An attacker can append junk signature entries to a cert (they live
/// outside the signed body, so no key material is needed). `satisfies_threshold`
/// must ignore them and still count the real signers — before the fix,
/// `verify_all`'s `?` on the junk made the whole check error out, denying a
/// valid 2-of-2 cert.
#[test]
fn satisfies_threshold_ignores_appended_junk_signatures() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp1.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
cert.countersign(&kp2).unwrap();
// Junk sits AHEAD of the real entries so the scan must actually skip
// it: appended last, the threshold-2 early return at the second real
// signature would fire before any junk is examined and the test would
// stay green even if the skip logic never ran.
cert.signatures.insert(
0,
CertSignature {
signer: kp1.did(),
sig: "!!!not-base64!!!".to_string(),
},
);
// Valid base64 but only 3 bytes — fails the 64-byte length check.
cert.signatures.insert(
0,
CertSignature {
signer: kp2.did(),
sig: "AAAA".to_string(),
},
);
// Exactly ENCODED_SIG_LEN of valid base64 decoding to garbage —
// exercises the decode+verify path before the skip, not just the
// length gate.
cert.signatures.insert(
0,
CertSignature {
signer: kp1.did(),
sig: "A".repeat(ENCODED_SIG_LEN),
},
);
let maintainers = vec![kp1.did(), kp2.did()];
assert!(cert.satisfies_threshold(&maintainers, 2).unwrap());
}
/// A junk entry that names a real maintainer DID but carries a signature
/// that does not verify must not be counted — otherwise appending garbage
/// under a maintainer's DID could forge a threshold.
#[test]
fn satisfies_threshold_does_not_count_a_forged_maintainer_signature() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp1.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
// Well-formed 64-byte signature from kp2, but over unrelated bytes, so
// it fails verification against the cert body.
cert.signatures.push(CertSignature {
signer: kp2.did(),
sig: kp2.sign_b64(b"unrelated bytes"),
});
let maintainers = vec![kp1.did(), kp2.did()];
// Only kp1 actually signed the body: 2-of-2 must fail, 1-of-2 holds.
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
}
/// The tolerant scan must not be an unbounded verification path: a list
/// past MAX_SIGNATURE_ENTRIES is rejected loudly before any curve work,
/// while a list at the cap still verifies normally.
#[test]
fn satisfies_threshold_rejects_an_oversized_signature_list() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
let junk = || CertSignature {
signer: Keypair::generate().did(),
sig: "AAAA".to_string(),
};
while cert.signatures.len() < MAX_SIGNATURE_ENTRIES {
cert.signatures.push(junk());
}
let maintainers = vec![kp.did()];
// At the cap: still fine, the real signature counts.
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
// One past the cap: loud rejection, no tolerant scan.
cert.signatures.push(junk());
let err = cert.satisfies_threshold(&maintainers, 1).unwrap_err();
assert!(err.to_string().contains("signature entries"));
}
/// An entry whose encoded signature is not the exact base64 length of a
/// 64-byte Ed25519 signature is skipped before any base64 decode — an
/// attacker-sized sig string must cost neither allocation nor curve work,
/// and must not affect the verdict.
#[test]
fn satisfies_threshold_skips_an_oversized_encoded_signature() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp1.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
// Names a real maintainer, valid base64 alphabet, but megabytes long:
// the length gate must refuse it without decoding.
cert.signatures.push(CertSignature {
signer: kp2.did(),
sig: "A".repeat(2 * 1024 * 1024),
});
let maintainers = vec![kp1.did(), kp2.did()];
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
}
#[test]
fn threshold_zero_is_rejected() {
let kp = Keypair::generate();
+299
View File
@@ -0,0 +1,299 @@
//! Where the identity key and the delegation store live.
//!
//! `gl` and `git-remote-gitlawb` have to agree on this. `gl ucan import` writes a
//! delegation to `<dir>/delegations/`, and the helper reads it back from the same
//! place when it builds the `X-Ucan` header on push. If the two resolve
//! `GITLAWB_KEY` differently the push goes out with no header and the node refuses
//! the delegate, with nothing on either side to say why — so the rules live here
//! once, in the crate both binaries already depend on, rather than being written
//! twice and drifting.
//!
//! The home directory is a parameter rather than something this module looks up.
//! `gitlawb-core` is embedded by every consumer and is held to an explicit
//! dependency allowlist (`ci/gitlawb-core-allowed-deps.txt`); the callers already
//! carry `dirs`, so taking `home` keeps the rules shared without widening core's
//! tree. It also makes every case below testable against a fixed home.
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use crate::{Error, Result};
/// Environment variable naming the identity PEM.
pub const KEY_ENV: &str = "GITLAWB_KEY";
/// Name of the PEM file inside the identity directory.
pub const KEY_FILE_NAME: &str = "identity.pem";
/// Directory under the home directory used when `GITLAWB_KEY` is unset.
pub const DEFAULT_DIR_NAME: &str = ".gitlawb";
/// Absolute path of the identity PEM: `$GITLAWB_KEY`, else
/// `<home>/.gitlawb/identity.pem`.
///
/// An empty `GITLAWB_KEY` counts as unset. That is what a shell leaves behind for
/// `FOO=` and for an unset variable expanded into a wrapper script, and reading it
/// as a path would resolve against the process working directory instead.
pub fn identity_key_path(home: Option<&Path>) -> Result<PathBuf> {
// `var_os`, not `var`: `var` folds a non-UTF-8 value into the same `Err` as
// unset, so an operator whose key path is not valid UTF-8 would silently get
// the default directory rather than theirs — or an error naming the real
// problem.
match std::env::var_os(KEY_ENV) {
Some(raw) if !raw.is_empty() => resolve_key_value(Path::new(&raw), home),
_ => Ok(require_home(home)?
.join(DEFAULT_DIR_NAME)
.join(KEY_FILE_NAME)),
}
}
/// The directory holding `identity.pem` and `delegations/` — the parent of
/// [`identity_key_path`].
pub fn identity_dir(home: Option<&Path>) -> Result<PathBuf> {
let key = identity_key_path(home)?;
key.parent().map(Path::to_path_buf).ok_or_else(|| {
Error::Key(format!(
"{KEY_ENV} has no parent directory: {}",
key.display()
))
})
}
/// The home directory, demanded only where the value being resolved needs it.
///
/// An absolute `GITLAWB_KEY` never needs home, so requiring it up front would
/// discard a perfectly good key on a host where `dirs::home_dir()` returns `None`
/// — no `HOME` and no passwd entry, which is an ordinary container shape. The
/// helper would then push unsigned, blaming the home directory for a setting the
/// operator had configured correctly.
fn require_home(home: Option<&Path>) -> Result<&Path> {
home.ok_or_else(|| {
Error::Key(format!(
"could not determine the home directory, which is needed to resolve this \
{KEY_ENV} value. Set {KEY_ENV} to an absolute path to avoid needing it."
))
})
}
/// Apply the `GITLAWB_KEY` rules to a raw value.
///
/// Split out from [`identity_key_path`] so the rules can be tested without setting
/// a process-global environment variable, which would make the tests race.
fn resolve_key_value(raw: &Path, home: Option<&Path>) -> Result<PathBuf> {
let path = expand_tilde(raw, home)?;
if !path.is_absolute() {
return Err(Error::Key(format!(
"{KEY_ENV} must be an absolute path (got {}). It also determines where \
delegations are stored, and `gl` and `git-remote-gitlawb` do not share a \
working directory, so a relative path sends them to different stores.",
raw.display()
)));
}
if path.parent().is_none() {
return Err(Error::Key(format!(
"{KEY_ENV} must name the key file, not the filesystem root (got {}). \
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
raw.display()
)));
}
Ok(path)
}
/// Expand a leading `~/`, and only that.
///
/// `~user` is shell syntax this does not implement; leaving its `~` in place makes
/// it fail the absolute-path check with a message that names the real problem,
/// which beats resolving it somewhere the operator did not ask for. A bare `~` or
/// `~/` is refused outright: it names a directory where a file is required, and
/// expanding it to the home directory would put the delegation store beside the
/// home directory rather than inside it, since the store is the key's *parent*.
///
/// Matched on the first path component rather than on a string prefix. That is
/// what lets the value stay an `OsStr` end to end: the helper's old
/// `str::strip_prefix("~/")` needed a `String` first, which is why it reached for
/// `env::var` and folded every non-UTF-8 path into "unset".
fn expand_tilde(path: &Path, home: Option<&Path>) -> Result<PathBuf> {
let mut components = path.components();
match components.next() {
Some(Component::Normal(first)) if first == OsStr::new("~") => {
let rest = components.as_path();
if rest.as_os_str().is_empty() {
return Err(Error::Key(format!(
"{KEY_ENV} must name the key file, not a directory (got {}). \
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
path.display()
)));
}
Ok(require_home(home)?.join(rest))
}
_ => Ok(path.to_path_buf()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A home that is absolute on the host running the tests. `/home/op` is not
/// absolute on Windows — it has a root but no prefix — so a shared literal
/// would make the absolute-path assertions test the wrong thing there.
fn home() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\Users\op")
} else {
PathBuf::from("/home/op")
}
}
#[test]
fn absolute_value_is_taken_verbatim() {
let raw = home().join("data").join("keys").join(KEY_FILE_NAME);
assert_eq!(resolve_key_value(&raw, Some(&home())).unwrap(), raw);
}
#[test]
fn tilde_slash_expands_to_the_home_directory() {
let resolved = resolve_key_value(Path::new("~/keys/identity.pem"), Some(&home())).unwrap();
assert_eq!(resolved, home().join("keys").join(KEY_FILE_NAME));
}
/// The shell-style spelling of the default resolves to the default. Worth
/// pinning: this is the form an operator gets by copying a path out of their
/// shell, and the two binaries used to reach it by different routes.
#[test]
fn the_tilde_spelling_of_the_default_resolves_to_the_default() {
let resolved =
resolve_key_value(Path::new("~/.gitlawb/identity.pem"), Some(&home())).unwrap();
assert_eq!(resolved, home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME));
}
/// A relative value resolves against the working directory, and `gl` and
/// `git-remote-gitlawb` do not share one: the import would land where the helper
/// never looks.
#[test]
fn relative_values_are_refused() {
for raw in ["identity.pem", "keys/identity.pem", "./keys/identity.pem"] {
assert!(
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
"{raw} is relative and must be refused"
);
}
}
/// `~user` is shell syntax, not a path, and a bare `~` names a directory where
/// a file is required. Refused rather than guessed at.
#[test]
fn unsupported_tilde_forms_are_refused() {
for raw in ["~", "~/", "~someone/keys/identity.pem"] {
assert!(
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
"{raw} must be refused rather than resolved"
);
}
}
/// The root has no parent, so the delegation store would have nowhere to go.
#[test]
fn the_filesystem_root_is_refused() {
assert!(resolve_key_value(Path::new("/"), Some(&home())).is_err());
}
/// The whole point of `var_os`: a non-UTF-8 value must reach the rules rather
/// than being folded into "unset" by `var`. Byte 0xFF is not valid UTF-8 in any
/// position, so this value is unreachable through `env::var`.
#[cfg(unix)]
#[test]
fn non_utf8_values_reach_the_rules() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let relative = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
assert!(
resolve_key_value(Path::new(&relative), Some(&home())).is_err(),
"a non-UTF-8 relative path must be refused, not silently defaulted"
);
let mut absolute = OsString::from("/data/");
absolute.push(OsString::from_vec(vec![0xFF]));
absolute.push("/identity.pem");
let resolved = resolve_key_value(Path::new(&absolute), Some(&home())).unwrap();
assert_eq!(resolved.as_os_str(), absolute.as_os_str());
}
/// Unset and empty both mean "use the default", and the two accessors must stay
/// consistent: the directory is the parent of the key, never a sibling of it.
/// The process environment is global, so the two cases share one test and one
/// lock rather than racing each other.
#[test]
fn unset_and_empty_both_select_the_default_directory() {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let restore = std::env::var_os(KEY_ENV);
std::env::remove_var(KEY_ENV);
let unset = (
identity_key_path(Some(&home())),
identity_dir(Some(&home())),
);
std::env::set_var(KEY_ENV, "");
let empty = (
identity_key_path(Some(&home())),
identity_dir(Some(&home())),
);
match restore {
Some(v) => std::env::set_var(KEY_ENV, v),
None => std::env::remove_var(KEY_ENV),
}
for (label, (key, dir)) in [("unset", unset), ("empty", empty)] {
assert_eq!(
key.unwrap(),
home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME),
"{label} key path"
);
assert_eq!(
dir.unwrap(),
home().join(DEFAULT_DIR_NAME),
"{label} directory"
);
}
}
}
#[cfg(test)]
mod no_home_tests {
use super::*;
/// An absolute key needs no home directory. Demanding one up front discarded a
/// correctly-configured `GITLAWB_KEY` on any host where `dirs::home_dir()`
/// returns `None` — no `HOME` and no passwd entry, an ordinary container shape —
/// and the helper then pushed unsigned while blaming the home directory.
#[test]
fn an_absolute_key_resolves_without_a_home_directory() {
let raw = if cfg!(windows) {
r"C:\data\keys\identity.pem"
} else {
"/data/keys/identity.pem"
};
let resolved = resolve_key_value(Path::new(raw), None)
.expect("an absolute key must not need a home directory");
assert_eq!(resolved, PathBuf::from(raw));
assert_eq!(resolved.parent().unwrap(), Path::new(raw).parent().unwrap());
}
/// The forms that genuinely need a home still say so, rather than resolving
/// somewhere arbitrary.
#[test]
fn the_forms_that_need_a_home_report_its_absence() {
let err = resolve_key_value(Path::new("~/keys/identity.pem"), None)
.expect_err("a ~/ path cannot resolve without a home directory");
assert!(
err.to_string().contains("home directory"),
"the error must name the missing home directory, got: {err}"
);
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod identity;
pub mod identity_path;
// `url` is the one dependency here that drags a tail (idna, then the icu
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
// needs this predicate already parses URLs, so they opt in and nothing else
+20
View File
@@ -30,10 +30,30 @@ pub fn is_bidi_format(c: char) -> bool {
)
}
/// Both halves of INV-6 in one call: drop every `Cc` control (which defangs
/// ANSI and OSC escapes) and every bidi/format control that
/// [`is_bidi_format`] names. What remains is safe to write to a terminal.
///
/// Length is not this function's business: a caller that shows the text
/// decides how much of it to show.
pub fn strip_terminal_controls(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && !is_bidi_format(*c))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_removes_escapes_and_reordering_but_keeps_text() {
let hostile = "ok\u{1b}[31m red\u{1b}]0;title\u{7}\u{202E}dlrow\u{202C} \u{0627}\u{200D}";
let clean = strip_terminal_controls(hostile);
assert_eq!(clean, "ok[31m red]0;titledlrow \u{0627}\u{200D}");
assert!(!clean.chars().any(|c| c.is_control() || is_bidi_format(c)));
}
#[test]
fn strips_every_reordering_code_point() {
// Asserted individually so a partial predicate (e.g. one that covers the
+785 -29
View File
@@ -16,6 +16,19 @@ use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// Proof chains deeper than this fail closed, in every walk over `prf`:
/// [`Ucan::verify_chain`], [`Ucan::chain_lifetime_is_bounded`] and
/// [`Ucan::chain_grants_push_to`] all stop here. Nothing this codebase mints is
/// longer than owner → agent → node. The bound exists for the hand-built token:
/// the node verifies the chain on the `X-Ucan` header of any signed request, and
/// nothing else says how deep a sender may nest `prf`. In practice not very
/// deep — a proof is a JSON string inside the next link's JSON, so each level
/// re-escapes the one beneath it and the encoding roughly doubles per link
/// (eight links is about 11 KiB, thirteen about 270 KiB) — but a walk should
/// refuse on its own terms rather than count on the encoding running the sender
/// out of room first.
pub const MAX_CHAIN_DEPTH: usize = 8;
/// A UCAN capability: what resource the token grants access to.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Capability {
@@ -49,11 +62,32 @@ impl Capability {
/// action field and `repo/admin` in the parent's action position act as
/// wildcards that cover any delegated value; wildcards on `self` carry no
/// special meaning.
///
/// Constraints (`nb`) participate, and conservatively:
///
/// | parent | child | verdict |
/// |---|---|---|
/// | none | anything | attenuated — adding constraints narrows |
/// | some | identical | attenuated |
/// | some | different | refused — narrowing is unprovable without semantics |
/// | some | none | refused — dropping constraints widens |
///
/// The last row is the one that matters. Ignoring `nb` here let a holder of a
/// constrained capability re-delegate the same resource and action with the
/// constraints removed, and the chain still verified — so a consumer that
/// refuses constrained capabilities at the leaf saw an unconstrained one and
/// granted it. Since `nb` has no interpreted semantics yet, "different" cannot
/// be shown to be narrower and is refused with it.
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
let resource_ok = parent.with == self.with || parent.with == "*";
let action_ok =
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
resource_ok && action_ok
let constraints_ok = match (&parent.constraints, &self.constraints) {
(None, _) => true,
(Some(p), Some(c)) => p == c,
(Some(_), None) => false,
};
resource_ok && action_ok && constraints_ok
}
}
@@ -71,6 +105,157 @@ pub mod caps {
pub const REPO_ADMIN: &str = "repo/admin";
}
/// Rules shared by every boundary that decides whether a UCAN can push.
///
/// Four places answer "can this token push to this repository?": `gl ucan
/// delegate` and the MCP `ucan_delegate` tool when a token is issued, `gl ucan
/// import` when one is stored, `git-remote-gitlawb` when it mints an invocation,
/// and the node when it authorizes the push. For three review rounds each of them
/// carried its own copy of the answer, and every round found a token that one
/// boundary accepted and the next refused: a constrained grant that imported and
/// then authorized nothing; a wildcard proof the helper narrowed and the node
/// rejected; a resource whose owner the leaf named but the chain root did not.
///
/// The rules live here, once, in the crate all four already depend on. A boundary
/// may add checks of its own — the node anchors the chain root to a repository
/// record it holds independently, import binds the audience to the local key —
/// but the definition of a *usable push capability* is not one of them.
pub mod push {
use super::{caps, Capability, Ucan};
/// The `did:key` representation rule: `did:key:z6Mk…` and bare `z6Mk…` are
/// the same identity. Mirror rows store the bare form; canonical rows and
/// every token store the full form.
///
/// Collapses representation only within `did:key`. `did:web` and
/// `did:gitlawb` share the base58 space, so a trailing-segment compare would
/// treat `did:key:X` and `did:gitlawb:X` as equal; after stripping the prefix,
/// a value that still contains `:` is a non-key DID and matches nothing bare.
pub fn did_key_eq(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// The actions that authorize a push: `git/push` itself, the action wildcard,
/// and `repo/admin`, which covers it.
pub fn is_push_action(can: &str) -> bool {
can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN
}
/// `gitlawb://repos/<owner>/<repo>` → `(owner, repo)`, or `None` for any other
/// shape. Exactly two non-empty segments: an owner DID never contains `/`, so
/// a third segment, a trailing slash, or an empty half is malformed rather than
/// something to interpret.
pub fn parse_repo_resource(with: &str) -> Option<(&str, &str)> {
let rest = with.strip_prefix("gitlawb://repos/")?;
let (owner, repo) = rest.split_once('/')?;
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return None;
}
Some((owner, repo))
}
/// The canonical repository name a capability or URL names: at most one
/// trailing `.git` stripped, the same normalization the node's smart-HTTP
/// routes apply (`smart_http_repo_name`). A git remote carries the suffix;
/// `RepoRecord.name` does not, so every boundary that compares a capability
/// to a record must go through the same strip or a `…/myrepo.git` capability
/// names a repository that never exists.
///
/// At-most-once, not repeatedly: a repository literally named `foo.git`
/// (creatable via the peer mirror path, which skips API name validation) is
/// addressed as `foo.git.git`, and stripping twice would send it to `foo`.
pub fn canonical_repo_name(repo: &str) -> &str {
repo.strip_suffix(".git").unwrap_or(repo)
}
/// A push-class capability whose resource is the wildcard. Refused at
/// issuance, at import, by the helper, and by the node: a delegation's scope is
/// fixed when it is issued, and `*` cannot say which repositories it covered
/// at that moment.
pub fn is_push_wildcard(with: &str, can: &str) -> bool {
with == "*" && is_push_action(can)
}
pub use super::MAX_CHAIN_DEPTH;
impl Capability {
/// Push-class, unconstrained, and naming exactly this repository.
///
/// Unconstrained because `nb` has no semantics yet: an owner who wrote
/// constraints meant to restrict, and honouring the capability while
/// ignoring them would grant more than was intended. The owner segment is
/// compared with [`did_key_eq`], so a token issued against the full DID
/// matches a mirror row keyed on the bare form.
///
/// The capability's repo segment goes through [`canonical_repo_name`]
/// while `repo` (a `RepoRecord.name`, already canonical) does not: a
/// delegation written `…/myrepo.git` authorizes `myrepo`, the same
/// mapping the smart-HTTP URL applies.
pub fn grants_push_to(&self, owner: &str, repo: &str) -> bool {
self.constraints.is_none()
&& is_push_action(&self.can)
&& parse_repo_resource(&self.with)
.is_some_and(|(o, r)| did_key_eq(o, owner) && canonical_repo_name(r) == repo)
}
}
impl Ucan {
/// Whether this chain — the leaf **and every proof behind it** — carries a
/// capability that [`Capability::grants_push_to`] this repository.
///
/// Every link, not just the leaf. [`Capability::is_attenuated_by`] accepts
/// a concrete child under a `*` parent, so a leaf that names the repository
/// can sit on a proof that names every repository the root owns — including
/// ones created after the delegation was issued. Checking the leaf alone
/// let exactly that through.
///
/// This establishes scope, not trust. It does not verify signatures,
/// expiry, or audience; call [`Ucan::verify_chain`] first, and anchor the
/// root it returns to something held independently of the token.
///
/// Fails closed on anything it cannot vouch for: a proof that does not
/// decode, more than one proof per link, or a chain deeper than
/// [`MAX_CHAIN_DEPTH`].
pub fn chain_grants_push_to(&self, owner: &str, repo: &str) -> bool {
self.chain_grants_push_to_at(owner, repo, 0)
}
fn chain_grants_push_to_at(&self, owner: &str, repo: &str, depth: usize) -> bool {
if depth >= MAX_CHAIN_DEPTH {
return false;
}
if !self
.payload
.att
.iter()
.any(|c| c.grants_push_to(owner, repo))
{
return false;
}
// `verify_chain` refuses more than one proof per link for the same
// reason: two proofs mean two roots, and nothing says which authorized
// what.
if self.payload.prf.len() > 1 {
return false;
}
match self.payload.prf.first() {
None => true,
Some(token) => match Ucan::decode(token) {
Ok(proof) => proof.chain_grants_push_to_at(owner, repo, depth + 1),
Err(_) => false,
},
}
}
}
}
/// The UCAN payload (what gets signed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UcanPayload {
@@ -146,6 +331,37 @@ impl Ucan {
}
}
/// Whether every link in this chain carries a finite `exp`.
///
/// `exp` is optional in the format, and [`Self::is_expired`] reports `false`
/// when it is absent — so a link without one never expires. With no revocation
/// mechanism, a chain containing such a link is a permanent grant: a leaked
/// token cannot be withdrawn, and the issuer's only remedy is to rotate the
/// identity the resource is keyed on.
///
/// Consumers that turn a UCAN into write authority should require this. It is
/// deliberately not enforced inside [`Self::verify_chain`], because a
/// non-expiring token is well-formed and may be perfectly appropriate for a
/// read-only or advisory capability; whether an unbounded grant is acceptable
/// is the consumer's policy, not the format's.
///
/// Fails closed past [`MAX_CHAIN_DEPTH`], like every other walk over `prf`.
pub fn chain_lifetime_is_bounded(&self) -> bool {
self.chain_lifetime_is_bounded_at(0)
}
fn chain_lifetime_is_bounded_at(&self, depth: usize) -> bool {
if depth >= MAX_CHAIN_DEPTH {
return false;
}
if self.payload.exp.is_none() {
return false;
}
self.payload.prf.iter().all(|token| {
Self::decode(token).is_ok_and(|proof| proof.chain_lifetime_is_bounded_at(depth + 1))
})
}
/// Check if this UCAN's not-before time is in the future (token not yet valid).
pub fn is_before_valid(&self) -> bool {
if let Some(nbf) = self.payload.nbf {
@@ -248,8 +464,33 @@ impl Ucan {
/// 3. Check the proof is not expired
/// 4. Recursively verify the proof's own chain
///
/// A UCAN with no proofs (root capability) passes trivially.
pub fn verify_chain(&self) -> Result<()> {
/// A UCAN with no proofs is its own root, so it returns its own issuer.
///
/// **This establishes internal consistency, not trust.** `did:key` is
/// self-certifying, so anyone can mint a keypair and produce a chain that
/// verifies. A caller making an authorization decision MUST compare the
/// returned root against an identity it trusts for some reason outside this
/// token — a repo owner, a configured value, a registry lookup. Discarding
/// the return value is only correct when the caller is checking that a token
/// is well-formed and deliberately does not care who issued it.
///
/// The walk stops at [`MAX_CHAIN_DEPTH`] and fails closed there. The node
/// runs this on the `X-Ucan` header of any signed request before it asks
/// what the chain grants, so this is the first place sender-chosen depth is
/// felt: each link costs a signature check and a decode of everything
/// beneath it. The scope walk was bounded and this one was not, which made
/// that bound decorative — a deep chain met the unbounded walk first.
pub fn verify_chain(&self) -> Result<Did> {
self.verify_chain_at(0)
}
fn verify_chain_at(&self, depth: usize) -> Result<Did> {
if depth >= MAX_CHAIN_DEPTH {
return Err(Error::Ucan(format!(
"proof chain deeper than {MAX_CHAIN_DEPTH} links is not accepted"
)));
}
// First verify our own signature
self.verify_signature()?;
@@ -261,34 +502,44 @@ impl Ucan {
return Err(Error::Ucan("token is not yet valid".to_string()));
}
for proof_token in &self.payload.prf {
let proof = Self::decode(proof_token)
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
// The proof's audience must be this UCAN's issuer
if proof.payload.aud != self.payload.iss {
return Err(Error::Ucan(format!(
"proof chain broken: proof audience {} does not match issuer {}",
proof.payload.aud, self.payload.iss
)));
}
// Every delegated capability must be covered by the proof (attenuation).
for cap in &self.payload.att {
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
if !covered {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
)));
}
}
// Verify the proof's signature and chain recursively
proof.verify_chain()?;
if self.payload.prf.len() > 1 {
return Err(Error::Ucan(
"multi-proof chains are not supported: more than one proof means \
more than one root, and which root authorized a given capability \
is ambiguous"
.to_string(),
));
}
Ok(())
let Some(proof_token) = self.payload.prf.first() else {
// No proofs: this token is its own root.
return Ok(self.payload.iss.clone());
};
let proof = Self::decode(proof_token)
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
// The proof's audience must be this UCAN's issuer
if proof.payload.aud != self.payload.iss {
return Err(Error::Ucan(format!(
"proof chain broken: proof audience {} does not match issuer {}",
proof.payload.aud, self.payload.iss
)));
}
// Every delegated capability must be covered by the proof (attenuation).
for cap in &self.payload.att {
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
if !covered {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
)));
}
}
// Recurse; the root of the proof's chain is the root of ours.
proof.verify_chain_at(depth + 1)
}
}
@@ -664,4 +915,509 @@ mod tests {
delegated.verify_chain().unwrap();
}
#[test]
fn verify_chain_returns_the_root_issuer_of_a_delegated_chain() {
// owner -> agent (delegation), agent -> node (invocation).
// The root is the owner: that is the identity the whole chain rests on,
// and the only one a caller can meaningfully anchor a trust decision to.
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let delegation =
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
.expect("wrap invocation");
assert_eq!(
invocation.verify_chain().expect("chain must verify"),
owner.did(),
"the root issuer is the owner who started the chain, not the agent presenting it"
);
}
/// A chain is only bounded if EVERY link is. An unbounded link anywhere makes
/// the whole grant permanent, because `is_expired` reports false for it and
/// there is no revocation path to withdraw it.
#[test]
fn chain_lifetime_is_bounded_requires_an_expiry_on_every_link() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let hour = Utc::now() + chrono::Duration::hours(1);
let bounded_root = Ucan::issue(&owner, agent.did(), cap(), Some(hour)).expect("issue");
let unbounded_root = Ucan::issue(&owner, agent.did(), cap(), None).expect("issue");
assert!(
Ucan::delegate(&agent, node.did(), cap(), Some(hour), &bounded_root)
.expect("wrap")
.chain_lifetime_is_bounded(),
"both links finite"
);
assert!(
!Ucan::delegate(&agent, node.did(), cap(), None, &bounded_root)
.expect("wrap")
.chain_lifetime_is_bounded(),
"the leaf has no expiry, so the grant never lapses"
);
assert!(
!Ucan::delegate(&agent, node.did(), cap(), Some(hour), &unbounded_root)
.expect("wrap")
.chain_lifetime_is_bounded(),
"a bounded leaf cannot rescue an unbounded proof: the holder can always \
mint a fresh leaf from it"
);
assert!(
!unbounded_root.chain_lifetime_is_bounded(),
"a self-issued token with no expiry is itself unbounded"
);
}
/// A three-link chain: owner -> lead -> agent, which is the real shape of an
/// org delegating to a team lead who delegates to a CI identity.
///
/// Every other chain here is depth two, where the immediate proof IS the root —
/// so nothing distinguishes recursing to the true root from simply returning the
/// proof's issuer. Both `assert_eq!` and `assert_ne!` below are load-bearing:
/// without the second, returning the middle issuer would still satisfy a test
/// that only checked "not the leaf".
#[test]
fn verify_chain_walks_past_the_immediate_proof_to_the_true_root() {
let owner = Keypair::generate();
let lead = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let root = Ucan::issue(&owner, lead.did(), cap(), None).expect("owner -> lead");
let mid = Ucan::delegate(&lead, agent.did(), cap(), None, &root).expect("lead -> agent");
let leaf = Ucan::delegate(&agent, node.did(), cap(), None, &mid).expect("agent -> node");
let found = leaf.verify_chain().expect("a three-link chain must verify");
assert_eq!(
found,
owner.did(),
"the root is the owner who started the chain, two hops up"
);
assert_ne!(
found,
lead.did(),
"returning the immediate proof's issuer is not walking to the root"
);
}
#[test]
fn verify_chain_returns_self_as_root_for_a_self_issued_token() {
// A token with no proofs roots at its own issuer. This is what makes a
// self-minted token useless: the caller compares this against the repo
// owner and it will only ever match when the presenter IS the owner.
let agent = Keypair::generate();
let node = Keypair::generate();
let ucan =
Ucan::issue(&agent, node.did(), vec![Capability::new("*", "*")], None).expect("issue");
assert_eq!(
ucan.verify_chain().expect("a root token still verifies"),
agent.did(),
"a self-minted token roots at the minter, however permissive its capabilities"
);
}
/// Stripping `nb` is a widening, and a widening must fail attenuation.
///
/// Without this, a constrained delegation is trivially escalated: the holder
/// re-delegates the same resource and action with the constraints removed,
/// `verify_chain` accepts the chain because attenuation only compared `with`
/// and `can`, and a consumer that refuses constrained capabilities at the leaf
/// (as the node's push gate does) then sees an unconstrained one and grants it.
/// Guarding only the leaf guards the wrong end of the chain.
#[test]
fn verify_chain_rejects_a_child_that_strips_the_parents_constraints() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }));
let delegation = Ucan::issue(&owner, agent.did(), vec![constrained], None)
.expect("issue constrained delegation");
// Same resource, same action, constraints dropped.
let widened = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
let forged =
Ucan::delegate(&agent, node.did(), vec![widened], None, &delegation).expect("wrap");
let err = forged
.verify_chain()
.expect_err("dropping the parent's constraints must fail attenuation");
assert!(
err.to_string().contains("attenuation"),
"the failure must name attenuation, got: {err}"
);
}
#[test]
fn verify_chain_accepts_a_child_that_keeps_the_parents_constraints() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let nb = serde_json::json!({ "refs": ["refs/heads/feat/*"] });
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
.with_constraints(nb.clone());
let delegation =
Ucan::issue(&owner, agent.did(), vec![constrained.clone()], None).expect("issue");
let invocation =
Ucan::delegate(&agent, node.did(), vec![constrained], None, &delegation).expect("wrap");
assert_eq!(
invocation
.verify_chain()
.expect("an unchanged constraint must verify"),
owner.did()
);
}
#[test]
fn an_unconstrained_parent_still_allows_a_child_to_add_constraints() {
// Adding `nb` narrows, which is always a legal attenuation.
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let open = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
let delegation = Ucan::issue(&owner, agent.did(), vec![open], None).expect("issue");
let narrowed = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
.with_constraints(serde_json::json!({ "refs": ["refs/heads/main"] }));
let invocation =
Ucan::delegate(&agent, node.did(), vec![narrowed], None, &delegation).expect("wrap");
assert_eq!(
invocation.verify_chain().expect("narrowing must verify"),
owner.did()
);
}
#[test]
fn verify_chain_rejects_a_multi_proof_chain() {
// Two proofs mean two roots, and nothing says which root authorized a
// given capability. Returning either one would be unsound, so refuse.
let owner_a = Keypair::generate();
let owner_b = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let proof_a = Ucan::issue(&owner_a, agent.did(), caps_vec.clone(), None).expect("issue a");
let proof_b = Ucan::issue(&owner_b, agent.did(), caps_vec.clone(), None).expect("issue b");
// `delegate` only ever writes one proof, so build the two-proof payload by hand.
let payload = UcanPayload {
ucan: "1.0.0".to_string(),
iss: agent.did(),
aud: node.did(),
att: caps_vec,
exp: None,
nbf: None,
prf: vec![
proof_a.encode().expect("encode a"),
proof_b.encode().expect("encode b"),
],
};
let signing_bytes = serde_json::to_vec(&payload).expect("serialize payload");
let s = agent.sign_b64(&signing_bytes);
let multi = Ucan { payload, s };
let err = multi
.verify_chain()
.expect_err("a two-proof chain must be refused");
assert!(
err.to_string().contains("multi-proof"),
"the error must name the reason, got: {err}"
);
}
}
#[cfg(test)]
mod push_scope_tests {
use super::push::*;
use super::{caps, Capability, Ucan};
use crate::identity::Keypair;
fn hour() -> chrono::DateTime<chrono::Utc> {
chrono::Utc::now() + chrono::Duration::hours(1)
}
#[test]
fn did_key_eq_collapses_representation_only_within_did_key() {
assert!(did_key_eq("did:key:z6MkAbc", "z6MkAbc"));
assert!(did_key_eq("z6MkAbc", "did:key:z6MkAbc"));
assert!(did_key_eq("did:key:z6MkAbc", "did:key:z6MkAbc"));
assert!(!did_key_eq("did:key:z6MkAbc", "did:key:z6MkXyz"));
// A bare id must never match across methods: `did:gitlawb` shares the
// base58 space with `did:key`.
assert!(!did_key_eq("did:gitlawb:z6MkAbc", "z6MkAbc"));
assert!(!did_key_eq("did:key:z6MkAbc", "did:gitlawb:z6MkAbc"));
assert!(!did_key_eq("did:web:example.com", "example.com"));
}
#[test]
fn parse_repo_resource_requires_exactly_two_segments() {
assert_eq!(
parse_repo_resource("gitlawb://repos/did:key:z6Mk/r"),
Some(("did:key:z6Mk", "r"))
);
for bad in [
"*",
"",
"gitlawb://repos/",
"gitlawb://repos/owner",
"gitlawb://repos/owner/",
"gitlawb://repos//r",
"gitlawb://repos/owner/r/extra",
"https://repos/owner/r",
] {
assert_eq!(parse_repo_resource(bad), None, "{bad:?}");
}
}
#[test]
fn is_push_wildcard_is_the_resource_wildcard_with_a_push_action() {
for can in [caps::GIT_PUSH, "*", caps::REPO_ADMIN] {
assert!(is_push_wildcard("*", can), "{can}");
}
assert!(!is_push_wildcard("*", caps::GIT_FETCH));
assert!(!is_push_wildcard("*", caps::PR_OPEN));
assert!(!is_push_wildcard("gitlawb://repos/o/r", caps::GIT_PUSH));
}
#[test]
fn a_capability_grants_push_only_when_concrete_unconstrained_and_push_class() {
let owner = "did:key:z6MkOwner";
let ok = Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH);
assert!(ok.grants_push_to(owner, "r"));
// Bare and full owner forms are the same identity.
assert!(ok.grants_push_to("z6MkOwner", "r"));
assert!(!Capability::new("*", caps::GIT_PUSH).grants_push_to(owner, "r"));
assert!(
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_FETCH)
.grants_push_to(owner, "r")
);
assert!(
!Capability::new("gitlawb://repos/did:key:z6MkOwner/other", caps::GIT_PUSH)
.grants_push_to(owner, "r")
);
assert!(
!Capability::new("gitlawb://repos/did:key:z6MkOther/r", caps::GIT_PUSH)
.grants_push_to(owner, "r")
);
assert!(
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH)
.with_constraints(serde_json::json!({"max_bytes": 1}))
.grants_push_to(owner, "r")
);
for can in ["*", caps::REPO_ADMIN] {
assert!(
Capability::new("gitlawb://repos/did:key:z6MkOwner/r", can)
.grants_push_to(owner, "r"),
"{can} covers git/push"
);
}
}
#[test]
fn a_git_suffixed_capability_names_the_canonical_repo() {
let owner = "did:key:z6MkOwner";
// A git remote carries `.git`; the record does not. The capability
// matches the repository the URL would reach, so a delegation pasted
// from the remote still authorizes.
assert!(Capability::new(
"gitlawb://repos/did:key:z6MkOwner/myrepo.git",
caps::GIT_PUSH
)
.grants_push_to(owner, "myrepo"));
// At most one suffix is stripped: a repo literally named `foo.git`
// (reachable as `foo.git.git` over smart HTTP) is a different
// repository from `foo`, and a single `.git` must not resolve to it.
assert!(Capability::new(
"gitlawb://repos/did:key:z6MkOwner/foo.git.git",
caps::GIT_PUSH
)
.grants_push_to(owner, "foo.git"));
assert!(
!Capability::new("gitlawb://repos/did:key:z6MkOwner/foo.git", caps::GIT_PUSH)
.grants_push_to(owner, "foo.git")
);
}
/// The chain walk, at every depth: a wildcard anywhere denies; concrete
/// everywhere grants. The grandparent case is the one a single-level check
/// misses, because the immediate proof already names the repo.
#[test]
fn chain_grants_push_only_when_every_link_names_the_repo() {
let owner = Keypair::generate();
let a = Keypair::generate();
let b = Keypair::generate();
let node = Keypair::generate();
let owner_s = owner.did().to_string();
let res = format!("gitlawb://repos/{owner_s}/r");
let concrete = || vec![Capability::new(&res, caps::GIT_PUSH)];
let wild = || vec![Capability::new("*", caps::GIT_PUSH)];
// Single self-issued link.
assert!(Ucan::issue(&owner, a.did(), concrete(), Some(hour()))
.unwrap()
.chain_grants_push_to(&owner_s, "r"));
assert!(!Ucan::issue(&owner, a.did(), wild(), Some(hour()))
.unwrap()
.chain_grants_push_to(&owner_s, "r"));
// Two links: concrete leaf on a wildcard proof must deny.
let wild_proof = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
let leaf = Ucan::delegate(&a, node.did(), concrete(), Some(hour()), &wild_proof).unwrap();
assert!(
leaf.verify_chain().is_ok(),
"attenuation accepts it — that is why we walk"
);
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
// Three links: wildcard grandparent, concrete parent, concrete leaf.
let gp = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
assert!(leaf.verify_chain().is_ok());
assert!(
!leaf.chain_grants_push_to(&owner_s, "r"),
"a wildcard two links up must deny"
);
// Three links, all concrete.
let gp = Ucan::issue(&owner, a.did(), concrete(), Some(hour())).unwrap();
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
// And the bare owner form is the same repo.
assert!(leaf.chain_grants_push_to(owner_s.strip_prefix("did:key:").unwrap(), "r"));
// But not another repo.
assert!(!leaf.chain_grants_push_to(&owner_s, "other"));
}
/// Fail closed on anything the walk cannot vouch for.
#[test]
fn chain_walk_fails_closed_on_undecodable_or_multi_proofs_or_excess_depth() {
let owner = Keypair::generate();
let a = Keypair::generate();
let owner_s = owner.did().to_string();
let res = format!("gitlawb://repos/{owner_s}/r");
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
let mut broken = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
broken.payload.prf = vec!["not a ucan".into()];
assert!(!broken.chain_grants_push_to(&owner_s, "r"));
let good = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
let mut two = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
two.payload.prf = vec![good.encode().unwrap(), good.encode().unwrap()];
assert!(!two.chain_grants_push_to(&owner_s, "r"));
// Deeper than MAX_CHAIN_DEPTH, all concrete: refused on depth alone.
let mut cur = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
let mut signer = a;
for _ in 0..MAX_CHAIN_DEPTH {
let next = Keypair::generate();
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
signer = next;
}
assert!(
!cur.chain_grants_push_to(&owner_s, "r"),
"depth bound must fail closed"
);
}
}
/// Every walk over `prf` stops at the same depth. The round-ten P1: the scope
/// walk was bounded while `verify_chain` and `chain_lifetime_is_bounded` were
/// not, and the node runs `verify_chain` on an attacker-supplied `X-Ucan` header
/// before the bounded walk ever sees it, so the bound protected nothing.
#[cfg(test)]
mod chain_depth_tests {
use super::{caps, Capability, Ucan, MAX_CHAIN_DEPTH};
use crate::identity::Keypair;
fn hour() -> chrono::DateTime<chrono::Utc> {
chrono::Utc::now() + chrono::Duration::hours(1)
}
/// A well-formed, fully signed, expiring chain of exactly `links` links, every
/// one naming the owner's repository, so the only thing any walk can object
/// to is how long it is. Returns the leaf and the owner it roots at.
fn concrete_chain(links: usize) -> (Ucan, Keypair) {
assert!(links >= 1);
let owner = Keypair::generate();
let owner_s = owner.did().to_string();
let res = format!("gitlawb://repos/{owner_s}/r");
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
let mut signer = Keypair::generate();
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour())).unwrap();
for _ in 1..links {
let next = Keypair::generate();
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
signer = next;
}
(cur, owner)
}
#[test]
fn a_chain_at_the_bound_passes_every_walk() {
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH);
let owner_s = owner.did().to_string();
assert_eq!(
leaf.verify_chain()
.expect("a chain at the bound must verify"),
owner.did()
);
assert!(leaf.chain_lifetime_is_bounded());
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
}
#[test]
fn one_link_past_the_bound_fails_every_walk_closed() {
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH + 1);
let owner_s = owner.did().to_string();
let err = leaf
.verify_chain()
.expect_err("verify_chain must refuse a chain past the bound");
assert!(
err.to_string().contains("deeper than"),
"the refusal must say depth was the reason, not a fake signature failure: {err}"
);
assert!(
!leaf.chain_lifetime_is_bounded(),
"an unwalkable chain cannot be vouched for as bounded"
);
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
}
/// A chain well past the bound is refused from the top, before any per-link
/// work. Only a few links past it, deliberately: a proof is embedded as a JSON
/// string inside the next link's JSON, so every level re-escapes the one
/// inside it and the encoding roughly doubles per link — eight links is about
/// 10 KiB, sixteen is megabytes, and a chain of "a few hundred levels" cannot
/// physically be encoded, let alone sent as a header. The bound is still
/// what stops the reachable dozen-odd levels from each costing a decode of
/// everything beneath them.
#[test]
fn a_chain_well_past_the_bound_is_refused_not_walked() {
let (leaf, _) = concrete_chain(MAX_CHAIN_DEPTH + 4);
let err = leaf.verify_chain().expect_err("must refuse");
assert!(err.to_string().contains("deeper than"), "{err}");
assert!(!leaf.chain_lifetime_is_bounded());
}
}
+31 -2
View File
@@ -6,12 +6,31 @@ 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 = ["dep:tempfile"]
# 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" }
async-trait = "0.1"
ed25519-dalek = { workspace = true }
base64 = { workspace = true }
tokio = { workspace = true }
@@ -40,6 +59,7 @@ cid = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
chacha20poly1305 = "0.10"
http-body-util = "0.1"
tokio-util = { version = "0.7", features = ["io"] }
dirs-next = "2"
@@ -77,10 +97,19 @@ alloy = { version = "1", default-features = false, features = [
] }
rand = { workspace = true }
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
# Optional: only needed by the test-harness feature's lib modules
# (test_git_shim, test_harness); dev-deps are invisible to lib code.
tempfile = { version = "3", optional = true }
[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"
File diff suppressed because it is too large. Load diff
+10 -31
View File
@@ -6,29 +6,24 @@ use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
use crate::visibility::{visibility_check, Decision};
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
/// so access control here is repo readability and decryption is gated by the
/// envelope crypto (only a real recipient can open an envelope).
///
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
/// same as issues/changelogs — so a public-but-quarantined mirror cannot leak
/// its encrypted blob index.
pub async fn list_encrypted_blobs(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
@@ -45,17 +40,9 @@ pub async fn get_encrypted_blob(
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo, oid)): Path<(String, String, String)>,
) -> Result<Vec<u8>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
}
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let cid = state
.db
.encrypted_blob_cid(&record.id, &oid)
@@ -81,17 +68,9 @@ pub async fn replicate_encrypted_blobs(
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
+580 -42
View File
@@ -164,6 +164,8 @@ pub async fn create_issue_comment(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
headers: axum::http::HeaderMap,
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
Json(req): Json<CreateIssueCommentRequest>,
) -> Result<(StatusCode, Json<IssueComment>)> {
if req.body.trim().is_empty() {
@@ -176,6 +178,17 @@ pub async fn create_issue_comment(
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
// Shared per-IP write brake, debited AFTER the read gate so a 429 cannot
// distinguish a hidden repo from a missing one (INV-12 — the same
// placement close_issue uses for its brake). The route group carries no
// outer brake for exactly this reason.
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
if let Some(retry_after) = state.write_rate_limiter.check_retry(&key).await {
tracing::warn!(repo = %repo, key = %key, "create_issue_comment rate limited");
return Err(AppError::RateLimited(retry_after));
}
}
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
@@ -238,49 +251,32 @@ pub async fn close_issue(
headers: axum::http::HeaderMap,
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// READ-GATE before any snapshot work or rate charging. The author fallback below
// needs the issue blob, which needs the repo tree, so authorship cannot be established
// without a download; but a caller who cannot even READ the repo must be
// stopped here, cheaply, before any Tigris transfer or extraction happens.
// Without this, any signed non-owner could issue parallel close requests for
// arbitrary issue ids and drive unbounded downloads and blocking extraction
// (a disposable-identity DoS), because the route has no other pre-authorization.
// READ-GATE before any snapshot work or rate charging, through the shared
// authorize_repo_read gate so quarantined repos are opaque here too. The
// author fallback below needs the issue blob, which needs the repo tree, so
// authorship cannot be established without a download; but a caller who
// cannot even READ the repo must be stopped here, cheaply, before any
// Tigris transfer or extraction happens. Without this, any signed non-owner
// could issue parallel close requests for arbitrary issue ids and drive
// unbounded downloads and blocking extraction (a disposable-identity DoS),
// because the route has no other pre-authorization.
//
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden repo
// from a missing one (INV-12 read-denial status contract).
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden
// repo from a missing one (INV-12 read-denial status contract).
//
// mirror-rows-handled: a repo row synced from a peer is stored public and
// carries none of the owner's visibility rules, so for such a row this check
// can only return allow. That is deliberate rather than overlooked, and it is
// the same verdict every other read gate in this API reaches for one. Refusing
// instead would deny the repo's real owner and the issue's real author on any
// node whose only copy of the repo is a synced one, which is an ordinary state
// here, and it would not buy the protection it appears to, because a synced
// row's recorded owner comes from the peer that sent it. The expensive work
// this check guards is bounded ahead of it by the per-IP limiter and the read
// pool slot taken below, and the authoritative owner-or-author decision still
// runs below and again under the write lock.
{
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.0.as_str();
if crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
Some(caller),
"/",
) == crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
}
// carries none of the owner's visibility rules, so for such a row this gate
// can only return allow. That is deliberate rather than overlooked, and it
// is the same verdict every other read gate in this API reaches for one.
// Refusing instead would deny the repo's real owner and the issue's real
// author on any node whose only copy of the repo is a synced one, which is
// an ordinary state here, and it would not buy the protection it appears
// to, because a synced row's recorded owner comes from the peer that sent
// it. The expensive work this check guards is bounded ahead of it by the
// per-IP limiter and the read pool slot taken below, and the authoritative
// owner-or-author decision still runs below and again under the write lock.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
// policy as the push advertisement. The pre-lock snapshot downloads the
@@ -289,7 +285,22 @@ pub async fn close_issue(
// close requests for arbitrary issue ids. Applied after the read gate so a
// denied reader still sees 404, not 429.
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
// Both buckets debit here, after the read gate (INV-12): the shared
// per-IP write brake — the route group deliberately carries no outer
// brake — and this route's own close budget.
//
// A close refused by either cap spends nothing from the other bucket.
// The write bucket is debited first, and refunded when the close cap
// then refuses, so the guarantee holds even when a concurrent close
// takes the last close slot between the two checks. A write-bucket
// refusal advertises its window's Retry-After; the close-cap refusal
// keeps the plain 429 it has always returned.
if let Some(retry_after) = state.write_rate_limiter.check_retry(&key).await {
tracing::warn!(repo = %repo, key = %key, "close_issue rate limited");
return Err(AppError::RateLimited(retry_after));
}
if !state.close_issue_rate_limiter.check(&key).await {
state.write_rate_limiter.refund(&key).await;
tracing::warn!(repo = %repo, key = %key, "close_issue rate limited");
return Err(AppError::TooManyRequests(
"rate limit exceeded — try again later".into(),
@@ -451,11 +462,47 @@ pub async fn close_issue(
}
}
// Snapshot the issue's pre-close JSON so a failed durable upload can roll
// the close back on local disk, mirroring create_issue. Best-effort: if the
// snapshot read fails we proceed without a rollback closure (the close still
// applies and a failed upload still surfaces as a 5xx below).
//
// Resolve first so the compensating rewrite below lands on the SAME ref the
// close mutates. The path id may be an 8-char prefix, and rewriting
// refs/gitlawb/issues/{prefix} with the pre-close content would leave the
// canonical {full_id} ref closed while the client saw a 5xx.
let full_issue_id = git_issues::resolve_issue_id(&disk_path, &issue_id)
.ok()
.flatten();
let prior_json = full_issue_id
.as_deref()
.and_then(|id| git_issues::get_issue(&disk_path, id).ok().flatten());
let close_result = git_issues::close_issue(&disk_path, &issue_id);
// Always release the advisory lock — even on error; upload to Tigris only on success.
// Same short-circuit as create_issue, and before the 200 body below.
guard.release(close_result.is_ok()).await.into_result()?;
// Same short-circuit as create_issue, and before the 200 body below. With a
// snapshot in hand, a failed upload rewrites the ref back to the pre-close
// content instead of leaving the local repo closed while the client saw 5xx.
let release_result = match (close_result.is_ok(), prior_json, full_issue_id) {
(true, Some(json), Some(resolved_id)) => {
let git_bin = state.git_bin.clone();
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs(state.config.git_service_timeout_secs);
guard
.release_compensating(true, move |path| {
// Same bounded-write strategy create_issue's compensator
// uses: the rollback rewrites the ref with the pre-close
// content through the deadline-aware runner, so a hung git
// cannot stall the guard past git_service_timeout.
git_issues::create_issue_bounded(&git_bin, path, &resolved_id, &json, deadline)
})
.await
.into_result()
}
_ => guard.release(close_result.is_ok()).await.into_result(),
};
release_result?;
let updated = close_result
.map_err(|e| AppError::Git(e.to_string()))?
@@ -502,6 +549,15 @@ mod tests {
.await
.expect("get_repo")
.expect("repo exists");
// upsert_mirror_repo seeds the row quarantined, and the read gate now
// hides quarantined rows as opaque 404 before the owner-or-author check
// this test names. Release it so the stranger is refused by the 403, not
// by the quarantine short-circuit.
state
.db
.set_repo_quarantine(&record.id, false)
.await
.expect("release seed quarantine");
// An independent session holds the repo's write lock for the whole call.
let key = crate::git::repo_store::advisory_lock_key_for_test(
@@ -703,6 +759,15 @@ mod tests {
.await
.expect("get_repo")
.expect("seeded repo exists");
// upsert_mirror_repo seeds the admission flag as quarantined, but the
// gate now hides quarantined rows from every caller class. Release it
// so these tests exercise what they name: the owner-or-author check
// after the read gate, not the quarantine short-circuit.
state
.db
.set_repo_quarantine(&record.id, false)
.await
.expect("release seed quarantine");
let _ = owner_did;
let path = state
.repo_store
@@ -728,6 +793,40 @@ mod tests {
path
}
/// The read gate blocks the owner (and any caller) before the
/// owner-or-author checks: even the owner gets the opaque 404 on a
/// quarantined repo, and the seeded row's quarantined flag is left set
/// deliberately. The released-mirror variants above exercise the
/// owner-or-author path.
#[sqlx::test]
async fn quarantined_repo_close_is_opaque_404(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let owner_did = "did:key:z6MkT1Owner";
state
.db
.upsert_mirror_repo("z6MkT1Owner", "tquar", "/unused", None, true)
.await
.expect("seed repo row");
let res = close_issue(
axum::extract::State(state.clone()),
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
axum::extract::Path((
"z6MkT1Owner".to_string(),
"tquar".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some("203.0.113.70:5000".parse().unwrap())),
)
.await;
assert!(
matches!(res, Err(crate::error::AppError::RepoNotFound(_))),
"a quarantined repo must 404 close_issue for every caller: {:?}",
res.ok().map(|_| "unexpected success")
);
}
/// INV-21(c) positive twin 1: the OWNER can still close. The reorder moved the
/// owner check above the lock, so this is the arm most likely to have broken,
/// and the deny test alone could not see it.
@@ -1198,6 +1297,198 @@ mod lock_pool_shed_tests {
);
}
/// A close refused by its own cap must not spend the shared write budget:
/// the 429 completed no close, so stars/comments/GraphQL keep their slot.
#[sqlx::test]
async fn close_issue_refused_by_close_cap_does_not_debit_write_bucket(pool: PgPool) {
use std::net::SocketAddr;
use std::time::Duration;
let owner = "did:key:zCLOSEDUALOWNERAAAAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool).await;
state.close_issue_rate_limiter =
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.write_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
state
.db
.create_repo(&seed_repo(owner, "dual-close"))
.await
.expect("seed repo");
let peer: SocketAddr = "203.0.113.77:7000".parse().unwrap();
let key = peer.ip().to_string();
assert!(
state.close_issue_rate_limiter.check(&key).await,
"exhaust only the close bucket"
);
let res = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "dual-close".to_string(), "1".to_string())),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some(peer)),
)
.await;
assert!(
matches!(res, Err(AppError::TooManyRequests(_))),
"close cap exhausted must 429: {:?}",
res
);
assert!(
state.write_rate_limiter.check(&key).await,
"a close refused by the close cap must leave the write bucket untouched"
);
}
/// A close refused by the shared write cap must not spend the close budget.
#[sqlx::test]
async fn close_issue_refused_by_write_cap_does_not_debit_close_bucket(pool: PgPool) {
use std::net::SocketAddr;
use std::time::Duration;
let owner = "did:key:zCLOSEDUALOWNERBBBBBBBBBBBBBBBBBBBBBBB";
let mut state = crate::test_support::test_state(pool).await;
state.close_issue_rate_limiter =
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.write_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
state
.db
.create_repo(&seed_repo(owner, "dual-write"))
.await
.expect("seed repo");
let peer: SocketAddr = "203.0.113.78:7000".parse().unwrap();
let key = peer.ip().to_string();
assert!(
state.write_rate_limiter.check(&key).await,
"exhaust only the write bucket"
);
let res = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "dual-write".to_string(), "1".to_string())),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some(peer)),
)
.await;
let resp = res
.expect_err("write cap exhausted must refuse")
.into_response();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"write cap exhausted must 429"
);
assert!(
state.close_issue_rate_limiter.check(&key).await,
"a close refused by the write cap must leave the close bucket untouched"
);
}
/// 429 from the shared write bucket must advertise a window-derived
/// Retry-After, as the outer-middleware 429s on the other write routes do.
fn assert_429_with_window_retry_after(err: AppError, what: &str) {
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"{what}: an exhausted write bucket must 429"
);
let secs: u64 = resp
.headers()
.get("retry-after")
.unwrap_or_else(|| panic!("{what}: the write-bucket 429 must carry retry-after"))
.to_str()
.unwrap()
.parse()
.expect("retry-after must be integer seconds");
assert!(
(1..=3600).contains(&secs),
"{what}: retry-after {secs}s must fall inside the 3600s window, not a constant"
);
}
#[sqlx::test]
async fn close_issue_write_cap_429_carries_retry_after(pool: PgPool) {
use std::net::SocketAddr;
use std::time::Duration;
let owner = "did:key:zCLOSERETRYOWNERAAAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool).await;
state.write_rate_limiter =
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
state
.db
.create_repo(&seed_repo(owner, "retry-close"))
.await
.expect("seed repo");
let peer: SocketAddr = "203.0.113.61:7000".parse().unwrap();
assert!(state.write_rate_limiter.check(&peer.ip().to_string()).await);
let res = close_issue(
State(state),
Extension(AuthenticatedDid(owner.to_string())),
Path((
owner.to_string(),
"retry-close".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some(peer)),
)
.await;
assert_429_with_window_retry_after(
res.expect_err("write cap exhausted must refuse"),
"close_issue",
);
}
#[sqlx::test]
async fn create_issue_comment_write_cap_429_carries_retry_after(pool: PgPool) {
use std::net::SocketAddr;
use std::time::Duration;
let owner = "did:key:zCOMMENTRETRYOWNERAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool).await;
state.write_rate_limiter =
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
state
.db
.create_repo(&seed_repo(owner, "retry-comment"))
.await
.expect("seed repo");
let peer: SocketAddr = "203.0.113.62:7000".parse().unwrap();
assert!(state.write_rate_limiter.check(&peer.ip().to_string()).await);
let res = create_issue_comment(
State(state),
Extension(AuthenticatedDid(owner.to_string())),
Path((
owner.to_string(),
"retry-comment".to_string(),
"1".to_string(),
)),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(Some(peer)),
Json(CreateIssueCommentRequest {
body: "x".to_string(),
}),
)
.await;
assert_429_with_window_retry_after(
res.expect_err("write cap exhausted must refuse"),
"create_issue_comment",
);
}
/// close_issue and receive-pack must not share one per-IP bucket.
#[sqlx::test]
async fn close_issue_rate_limit_does_not_drain_push_bucket(pool: PgPool) {
@@ -1285,4 +1576,251 @@ mod lock_pool_shed_tests {
server.abort();
}
/// The compensating rewrite after a refused publish must land on the ref the
/// close actually mutated. A close addressed by an 8-char prefix resolves to
/// the full id and closes `refs/gitlawb/issues/{full_id}`; a compensator
/// that rewrites the path id verbatim mints `refs/gitlawb/issues/{prefix}`
/// with the pre-close content and leaves the canonical ref closed while the
/// client saw a 5xx.
///
/// Reaching the compensator observably takes one contrivance: a definite
/// refusal invalidates (deletes) the live tree BEFORE the closure runs, so
/// the ref it writes exists only when that deletion cannot complete. The
/// fixture makes the tree read-only except the two directories the close
/// and its rollback write (`refs/gitlawb/issues/` and its reflog), and
/// pre-stores the closed blob so the mutation's `hash-object -w` needs no
/// write either. Without the contrivance the compensator runs against a
/// path that is already gone and there is nothing to assert on.
#[sqlx::test]
#[cfg(unix)]
async fn close_via_prefix_restores_the_full_ref_when_publish_refuses(pool: PgPool) {
use axum::response::IntoResponse;
use std::os::unix::fs::PermissionsExt;
// Nothing is stored under the key (404 on HEAD: no under-lock download,
// a create-only publish fence) and the PUT is refused outright (a 4xx
// classifies as provably never dispatched), so release reaches the
// definite-refusal arm that invalidates the tree and runs the
// compensator.
let app = axum::Router::new().route(
"/{*key}",
axum::routing::any(|method: axum::http::Method| async move {
if method == axum::http::Method::HEAD {
axum::http::StatusCode::NOT_FOUND.into_response()
} else {
axum::http::StatusCode::FORBIDDEN.into_response()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let owner = "did:key:zISSUECLOSEROLLBACKBBBBBBBBBBBBBBB";
let repo_name = "rollback-close-prefix";
let mut state = crate::test_support::test_state(pool.clone()).await;
let disk = tempfile::TempDir::new().unwrap();
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
disk.path().to_path_buf(),
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
);
state
.db
.create_repo(&seed_repo(owner, repo_name))
.await
.expect("seed repo");
// Seed the live tree at the path the store resolves, with the issue
// under its full id so the route can be addressed by the 8-char prefix.
let live = state
.repo_store
.acquire(owner, repo_name)
.await
.expect("resolve live path");
crate::git::store::init_bare(&live).expect("init bare repo");
let full_id = "abc12345-0000-0000-0000-000000000000";
let prefix = "abc12345";
let open_json = serde_json::to_string(&IssueRecord {
id: full_id.to_string(),
title: "seeded".to_string(),
body: Some(String::new()),
author: Some(owner.to_string()),
created_at: chrono::Utc::now().to_rfc3339(),
status: "open".to_string(),
signed_payload: None,
})
.expect("serialize seeded issue");
git_issues::create_issue(&live, full_id, &open_json).expect("seed issue blob");
// Pre-store the blob `git_issues::close_issue` will write: the same
// parse/set-status/serialize sequence, so the bytes are identical and
// the mutation's `hash-object -w` is a no-op against read-only objects/.
let mut closed_value: serde_json::Value =
serde_json::from_str(&open_json).expect("parse seeded issue");
closed_value["status"] = serde_json::Value::String("closed".into());
let closed_json = serde_json::to_string(&closed_value).expect("closed issue json");
{
use std::io::Write;
let mut child = std::process::Command::new("git")
.args(["hash-object", "--stdin", "-w"])
.current_dir(&live)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn hash-object");
child
.stdin
.take()
.expect("stdin")
.write_all(closed_json.as_bytes())
.expect("write blob");
assert!(
child.wait().expect("hash-object").success(),
"pre-storing the closed blob must succeed"
);
}
// Stand the tree through the refusal's remove_dir_all: read-only
// everywhere, then write restored only where the close mutation and its
// rollback actually land (the ref and its reflog).
fn make_readonly(path: &std::path::Path) -> std::io::Result<()> {
let mut p = path.metadata()?.permissions();
p.set_readonly(true);
std::fs::set_permissions(path, p)?;
if path.is_dir() {
for entry in std::fs::read_dir(path)? {
make_readonly(&entry?.path())?;
}
}
Ok(())
}
make_readonly(&live).expect("make the live tree unremovable");
for dir in [
live.join("refs/gitlawb/issues"),
live.join("logs/refs/gitlawb/issues"),
] {
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
.expect("restore write where the rollback lands");
}
let shed = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), repo_name.to_string(), prefix.to_string())),
axum::http::HeaderMap::new(),
crate::rate_limit::PeerAddr(None),
)
.await;
let err = shed.expect_err("a refused publish must not report a closed issue");
assert!(
err.into_response().status().is_server_error(),
"a definite publish refusal must surface as a 5xx"
);
let restored = git_issues::get_issue(&live, full_id).expect("read the full ref");
assert_eq!(
restored.as_deref(),
Some(open_json.as_str()),
"the compensator must restore the pre-close content on the FULL ref, \
not on refs/gitlawb/issues/{prefix}"
);
assert_eq!(
git_issues::resolve_issue_id(&live, prefix).expect("resolve the prefix"),
Some(full_id.to_string()),
"no stray refs/gitlawb/issues/{{prefix}} may be minted beside the \
canonical ref: the prefix must still resolve to the full id"
);
let issues = git_issues::list_issues(&live).expect("list issues");
assert_eq!(
issues.len(),
1,
"exactly one issue ref may survive the rollback: {issues:?}"
);
// Restore writability so the TempDir can clean itself up.
let _ = std::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&live)
.output();
server.abort();
}
/// INV-12 ordering: both issue write routes debit the shared write bucket
/// AFTER authorize_repo_read, so an exhausted bucket cannot answer 429 to a
/// caller the gate must 404 — that status difference would leak the repo's
/// existence. The route group carries no outer brake for this reason.
#[sqlx::test]
async fn an_exhausted_write_bucket_cannot_preempt_the_read_gate(pool: PgPool) {
let mut state = crate::test_support::test_state(pool.clone()).await;
state.write_rate_limiter =
crate::rate_limit::RateLimiter::new(1, std::time::Duration::from_secs(3600));
state
.db
.create_repo(&crate::db::RepoRecord {
id: "rid-write-brake-order".to_string(),
name: "priv".to_string(),
owner_did: "did:key:z6MkWriteBrakeOwner".to_string(),
description: None,
is_public: false,
default_branch: "main".to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/tmp/write-brake-order".to_string(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
let headers = axum::http::HeaderMap::new();
let peer = || crate::rate_limit::PeerAddr(Some("203.0.113.77:5000".parse().unwrap()));
let key = crate::rate_limit::client_key(&headers, peer().0, state.push_limiter_trust)
.expect("a peer addr yields a brake key");
assert!(state.write_rate_limiter.check(&key).await);
assert!(
!state.write_rate_limiter.check(&key).await,
"the shared write bucket is empty for this caller"
);
let stranger = || crate::auth::AuthenticatedDid("did:key:z6MkStranger".to_string());
let path = || {
(
"did:key:z6MkWriteBrakeOwner".to_string(),
"priv".to_string(),
"1".to_string(),
)
};
let res = close_issue(
axum::extract::State(state.clone()),
axum::Extension(stranger()),
axum::extract::Path(path()),
headers.clone(),
peer(),
)
.await;
assert!(
matches!(res, Err(AppError::RepoNotFound(_))),
"close on an unreadable repo must reach the read gate, not the empty write bucket: {res:?}"
);
let res = create_issue_comment(
axum::extract::State(state),
axum::Extension(stranger()),
axum::extract::Path(path()),
headers,
peer(),
axum::Json(CreateIssueCommentRequest {
body: "x".to_string(),
}),
)
.await;
assert!(
matches!(res, Err(AppError::RepoNotFound(_))),
"comment on an unreadable repo must reach the read gate, not the empty write bucket: {res:?}"
);
}
}
+4 -10
View File
@@ -44,11 +44,8 @@ pub async fn add_label(
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
let added = state.db.add_label(&record.id, &label).await?;
@@ -71,11 +68,8 @@ pub async fn remove_label(
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
let removed = state.db.remove_label(&record.id, &label).await?;
+78 -17
View File
@@ -22,6 +22,7 @@ pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
pub mod task_cursor;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
@@ -68,16 +69,9 @@ pub(crate) async fn authorize_repo_read(
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
// One implementation, shared with `gl` and `git-remote-gitlawb` through core:
// every boundary that compares a DID applies the same collapse rule.
gitlawb_core::ucan::push::did_key_eq(a, b)
}
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
@@ -176,16 +170,28 @@ mod authz_guard {
"\nasync fn ",
"\npub fn ",
"\nfn ",
"#[cfg(test)]",
]
.iter()
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
.min()
.unwrap_or(rest.len());
rest[..end]
// Strip block comments too: a marker surviving only inside /* */ would
// otherwise satisfy a row with the real gate deleted.
// Line comments first: a `//` line containing `/*` must not open a
// block range or real code (and gate markers) gets stripped with it.
let mut body = rest[..end]
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
.join("\n");
while let Some(open) = body.find("/*") {
match body[open..].find("*/").map(|i| open + i + 2) {
Some(close) => body.replace_range(open..close, ""),
None => break,
}
}
body
}
#[test]
@@ -208,9 +214,14 @@ mod authz_guard {
// (source, handler, expected gate marker)
let rows: &[(&str, &str, &str)] = &[
// Bucket A — owner-gate (require_repo_owner -> 403)
// Bucket A — owner-gate (require_repo_owner -> 403), now behind
// the shared read gate first so a hidden repo 404s for every
// caller class before the owner check can answer 403.
(pulls, "merge_pr", "authorize_repo_read("),
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "create_webhook", "authorize_repo_read("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "authorize_repo_read("),
(webhooks, "delete_webhook", "require_repo_owner("),
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
// authorize_repo_read 404s a non-reader of a private repo, then
@@ -219,10 +230,15 @@ mod authz_guard {
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
(labels, "add_label", "authorize_repo_read("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "authorize_repo_read("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
// Bucket A' — owner OR author (did_matches against the author),
// also behind the shared read gate first.
(pulls, "close_pr", "authorize_repo_read("),
(pulls, "close_pr", "did_matches("),
(issues, "close_issue", "authorize_repo_read("),
(issues, "close_issue", "did_matches("),
// Bucket B — read-gate (authorize_repo_read)
(pulls, "create_review", "authorize_repo_read("),
@@ -268,21 +284,66 @@ mod authz_guard {
(bounties, "dispute_bounty", "did_matches("),
(replicas, "register_replica", "did_matches("),
(replicas, "unregister_replica", "replica_did = &auth.0"),
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
// PRE-GATED — owner-gated behind the shared read gate; guard both
(protect, "protect_branch", "authorize_repo_read("),
(protect, "protect_branch", "did_matches("),
(protect, "unprotect_branch", "authorize_repo_read("),
(protect, "unprotect_branch", "did_matches("),
(visibility, "set_visibility", "authorize_repo_read("),
(visibility, "set_visibility", "require_owner("),
(visibility, "remove_visibility", "authorize_repo_read("),
(visibility, "remove_visibility", "require_owner("),
(visibility, "list_visibility", "authorize_repo_read("),
(visibility, "list_visibility", "require_owner("),
];
// The visibility rows prove require_owner is CALLED; this proves the helper
// itself does DID-safe matching, not a raw/trailing-segment compare.
// Rows above prove each visibility handler calls authorize_repo_read and
// require_owner; this proves the helper itself does DID-safe matching.
assert!(
fn_body(visibility, "require_owner").contains("did_matches("),
"visibility::require_owner must use did_matches for DID-safe owner matching"
);
// Order matters as much as presence: hoisting the owner/author check
// above the read gate would 403 a non-owner on a hidden repo, an
// existence oracle. Pin read-gate-then-owner on every handler that
// takes both; the owner marker differs per bucket (require_repo_owner
// for owner-only, require_owner for visibility, did_matches for the
// owner-or-author and protect paths).
for (src, func, owner_marker) in [
(visibility, "set_visibility", "require_owner("),
(visibility, "remove_visibility", "require_owner("),
(visibility, "list_visibility", "require_owner("),
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "list_webhooks", "require_repo_owner("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
(pulls, "close_pr", "did_matches("),
(issues, "close_issue", "did_matches("),
(protect, "protect_branch", "did_matches("),
(protect, "unprotect_branch", "did_matches("),
] {
let body = fn_body(src, func);
let gate = body.find("authorize_repo_read(").expect("gate marker");
let owner = body.find(owner_marker).expect("owner marker");
assert!(
gate < owner,
"{func} must run authorize_repo_read before {owner_marker}"
);
// The owner-or-author and protect handlers also carry
// require_repo_owner: a hoist of that call above the read gate is
// the same 403-vs-404 oracle, so pin it too where it exists.
if func == "close_pr" || func == "close_issue" {
let owner_gate = body.find("require_repo_owner(").expect("owner marker");
assert!(
gate < owner_gate,
"{func} must run authorize_repo_read before require_repo_owner("
);
}
}
// The CID read surface (#173) enforces its gate inside the shared
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
// the markers must live there (the get_by_cid row above only proves delegation).
+178 -56
View File
@@ -84,7 +84,7 @@ pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) U
/// sits at a prefix-length-dependent offset (RFC 6052 §2.2) — so they return
/// `None`. Any caller that needs them blocked must reject the wider
/// `64:ff9b::/32` itself; `is_public_http_url` does this in its native-v6 arm.
fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
pub(crate) fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
return Some(v4);
}
@@ -104,6 +104,116 @@ fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
None
}
/// Whether an IP literal is outside the local/private ranges that an
/// attacker-controlled outbound URL must never reach. Kept separate from URL
/// parsing so callers that resolve a DNS name themselves can apply the same
/// classification to every answer and then pin the approved addresses.
pub(crate) fn is_public_ip(ip: std::net::IpAddr) -> bool {
// Reject loopback/unspecified on the literal as given — catches `::1` and
// `::` before the IPv4-folding below (`::1`.to_ipv4() would otherwise map
// to a non-loopback `0.0.0.1`).
if ip.is_loopback() || ip.is_unspecified() {
return false;
}
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
// loopback/private addresses smuggled in via an IPv6 encoding, then
// re-check loopback/unspecified.
let ip = match ip {
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
v4 => v4,
};
if ip.is_loopback() || ip.is_unspecified() {
return false;
}
match ip {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
// 0.0.0.0 itself is already caught by the is_unspecified check).
if v4.is_private()
|| v4.is_link_local()
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|| o[0] == 0
{
return false;
}
}
std::net::IpAddr::V6(v6) => {
let s = v6.segments();
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
// (site-local, deprecated by RFC 3879; the link-local mask does
// not cover it).
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] & 0xffc0) == 0xfec0 {
return false;
}
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
// decodable well-known /96 — e.g. the RFC 8215 local-use
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
// Rather than risk a wrong decode across every prefix length we
// reject the whole NAT64 space here. The well-known /96 was already
// folded to its v4 above and never reaches this arm.
if s[0] == 0x0064 && s[1] == 0xff9b {
return false;
}
}
}
true
}
/// Mirror-upstream variant of [`is_public_ip`]: in addition to the shared
/// peer classification, rejects IANA special-purpose ranges that are
/// non-global, locally meaningful, or unsafe as owner-controlled outbound
/// fetch endpoints. For IP-literal upstreams, configure-time URL validation
/// and the fetch-time egress policy share this classifier, so a stored
/// literal can never fail the worker it is scheduled for. Hostname upstreams
/// are only shape-checked at configure time: each fetch resolves DNS and
/// applies this classifier to the answers, so a hostname whose answers turn
/// non-public still fails at fetch time.
pub(crate) fn mirror_upstream_ip_permitted(ip: std::net::IpAddr) -> bool {
if !is_public_ip(ip) {
return false;
}
let classified = match ip {
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
_ => ip,
};
match classified {
std::net::IpAddr::V4(v4) => {
let [a, b, c, _] = v4.octets();
// IANA special-purpose ranges that are not globally reachable but
// may be routed inside labs, clouds, or operator networks. They are
// unsuitable for an owner-controlled outbound fetch even though
// `Ipv4Addr::is_private` does not classify them as RFC1918.
!(a >= 224
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 192 && b == 88 && c == 99)
|| (a == 198 && matches!(b, 18 | 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113))
}
std::net::IpAddr::V6(v6) => {
let s = v6.segments();
// IANA special-purpose ranges that are non-global, locally
// meaningful, or unsafe to treat as ordinary forge endpoints.
// Reject the complete IETF protocol-assignment /23 rather than
// trying to maintain its narrow globally reachable exceptions;
// a public forge has no reason to depend on those anycast ranges.
!(v6.is_multicast()
|| (s[0] & 0xffc0) == 0xfec0
|| (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0)
|| (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1)
|| (s[0] == 0x2001 && (s[1] & 0xfe00) == 0)
|| (s[0] == 0x2001 && s[1] == 0x0db8)
|| (s[0] == 0x3fff && (s[1] & 0xf000) == 0)
|| s[0] == 0x5f00)
}
}
}
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
/// and `localhost` / `.localhost` / `.local` / `.internal` hostnames. Used at
@@ -137,60 +247,9 @@ pub fn is_public_http_url(raw: &str) -> bool {
// before parsing as an IP.
let ip_candidate = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = ip_candidate.parse::<std::net::IpAddr>() {
// Reject loopback/unspecified on the literal as given — catches `::1`
// and `::` before the IPv4-folding below (`::1`.to_ipv4() would
// otherwise map to a non-loopback `0.0.0.1`).
if ip.is_loopback() || ip.is_unspecified() {
return false;
}
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
// loopback/private addresses smuggled in via an IPv6 encoding, then
// re-check loopback/unspecified.
let ip = match ip {
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
v4 => v4,
};
if ip.is_loopback() || ip.is_unspecified() {
return false;
}
match ip {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
// 0.0.0.0 itself is already caught by the is_unspecified check).
if v4.is_private()
|| v4.is_link_local()
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|| o[0] == 0
{
return false;
}
}
std::net::IpAddr::V6(v6) => {
let s = v6.segments();
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
// (site-local, deprecated by RFC 3879 and so never a legitimate
// peer address, but still routable on networks that kept it).
if (s[0] & 0xfe00) == 0xfc00
|| (s[0] & 0xffc0) == 0xfe80
|| (s[0] & 0xffc0) == 0xfec0
{
return false;
}
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
// decodable well-known /96 — e.g. the RFC 8215 local-use
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
// Rather than risk a wrong decode across every prefix length we
// reject the whole NAT64 space here. The well-known /96 was
// already folded to its v4 above and never reaches this arm.
if s[0] == 0x0064 && s[1] == 0xff9b {
return false;
}
}
}
// IP literals share the single DNS/literal classification in
// `is_public_ip`; hostname-only rules stay local to this function.
return is_public_ip(ip);
} else if !host.contains('.') {
// A name with no dot at all. The `.local` / `.internal` rules above are
// suffix checks, so a single label slips both, and the resolver decides
@@ -380,6 +439,7 @@ pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_js
"refs/heads/main",
"0000000000000000000000000000000000000000",
None,
None,
)
.await;
enqueued += 1;
@@ -415,6 +475,13 @@ pub struct NotifyRequest {
pub old_sha: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
/// The instant the origin push landed, for sync-lag measurement only —
/// mirrors RefUpdateEvent::lag_origin on the gossip transport. `timestamp`
/// stays publish semantics to match what gossip ingest stores on the feed
/// row; when an older sender omits this field the enqueue falls back to
/// `timestamp`, which was the pre-split approximation of land time.
#[serde(default)]
pub lag_origin: Option<String>,
#[serde(default)]
pub cert_id: Option<String>,
/// Full owner DID — added in #144 for DID-aware feed gating.
@@ -461,7 +528,14 @@ pub async fn notify_sync(
state
.db
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
.enqueue_sync(
&req.repo,
&req.node_did,
&req.ref_name,
&req.new_sha,
None,
req.lag_origin.as_deref().or(req.timestamp.as_deref()),
)
.await?;
// Mirror the gossipsub-receive handler: insert the same record we'd
@@ -616,6 +690,8 @@ mod tests {
fn rejects_deprecated_site_local_v6() {
// fec0::/10, deprecated by RFC 3879. The link-local mask (0xffc0 ==
// 0xfe80) does not cover it: 0xfec0 & 0xffc0 is 0xfec0.
assert!(!super::is_public_ip("fec0::1".parse().unwrap()));
assert!(!super::is_public_ip("feff:ffff::1".parse().unwrap()));
for bad in ["http://[fec0::1]/", "http://[feff:ffff::1]:7545"] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
@@ -1221,6 +1297,52 @@ mod tests {
);
}
#[sqlx::test]
async fn sync_notify_preserves_origin_timestamp_for_sync_lag(pool: PgPool) {
let state = test_state(pool).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
// Distinct publish and land instants: the feed row must carry publish
// semantics while only the queue row sees lag_origin.
let timestamp = "2026-08-16T00:00:00Z";
let lag_origin = "2026-08-15T23:59:00Z";
let body = serde_json::json!({
"repo": "z6Mkfoo/timestamped",
"ref_name": "refs/heads/main",
"new_sha": "0000000000000000000000000000000000000000",
"node_did": peer_did,
"timestamp": timestamp,
"lag_origin": lag_origin,
})
.to_string();
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&body,
"198.51.100.23:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].origin_timestamp.as_deref(), Some(lag_origin));
let feed = db
.list_ref_updates_keyset(Some("z6Mkfoo/timestamped"), 10, None)
.await
.unwrap();
assert_eq!(
feed.iter()
.find(|u| u.repo == "z6Mkfoo/timestamped")
.map(|u| u.timestamp.as_str()),
Some(timestamp),
"the feed row must keep publish semantics, matching gossip ingest"
);
}
#[sqlx::test]
async fn sync_notify_unknown_peer_wins_over_malformed_slug(pool: PgPool) {
// Ordering: the slug check sits after the peer gate, so an unknown peer
+4 -10
View File
@@ -17,11 +17,8 @@ pub async fn protect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
// Only the repo owner can protect branches (DID-safe match, shared idiom).
let caller = &auth.0;
@@ -51,11 +48,8 @@ pub async fn unprotect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
+172 -11
View File
@@ -95,6 +95,7 @@ pub async fn create_pr(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.opened",
serde_json::json!({
@@ -199,11 +200,8 @@ pub async fn merge_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
// Owner-only merge (N7). Merging writes the served tree, so this is the same
// trust boundary as owner-only push; it subsumes branch protection (a
@@ -227,6 +225,19 @@ pub async fn merge_pr(
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
// Snapshot the target ref's pre-merge OID so a refused durable upload can
// rewind the local merge, mirroring the close_issue / create_issue
// compensating pattern. A read failure or an absent ref (a first-commit
// merge onto an unborn branch) yields no rollback closure: the merge still
// applies and a refused publish still surfaces as 5xx.
let target_ref = format!("refs/heads/{}", pr.target_branch);
let git_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
let prior_tip = store::read_ref_bounded(&state.git_bin, &disk_path, &target_ref, git_timeout)
.await
.ok()
.flatten();
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
@@ -239,7 +250,27 @@ pub async fn merge_pr(
// Short-circuit on a refused publish before the PR is marked merged and
// before the webhook fires. Both are irreversible announcements of a merge
// commit that only exists on this node's disk.
guard.release(merge_result.is_ok()).await.into_result()?;
let release_result = match (merge_result.is_ok(), prior_tip) {
(true, Some(tip)) => {
let git_bin = state.git_bin.clone();
let deadline = std::time::Instant::now() + git_timeout;
guard
.release_compensating(true, move |path| {
crate::git::visibility_pack::run_bounded_git(
&git_bin,
&["update-ref", &target_ref, &tip],
path,
b"",
deadline,
)
.map(|_| ())
})
.await
.into_result()
}
_ => guard.release(merge_result.is_ok()).await.into_result(),
};
release_result?;
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -249,6 +280,7 @@ pub async fn merge_pr(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.merged",
serde_json::json!({
@@ -274,11 +306,8 @@ pub async fn close_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let pr = state
.db
@@ -300,6 +329,7 @@ pub async fn close_pr(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.closed",
serde_json::json!({
@@ -350,6 +380,7 @@ pub async fn create_review(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.reviewed",
serde_json::json!({
@@ -439,6 +470,136 @@ pub async fn list_comments(
Ok(Json(serde_json::json!({ "comments": comments })))
}
#[cfg(test)]
mod merge_rollback_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
fn git(dir: &std::path::Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.args(["-C", &*dir.to_string_lossy()])
.args([
"-c",
"user.name=merge-rollback-test",
"-c",
"[email protected]",
])
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
/// Build a bare repo whose `main` and `feature` refs diverge: one root
/// commit on main, one commit on feature, so merge_branch creates a real
/// merge commit that a refused publish must rewind.
fn seeded_merge_repo(dir: &std::path::Path) -> (String, String) {
crate::git::store::init_bare(dir).expect("bare repo");
let empty_tree = git(dir, &["mktree"]);
let root = git(dir, &["commit-tree", &empty_tree, "-m", "root"]);
git(dir, &["update-ref", "refs/heads/main", &root]);
let tip2 = git(
dir,
&["commit-tree", &empty_tree, "-m", "feature", "-p", &root],
);
git(dir, &["update-ref", "refs/heads/feature", &tip2]);
(root, tip2)
}
/// On a definite publish refusal, the local merge commit must be rewound:
/// the client got a 5xx, so the repo cannot show a merge the mirror never
/// learned about (same contract create_issue's rollback enforces).
#[sqlx::test]
async fn merge_pr_rewinds_local_merge_when_publish_refuses(pool: PgPool) {
// Endpoint that answers HEAD with an etag but refuses every write.
let app = axum::Router::new().route(
"/{*key}",
axum::routing::any(|method: axum::http::Method| async move {
if method == axum::http::Method::HEAD {
let mut resp = axum::http::StatusCode::OK.into_response();
resp.headers_mut()
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
resp
} else {
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let owner = "did:key:zMERGEROLLBACKAAAAAAAAAAAAAAAAAAAAAAAA";
let repo_name = "rollback-merge";
let mut state = crate::test_support::test_state(pool.clone()).await;
let disk = tempfile::TempDir::new().unwrap();
let repo_path = disk.path().join("repo.git");
let (main_tip, _feature_tip) = seeded_merge_repo(&repo_path);
let mut seeded = crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: repo_name.to_string(),
owner_did: owner.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: String::new(),
forked_from: None,
machine_id: None,
};
seeded.disk_path = repo_path.to_string_lossy().to_string();
let repo_id = seeded.id.clone();
state.db.create_repo(&seeded).await.expect("seed repo");
let now = Utc::now().to_rfc3339();
state
.db
.create_pr(&PullRequest {
id: Uuid::new_v4().to_string(),
repo_id,
number: 1,
title: "rb".to_string(),
body: None,
author_did: owner.to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
})
.await
.expect("seed PR");
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
disk.path().to_path_buf(),
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
);
let res = merge_pr(
State(state),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), repo_name.to_string(), 1)),
)
.await;
assert!(res.is_err(), "a refused publish must surface an error");
let tip_after = git(&repo_path, &["rev-parse", "refs/heads/main"]);
assert_eq!(
tip_after, main_tip,
"a refused publish must rewind the local merge to the pre-merge tip"
);
}
}
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
/// pool must shed 503 + Retry-After.
#[cfg(test)]
File diff suppressed because it is too large. Load diff
+782
View File
@@ -0,0 +1,782 @@
//! Opaque, integrity-protected continuation tokens for the task read surfaces.
//!
//! The task list pages by keyset over `(created_at, id)`, but the rows a
//! caller may *see* are a filtered subset of the rows the query has to
//! *examine*. Handing the caller a raw `(created_at, id)` cursor therefore
//! forced a choice between two broken options (#327 review): anchor the cursor
//! on the last visible row, and a window of denied tasks longer than the scan
//! budget stalls paging forever; or anchor it on the last examined row, and a
//! denied read leaks the id and timestamp of a task `GET /tasks/{id}` other-
//! wise 404s.
//!
//! A server-issued token removes the choice. The position it carries is the
//! last *examined* candidate, so paging always advances, and the payload is
//! opaque and MAC'd, so the caller learns nothing from it and cannot forge one
//! naming a row of their choosing.
//!
//! The position must be *confidential*, not merely unforgeable: a token that
//! merely signed a base64 payload would still let its holder read the id and
//! timestamp of the denied row it names, which is the disclosure the token
//! exists to prevent. The payload is therefore encrypted under a
//! synthetic-IV construction (SIV): the tag is an HMAC over the filter and the
//! plaintext, and it doubles as the IV seeding the keystream the plaintext is
//! XORed with. That needs no randomness source and no dependency beyond the
//! `hmac`/`sha2` pair already used for webhook signatures and blob recipient
//! tags, and it is decrypt-last: nothing is parsed until the tag verifies.
//!
//! Making the token the *only* accepted cursor also fixes the ordering-domain
//! bug the same review found. `agent_tasks.created_at` is TEXT and compared as
//! TEXT, so `...Z` and `...+00:00` denote one instant but sort differently. A
//! caller-typed timestamp could silently skip or repeat same-time rows. The
//! token instead carries the stored string verbatim, so the value compared is
//! always a value the server wrote.
//!
//! A token is bound to the *caller* as well as the filter. The position it
//! names is the last examined candidate, not the last visible one, so it
//! encodes how far a scan got under one caller's visibility. Resuming it as a
//! different caller would start the scan past rows that caller is entitled to
//! read, silently dropping them from the answer (#327 review). The presenting
//! caller's normalized DID (empty and flagged absent when anonymous) is
//! therefore part of the MAC input, so a token presented by anyone else fails
//! to verify rather than under-reporting.
//!
//! Wire form: `v1.<iv>.<body>`, both parts base64url unpadded: the 16-byte
//! synthetic IV (the truncated MAC, which also seeds the keystream) and the
//! padded, encrypted JSON payload.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::error::AppError;
type HmacSha256 = Hmac<Sha256>;
const CURSOR_PREFIX: &str = "v1";
const KEY_DERIVATION_LABEL: &[u8] = b"gitlawb/tasks-cursor-key/v1";
const TAG_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-tag/v1";
const STREAM_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-stream/v1";
/// Truncated MAC length, and the synthetic IV width. 128 bits is far beyond
/// forgery reach for a token that carries no authority of its own (every page
/// re-runs the visibility gate against the presenting caller), and keeps the
/// token short enough to sit in a query string.
const TAG_LEN: usize = 16;
/// Padding bucket for the serialized cursor payload, in bytes. The keystream
/// XORs the plaintext byte for byte, so token length would otherwise track
/// payload length and leak the named row's field widths to a holder who
/// cannot decrypt anything. 128 comfortably covers the widest realistic
/// payload: a 35-char RFC3339 timestamp (`to_rfc3339()` renders 29 chars at
/// millisecond precision and 32 at microsecond, 35 only with all nine
/// fractional digits), a 36-char server-minted UUID, and the fixed JSON
/// envelope plus a 10-digit expiry, which together total 101 bytes. Should a future payload ever exceed the bucket, the length
/// rounds up to the next multiple instead of failing, so `encode` never
/// breaks — it just mints a longer token.
const PAYLOAD_BUCKET: usize = 128;
/// How long an issued cursor stays acceptable. Keyset positions never go stale
/// on their own — `created_at`/`id` are immutable — so this is not a
/// correctness bound. It bounds how long a token stays valid across a node
/// restart-and-rotate and keeps an abandoned page from being resumed
/// indefinitely.
const CURSOR_TTL_SECS: i64 = 24 * 60 * 60;
/// Node-keyed MAC key for continuation tokens, derived from the node keypair
/// seed so it needs no configuration and survives restarts of the same node.
/// Derived rather than used directly so a token forgery oracle could not
/// bear on the signing key itself.
#[derive(Clone)]
pub struct TaskCursorKey([u8; 32]);
impl std::fmt::Debug for TaskCursorKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TaskCursorKey(<redacted>)")
}
}
impl TaskCursorKey {
pub fn derive(node_seed: &[u8; 32]) -> Self {
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
mac.update(KEY_DERIVATION_LABEL);
let mut key = [0u8; 32];
key.copy_from_slice(&mac.finalize().into_bytes());
Self(key)
}
}
/// The keyset position a token carries: the last candidate row the previous
/// request examined, whether or not the caller was allowed to see it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskPosition {
pub created_at: String,
pub id: String,
}
impl TaskPosition {
pub fn new(created_at: impl Into<String>, id: impl Into<String>) -> Self {
Self {
created_at: created_at.into(),
id: id.into(),
}
}
pub fn as_pair(&self) -> (&str, &str) {
(self.created_at.as_str(), self.id.as_str())
}
}
#[derive(Serialize, Deserialize)]
struct CursorPayload<'a> {
/// `created_at` of the last examined candidate, verbatim as stored.
t: &'a str,
/// `id` of the last examined candidate.
i: &'a str,
/// Unix expiry.
e: i64,
}
/// The filter a page was issued under. A cursor is only meaningful against the
/// same `status`/`assignee_did` filter that produced it: resuming a filtered
/// scan from an unfiltered position (or the reverse) silently skips rows.
/// Bound into the MAC rather than stored in the payload, so it costs no token
/// length and cannot be edited without invalidating the tag.
#[derive(Debug, Clone, Copy)]
pub struct TaskFilter<'a> {
pub status: Option<&'a str>,
pub assignee_did: Option<&'a str>,
}
/// The visibility identity a token was issued to. Normalized through
/// `normalize_owner_key` so the two spellings of one `did:key` identity
/// (`did:key:X` and bare `X`) bind identically, matching `did_matches` on the
/// read path: a caller who authenticates with the other spelling of their own
/// DID must be able to resume their own page.
fn caller_binding(caller: Option<&str>) -> &str {
caller.map(crate::db::normalize_owner_key).unwrap_or("")
}
/// The assignee filter a token was issued under. Normalized through
/// `normalize_owner_key` so the two spellings of one `did:key` identity
/// (`did:key:X` and bare `X`) bind identically, matching `list_tasks_keyset`
/// in SQL: minting under `did:key:X` and resuming with bare `X` (or the reverse)
/// hits the same rows and must verify under the MAC.
fn assignee_binding(assignee: Option<&str>) -> &str {
assignee.map(crate::db::normalize_owner_key).unwrap_or("")
}
fn cursor_mac(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
plaintext: &[u8],
) -> HmacSha256 {
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
mac.update(TAG_DOMAIN);
// Length-prefix every field so no two distinct filter/caller/plaintext
// tuples can produce the same MAC input.
for field in [
filter.status.unwrap_or("").as_bytes(),
assignee_binding(filter.assignee_did).as_bytes(),
caller_binding(caller).as_bytes(),
plaintext,
] {
mac.update(&(field.len() as u64).to_be_bytes());
mac.update(field);
}
// Presence is distinct from emptiness for all three optional fields, so an
// anonymous token cannot collide with one issued to a caller whose
// normalized DID is the empty string.
mac.update(&[
u8::from(filter.status.is_some()),
u8::from(filter.assignee_did.is_some()),
u8::from(caller.is_some()),
]);
mac
}
/// Synthetic IV: the authentication tag over the filter and plaintext, which
/// also seeds the keystream. Deterministic by construction, so no randomness
/// source is needed and two tokens for the same page are byte-identical.
fn siv(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
plaintext: &[u8],
) -> [u8; TAG_LEN] {
let mut out = [0u8; TAG_LEN];
out.copy_from_slice(
&cursor_mac(key, filter, caller, plaintext)
.finalize()
.into_bytes()[..TAG_LEN],
);
out
}
/// XOR `buf` with the keystream for `iv`. Its own inverse, so encrypt and
/// decrypt are the same call.
fn apply_keystream(key: &TaskCursorKey, iv: &[u8; TAG_LEN], buf: &mut [u8]) {
for (block_index, chunk) in buf.chunks_mut(32).enumerate() {
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
mac.update(STREAM_DOMAIN);
mac.update(iv);
mac.update(&(block_index as u64).to_be_bytes());
let block = mac.finalize().into_bytes();
for (byte, k) in chunk.iter_mut().zip(block.iter()) {
*byte ^= k;
}
}
}
/// Mint a token resuming at `position` for `filter`, usable only by `caller`.
///
/// The serialized payload is padded with trailing whitespace to a fixed
/// bucket before the SIV tag is computed: the keystream preserves plaintext
/// length, so an unpadded payload would let the token's length leak the
/// named row's field widths (a 25-char `to_rfc3339()` rendering mints a
/// shorter token than a 35-char one). `decode` tolerates the padding because
/// `serde_json` ignores trailing whitespace, and the tag covers the padded
/// bytes, so a token minted before padding landed still verifies.
pub fn encode(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
position: &TaskPosition,
) -> String {
let mut payload = serde_json::to_vec(&CursorPayload {
t: &position.created_at,
i: &position.id,
e: chrono::Utc::now().timestamp() + CURSOR_TTL_SECS,
})
.expect("cursor payload is plain strings and an integer");
let padded_len = payload.len().next_multiple_of(PAYLOAD_BUCKET);
payload.resize(padded_len, b' ');
let iv = siv(key, filter, caller, &payload);
apply_keystream(key, &iv, &mut payload);
format!(
"{CURSOR_PREFIX}.{}.{}",
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(&payload)
)
}
/// One rejection message for every way a token can fail to verify. A caller
/// who mangled a token, replayed an expired one, or tried to move one to a
/// different filter or a different presenting identity learns only that the
/// cursor is not usable — never which of those it was, and never anything
/// about the row it named.
const INVALID_CURSOR: &str = "invalid or expired cursor";
fn reject() -> AppError {
AppError::BadRequest(INVALID_CURSOR.into())
}
/// Verify a token against `filter` and the presenting `caller`, and return the
/// position it carries.
pub fn decode(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
token: &str,
) -> crate::error::Result<TaskPosition> {
let mut parts = token.split('.');
let (Some(version), Some(iv_b64), Some(body_b64), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(reject());
};
if version != CURSOR_PREFIX {
return Err(reject());
}
let iv_bytes = URL_SAFE_NO_PAD.decode(iv_b64).map_err(|_| reject())?;
let mut plaintext = URL_SAFE_NO_PAD.decode(body_b64).map_err(|_| reject())?;
let iv: [u8; TAG_LEN] = iv_bytes.try_into().map_err(|_| reject())?;
apply_keystream(key, &iv, &mut plaintext);
// Authenticate before parsing: until the tag matches, `plaintext` is just
// attacker-chosen bytes run through a keystream. `verify_truncated_left`
// is the constant-time compare, so a forgery attempt cannot be steered by
// timing the first differing byte.
cursor_mac(key, filter, caller, &plaintext)
.verify_truncated_left(&iv)
.map_err(|_| reject())?;
let decoded: CursorPayload<'_> = serde_json::from_slice(&plaintext).map_err(|_| reject())?;
if decoded.e < chrono::Utc::now().timestamp() {
return Err(reject());
}
Ok(TaskPosition::new(decoded.t, decoded.i))
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> TaskCursorKey {
TaskCursorKey::derive(&[7u8; 32])
}
fn unfiltered() -> TaskFilter<'static> {
TaskFilter {
status: None,
assignee_did: None,
}
}
#[test]
fn round_trips_position_verbatim() {
let k = key();
// A stored timestamp the server wrote, fractional digits and all.
let pos = TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a");
let token = encode(&k, unfiltered(), None, &pos);
assert_eq!(decode(&k, unfiltered(), None, &token).unwrap(), pos);
// A sub-bucket payload pads to exactly one bucket: the padding
// claim is an exact width, and every other width assertion here is
// bucket-relative, so only an absolute check catches a pad that
// skips "next multiple" or a drifted bucket constant.
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
assert_eq!(
raw.len(),
PAYLOAD_BUCKET,
"normal token must pad to exactly one bucket"
);
}
/// The whole point of the token is that the caller may hold it without
/// learning the denied row it names. A signed-but-plaintext payload would
/// pass every other test here and still fail this one.
#[test]
fn token_does_not_expose_the_row_it_names() {
let k = key();
let token = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00+00:00", "denied-task-id"),
);
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
let as_text = String::from_utf8_lossy(&raw);
for secret in ["denied-task-id", "2026-01-03", "\"t\"", "\"i\""] {
assert!(
!as_text.contains(secret),
"token body must not carry {secret:?} in the clear: {as_text:?}"
);
}
// Also assert it is not merely reordered or whitespace-mangled JSON.
assert!(
serde_json::from_slice::<serde_json::Value>(&raw).is_err(),
"token body must not be parseable as JSON"
);
}
/// The keystream preserves plaintext length, so without the fixed-width
/// padding a token naming a 35-char timestamp would be measurably longer
/// than one naming a 32-char one: the holder would learn the withheld
/// row's field widths without decrypting anything. Mint the same shape
/// both ways and require identical token length.
#[test]
fn token_length_does_not_vary_with_position_width() {
let k = key();
// Ids are server-minted fixed-length UUIDs; the timestamp is the
// width that varies in production (`to_rfc3339()` renders 35 chars
// with nanoseconds, 25 without fractional seconds).
let id = "b3f7a1c2-4d5e-4f6a-8b9c-0d1e2f3a4b5c";
let narrow = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00+00:00", id),
);
let wide = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id),
);
assert_eq!(
narrow.len(),
wide.len(),
"tokens must not leak the named row's field widths: {narrow} vs {wide}"
);
// The padded token still decodes to the exact stored string.
assert_eq!(
decode(&k, unfiltered(), None, &wide).unwrap(),
TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id)
);
}
/// A token minted before the fixed-width padding landed (unpadded payload,
/// tag over the raw bytes) must still verify: `decode` MACs whatever
/// plaintext the keystream recovers and `serde_json` tolerates the
/// missing trailing whitespace. This pins the back-compat claim in the
/// `encode` docstring with a frozen v1 artifact, so a change to the v1
/// recipe pins (domains, MAC input, keystream, segment layout, field
/// names) breaks this test instead of silently re-deriving under the
/// new internals. It does not pin the padding: `decode` never consults
/// `PAYLOAD_BUCKET` and the fixture is unpadded, so the padding-width
/// claim belongs to `oversized_payload_rounds_up_to_next_bucket`.
///
/// Fixture expiry is fixed at 2100-01-01 (`4102444800`): it must stay in
/// the future whenever the test runs.
#[test]
fn unpadded_legacy_token_still_verifies() {
let k = key();
let filter = unfiltered();
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", "legacy-task");
// Pre-padding recipe minted once and frozen: serialize with no
// padding, tag, and encrypt. Deterministic by construction (SIV needs
// no randomness), so this literal is stable across runs.
let token = "v1.ILJyy7JZScm_h-HM1-CggA.l4kJUoHTbqSGTVKL0DP4hORnbOS1_gHYV2fwJYXd2QtM-6TBMCKh-wvluIiaEYC9rUSUPVSteAKkp7jBCmK_bR2D";
assert_eq!(
decode(&k, filter, None, token).unwrap(),
position,
"pre-padding token must verify under the current decode"
);
// The fixture is a genuine pre-padding artifact, so its decoded
// body must stay below one bucket: a literal re-minted under the
// padded recipe would retire this back-compat pin silently.
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
assert!(
raw.len() < PAYLOAD_BUCKET,
"legacy fixture must be the unpadded shape: {} bytes",
raw.len()
);
}
/// The `PAYLOAD_BUCKET` docstring promises an oversized payload rounds up
/// to the next multiple instead of failing. Mint a position whose id
/// pushes the serialized payload past one bucket and require the token
/// to round-trip (at two buckets).
#[test]
fn oversized_payload_rounds_up_to_next_bucket() {
let k = key();
// ~140-char id: payload crosses the 128-byte bucket.
let long_id = "a".repeat(140);
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", long_id.clone());
let probe = serde_json::to_vec(&CursorPayload {
t: &position.created_at,
i: &position.id,
e: 1_700_000_000,
})
.expect("cursor payload is plain strings and an integer");
assert!(
probe.len() > PAYLOAD_BUCKET,
"fixture payload must exceed one bucket: {} bytes",
probe.len()
);
assert!(
probe.len() <= 2 * PAYLOAD_BUCKET,
"fixture payload must fit in two buckets: {} bytes",
probe.len()
);
let token = encode(&k, unfiltered(), None, &position);
assert_eq!(
decode(&k, unfiltered(), None, &token).unwrap(),
position,
"oversized payload must round-trip at two buckets"
);
// The padded body must be exactly two buckets, not merely longer
// than one: the padding claim is an exact width, not a direction.
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
assert_eq!(
raw.len(),
2 * PAYLOAD_BUCKET,
"oversized payload must pad to exactly two buckets"
);
}
/// Callers paste the token straight into a query string, so it must carry
/// no character that needs percent-encoding.
#[test]
fn token_is_url_safe_verbatim() {
let token = encode(
&key(),
TaskFilter {
status: Some("pending"),
assignee_did: Some("did:key:z6MkAssignee"),
},
None,
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a"),
);
assert!(
token
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')),
"token must be query-safe without encoding: {token}"
);
}
/// Flipping any byte of a token must fail the tag, not decode to a
/// different position: the keystream is malleable on its own, the tag is
/// what stops a chosen-position forgery.
#[test]
fn rejects_any_tampered_byte() {
let k = key();
let token = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a"),
);
let parts: Vec<&str> = token.split('.').collect();
for part in [1usize, 2] {
for position in [0usize, 3] {
let mut bytes = parts[part].as_bytes().to_vec();
bytes[position] = if bytes[position] == b'A' { b'B' } else { b'A' };
let mut mangled: Vec<String> = parts.iter().map(|p| p.to_string()).collect();
mangled[part] = String::from_utf8(bytes).unwrap();
assert!(
decode(&k, unfiltered(), None, &mangled.join(".")).is_err(),
"byte {position} of part {part} must not be malleable"
);
}
}
// Sanity: the untouched token still decodes, so the loop above is not
// passing because every token is rejected.
assert!(decode(&k, unfiltered(), None, &token).is_ok());
}
#[test]
fn rejects_token_from_another_node() {
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&TaskCursorKey::derive(&[1u8; 32]), unfiltered(), None, &pos);
assert!(decode(
&TaskCursorKey::derive(&[2u8; 32]),
unfiltered(),
None,
&token
)
.is_err());
}
#[test]
fn rejects_cursor_moved_to_a_different_filter() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(
&k,
TaskFilter {
status: Some("pending"),
assignee_did: None,
},
None,
&pos,
);
assert!(decode(&k, unfiltered(), None, &token).is_err());
assert!(decode(
&k,
TaskFilter {
status: Some("claimed"),
assignee_did: None
},
None,
&token
)
.is_err());
assert!(decode(
&k,
TaskFilter {
status: Some("pending"),
assignee_did: None
},
None,
&token
)
.is_ok());
}
/// A filter of `Some("")` must not verify a token minted with `None`.
#[test]
fn distinguishes_absent_filter_from_empty_filter() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&k, unfiltered(), None, &pos);
assert!(decode(
&k,
TaskFilter {
status: Some(""),
assignee_did: None
},
None,
&token
)
.is_err());
}
/// A cursor names how far a scan got under one caller's visibility. Moved
/// to a different presenting identity it would start that caller's scan
/// past rows they are entitled to read, so it must fail to verify rather
/// than silently under-report (#327 review).
#[test]
fn rejects_cursor_moved_to_a_different_caller() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let alice = Some("did:key:z6MkAlice");
let bob = Some("did:key:z6MkBob");
let anon_token = encode(&k, unfiltered(), None, &pos);
assert!(
decode(&k, unfiltered(), alice, &anon_token).is_err(),
"an anonymously minted cursor must not resume an authenticated scan"
);
assert!(decode(&k, unfiltered(), None, &anon_token).is_ok());
let alice_token = encode(&k, unfiltered(), alice, &pos);
assert!(
decode(&k, unfiltered(), bob, &alice_token).is_err(),
"one caller's cursor must not resume another caller's scan"
);
assert!(
decode(&k, unfiltered(), None, &alice_token).is_err(),
"an authenticated cursor must not resume an anonymous scan"
);
assert!(decode(&k, unfiltered(), alice, &alice_token).is_ok());
}
/// The binding is on identity, not spelling: `did:key:X` and bare `X` are
/// one caller everywhere else on the read path (`did_matches`), so a caller
/// who presents the other form of their own DID must keep their own page.
#[test]
fn caller_binding_is_normalized_across_did_key_spellings() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&k, unfiltered(), Some("did:key:z6MkAlice"), &pos);
assert_eq!(
decode(&k, unfiltered(), Some("z6MkAlice"), &token).unwrap(),
pos
);
// A different method sharing the base58 tail is a different principal.
assert!(decode(&k, unfiltered(), Some("did:web:z6MkAlice"), &token).is_err());
}
/// The filter binding is on identity, not spelling: `assignee_did` matches
/// normalized keys in SQL (`normalize_owner_key`), so minting under
/// `did:key:X` and resuming under bare `X` (or vice versa) must succeed,
/// while `did:web:X` remains distinct.
#[test]
fn assignee_filter_binding_is_normalized_across_did_key_spellings() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let filter_did_key = TaskFilter {
status: Some("pending"),
assignee_did: Some("did:key:z6MkAssignee"),
};
let filter_bare = TaskFilter {
status: Some("pending"),
assignee_did: Some("z6MkAssignee"),
};
let filter_web = TaskFilter {
status: Some("pending"),
assignee_did: Some("did:web:z6MkAssignee"),
};
let token_from_did_key = encode(&k, filter_did_key, None, &pos);
assert_eq!(
decode(&k, filter_bare, None, &token_from_did_key).unwrap(),
pos
);
assert!(decode(&k, filter_web, None, &token_from_did_key).is_err());
let token_from_bare = encode(&k, filter_bare, None, &pos);
assert_eq!(
decode(&k, filter_did_key, None, &token_from_bare).unwrap(),
pos
);
assert!(decode(&k, filter_web, None, &token_from_bare).is_err());
}
/// Anonymous is a distinct binding from a caller whose normalized DID is
/// the empty string, the same way `Some("")` is distinct from `None` for
/// the filter fields.
#[test]
fn distinguishes_anonymous_from_empty_caller() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&k, unfiltered(), None, &pos);
assert!(decode(&k, unfiltered(), Some(""), &token).is_err());
}
/// Length-prefixing must stop a status/assignee pair from being re-split.
#[test]
fn rejects_field_boundary_shift() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(
&k,
TaskFilter {
status: Some("pend"),
assignee_did: Some("ing"),
},
None,
&pos,
);
assert!(decode(
&k,
TaskFilter {
status: Some("pending"),
assignee_did: Some("")
},
None,
&token
)
.is_err());
}
#[test]
fn rejects_expired_token() {
let k = key();
// Minted the same way `encode` does, but already past its expiry.
let mut payload = serde_json::to_vec(&CursorPayload {
t: "2026-01-03T00:00:00+00:00",
i: "task-a",
e: chrono::Utc::now().timestamp() - 1,
})
.unwrap();
let iv = siv(&k, unfiltered(), None, &payload);
apply_keystream(&k, &iv, &mut payload);
let expired = format!(
"v1.{}.{}",
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(&payload)
);
let err = decode(&k, unfiltered(), None, &expired).unwrap_err();
assert!(err.to_string().contains(INVALID_CURSOR));
}
#[test]
fn rejects_malformed_shapes() {
let k = key();
for bad in [
"",
"v1",
"v1.",
"v1.abc",
"v2.abc.def",
"v1.abc.def.ghi",
"v1.!!!.def",
] {
assert!(
decode(&k, unfiltered(), None, bad).is_err(),
"must reject {bad:?}"
);
}
}
/// Every rejection path renders the same text, so a caller cannot tell a
/// forged token from an expired one from a filter mismatch.
#[test]
fn every_rejection_is_indistinguishable() {
let k = key();
for bad in ["v1.abc.def", "not-a-cursor", "v1..", "v9.a.b"] {
assert_eq!(
decode(&k, unfiltered(), None, bad).unwrap_err().to_string(),
format!("invalid request: {INVALID_CURSOR}")
);
}
}
}
File diff suppressed because it is too large. Load diff
+16 -32
View File
@@ -85,11 +85,10 @@ pub async fn set_visibility(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<SetVisibilityRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Quarantine first (via authorize_repo_read), then owner — same posture as
// list_visibility so a quarantined repo cannot be mutated while reads 404.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
require_owner(&record, &auth.0)?;
validate_path_glob(&req.path_glob)?;
@@ -141,11 +140,8 @@ pub async fn remove_visibility(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<RemoveVisibilityRequest>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
require_owner(&record, &auth.0)?;
state
@@ -171,14 +167,12 @@ pub async fn list_visibility(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Quarantine first (via authorize_repo_read), then owner — a quarantined
// mirror must be opaque even to a caller matching owner_did.
let (record, rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
require_owner(&record, &auth.0)?;
let rules = state.db.list_visibility_rules(&record.id).await?;
let rules_json: Vec<_> = rules
.into_iter()
.map(|r| {
@@ -205,28 +199,18 @@ pub async fn list_visibility(
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
/// denied subtrees while re-including the allowed nested paths. Unlike
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
///
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
/// same posture as encrypted-blob discovery — so admission and private-subtree
/// layout are not disclosed to anon, owner, or peers.
pub async fn withheld_paths(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
// matching the git read endpoints, so this never discloses a private repo's
// existence or its path layout to an unauthorized caller.
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
== crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
let (record, rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let withheld =
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
+150 -23
View File
@@ -33,11 +33,8 @@ pub async fn create_webhook(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<(StatusCode, Json<Webhook>)> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Gate the target through the same hardened public-host validator the peer
@@ -59,7 +56,14 @@ pub async fn create_webhook(
id: Uuid::new_v4().to_string(),
repo_id: record.id,
url: req.url,
secret: req.secret,
// Seal the secret before it touches the database (#506): the column and
// any dump/backup of it hold ciphertext, never the caller's signing key.
secret: req.secret.map(|plain| {
crate::webhook_secret::WebhookSecret::new(crate::webhook_secret::seal(
state.webhook_secret_key.as_ref(),
&plain,
))
}),
events,
created_by_did,
created_at: Utc::now().to_rfc3339(),
@@ -96,19 +100,11 @@ pub async fn list_webhooks(
crate::api::require_repo_owner(&record, &caller)?;
let hooks = state.db.list_webhooks(&record.id).await?;
// Redact secrets in list response
let redacted: Vec<_> = hooks
.into_iter()
.map(|mut h| {
if h.secret.is_some() {
h.secret = Some("***".into());
}
h
})
.collect();
// `WebhookSecret` redacts itself on serialize, so a set secret is emitted as
// "***" and the signing material never appears in the body (see webhook_secret).
let count = hooks.len();
Ok(Json(
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
serde_json::json!({ "webhooks": hooks, "count": count }),
))
}
@@ -118,11 +114,8 @@ pub async fn delete_webhook(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Verify the webhook belongs to this repo
@@ -155,6 +148,7 @@ mod tests {
use crate::db::RepoRecord;
use crate::error::AppError;
use crate::test_support::test_state;
use rand::Rng;
// create_webhook gates req.url through is_public_http_url. Pin the exact
// SSRF targets from issue #81 so the webhook path can never regress to the
@@ -232,4 +226,137 @@ mod tests {
"a rejected .localhost webhook must leave no row behind"
);
}
/// The startup re-encryption pass must seal any webhook secret still resting
/// as plaintext (a row written before at-rest encryption shipped), and leave
/// a second pass with nothing to do.
#[sqlx::test]
async fn reencrypt_legacy_secrets_encrypts_existing_cleartext(pool: PgPool) {
let state = test_state(pool).await;
// Seed a webhook the way a pre-encryption node would have: plaintext
// secret written straight into the row, bypassing the handler's seal.
let hook = crate::db::Webhook {
id: Uuid::new_v4().to_string(),
repo_id: "owner/legacy-repo".to_string(),
url: "https://hooks.example.com/legacy".to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(
"legacy-plaintext".to_string(),
)),
events: vec!["*".to_string()],
created_by_did: "did:key:zLEGACYOWNER".to_string(),
created_at: Utc::now().to_rfc3339(),
active: true,
};
state.db.create_webhook(&hook).await.unwrap();
let rewritten = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(rewritten, 1, "exactly the one plaintext row is rewritten");
let stored = state.db.get_webhook(&hook.id).await.unwrap().unwrap();
let secret = stored.secret.expect("secret retained after re-encryption");
assert!(secret.is_sealed(), "re-encrypted secret must be sealed");
assert_eq!(
crate::webhook_secret::open(state.webhook_secret_key.as_ref(), secret.as_str())
.as_deref(),
Some("legacy-plaintext"),
"the node-held key must recover the original secret"
);
// Idempotent: a second pass finds nothing left to rewrite.
let again = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(again, 0);
}
/// A legacy plaintext secret that literally begins with the seal prefix is
/// not ciphertext: prefix-matching alone would skip it forever while
/// `reveal` fails to open it, wedging the hook. Classification is by
/// `open`, so the pass seals it and the literal secret survives.
#[sqlx::test]
async fn reencrypt_seals_a_prefixed_plaintext_secret(pool: PgPool) {
let state = test_state(pool).await;
let literal = format!(
"{}not-a-real-ciphertext",
crate::webhook_secret::SEAL_PREFIX
);
let hook = crate::db::Webhook {
id: Uuid::new_v4().to_string(),
repo_id: "owner/prefixed-repo".to_string(),
url: "https://hooks.example.com/prefixed".to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(literal.clone())),
events: vec!["*".to_string()],
created_by_did: "did:key:zLEGACYOWNER".to_string(),
created_at: Utc::now().to_rfc3339(),
active: true,
};
state.db.create_webhook(&hook).await.unwrap();
let rewritten = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(rewritten, 1, "the prefixed plaintext row is rewritten");
let stored = state.db.get_webhook(&hook.id).await.unwrap().unwrap();
let secret = stored.secret.unwrap();
assert!(secret.is_sealed());
assert_eq!(
crate::webhook_secret::reveal(state.webhook_secret_key.as_ref(), &secret).as_deref(),
Some(literal.as_str()),
"the literal plaintext, prefix included, is the recovered secret"
);
}
/// A well-formed sealed payload that does not open under this node's key
/// (foreign or rotated ciphertext) must survive the re-encrypt pass
/// untouched: re-wrapping it would make `reveal` return the outer blob as
/// a signing secret and flip a broken hook from withheld into garbage
/// signatures.
#[sqlx::test]
async fn reencrypt_leaves_foreign_ciphertext_untouched(pool: PgPool) {
let state = test_state(pool).await;
let foreign_key: [u8; 32] = chacha20poly1305::aead::OsRng.gen();
let foreign_ciphertext = crate::webhook_secret::seal(&foreign_key, "owner-real-secret");
let hook = crate::db::Webhook {
id: Uuid::new_v4().to_string(),
repo_id: "owner/foreign-repo".to_string(),
url: "https://hooks.example.com/foreign".to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(
foreign_ciphertext.clone(),
)),
events: vec!["*".to_string()],
created_by_did: "did:key:zLEGACYOWNER".to_string(),
created_at: Utc::now().to_rfc3339(),
active: true,
};
state.db.create_webhook(&hook).await.unwrap();
let rewritten = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(rewritten, 0, "foreign ciphertext is left unchanged");
let stored = state.db.get_webhook(&hook.id).await.unwrap().unwrap();
let secret = stored.secret.unwrap();
assert_eq!(
secret.as_str(),
foreign_ciphertext,
"the stored value is still the unopenable ciphertext, not a re-wrap"
);
assert!(
crate::webhook_secret::reveal(state.webhook_secret_key.as_ref(), &secret).is_none(),
"delivery still withholds: reveal cannot open the foreign blob"
);
}
}
+556 -14
View File
@@ -17,16 +17,74 @@ use crate::state::AppState;
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
/// A UCAN that passed full chain validation, with the root issuer the chain
/// rests on. Inserted into request extensions by [`require_ucan_chain`] when
/// `X-Ucan` is present; absent when the header is.
///
/// `root` is carried rather than recomputed so the chain is walked once per
/// request. Holding this is not itself an authorization decision — a caller must
/// still compare `root` against an identity it independently trusts, because
/// `did:key` is self-certifying and anyone can mint a chain that verifies.
#[derive(Clone, Debug)]
pub struct VerifiedUcan {
pub ucan: Ucan,
pub root: Did,
}
/// Whether `caller` is authorized to push to `record`.
///
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
/// intentionally a distinct, intent-named gate rather than a bare owner check so
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
/// call sites.
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
/// The repo owner, or a caller presenting a verified UCAN whose chain roots at
/// that owner and which carries `git/push` for this repo.
///
/// `verified` is optional because `X-Ucan` is: a push carrying no token reaches
/// the same owner-only decision it always did. The owner check is unconditional
/// and runs first, so this can only ever turn a refusal into an acceptance,
/// never the reverse.
pub fn caller_authorized_to_push(
record: &crate::db::RepoRecord,
caller: &str,
verified: Option<&VerifiedUcan>,
) -> bool {
crate::api::did_matches(caller, &record.owner_did)
|| verified.is_some_and(|v| ucan_grants_push(record, v))
}
/// Whether a verified UCAN authorizes a push to `record`.
///
/// Three conditions, all required:
/// 1. The chain roots at this repo's owner. This is the trust anchor — the
/// repo record is data the node holds independently of the token, so a
/// self-minted chain cannot satisfy it.
/// 2. Every link is bounded. There is no revocation path, so an unbounded link
/// is a permanent grant.
/// 3. Every link — the leaf and every proof behind it — carries an
/// unconstrained push-class capability naming this repository. That is
/// [`gitlawb_core::ucan::Ucan::chain_grants_push_to`], the same rule
/// `gl ucan import` applies before storing a token and `git-remote-gitlawb`
/// applies before minting an invocation, so what one boundary accepts the
/// next does not refuse.
///
/// (3) walks the whole chain on purpose. Refusing a wildcard leaf alone did
/// nothing: `is_attenuated_by` accepts a concrete child under a `*` parent, and
/// that narrowing was exactly what the helper performed, so one owner-issued
/// `with: "*"` proof let a delegate mint a concrete leaf for ANY repository the
/// owner had — or created later — and both the chain check and the owner-root
/// check passed. A delegation's scope is fixed when it is issued.
pub fn ucan_grants_push(record: &crate::db::RepoRecord, verified: &VerifiedUcan) -> bool {
if !crate::api::did_matches(&verified.root.to_string(), &record.owner_did) {
return false;
}
// A write capability must lapse on its own. `exp` is optional in the format and
// there is no revocation path, so a chain with an unbounded link is a permanent
// grant: once the token leaks, the owner cannot withdraw it short of rotating
// the DID the repo is keyed on. Refusing here is what makes "the damage window
// is the token's expiry" a true statement rather than an aspiration.
if !verified.ucan.chain_lifetime_is_bounded() {
return false;
}
verified
.ucan
.chain_grants_push_to(&record.owner_did, &record.name)
}
use gitlawb_core::http_sig::{
@@ -355,7 +413,7 @@ fn validate_ucan_chain(
token: &str,
expected_aud: &Did,
signer_did: &Did,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
let ucan = Ucan::decode(token).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
@@ -383,14 +441,14 @@ fn validate_ucan_chain(
)
})?;
ucan.verify_chain().map_err(|e| {
let root = ucan.verify_chain().map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
Ok(())
Ok(VerifiedUcan { ucan, root })
}
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
@@ -443,11 +501,18 @@ pub async fn require_ucan_chain(
}
};
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
return (status, body).into_response();
}
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
Ok(v) => v,
Err((status, body)) => return (status, body).into_response(),
};
tracing::debug!(did = %signer_did, "UCAN chain validated");
tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated");
// Park the verified token where a handler can reach it. Validation alone
// grants nothing; the authorization decision is made downstream, by a caller
// that knows which identity it trusts for the resource being touched.
let mut request = request;
request.extensions_mut().insert(verified);
next.run(request).await
}
@@ -483,6 +548,37 @@ mod tests {
Ucan::bootstrap(node, agent_did).unwrap()
}
/// The middleware validated a token and threw the result away, so no handler
/// could ever read it and `Ucan::can` had no call site in the node. Validation
/// must hand back both the token and the root the chain rests on.
#[test]
fn validate_ucan_chain_hands_back_the_root_and_the_token() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let delegation =
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
.expect("wrap invocation");
let token = invocation.encode().expect("encode");
let verified = validate_ucan_chain(&token, &node.did(), &agent.did())
.expect("a well-formed owner-rooted invocation must validate");
assert_eq!(
verified.root,
owner.did(),
"the root must be the owner, so a caller can anchor against the repo record"
);
assert_eq!(
verified.ucan.payload.iss,
agent.did(),
"the token itself must come back so a caller can read its capabilities"
);
}
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
Ucan::delegate(
agent,
@@ -628,16 +724,25 @@ mod tests {
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let webhook_secret_key = crate::state::AppState::derive_webhook_secret_key(&keypair);
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
.expect("lazy pool creation should not fail");
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
let task_cursor_key = crate::api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
let schema = Arc::new(graphql::build_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
task_cursor_key.clone(),
));
let ws_schema = Arc::new(graphql::build_ws_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
task_cursor_key.clone(),
));
crate::state::AppState {
config: Arc::new(Config::parse_from(["gitlawb-node"])),
@@ -649,10 +754,13 @@ mod tests {
ref_update_tx: ref_tx,
task_event_tx: task_tx,
graphql_schema: schema,
graphql_ws_schema: ws_schema,
task_cursor_key,
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
write_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
@@ -664,6 +772,7 @@ mod tests {
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),
webhook_secret_key: Arc::new(webhook_secret_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
@@ -684,6 +793,7 @@ mod tests {
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),
task_read_rate_limiter: RateLimiter::new(1200, Duration::from_secs(3600)),
git_bin: "git".to_string(),
}
}
@@ -850,3 +960,435 @@ mod tests {
);
}
}
#[cfg(test)]
mod ucan_push_tests {
use super::*;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
const OWNER_KEY: &str = "z6MkOwnerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
/// `RepoRecord` does not derive `Default`, and adding the derive to a
/// production DB type purely to serve a test is the wrong direction.
fn repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
crate::db::RepoRecord {
id: "repo-id".to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/unused".to_string(),
forked_from: None,
machine_id: None,
}
}
/// The token's own issuer and audience are irrelevant to this predicate: the
/// middleware has already bound `iss` to the request signer and `aud` to this
/// node. Only the capabilities and the chain's root matter here.
fn verified(root: &str, caps_vec: Vec<Capability>) -> VerifiedUcan {
verified_with_exp(
root,
caps_vec,
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
)
}
fn verified_with_exp(
root: &str,
caps_vec: Vec<Capability>,
exp: Option<chrono::DateTime<chrono::Utc>>,
) -> VerifiedUcan {
let agent = Keypair::generate();
let node = Keypair::generate();
let ucan = Ucan::issue(&agent, node.did(), caps_vec, exp).expect("issue");
VerifiedUcan {
ucan,
root: root.parse().expect("root DID must parse"),
}
}
fn owner_full() -> String {
format!("did:key:{OWNER_KEY}")
}
fn push_cap_for(owner: &str, name: &str) -> Capability {
Capability::new(format!("gitlawb://repos/{owner}/{name}"), caps::GIT_PUSH)
}
#[test]
fn grants_push_when_the_chain_roots_at_the_owner_and_names_the_repo() {
let rec = repo(&owner_full(), "myrepo");
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
assert!(ucan_grants_push(&rec, &v));
}
#[test]
fn matches_a_bare_owner_key_against_a_full_did_record() {
// Mirror rows store the bare key. A literal string compare would fail
// here, denying a delegation that is in fact valid.
let rec = repo(OWNER_KEY, "myrepo");
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
assert!(ucan_grants_push(&rec, &v));
}
/// A perpetual grant is refused even when it is otherwise perfectly valid:
/// owner-rooted, right repo, right action. Without revocation, an unbounded
/// delegation cannot be withdrawn once it leaks.
#[test]
fn refuses_a_delegation_that_never_expires() {
let rec = repo(&owner_full(), "myrepo");
let v = verified_with_exp(
&owner_full(),
vec![push_cap_for(&owner_full(), "myrepo")],
None,
);
assert!(!ucan_grants_push(&rec, &v));
}
#[test]
fn refuses_a_self_minted_root() {
// The whole point: a token nobody delegated grants nothing, however
// permissive its capabilities look.
let stranger = Keypair::generate();
let rec = repo(&owner_full(), "myrepo");
let v = verified(&stranger.did().to_string(), vec![Capability::new("*", "*")]);
assert!(!ucan_grants_push(&rec, &v));
}
#[test]
fn refuses_a_capability_for_a_different_repo() {
let rec = repo(&owner_full(), "myrepo");
let v = verified(
&owner_full(),
vec![push_cap_for(&owner_full(), "otherrepo")],
);
assert!(!ucan_grants_push(&rec, &v));
}
/// A delegation can be issued against the remote's spelling (`myrepo.git`):
/// the matcher strips the same single `.git` the smart-HTTP routes do, so
/// the capability lands on the canonical record name.
#[test]
fn grants_push_when_the_capability_carries_the_git_suffix() {
let rec = repo(&owner_full(), "myrepo");
let v = verified(
&owner_full(),
vec![push_cap_for(&owner_full(), "myrepo.git")],
);
assert!(ucan_grants_push(&rec, &v));
}
/// Mirror-created rows can carry a literal `.git` name; reaching one takes
/// `foo.git.git`, the same spelling the remote URL uses. A single `.git`
/// capability resolves to `foo` and must not fall through to `foo.git`.
#[test]
fn a_repo_literally_named_dot_git_needs_the_doubled_suffix() {
let rec = repo(&owner_full(), "foo.git");
let single = verified(&owner_full(), vec![push_cap_for(&owner_full(), "foo.git")]);
assert!(!ucan_grants_push(&rec, &single));
let doubled = verified(
&owner_full(),
vec![push_cap_for(&owner_full(), "foo.git.git")],
);
assert!(ucan_grants_push(&rec, &doubled));
}
#[test]
fn refuses_a_capability_carrying_constraints() {
// `nb` is not interpreted yet. An owner who writes {"refs": [...]} means
// to restrict; honouring the capability while ignoring nb would grant
// strictly more than they intended, so it authorizes nothing.
let rec = repo(&owner_full(), "myrepo");
let v = verified(
&owner_full(),
vec![push_cap_for(&owner_full(), "myrepo")
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }))],
);
assert!(!ucan_grants_push(&rec, &v));
}
#[test]
fn refuses_a_non_push_capability() {
let rec = repo(&owner_full(), "myrepo");
let v = verified(
&owner_full(),
vec![Capability::new(
format!("gitlawb://repos/{}/myrepo", owner_full()),
caps::ISSUE_CREATE,
)],
);
assert!(!ucan_grants_push(&rec, &v));
}
/// A resource wildcard must NOT authorize a push, even though attenuation
/// accepts it. The helper narrows a `*` delegation before signing, but the node
/// cannot rely on that: a delegate can sign an invocation that keeps the
/// wildcard, and it would otherwise reach every repository the owner has — or
/// will later create. This test previously asserted the opposite.
#[test]
fn refuses_a_resource_wildcard_and_honours_repo_admin() {
let rec = repo(&owner_full(), "myrepo");
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
assert!(
!ucan_grants_push(&rec, &wildcard),
"a wildcard resource must not authorize a push at the node"
);
// The ACTION wildcard is a different axis and stays: attenuation bounds it,
// and it still has to name a concrete repository.
let action_wildcard = verified(
&owner_full(),
vec![Capability::new(
format!("gitlawb://repos/{}/myrepo", owner_full()),
"*",
)],
);
assert!(ucan_grants_push(&rec, &action_wildcard));
let admin = verified(
&owner_full(),
vec![Capability::new(
format!("gitlawb://repos/{}/myrepo", owner_full()),
caps::REPO_ADMIN,
)],
);
assert!(ucan_grants_push(&rec, &admin));
}
/// The attack the wildcard refusal exists to stop: one `*` delegation reaching a
/// repository it was never issued against, including one created afterwards.
#[test]
fn a_wildcard_delegation_cannot_reach_a_second_repository() {
let other = repo(&owner_full(), "a-repo-created-later");
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
assert!(!ucan_grants_push(&other, &wildcard));
}
/// The round-8 P1, executed rather than reasoned: an owner-issued `*` PROOF with
/// a concrete leaf for a repository that did not exist at issuance. Refusing a
/// wildcard *leaf* did nothing here — `is_attenuated_by` accepts a concrete child
/// under a `*` parent, and that narrowing is exactly what `build_invocation` did,
/// so the first-party helper was the working mint path.
#[test]
fn a_wildcard_proof_cannot_reach_a_repo_created_later() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let parent = Ucan::issue(
&owner,
agent.did(),
vec![Capability::new("*", caps::GIT_PUSH)],
Some(hour),
)
.unwrap();
let later = format!("gitlawb://repos/{}/a-repo-created-later", owner.did());
let invocation = Ucan::delegate(
&agent,
node.did(),
vec![Capability::new(&later, caps::GIT_PUSH)],
Some(hour),
&parent,
)
.unwrap();
let root = invocation.verify_chain().expect("the chain still verifies");
let rec = repo(&owner.did().to_string(), "a-repo-created-later");
assert!(
!ucan_grants_push(
&rec,
&VerifiedUcan {
ucan: invocation,
root
}
),
"a wildcard proof must not authorize a repo it never named"
);
}
/// The shipping flow must keep working: a proof that names the repository
/// authorizes a push to it. Guards against fixing the wildcard by refusing
/// everything with a `prf`.
#[test]
fn a_concrete_proof_still_authorizes_the_repo_it_names() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
let parent = Ucan::issue(
&owner,
agent.did(),
vec![Capability::new(&resource, caps::GIT_PUSH)],
Some(hour),
)
.unwrap();
let invocation = Ucan::delegate(
&agent,
node.did(),
vec![Capability::new(&resource, caps::GIT_PUSH)],
Some(hour),
&parent,
)
.unwrap();
let root = invocation.verify_chain().expect("chain verifies");
let rec = repo(&owner.did().to_string(), "myrepo");
assert!(ucan_grants_push(
&rec,
&VerifiedUcan {
ucan: invocation,
root
}
));
}
/// A `*` two links up. The immediate proof names the repository, so a walk that
/// checks only one level of `prf` stays green here — this is the case that
/// makes the recursion load-bearing rather than incidental.
#[test]
fn a_wildcard_grandparent_cannot_reach_the_repo_through_a_concrete_parent() {
let owner = Keypair::generate();
let intermediary = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
let grandparent = Ucan::issue(
&owner,
intermediary.did(),
vec![Capability::new("*", caps::GIT_PUSH)],
Some(hour),
)
.unwrap();
let parent = Ucan::delegate(
&intermediary,
agent.did(),
vec![Capability::new(&resource, caps::GIT_PUSH)],
Some(hour),
&grandparent,
)
.unwrap();
let invocation = Ucan::delegate(
&agent,
node.did(),
vec![Capability::new(&resource, caps::GIT_PUSH)],
Some(hour),
&parent,
)
.unwrap();
let root = invocation.verify_chain().expect("chain verifies");
assert_eq!(root, owner.did());
let rec = repo(&owner.did().to_string(), "myrepo");
assert!(
!ucan_grants_push(
&rec,
&VerifiedUcan {
ucan: invocation,
root
}
),
"a wildcard anywhere in the chain must deny, not just in the immediate proof"
);
}
/// And the same three-link shape with every link concrete must still grant, so
/// the walk is refusing the wildcard and not the depth.
#[test]
fn a_three_link_concrete_chain_still_authorizes() {
let owner = Keypair::generate();
let intermediary = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
let grandparent = Ucan::issue(&owner, intermediary.did(), cap(), Some(hour)).unwrap();
let parent =
Ucan::delegate(&intermediary, agent.did(), cap(), Some(hour), &grandparent).unwrap();
let invocation = Ucan::delegate(&agent, node.did(), cap(), Some(hour), &parent).unwrap();
let root = invocation.verify_chain().unwrap();
let rec = repo(&owner.did().to_string(), "myrepo");
assert!(ucan_grants_push(
&rec,
&VerifiedUcan {
ucan: invocation,
root
}
));
}
/// The round-ten P1, pinned where it bites. The middleware verifies the chain
/// on the `X-Ucan` header of any signed request before the scope walk runs,
/// so a depth bound that lived only in `chain_grants_push_to` protected
/// nothing: a hand-built header reached the unbounded recursion first. A
/// chain at `MAX_CHAIN_DEPTH` links validates; one link more is refused here,
/// with 401 and a message that names depth, before any authorization runs.
#[test]
fn validate_ucan_chain_stops_at_the_depth_bound() {
use gitlawb_core::ucan::MAX_CHAIN_DEPTH;
let owner = Keypair::generate();
let node = Keypair::generate();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
// owner -> k1 -> ... -> signer: MAX_CHAIN_DEPTH - 1 links, all concrete,
// all signed, all expiring. Only the length is in question.
let mut signer = Keypair::generate();
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour)).unwrap();
for _ in 2..MAX_CHAIN_DEPTH {
let next = Keypair::generate();
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
signer = next;
}
// Wrapped into an invocation for the node: exactly MAX_CHAIN_DEPTH links.
let at_bound = Ucan::delegate(&signer, node.did(), cap(), Some(hour), &cur).unwrap();
let verified = validate_ucan_chain(&at_bound.encode().unwrap(), &node.did(), &signer.did())
.expect("a chain at the bound must still validate");
assert_eq!(verified.root, owner.did());
// One more link and it is past the bound.
let next = Keypair::generate();
let deeper = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
let past_bound = Ucan::delegate(&next, node.did(), cap(), Some(hour), &deeper).unwrap();
let (status, body) =
match validate_ucan_chain(&past_bound.encode().unwrap(), &node.did(), &next.did()) {
Ok(_) => panic!("a chain past the bound must be refused, not walked"),
Err(refusal) => refusal,
};
assert_eq!(status, StatusCode::UNAUTHORIZED);
let message = body.0["message"].as_str().unwrap_or_default();
assert!(
message.contains("deeper than"),
"the refusal must name depth as the reason: {message}"
);
}
#[test]
fn refuses_a_malformed_resource_uri() {
let rec = repo(&owner_full(), "myrepo");
for bad in [
"",
"myrepo",
"https://repos/x/myrepo",
"gitlawb://repos/myrepo",
] {
let v = verified(&owner_full(), vec![Capability::new(bad, caps::GIT_PUSH)]);
assert!(!ucan_grants_push(&rec, &v), "{bad} must not grant push");
}
}
}
+124 -8
View File
@@ -11,6 +11,8 @@ use uuid::Uuid;
use crate::db::RefCertificate;
use crate::state::AppState;
/// Issue a signed ref-update certificate for a successful push. The
/// live receive-pack handler calls this on every successful push.
/// Build the v1 signing payload — the EXACT bytes the node signs and
/// gl's `verify_signature` reconstructs. This is the single source
/// of truth shared by `issue_ref_certificate` and the frozen
@@ -81,8 +83,36 @@ pub(crate) fn v2_signing_payload(
/// Issue a signed ref-update certificate for a successful push.
///
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
/// persists the certificate, and returns it.
/// `cert_id` is the deterministic id derived from `(request_id,
/// ref_name)` (see [`crate::db::ref_cert_id_for`]). It is required so
/// the recovery drain and the live handler produce the same primary
/// key: a live push followed by a recovery pass collapses to a
/// single cert row, and a re-push to the same `(repo, ref)` updates
/// the existing row's `old_sha` / `new_sha` / `pusher_did` /
/// `issued_at` / `signature` to the new transition while preserving
/// the original `id` (the `insert_ref_certificate` upsert is
/// keyed on `(repo_id, ref_name)` and only updates fields when the
/// new `issued_at` is strictly greater).
///
/// #26 Split PR 1 P1-B: the live handler routes through this
/// function (the upsert). After the reviewer-1 round-2 fix, the
/// recovery drain also routes through this function (P1: refresh a
/// stale cert), so both paths use the same deterministic `cert_id`
/// and the same upsert. A re-pass is always safe:
///
/// - Live handler → live upsert: re-push updates the row, preserves
/// the original `id`. The contract pinned by
/// `insert_ref_certificate_upserts_on_repo_ref` is restored.
/// - Live handler → recovery: live's `ON CONFLICT (id) DO UPDATE`
/// preserves the original `id`; the recovery's same upsert is
/// a no-op for an equal-`issued_at` re-run and a refresh for a
/// strictly-newer one.
/// - Recovery → live handler: the recovery wrote a row with the
/// deterministic `id`; the live upsert (which preserves `id` and
/// only updates other fields when `issued_at` is strictly newer)
/// is a no-op for an equal-`issued_at` re-run and a refresh for
/// a strictly-newer one.
#[allow(dead_code)] // round-trip test in db/mod.rs pins the upsert contract; the live path and the drain use issue_ref_certificate_with_issued_at
pub async fn issue_ref_certificate(
state: &AppState,
repo_id: &str,
@@ -90,9 +120,92 @@ pub async fn issue_ref_certificate(
old_sha: &str,
new_sha: &str,
pusher_did: &str,
cert_id: &str,
) -> Result<RefCertificate> {
issue_ref_certificate_with_issued_at(
state, repo_id, ref_name, old_sha, new_sha, pusher_did, cert_id, None,
)
.await
}
/// #26 Split PR 1 round 4 — variant that lets the caller stamp the
/// cert's `issued_at` with a transition-time timestamp instead of
/// `Utc::now()`. The recovery drain passes the persisted
/// `row.created_at` so a replay after a later live cert does not
/// outrank the live cert in the `EXCLUDED.issued_at >
/// ref_certificates.issued_at` upsert guard.
///
/// The live handler uses the default `issue_ref_certificate` (no
/// override), which keeps `Utc::now()` — the reviewer's invariant
/// is that `issued_at` reflects the transition time, and for a
/// live push the transition time and the wall-clock are the same.
///
/// `issued_at_override` is honored verbatim; passing a value not in
/// RFC 3339 form is a logic bug (the upsert will mis-order), so
/// callers must use the row's persisted `created_at`.
///
/// # Clippy allow — too many arguments
/// This is the explicit "stamp a transition-time `issued_at`"
/// variant of `issue_ref_certificate`. The drain
/// (`durable_outbox::derive_one`) is the in-crate caller; the
/// test `replay_of_stale_row_does_not_overwrite_live_cert_b` pins
/// the contract that a recovery replay's `issued_at` does NOT
/// outrank a later live cert. Adding a struct-arg would be a
/// larger refactor for two callers (live + drain) and obscure the
/// parallel to `issue_ref_certificate` (which is `#[allow]`'d for
/// the same reason historically).
#[allow(clippy::too_many_arguments)]
pub async fn issue_ref_certificate_with_issued_at(
state: &AppState,
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher_did: &str,
cert_id: &str,
issued_at_override: Option<String>,
) -> Result<RefCertificate> {
let cert = build_ref_certificate(
state,
repo_id,
ref_name,
old_sha,
new_sha,
pusher_did,
Some(cert_id.to_string()),
issued_at_override,
)
.await?;
state.db.insert_ref_certificate(&cert).await
}
/// Shared cert construction: build the JSON payload, sign it with the
/// node key, and assemble the `RefCertificate` row. `cert_id_override`
/// lets the recovery path plug in a deterministic id; the live path
/// passes `None` and gets a fresh UUID. `issued_at_override` lets
/// the recovery path stamp the cert with the original transition
/// time so the upsert's `issued_at > issued_at` guard correctly
/// orders transitions regardless of write order.
#[allow(clippy::too_many_arguments)]
async fn build_ref_certificate(
state: &AppState,
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher_did: &str,
cert_id_override: Option<String>,
issued_at_override: Option<String>,
) -> Result<RefCertificate> {
let node_did = state.node_did.to_string();
let issued_at = Utc::now().to_rfc3339();
// P1 (reviewer-1 round 4): when the caller passes a transition-
// time `issued_at` (the recovery drain passes `row.created_at`),
// use it verbatim so the upsert's per-column guard
// `EXCLUDED.issued_at > ref_certificates.issued_at` correctly
// orders transitions regardless of write order. The live handler
// passes `None` and gets `Utc::now()` — for a live push the
// transition time and the wall-clock are the same.
let issued_at = issued_at_override.unwrap_or_else(|| Utc::now().to_rfc3339());
// Build the canonical signing payload via the shared builder so
// the frozen-vector test below and the live signer cannot drift
@@ -104,8 +217,9 @@ pub async fn issue_ref_certificate(
let signature = state.node_keypair.sign_b64(&payload_bytes);
let id = cert_id_override.unwrap_or_else(|| Uuid::new_v4().to_string());
let cert = RefCertificate {
id: Uuid::new_v4().to_string(),
id,
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
@@ -376,14 +490,16 @@ mod v1_payload_tests {
let old = "0".repeat(40);
let new = "a".repeat(40);
let pusher = "did:key:z6MkPusher";
let cert_id = "cert-issuer-observe";
// Drive the live issuer and read the persisted row back by
// id, so the test covers the stored `version` column and
// not just the in-process return value.
let cert =
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
.await
.expect("issue_ref_certificate must succeed");
let cert = crate::cert::issue_ref_certificate(
&state, repo_id, ref_name, &old, &new, pusher, cert_id,
)
.await
.expect("issue_ref_certificate must succeed");
// The cert returned by the issuer is the source of truth
// for both the version claim and the signed bytes.
+229 -9
View File
@@ -1,4 +1,4 @@
use clap::Parser;
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
/// Upper bound on `git_service_timeout_secs`, `ipfs_request_budget_secs`, and
@@ -28,18 +28,45 @@ use std::path::{Path, PathBuf};
/// panicked are rejected.
pub const GIT_SERVICE_TIMEOUT_SECS_MAX: u64 = 100 * 365 * 24 * 60 * 60;
/// Optional admin subcommands. When none is given, the binary runs the node
/// daemon as before — the default (no-subcommand) startup path is unchanged.
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Dry-run (default) or, with --execute, delete empty spam-burst repos owned
/// by the known burst DID. Never touches the hard-excluded DIDs, and verifies
/// each repo is empty (zero git refs) per repo before selecting it.
PurgeSpam {
/// Actually delete the candidates. Omit for a dry-run that prints the
/// candidate list and deletes nothing.
#[arg(long, default_value_t = false)]
execute: bool,
},
}
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "Twigpine node daemon", version)]
pub struct Config {
/// Directory where bare git repositories are stored
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
/// Admin subcommand to run instead of the node daemon. Absent = run the node.
#[command(subcommand)]
pub command: Option<Command>,
/// Directory where bare git repositories are stored. `global` so it can
/// follow a subcommand (e.g. `gitlawb-node purge-spam --repos-dir …`).
#[arg(
long,
env = "GITLAWB_REPOS_DIR",
default_value = "./data/repos",
global = true
)]
pub repos_dir: PathBuf,
/// PostgreSQL connection URL (Supabase or any Postgres instance)
/// PostgreSQL connection URL (Supabase or any Postgres instance). `global` so
/// admin subcommands accept it in either position.
#[arg(
long,
env = "DATABASE_URL",
default_value = "postgresql://localhost/gitlawb"
default_value = "postgresql://localhost/gitlawb",
global = true
)]
pub database_url: String,
@@ -152,6 +179,46 @@ pub struct Config {
#[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)]
pub auto_sync: bool,
/// Periodically fetch configured INBOUND repositories from their external
/// HTTPS upstream. Disabled by default: enabling this also requires
/// `GITLAWB_ENFORCE_OWNER_PUSH=true`, so an upstream-authoritative mirror
/// cannot accept writes from arbitrary signed identities between fetches.
#[arg(long, env = "GITLAWB_UPSTREAM_MIRROR_ENABLED", default_value_t = false)]
pub upstream_mirror_enabled: bool,
/// Pause between complete upstream-mirror scans. A scan is sequential and
/// paginated; the pause begins only after the prior scan has finished, so a
/// slow forge cannot create overlapping worker cycles.
#[arg(
long,
env = "GITLAWB_UPSTREAM_MIRROR_INTERVAL_SECS",
default_value_t = 300,
value_parser = clap::value_parser!(u64).range(5..=86_400)
)]
pub upstream_mirror_interval_secs: u64,
/// Maximum wall-clock time for one external `git fetch`. The child runs in
/// its own process group and is fully reaped on timeout. The preflight
/// URL-rewrite and local-config probes run on their own short bounds and
/// do not consume this budget.
#[arg(
long,
env = "GITLAWB_UPSTREAM_MIRROR_FETCH_TIMEOUT_SECS",
default_value_t = 600,
value_parser = clap::value_parser!(u64).range(1..=GIT_SERVICE_TIMEOUT_SECS_MAX)
)]
pub upstream_mirror_fetch_timeout_secs: u64,
/// Number of durable INBOUND rows read per database page. Every page is
/// processed before the scan sleeps; this bounds query memory without
/// permanently starving repositories beyond the first page.
#[arg(
long,
env = "GITLAWB_UPSTREAM_MIRROR_PAGE_SIZE",
default_value_t = 100,
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_000)
)]
pub upstream_mirror_page_size: usize,
/// Enable the periodic reconciliation sweep that re-derives pin/seal sets
/// and fills durability gaps. Defaults to true; set to false to disable
/// the sweep even when a pin backend (IPFS/Pinata) is configured.
@@ -208,7 +275,7 @@ pub struct Config {
/// Tigris (S3-compatible) bucket for repo storage.
/// Leave empty to disable Tigris and use local-only storage.
#[arg(long, env = "GITLAWB_TIGRIS_BUCKET", default_value = "")]
#[arg(long, env = "GITLAWB_TIGRIS_BUCKET", default_value = "", global = true)]
pub tigris_bucket: String,
/// Maximum pack body size for git-receive-pack and git-upload-pack, in bytes.
@@ -238,12 +305,35 @@ pub struct Config {
#[arg(long, env = "GITLAWB_METRICS_ADDR", default_value = "")]
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.
/// Shutdown grace setting, in seconds. Currently not enforced: the value
/// is logged at startup and axum drains in-flight requests on the shutdown
/// signal with no upper bound. Default: 30s.
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
pub shutdown_grace_secs: u64,
/// Maximum number of creation requests (repo create, issue create, PR
/// create, register, fork) allowed per DID within the rate-limit window.
/// 0 is rejected at parse time: it would silently disable the limiter
/// (RateLimiter treats max_requests == 0 as allow-all), not block every
/// creation request.
#[arg(
long,
env = "GITLAWB_RATE_LIMIT_MAX",
default_value_t = 10,
value_parser = clap::value_parser!(u32).range(1..)
)]
pub rate_limit_max_requests: u32,
/// Rate-limit window size, in seconds, over which
/// `rate_limit_max_requests` applies. 0 is rejected at parse time: every
/// timestamp would prune immediately, silently disabling the limiter.
#[arg(
long,
env = "GITLAWB_RATE_LIMIT_WINDOW_SECS",
default_value_t = 3600,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub rate_limit_window_secs: u64,
/// Maximum wall-clock time a single served git operation (upload-pack /
/// receive-pack through `run_git_service`) may run before it is aborted and
/// its process group torn down, in seconds. Bounds a git that neither
@@ -773,6 +863,60 @@ pub struct Config {
value_parser = clap::builder::RangedU64ValueParser::<u64>::new().range(0..=86_400)
)]
pub pin_repair_sweep_delay_secs: u64,
/// #26 Split PR 1 step 4 — receive-pack queue retention window.
/// Terminal `complete` and `rejected_at_git` rows older than
/// this are eligible for the periodic purge. `quarantined`
/// rows are never purged on a timer. The v30 partial index
/// `idx_receive_pack_requests_completed_at` keeps the scan
/// cheap regardless of the value.
#[arg(
long,
env = "GITLAWB_QUEUE_RETENTION_DAYS",
default_value_t = 7,
value_parser = clap::builder::RangedU64ValueParser::<i64>::new().range(1..=365)
)]
pub queue_retention_days: i64,
/// #26 Split PR 1 step 4 — receive-pack queue purge batch size.
/// Each periodic purge pass deletes at most this many terminal
/// rows per batch. The drain and the purge share the same
/// `DRAIN_PER_PASS_LIMIT` budget; see the spawn function in
/// `main.rs` for the wiring.
#[arg(
long,
env = "GITLAWB_QUEUE_PURGE_BATCH",
default_value_t = 1000,
value_parser = clap::builder::RangedU64ValueParser::<i64>::new().range(1..=100_000)
)]
pub queue_purge_batch: i64,
/// #26 Split PR 1 step 5 — effects-executor retry bound. The
/// drain flips a request to `quarantined` after this many
/// `EffectsOutcome::Retry` returns, closing the
/// infinite-retry DoS window. Default 8 matches the spec; set
/// lower in tests.
#[arg(
long,
env = "GITLAWB_EFFECTS_MAX_ATTEMPTS",
default_value_t = 8,
value_parser = clap::builder::RangedU64ValueParser::<i32>::new().range(1..=1000)
)]
pub effects_max_attempts: i32,
/// Per-client-IP rate limit for the anonymous task read routes
/// (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`), in requests per hour.
/// Both are publicly reachable (`optional_signature`). `GET /api/v1/tasks`
/// runs `collect_visible_tasks` and returns a visibility-filtered page;
/// `GET /api/v1/tasks/{id}` runs `get_visible_task` and returns an opaque 404
/// when the task is hidden or missing. These reads can require task,
/// repository, and visibility-rule queries even when no task is returned,
/// so the brake bounds the cost of anonymous probes. Keyed on the resolved
/// client IP via `GITLAWB_TRUSTED_PROXY`. `0` disables. Default: 1200 (a list page
/// followed by per-task reads is a normal client pattern, so this sits above
/// the `/ipfs` budget). GraphQL `tasks` / `task` queries, including WebSocket
/// operations, share this per-IP budget with REST task reads.
#[arg(long, env = "GITLAWB_TASK_READ_RATE_LIMIT", default_value_t = 1200)]
pub task_read_rate_limit: usize,
}
impl Config {
@@ -843,6 +987,13 @@ impl Config {
Self::DB_LOCK_POOL_NON_PUSH_HEADROOM
));
}
if self.upstream_mirror_enabled && !self.enforce_owner_push {
return Err(
"GITLAWB_UPSTREAM_MIRROR_ENABLED requires GITLAWB_ENFORCE_OWNER_PUSH=true: \
an upstream-authoritative mirror must not accept writes from arbitrary signed identities"
.to_string(),
);
}
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
if (self.db_max_connections as u64) < main_floor {
return Err(format!(
@@ -1042,6 +1193,51 @@ pub(crate) fn validate_web_url(raw: &str) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn upstream_mirror_worker_is_default_off_and_bounded() {
let mut config = Config::parse_from(["gitlawb-node"]);
assert!(!config.upstream_mirror_enabled);
assert_eq!(config.upstream_mirror_interval_secs, 300);
assert_eq!(config.upstream_mirror_fetch_timeout_secs, 600);
assert_eq!(config.upstream_mirror_page_size, 100);
config.validate().unwrap();
assert!(
Config::try_parse_from(["gitlawb-node", "--upstream-mirror-interval-secs", "4",])
.is_err()
);
assert!(
Config::try_parse_from(["gitlawb-node", "--upstream-mirror-page-size", "0",]).is_err()
);
}
#[test]
fn upstream_mirror_worker_requires_owner_push_enforcement() {
// enforce_owner_push defaults true on main, so the only unsafe shape
// is the explicit opt-out: mirror enabled plus enforcement refused.
let mut unsafe_config = Config::parse_from([
"gitlawb-node",
"--upstream-mirror-enabled",
"--enforce-owner-push",
"false",
]);
let error = unsafe_config.validate().unwrap_err();
assert!(error.contains("GITLAWB_ENFORCE_OWNER_PUSH=true"));
Config::parse_from([
"gitlawb-node",
"--upstream-mirror-enabled",
"--enforce-owner-push",
"true",
])
.validate()
.unwrap();
// The default posture alone also validates: enforcement is on.
Config::parse_from(["gitlawb-node", "--upstream-mirror-enabled"])
.validate()
.unwrap();
}
#[test]
fn lock_pool_size_defaults_to_40_and_rejects_zero() {
assert_eq!(
@@ -1270,6 +1466,30 @@ mod tests {
);
}
#[test]
fn queue_lifecycle_knobs_default_conservatively() {
let c = Config::parse_from(["gitlawb-node"]);
// 7-day retention matches the v30 partial index comment
// and the spec at .gravirei/plans/state-model-durable-post-receive.md.
assert_eq!(c.queue_retention_days, 7);
// 1000 rows per pass matches DRAIN_PER_PASS_LIMIT in durable_outbox.
assert_eq!(c.queue_purge_batch, 1000);
assert_eq!(
Config::parse_from(["gitlawb-node", "--queue-retention-days", "30"])
.queue_retention_days,
30
);
assert!(Config::try_parse_from(["gitlawb-node", "--queue-retention-days", "0"]).is_err());
assert!(Config::try_parse_from(["gitlawb-node", "--queue-retention-days", "366"]).is_err());
assert_eq!(
Config::parse_from(["gitlawb-node", "--queue-purge-batch", "500"]).queue_purge_batch,
500
);
assert!(Config::try_parse_from(["gitlawb-node", "--queue-purge-batch", "0"]).is_err());
}
#[test]
fn ipfs_walk_per_source_defaults_and_rejects_out_of_range() {
assert_eq!(Config::parse_from(["gitlawb-node"]).ipfs_walk_per_source, 4);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35
View File
@@ -12,6 +12,9 @@ pub enum AppError {
#[error("repo already exists: {0}")]
RepoExists(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("not found: {0}")]
NotFound(String),
@@ -47,6 +50,13 @@ pub enum AppError {
#[error("too many requests: {0}")]
TooManyRequests(String),
/// A 429 from a per-IP limiter that knows when its window frees a slot, so
/// the response advertises that delay as `Retry-After` the way the router
/// middleware's 429s do. `TooManyRequests` stays for limiters that have no
/// such delay to give.
#[error("too many requests: retry in {}s", .0.as_secs())]
RateLimited(std::time::Duration),
#[error("incomplete: {0}")]
Incomplete(String),
@@ -189,6 +199,7 @@ impl IntoResponse for AppError {
"repo_exists",
format!("repository '{r}' already exists"),
),
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
@@ -201,6 +212,11 @@ impl IntoResponse for AppError {
AppError::TooManyRequests(msg) => {
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
}
AppError::RateLimited(_) => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
crate::rate_limit::RATE_LIMIT_MESSAGE.to_string(),
),
AppError::Incomplete(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
}
@@ -217,6 +233,9 @@ impl IntoResponse for AppError {
// 504, distinct from the 500 git_error and from the read-gate's 404 /
// the auth 401, so the client can tell a deadline from a failure.
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
// 503, retryable: a transient resource contention (e.g. the repo lock
// pool is pinned by a write burst or a purge holds the key), distinct
// from the 500 git_error so the client knows to back off and retry.
// 503 with a FIXED body: the caller should retry, and must not be told
// which repo is contended or for how long.
AppError::RepoBusy => (
@@ -311,6 +330,12 @@ impl IntoResponse for AppError {
axum::http::HeaderValue::from_static("1"),
);
}
if let AppError::RateLimited(retry_after) = &self {
resp.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from(retry_after.as_secs().max(1)),
);
}
resp
}
}
@@ -321,6 +346,16 @@ pub type Result<T> = std::result::Result<T, AppError>;
mod tests {
use super::*;
#[test]
fn conflict_maps_to_409() {
assert_eq!(
AppError::Conflict("task not claimable".into())
.into_response()
.status(),
StatusCode::CONFLICT
);
}
#[test]
fn timeout_maps_to_504_distinct_from_git_500() {
assert_eq!(
+31
View File
@@ -61,6 +61,37 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
Ok(())
}
/// Deadline-bounded variant of [`create_issue`] for compensation paths: the
/// same hash-object + update-ref write, but every git child runs through the
/// shared bounded runner so a hung git cannot stall the write guard past
/// `git_service_timeout`.
pub fn create_issue_bounded(
git_bin: &str,
repo_path: &Path,
issue_id: &str,
json: &str,
deadline: std::time::Instant,
) -> Result<()> {
let out = crate::git::visibility_pack::run_bounded_git(
git_bin,
&["hash-object", "--stdin", "-w"],
repo_path,
json.as_bytes(),
deadline,
)?;
let hash = String::from_utf8(out).context("git hash-object output is not valid UTF-8")?;
let hash = hash.trim();
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
crate::git::visibility_pack::run_bounded_git(
git_bin,
&["update-ref", &ref_name, hash],
repo_path,
b"",
deadline,
)?;
Ok(())
}
/// Remove a single issue ref while the write guard still holds the advisory lock,
/// after a definite publish refusal. Best-effort: a failed delete leaves a
/// local-only orphan, which is still better than telling the client to retry into a
+247 -41
View File
@@ -3,7 +3,8 @@
//! Every handler that needs access to a git repo on disk goes through `RepoStore`:
//!
//! - `acquire()` — ensures the repo is on local disk (downloads from Tigris on cache miss).
//! - `release_after_write()` — uploads the updated repo to Tigris after a write operation.
//! - `upload_under_guard()`: uploads the updated repo to Tigris after a write, under a
//! per-repo advisory lock the caller already holds (the fork tail's durability step).
//! - `init()` — creates a new bare repo locally and uploads to Tigris.
//!
//! When Tigris is disabled (bucket empty), this is a simple passthrough to local disk.
@@ -50,7 +51,7 @@ impl ConfirmedLiveTree {
}
}
/// Centralized repo storage: local disk cache + optional Tigris backend.
/// Centralized repo storage: local disk cache + optional object-store backend.
#[derive(Clone)]
pub struct RepoStore {
repos_dir: PathBuf,
@@ -82,18 +83,9 @@ pub struct RepoStore {
}
impl RepoStore {
#[cfg(test)]
#[cfg(any(test, feature = "test-harness"))]
pub fn for_testing(repos_dir: PathBuf, lock_pool: PgPool) -> Self {
Self {
repos_dir,
tigris: None,
lock_pool,
lock_held_transfer_timeout: Duration::from_secs(300),
lock_acquire_deadline: LOCK_ACQUIRE_DEADLINE,
migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
pre_unlock_gate: None,
upload_site_reached: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
Self::new(repos_dir, None, lock_pool, Duration::from_secs(300))
}
/// Same as [`RepoStore::for_testing`] but with Tigris enabled, so the paths
@@ -810,7 +802,22 @@ impl RepoStore {
}
/// Initialize a new bare repo on local disk and upload to Tigris.
/// Test-only convenience over `init_handle`; every
/// production caller keeps the upload inside its lock.
#[cfg(test)]
pub async fn init(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
let (path, upload) = self.init_handle(owner_did, repo_name).await?;
drop(upload);
Ok(path)
}
/// [`init`](Self::init) plus the background archive upload's JoinHandle, so
/// callers holding a lock can keep the upload inside the lock's lifetime.
pub async fn init_handle(
&self,
owner_did: &str,
repo_name: &str,
) -> Result<(PathBuf, Option<tokio::task::JoinHandle<Result<()>>>)> {
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
store::init_bare(&local_path).context("initializing bare repo")?;
@@ -821,12 +828,13 @@ impl RepoStore {
clear_quarantine(&local_path, repo_name);
// Upload to Tigris in background
let mut upload_handle = None;
if let Some(ref tigris) = self.tigris {
let tigris = tigris.clone();
let owner_slug = owner_slug.clone();
let repo_name = repo_name.to_string();
let path = local_path.clone();
tokio::spawn(async move {
upload_handle = Some(tokio::spawn(async move {
// Create-only, and load-bearing: this uploads a freshly
// initialized EMPTY repo, so a user who pushes immediately
// after creating one would have their archive replaced by this
@@ -837,20 +845,26 @@ impl RepoStore {
.upload(&owner_slug, &repo_name, &path, UploadPrecondition::IfAbsent)
.await
{
Ok(_) => {}
// Distinct from the warn arm: the fence refusing is the
// design working, not a storage failure.
Ok(_) => Ok(()),
// Distinct from the error arm: the fence refusing means
// someone else already published this key, so the empty
// bytes dropped — but for a create caller the archive then
// belongs to another writer, which IS a creation failure.
Err(UploadError::PreconditionLost { status }) => {
info!(repo = %repo_name, status, "dropped the empty-repo upload: another writer already published this repo");
Err(anyhow::anyhow!(
"empty-repo upload fenced: key already published (status {status})"
))
}
Err(e) => {
warn!(repo = %repo_name, err = %e, "failed to upload new repo to tigris");
Err(anyhow::anyhow!("empty-repo upload failed: {e}"))
}
}
});
}));
}
Ok(local_path.into_path_buf())
Ok((local_path.into_path_buf(), upload_handle))
}
/// Upload a repo to Tigris after a write operation (push, merge, fork, etc.).
@@ -902,6 +916,102 @@ impl RepoStore {
Ok(())
}
/// Whether an object store is configured. Used by purge-spam to decide
/// whether a repo with no local copy can be a remote-unverified candidate
/// (its emptiness verified under the lock after a refresh) versus fail-closed.
pub fn has_object_store(&self) -> bool {
self.tigris.is_some()
}
/// Try to take the per-repo advisory lock WITHOUT the write guard's
/// download/upload pipeline: a bare lock for callers (purge-spam, the
/// create/fork namespace serialization) that must serialize against the
/// write path on the same key but are not themselves publishing a tree.
///
/// Returns `Some(guard)` if the lock was free, `None` if another writer holds
/// it (so the caller can skip rather than block). The guard holds a dedicated
/// pooled connection for its whole lifetime, so the lock lives on that one
/// connection and `release()` unlocks it on the SAME connection — a plain
/// pool query could unlock on a different connection and silently fail.
pub async fn try_lock_repo(
&self,
owner_did: &str,
repo_name: &str,
) -> Result<Option<RepoLockGuard>> {
self.try_lock_repo_on(&self.lock_pool, owner_did, repo_name)
.await
}
/// [`try_lock_repo`](Self::try_lock_repo) against a caller-chosen pool. The
/// Postgres advisory lock is DATABASE-global, so the lock still mutually
/// excludes holders of the same key from any other pool — only the connection
/// the guard pins comes from `pool`. Backs the `lock_pool`-based delegators.
async fn try_lock_repo_on(
&self,
pool: &PgPool,
owner_did: &str,
repo_name: &str,
) -> Result<Option<RepoLockGuard>> {
let (owner_slug, _local) = self.local_path(owner_did, repo_name)?;
let lock_key = advisory_lock_key(&owner_slug, repo_name);
let conn = pool.acquire().await.context("acquiring lock connection")?;
// Route through LockProbe so a cancellation racing the query cannot
// return a connection holding an acquired session lock to the pool:
// the probe's Drop closes the session unless take_conn hands it out.
let mut probe = LockProbe::new(conn);
if !probe
.try_lock(lock_key)
.await
.context("try advisory lock")?
{
return Ok(None);
}
let conn = probe
.take_conn()
.context("lock probe lost its connection after a confirmed acquire")?;
Ok(Some(RepoLockGuard {
conn: Some(conn),
lock_key,
repo_name: repo_name.to_string(),
}))
}
/// Bounded-wait lock-only acquire, without any object-store I/O. Retries
/// `try_lock_repo` with short backoff. Used by `create_repo` and `fork_repo`
/// to serialize their existence-check -> write -> row-insert spans against a
/// concurrent same-key purge (which holds this same lock across delete-row +
/// remove-dir). A key held past the bounded wait surfaces as `Ok(None)`,
/// which the create/fork handlers map to a retryable 503.
pub async fn lock_repo_blocking(
&self,
owner_did: &str,
repo_name: &str,
) -> Result<Option<RepoLockGuard>> {
self.lock_repo_blocking_on(&self.lock_pool, owner_did, repo_name)
.await
}
/// [`lock_repo_blocking`](Self::lock_repo_blocking) against a caller-chosen
/// pool. The advisory lock is DB-global, so the guard mutually excludes the same
/// key across pools; only the pinned connection comes from `pool`. Backs the
/// `lock_pool`-based [`lock_repo_blocking`](Self::lock_repo_blocking) delegator.
pub async fn lock_repo_blocking_on(
&self,
pool: &PgPool,
owner_did: &str,
repo_name: &str,
) -> Result<Option<RepoLockGuard>> {
for attempt in 0..30 {
if let Some(g) = self.try_lock_repo_on(pool, owner_did, repo_name).await? {
return Ok(Some(g));
}
if attempt < 29 {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
Ok(None)
}
/// Compute the local disk path and owner slug for a repo.
///
/// Three-layer defence against path traversal:
@@ -1012,6 +1122,12 @@ pub(crate) fn claim_fork_disk_path(disk_path: &Path, attempt: &PublishAttemptId)
let Some(path) = fork_attempt_path(disk_path) else {
return;
};
// The stamp may be claimed BEFORE the mirror clone starts, when the owner
// directory does not exist yet; create it so the claim is not silently lost
// (a missing stamp makes every later ownership check refuse to clean).
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&path, attempt.as_str()) {
warn!(
path = %disk_path.display(),
@@ -1237,7 +1353,7 @@ async fn retry_fork_archive_delete(
/// `remove_dir_all` / `rename` can take it and static analysers can treat it as
/// sanitised input (CodeQL `rust/path-injection`).
#[derive(Debug, Clone)]
pub(crate) struct ValidatedRepoDiskPath(PathBuf);
pub struct ValidatedRepoDiskPath(PathBuf);
impl ValidatedRepoDiskPath {
pub(crate) fn as_path(&self) -> &Path {
@@ -1557,6 +1673,61 @@ fn invalidate_local_write_cache(local_path: &Path, repo_name: &str, reason: &str
}
}
impl RepoStore {
/// Refresh the local copy from the authoritative object-store archive so an
/// emptiness recheck reflects remote state (used by purge-spam on a Tigris
/// deployment, where the admin node's local disk can be stale). Downloads
/// the archive over the local path when it exists; a no-op when no object
/// store is configured (single-machine). Errors propagate so the caller can
/// fail closed rather than delete on a stale-local view.
/// Returns the archive generation the refresh verified (its ETag), so the
/// caller can fence a later delete on exactly the view it checked.
pub async fn refresh_from_archive(
&self,
owner_did: &str,
repo_name: &str,
) -> Result<Option<String>> {
let Some(ref store) = self.tigris else {
return Ok(None);
};
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
let Some(generation) = store.head_generation(&owner_slug, repo_name).await? else {
return Ok(None);
};
store
.download(&owner_slug, repo_name, &local_path, None)
.await?;
Ok(generation.etag)
}
/// Delete the object-store archive for a repo. A no-op when no object store
/// is configured. Used by purge-spam so a deleted repo's archive cannot be
/// downloaded into a later repo created with the same owner/name. The
/// delete is fenced on the generation the store sees, so a cross-node
/// publish racing the purge makes it fail rather than erasing the
/// successor's archive.
/// `expected` is the generation the caller verified empty (an ETag from
/// [`refresh_from_archive`]); a moved generation means a PUT landed after
/// the check and the archive must be kept, so `Ok(false)` is a skip.
pub async fn delete_archive(
&self,
owner_did: &str,
repo_name: &str,
expected: Option<&str>,
) -> Result<bool> {
let Some(ref store) = self.tigris else {
return Ok(true);
};
let (owner_slug, _local_path) = self.local_path(owner_did, repo_name)?;
store.delete_current(&owner_slug, repo_name, expected).await
}
/// The bound applied to transfers that run while a repo lock is held.
pub(crate) fn transfer_bound(&self) -> std::time::Duration {
self.lock_held_transfer_timeout
}
}
/// Strict allowlist validator for `owner_did` and `repo_name`.
///
/// Rejects any character that isn't explicitly safe, plus length and
@@ -1713,7 +1884,7 @@ fn validate_owner_did(owner_did: &str) -> Result<()> {
Ok(())
}
fn validate_repo_name(repo_name: &str) -> Result<()> {
pub(crate) fn validate_repo_name(repo_name: &str) -> Result<()> {
if repo_name.is_empty() {
anyhow::bail!("repo_name is empty");
}
@@ -2408,7 +2579,7 @@ impl RepoWriteGuard {
/// Only the UNLOCK has to wait for the reaper. This token carries everything
/// the settlement itself needs, so the request future can hold it and settle
/// the tree at the disconnect instant while the reaper still holds the lock.
pub(crate) struct TreeSettlement {
pub struct TreeSettlement {
local_path: ValidatedRepoDiskPath,
repo_name: String,
/// Shared with the guard, so the stage read here is the stage the publish
@@ -2764,6 +2935,41 @@ pub fn build_lock_pool(source: &PgPool, max_connections: u32, acquire_timeout: D
.connect_lazy_with((*source.connect_options()).clone())
}
/// Lock-only guard from [`RepoStore::try_lock_repo`]. Holds the Postgres advisory
/// lock (and the dedicated connection it lives on) until `release()`. No Tigris
/// I/O — unlike [`RepoWriteGuard`], releasing does NOT upload anything.
pub struct RepoLockGuard {
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
lock_key: i64,
repo_name: String,
}
impl RepoLockGuard {
/// Release the advisory lock on the same connection that took it, then return
/// the connection to the pool.
pub async fn release(mut self) {
if let Some(mut conn) = self.conn.take() {
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(self.lock_key)
.execute(&mut *conn)
.await;
}
}
}
impl Drop for RepoLockGuard {
fn drop(&mut self) {
// Same guarantee as RepoWriteGuard: a lock guard dropped without
// release() closes its connection so Postgres frees the advisory lock,
// rather than returning a lock-holding connection to the pool.
if let Some(mut conn) = self.conn.take() {
warn!(repo = %self.repo_name,
"RepoLockGuard dropped without release() — closing its connection to free the advisory lock");
conn.close_on_drop();
}
}
}
/// Compute a stable i64 hash for a Postgres advisory lock key.
///
/// Uses SHA-256 (not `DefaultHasher`) so the same `(owner_slug, repo_name)`
@@ -2789,7 +2995,7 @@ pub(crate) fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
i64::from_le_bytes(digest[..8].try_into().expect("sha256 output is >= 8 bytes"))
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::git::tigris::ATTEMPT_METADATA_KEY;
use std::collections::HashMap;
@@ -4926,7 +5132,7 @@ mod tests {
}
/// Seed the minimum bare-repo shape so the upload has something to archive.
fn seed_bare_repo(path: &Path) {
pub(crate) fn seed_bare_repo(path: &Path) {
std::fs::create_dir_all(path.join("objects/info")).unwrap();
std::fs::create_dir_all(path.join("refs/heads")).unwrap();
std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
@@ -5089,7 +5295,7 @@ mod tests {
/// models the real S3 arm we care about (body fully transmitted, commit
/// decided later) with no timing in it.
#[derive(Clone, Debug)]
struct CapturedPut {
pub(crate) struct CapturedPut {
key: String,
body: Vec<u8>,
if_match: Option<String>,
@@ -5099,7 +5305,7 @@ mod tests {
/// One stored object.
#[derive(Clone, Debug)]
struct MockObject {
pub(crate) struct MockObject {
body: Vec<u8>,
etag: String,
/// The `x-amz-meta-gitlawb-attempt` it was written with. This is what
@@ -5112,14 +5318,14 @@ mod tests {
/// `status` is `None` while a PUT is parked: it arrived and was logged, but
/// no precondition has been evaluated for it yet.
#[derive(Clone, Debug, PartialEq)]
struct PutAttempt {
pub(crate) struct PutAttempt {
if_match: Option<String>,
if_none_match: Option<String>,
status: Option<u16>,
}
#[derive(Default)]
struct MockState {
pub(crate) struct MockState {
/// Keyed by request path. A single-slot store was enough while every test
/// drove one repo; fork creation touches the SOURCE key and the FORK key
/// in one request, and collapsing them would make an assertion about one
@@ -5154,7 +5360,7 @@ mod tests {
///
/// The fence tests downstream are only worth anything if a precondition can
/// actually fail here, so this helper carries its own semantics tests below.
struct S3Mock {
pub(crate) struct S3Mock {
endpoint: String,
state: Arc<std::sync::Mutex<MockState>>,
gate: Arc<tokio::sync::Notify>,
@@ -5224,7 +5430,7 @@ mod tests {
}
impl S3Mock {
async fn start() -> Self {
pub(crate) async fn start() -> Self {
use axum::response::IntoResponse;
let state = Arc::new(std::sync::Mutex::new(MockState::default()));
@@ -5433,7 +5639,7 @@ mod tests {
}
}
fn endpoint(&self) -> &str {
pub(crate) fn endpoint(&self) -> &str {
&self.endpoint
}
@@ -5454,7 +5660,7 @@ mod tests {
}
/// The stored object under a specific repo key.
fn object_for(&self, owner_slug: &str, repo_name: &str) -> Option<MockObject> {
pub(crate) fn object_for(&self, owner_slug: &str, repo_name: &str) -> Option<MockObject> {
let key = format!("test-bucket/repos/v1/{owner_slug}/{repo_name}.tar.zst");
self.state.lock().unwrap().objects.get(&key).cloned()
}
@@ -5468,7 +5674,7 @@ mod tests {
/// to pick up a precondition and then PUTs on it is therefore fencing
/// on a generation that is already gone, which is the only way to drive
/// two consecutive lost preconditions deterministically.
fn roll_generation_after_next_heads(&self, n: u32) {
pub(crate) fn roll_generation_after_next_heads(&self, n: u32) {
self.state.lock().unwrap().roll_after_heads = n;
}
@@ -5487,7 +5693,7 @@ mod tests {
/// Deliver the next PUT in full and then fail it WITHOUT committing. The
/// client's knowledge is identical to the arm above — that is the whole
/// point — so only asking the store can tell the two apart.
fn fail_next_put_after_delivery(&self) {
pub(crate) fn fail_next_put_after_delivery(&self) {
self.state.lock().unwrap().lose_next_response = Some(false);
}
@@ -5498,7 +5704,7 @@ mod tests {
/// Fail the next HEAD for ONE repo key with a 500, so a caller's
/// reconciliation cannot be answered either way.
fn fail_next_head_for(&self, owner_slug: &str, repo_name: &str) {
pub(crate) fn fail_next_head_for(&self, owner_slug: &str, repo_name: &str) {
let key = format!("test-bucket/repos/v1/{owner_slug}/{repo_name}.tar.zst");
*self
.state
@@ -5510,7 +5716,7 @@ mod tests {
}
/// How many DELETEs the mock actually carried out.
fn deletes(&self) -> u32 {
pub(crate) fn deletes(&self) -> u32 {
self.state.lock().unwrap().deletes
}
@@ -5540,7 +5746,7 @@ mod tests {
.0
}
fn shutdown(&self) {
pub(crate) fn shutdown(&self) {
self.server.abort();
}
}
@@ -6271,25 +6477,25 @@ mod tests {
/// A client aimed at the same key the store under test publishes to, so a
/// test can seed the archive or land an interfering publish of its own.
fn mock_tigris(mock: &S3Mock) -> TigrisClient {
pub(crate) fn mock_tigris(mock: &S3Mock) -> TigrisClient {
TigrisClient::for_testing_with_endpoint("test-bucket", mock.endpoint())
}
/// The slug `local_path` derives from a DID, needed because a test seeds
/// and reads the archive key directly.
fn owner_slug_of(owner_did: &str) -> String {
pub(crate) fn owner_slug_of(owner_did: &str) -> String {
owner_did.replace([':', '/'], "_")
}
/// A bare-repo-shaped directory carrying `marker`, so a test can tell whose
/// tree is stored without comparing compressed bytes.
fn marked_repo(path: &Path, marker: &str) {
pub(crate) fn marked_repo(path: &Path, marker: &str) {
seed_bare_repo(path);
std::fs::write(path.join("MARKER"), marker).unwrap();
}
/// The marker inside whatever archive is currently stored under the key.
async fn stored_marker(mock: &S3Mock, owner_slug: &str, repo_name: &str) -> String {
pub(crate) async fn stored_marker(mock: &S3Mock, owner_slug: &str, repo_name: &str) -> String {
let out = TempDir::new().unwrap();
let validated = super::validated_repo_disk_path(out.path(), "did:key:stored", "stored")
.expect("test repo path must validate");
+465
View File
@@ -220,6 +220,7 @@ pub fn response_served_pack(output: &[u8]) -> bool {
///
/// Accepts a push. The caller MUST verify HTTP Signature auth before
/// calling this function.
#[allow(dead_code)] // used by tests; production uses receive_pack_raw
pub async fn receive_pack(
git_bin: &str,
repo_path: &Path,
@@ -244,6 +245,219 @@ pub async fn receive_pack(
.body(Body::from(output))?)
}
/// Run `git-receive-pack` and return the raw stdout bytes together with
/// the process exit status. Unlike [`receive_pack`], this does NOT bail
/// on a non-zero exit: the caller needs the stdout (which contains the
/// report-status with per-ref ok/ng results) even when the process
/// exits non-zero. A timeout still returns `Err`.
///
/// `reflog_action` (typically `gitlawb-request:<request_id>`) is
/// exported as `GIT_REFLOG_ACTION` so the ref transaction writes
/// request-bound landing evidence into the reflog. Recovery requires
/// that evidence before auto-promoting a stranded row; without it a
/// later request recreating the same old/new tuple is
/// indistinguishable from the stranded one.
#[allow(dead_code)]
pub async fn receive_pack_raw(
git_bin: &str,
repo_path: &Path,
request_body: Bytes,
timeout: Duration,
admission: Option<AdmissionGuard>,
) -> Result<(Vec<u8>, bool)> {
receive_pack_raw_with_reflog(git_bin, repo_path, request_body, timeout, admission, None).await
}
/// [`receive_pack_raw`] with explicit reflog binding.
pub async fn receive_pack_raw_with_reflog(
git_bin: &str,
repo_path: &Path,
request_body: Bytes,
timeout: Duration,
admission: Option<AdmissionGuard>,
reflog_action: Option<&str>,
) -> Result<(Vec<u8>, bool)> {
let mut command = Command::new(git_bin);
command
.arg("receive-pack")
.arg("--stateless-rpc")
.arg(repo_path);
if let Some(action) = reflog_action {
command.env("GIT_REFLOG_ACTION", action);
}
let (out, err, status, _admission) = drive_git_child_raw(
command,
request_body,
timeout,
"git-receive-pack",
admission,
)
.await?;
// On timeout, drive_git_child_raw returns Err — we never reach here.
// On success/non-zero exit, we have the stdout and exit status.
if !status.success() {
let stderr = String::from_utf8_lossy(&err);
tracing::warn!(stderr = %stderr, "git-receive-pack exited non-zero");
}
Ok((out, status.success()))
}
/// Parse the git-receive-pack report-status output to determine per-ref
/// success/failure. Returns `(unpack_ok, per_ref_results)` where
/// `per_ref_results` is a list of `(ref_name, is_ok)`.
///
/// The report-status format (after the sideband framing) is:
/// ```text
/// unpack ok\n (or: unpack fail\n)
/// ok <refname>\n (per successful ref)
/// ng <refname> <reason>\n (per rejected ref)
/// \n (empty line terminates)
/// ```
///
/// Returns `None` if the output cannot be parsed (e.g. the client did
/// not request report-status, or the output is truncated). In that
/// case the caller should treat all refs as uncertain.
pub fn parse_report_status(output: &[u8]) -> Option<(bool, Vec<(String, bool)>)> {
let text = std::str::from_utf8(output).ok()?;
// Strip sideband framing: each line starts with a pkt-line length
// prefix and a channel byte (1 = stdout, 2 = stderr). The actual
// data starts after the first `0000` flush packet or after we
// strip sideband bytes.
//
// Framing completeness is part of authority, at whichever level the
// framing lives. A double-framed capture carries the report stream
// (itself pkt-line framed, flush-terminated) inside the sideband
// envelope — real git output has been observed with NO outer flush,
// so the outer level cannot require one; the inner frame's flush is
// authoritative there. A single-framed (or unframed) payload has no
// inner frame, so its only completion marker is the outer flush:
// output ending after a complete `unpack ok` / `ok <ref>` packet but
// before the outer `0000` is a truncated report and yields `None`
// (uncertain), not a successful report.
let (stripped, outer_flush) = strip_sideband(text, false)?;
// The report-status vocabulary (`unpack ...`, `ok ...`, `ng ...`)
// never opens with four hex digits, so a hex quartet here is inner
// pkt-line framing, not report text.
let stripped = if stripped.len() >= 4
&& stripped.as_bytes()[..4]
.iter()
.all(|b| b.is_ascii_hexdigit())
{
strip_sideband(&stripped, true)?.0
} else if !outer_flush {
return None;
} else {
stripped
};
// The report is framed TWICE when the client negotiated side-band-64k,
// which `git push` over smart HTTP does — so this is the common case, not
// an exotic one. The outer frame is the side-band envelope; band 1 carries
// the report-status stream, which is ITSELF pkt-line encoded. Real bytes
// from git 2.50.1 rejecting one ref of a two-ref push:
//
// 0057\x01000eunpack ok\n0028ng refs/heads/main non-fast-forward\n...
//
// After one pass the first line reads `000eunpack ok`, which fails the
// `unpack ok` check below and makes this return None — i.e. "no report",
// which the caller treats as inconclusive and keeps every declared ref.
// The per-ref gate would then be inert for exactly the pushes it exists to
// filter. The second pass above already removed the inner pkt-line
// framing where present (and required its flush); it is a no-op
// on the single-framed shape, because plain report text does not begin
// with four hex digits.
let lines: Vec<&str> = stripped.lines().collect();
if lines.is_empty() {
return None;
}
// First non-empty line is "unpack ok" or "unpack fail".
let unpack_line = lines.iter().find(|l| !l.is_empty())?;
let unpack_ok = if unpack_line.starts_with("unpack ok") {
true
} else if unpack_line.starts_with("unpack fail") {
false
} else {
return None;
};
let mut results = Vec::new();
for line in &lines[1..] {
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("ok ") {
results.push((rest.to_string(), true));
} else if let Some(rest) = line.strip_prefix("ng ") {
// "ng <refname> <reason>" — skip the reason
let ref_name = rest.split_whitespace().next()?.to_string();
results.push((ref_name, false));
}
}
Some((unpack_ok, results))
}
/// Strip git sideband framing from a pkt-line encoded output.
/// Sideband-encoded lines start with a 4-hex-digit length, then a
/// channel byte (0x01=stdout, 0x02=stderr), then payload. Returns the
/// decoded payload plus whether the stream saw its terminating flush
/// packet, or `None` if the framing is malformed. With `require_flush`,
/// a stream that never sees its flush is also `None` — the caller uses
/// this for the level whose completion marker is authoritative (see
/// [`parse_report_status`]).
fn strip_sideband(text: &str, require_flush: bool) -> Option<(String, bool)> {
let mut output = String::new();
let mut pos = 0;
let bytes = text.as_bytes();
let mut saw_flush = false;
loop {
if pos == bytes.len() {
break;
}
if pos + 4 > bytes.len() {
// Truncated framing mid-header: fail closed.
return None;
}
let len_str = std::str::from_utf8(&bytes[pos..pos + 4]).ok()?;
let len = usize::from_str_radix(len_str, 16).ok()?;
if len == 0 {
// Flush packet — end of sideband stream
saw_flush = true;
break;
}
if len < 4 || pos + len > bytes.len() {
// Truncated pkt-line extends past EOF: incomplete framing.
return None;
}
let pkt_data = std::str::from_utf8(&bytes[pos + 4..pos + len]).ok()?;
pos += len;
// Sideband: first byte is channel (1=stdout, 2=stderr)
if let Some(payload) = pkt_data.strip_prefix('\x01') {
output.push_str(payload);
} else if pkt_data.starts_with('\x02') {
// stderr — skip (git error messages)
} else {
// Not sideband encoded — pass through
output.push_str(pkt_data);
}
}
if require_flush && !saw_flush {
// Truncated at a packet boundary: every packet parsed, but the
// stream never completed. Indistinguishable from a report git was
// killed mid-write, so no authority.
return None;
}
if output.is_empty() {
None
} else {
Some((output, saw_flush))
}
}
/// Sends SIGTERM to a child's whole process group on drop, unless disarmed first.
///
/// A served `git upload-pack`/`receive-pack` forks helpers such as `pack-objects`.
@@ -578,6 +792,121 @@ async fn drive_git_child(
Ok((out, admission))
}
/// Like [`drive_git_child`], but returns stdout and stderr even on a
/// non-zero exit status. Used by [`receive_pack_raw`] so the caller
/// can parse the report-status output from a failed `git-receive-pack`.
async fn drive_git_child_raw(
mut command: Command,
input: Bytes,
timeout: Duration,
what: &str,
admission: Option<AdmissionGuard>,
) -> Result<(
Vec<u8>,
Vec<u8>,
std::process::ExitStatus,
Option<AdmissionGuard>,
)> {
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
command.process_group(0);
let mut child = command.spawn()?;
let mut stdin = child.stdin.take();
let mut stdout = child.stdout.take().context("git stdout was not piped")?;
let mut stderr = child.stderr.take().context("git stderr was not piped")?;
#[cfg(unix)]
let pgid = child.id().map(|id| id as i32);
#[cfg(unix)]
let mut group_guard = KillGroupOnDrop {
child: Some(child),
pgid,
admission,
};
let mut out = Vec::new();
let mut err = Vec::new();
let interact = async {
let write = async {
match stdin.take() {
Some(mut s) => s.write_all(&input).await,
None => Ok(()),
}
};
#[cfg(unix)]
let child_ref = group_guard.child_mut();
#[cfg(not(unix))]
let child_ref = &mut child;
let (write_result, r_out, r_err, status) = tokio::join!(
write,
stdout.read_to_end(&mut out),
stderr.read_to_end(&mut err),
child_ref.wait(),
);
r_out?;
r_err?;
Ok::<_, anyhow::Error>((write_result, status?))
};
let timed = tokio::time::timeout(timeout, interact).await;
let (write_result, status, admission) = match timed {
Ok(result) => {
#[cfg(unix)]
let admission = group_guard.disarm();
let (write_result, status) = result?;
(write_result, status, admission)
}
Err(_elapsed) => {
#[cfg(unix)]
{
reap_group_on_timeout(group_guard.child_mut()).await;
drop(group_guard.disarm());
}
#[cfg(not(unix))]
{
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
drop(admission);
}
return Err(GitServiceTimeout.into());
}
};
// Prefer the child's collected answer over a stdin-write error: an early
// rejection (unpack failure, command-level deny) makes git print a complete
// report-status and exit WITHOUT draining stdin, so a body larger than the
// pipe buffer EPIPEs the write while the answer is already on stdout. The
// child is reaped (the join above waited on it), so `out`/`err`/`status`
// are complete and authoritative — returning Err here would discard them:
// `receive_pack_raw_with_reflog` would propagate the write error and the
// handler would mark every prepared row `uncertain` instead of driving
// `cancelled` per the report. That durable fate is the effect: on a
// non-zero exit the client still gets `Err(AppError::Git)` (the handler
// maps the failed run itself, not this write), while on exit 0 an error
// here would answer a push git actually landed with a server error. The
// non-raw `drive_git_child` orders the same way (exit status and git's own
// failure first, stdin-write error last — pinned by
// `run_git_service_surfaces_git_stderr_over_a_stdin_epipe`), but it must
// fail on non-zero because its callers do not parse output from a failed
// child; the raw driver exists precisely to parse that output.
if let Err(e) = &write_result {
tracing::warn!(
err = %e,
what = %what,
"git stdin write failed after child exit; surfacing collected output"
);
}
Ok((out, err, status, admission))
}
/// Map a validated service name to its git subcommand. Only the two fixed
/// subcommands can come out, so an unrecognized service can never reach git's argv.
fn service_to_command(service: &str) -> Result<&'static str> {
@@ -868,6 +1197,86 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
/// Byte-for-byte capture from `git receive-pack` 2.50.1 rejecting one ref of
/// a two-ref push, with side-band-64k negotiated — what `git push` over
/// smart HTTP actually sends. The report is framed twice: the outer
/// side-band envelope, then the report-status stream's own pkt-lines.
///
/// This fixture is the point of the test. A hand-written single-framed
/// string parses fine with only one strip pass, so a fixture invented to
/// match the parser hides the exact case the parser is for.
const REAL_SIDEBAND_REPORT: &[u8] =
b"0057\x01000eunpack ok\n0028ng refs/heads/main non-fast-forward\n0018ok refs/heads/third\n0000";
#[test]
fn a_real_double_framed_sideband_report_is_read_not_treated_as_absent() {
let (unpack_ok, refs) =
parse_report_status(REAL_SIDEBAND_REPORT).expect("a real git report must parse");
assert!(unpack_ok, "unpack line must be read through both frames");
assert_eq!(
refs,
vec![
("refs/heads/main".to_string(), false),
("refs/heads/third".to_string(), true),
],
"the rejected ref must be distinguished from the accepted one"
);
}
/// The single-framed shape must keep working: the second pass has to be a
/// no-op there, not a corruption. Plain report text does not begin with
/// four hex digits, which is what makes that safe.
#[test]
fn a_single_framed_report_still_parses_after_the_second_pass() {
// Framed programmatically rather than by a hand-counted hex prefix: a
// wrong length makes the parser return None, which would look exactly
// like the regression this pair of tests exists to catch.
let payload = "\x01unpack ok\nok refs/heads/x\n";
let single = format!("{:04x}{payload}0000", payload.len() + 4);
let (unpack_ok, refs) =
parse_report_status(single.as_bytes()).expect("single-framed must parse");
assert!(unpack_ok);
assert_eq!(refs, vec![("refs/heads/x".to_string(), true)]);
}
#[test]
fn truncated_mid_packet_report_is_indeterminate_not_partial() {
// Truncate a valid single-framed report mid-packet (overrun):
// must return None so the handler persists the whole request as
// indeterminate instead of committing a partial ok set.
let payload = "\x01unpack ok\nok refs/heads/x\n";
let single = format!("{:04x}{payload}0000", payload.len() + 4);
let truncated = &single.as_bytes()[..single.len() - 6];
assert!(
parse_report_status(truncated).is_none(),
"mid-packet truncation must be indeterminate"
);
}
/// Truncation exactly at a packet boundary (every packet complete, but
/// the outer flush never arrived — e.g. git killed after the last `ok`
/// line) must also be indeterminate: the payload is complete but the
/// frame is not, and framing completeness is part of authority. The
/// mid-packet sibling above cannot catch this; only the flush gate can.
/// Removing the `require_flush` enforcement returns `Some` and turns
/// this red.
#[test]
fn truncated_at_packet_boundary_report_is_indeterminate_not_partial() {
let payload = "\x01unpack ok\nok refs/heads/x\n";
let single = format!("{:04x}{payload}0000", payload.len() + 4);
let no_flush = &single.as_bytes()[..single.len() - 4];
assert!(
parse_report_status(no_flush).is_none(),
"a report missing its outer flush must be indeterminate, \
even with every packet intact"
);
// Sanity: the untruncated fixture still parses (flush present).
assert!(
parse_report_status(single.as_bytes()).is_some(),
"the same bytes with the flush must parse"
);
}
#[test]
fn service_to_command_maps_allowlist_and_rejects_passthrough() {
assert_eq!(
@@ -2641,6 +3050,62 @@ mod tests {
);
}
// The raw driver's counterpart: a rejection-shaped early exit where git
// prints a COMPLETE report-status and exits without draining stdin, so a
// body larger than the pipe buffer (~64 KiB) EPIPEs the write while the
// answer is already on stdout. `receive_pack_raw` must surface that report
// (Ok + stdout + exit status), not discard it as a stdin-write error — an
// Err here loses parseable output: `receive_pack_raw_with_reflog`
// propagates it and the handler marks every prepared row `uncertain`
// instead of driving `cancelled` from the report (the durable fate is the
// effect; the client-visible status on a failed run is `AppError::Git`
// either way). Goes RED if the stdin-write check returns Err after the
// join. Both exit codes are pinned: receive-pack rejections and successes
// both carry reports the handler must parse.
#[cfg(unix)]
#[tokio::test]
async fn receive_pack_raw_surfaces_report_status_over_a_stdin_epipe() {
for exit_code in [0, 1] {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("report.bin"), REAL_SIDEBAND_REPORT).unwrap();
// Never reads stdin: the pending body blocks the pipe, then EPIPEs
// once the fake exits. `cat` delivers the report first.
let git_bin = write_fake_git(
tmp.path(),
&format!("#!/bin/sh\ncat \"$(dirname \"$0\")/report.bin\"\nexit {exit_code}\n"),
);
let body = Bytes::from(vec![0u8; 256 * 1024]);
let (out, ok) = receive_pack_raw(
git_bin.to_str().unwrap(),
tmp.path(),
body,
Duration::from_secs(60),
None,
)
.await
.expect(
"a report-status printed before exit must not be discarded as a \
stdin-write error",
);
assert_eq!(
ok,
exit_code == 0,
"the exit status rides the Ok return for both report shapes"
);
let (unpack_ok, results) = parse_report_status(&out)
.expect("git's report survives the EPIPE and stays parseable");
assert!(unpack_ok, "unpack ok parses");
assert_eq!(
results,
vec![
("refs/heads/main".to_string(), false),
("refs/heads/third".to_string(), true),
],
"the per-ref report reaches the handler instead of an HTTP error"
);
}
}
// ── #192 F1: response_served_pack detector (real git output) ────────────
// A completing request (want+flush+done) yields NAK + a real packfile;
File diff suppressed because it is too large. Load diff
+44 -1
View File
@@ -522,6 +522,49 @@ impl TigrisClient {
}
}
/// Delete whatever archive currently exists, fenced on the generation read
/// at call time: a concurrent publish between the HEAD and the DELETE makes
/// the store refuse rather than erasing the successor's object. Used by
/// purge-spam, which deletes a repo it did not publish (so
/// [`delete_if_attempt_matches`](Self::delete_if_attempt_matches) cannot
/// apply) while still needing the same moved-generation protection.
/// Returns `true` when an archive was deleted.
/// Delete only if the object's generation still matches `expected` — the
/// generation the caller checked for emptiness. A PUT landing between that
/// check and this delete makes the generation move; deleting the newer
/// (possibly non-empty) archive is exactly the outcome the fence exists to
/// prevent, so a moved generation refuses rather than deletes.
pub async fn delete_current(
&self,
owner_slug: &str,
repo_name: &str,
expected: Option<&str>,
) -> Result<bool> {
let Some(generation) = self.head_generation(owner_slug, repo_name).await? else {
return Ok(false);
};
if generation.etag.as_deref() != expected {
return Ok(false);
}
let key = Self::repo_key(owner_slug, repo_name);
let mut req = self.s3.delete_object().bucket(&self.bucket).key(&key);
if let Some(etag) = generation.etag.as_deref() {
req = req.if_match(etag);
}
match req.send().await {
Ok(_) => Ok(true),
Err(e) => {
if e.raw_response()
.map(|raw| raw.status().as_u16())
.is_some_and(|s| s == 412 || s == 409)
{
anyhow::bail!("archive generation moved between head and delete; refusing");
}
Err(anyhow::anyhow!("tigris DELETE {key}: {e}"))
}
}
}
/// Download a repo archive from Tigris and extract to local disk.
pub async fn download(
&self,
@@ -663,7 +706,7 @@ pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
/// before the outer future resumes still removes the directory.
pub(crate) struct TempSnapshotDir {
pub struct TempSnapshotDir {
path: PathBuf,
}
+300 -92
View File
@@ -43,6 +43,53 @@ const WALK_TIMEOUT: Duration = Duration::from_secs(600);
#[cfg(unix)]
const WATCHDOG_TERM_GRACE: Duration = Duration::from_secs(1);
/// Build a Git child command, optionally with an isolated environment for an
/// owner-selected HTTPS destination. The isolated form intentionally drops
/// ambient proxy, credential-helper, askpass, and Git config environment that
/// could otherwise forward node-operator secrets to the selected upstream.
fn git_child_command(
git_bin: &str,
args: &[&str],
repo_path: &Path,
isolated_https: bool,
isolated_config: &[(&str, &str)],
) -> std::process::Command {
let mut command = std::process::Command::new(git_bin);
command
.args(args)
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if isolated_https {
let path = std::env::var_os("PATH");
command.env_clear();
if let Some(path) = path {
command.env("PATH", path);
}
command
.env("GIT_CONFIG_NOSYSTEM", "1")
.env(
"GIT_CONFIG_GLOBAL",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "false")
.env("SSH_ASKPASS", "false")
.env("LANG", "C")
.env("LC_ALL", "C");
if !isolated_config.is_empty() {
command.env("GIT_CONFIG_COUNT", isolated_config.len().to_string());
for (index, (key, value)) in isolated_config.iter().enumerate() {
command
.env(format!("GIT_CONFIG_KEY_{index}"), key)
.env(format!("GIT_CONFIG_VALUE_{index}"), value);
}
}
}
command
}
/// Run one git child under a shared `deadline` with process-group teardown,
/// BLOCKING, and return its stdout. The child runs in its own process group; a
/// watchdog thread SIGTERMs (lets git clean up its `*.lock` files), then SIGKILLs,
@@ -81,24 +128,22 @@ fn child_terminated_without_reaping(pid: i32) -> bool {
}
#[cfg(unix)]
pub(crate) fn run_bounded_git_raw(
fn run_bounded_git_raw_impl(
git_bin: &str,
args: &[&str],
repo_path: &Path,
stdin_bytes: &[u8],
deadline: Instant,
isolated_https: bool,
isolated_config: &[(&str, &str)],
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
use std::sync::mpsc::RecvTimeoutError;
let label = args.first().copied().unwrap_or("git");
let mut child = std::process::Command::new(git_bin)
.args(args)
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
let mut command = git_child_command(git_bin, args, repo_path, isolated_https, isolated_config);
let mut child = command
.process_group(0)
.spawn()
.with_context(|| format!("failed to spawn git {label}"))?;
@@ -208,6 +253,51 @@ pub(crate) fn run_bounded_git_raw(
Ok((status, out, err))
}
pub(crate) fn run_bounded_git_raw(
git_bin: &str,
args: &[&str],
repo_path: &Path,
stdin_bytes: &[u8],
deadline: Instant,
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
run_bounded_git_raw_impl(git_bin, args, repo_path, stdin_bytes, deadline, false, &[])
}
/// Run a bounded Git child without inheriting ambient Git/proxy/credential
/// configuration. Reserved for owner-selected external HTTPS destinations;
/// ordinary local Git operations retain their existing environment.
pub(crate) fn run_bounded_git_raw_isolated_https(
git_bin: &str,
args: &[&str],
repo_path: &Path,
stdin_bytes: &[u8],
deadline: Instant,
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
run_bounded_git_raw_impl(git_bin, args, repo_path, stdin_bytes, deadline, true, &[])
}
/// Isolated HTTPS runner with command-scope Git configuration supplied as
/// separate key/value environment entries. Unlike `git -c key=value`, this
/// preserves valid URL subsection keys whose path contains `=`.
pub(crate) fn run_bounded_git_raw_isolated_https_with_config(
git_bin: &str,
args: &[&str],
repo_path: &Path,
stdin_bytes: &[u8],
deadline: Instant,
config: &[(&str, &str)],
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
run_bounded_git_raw_impl(
git_bin,
args,
repo_path,
stdin_bytes,
deadline,
true,
config,
)
}
/// Bounded git returning only stdout, `bail!`ing on any nonzero exit. The thin
/// wrapper the walk callers use. Probes that must distinguish exit classes —
/// `git cat-file` absence vs an object-store access failure — call
@@ -241,23 +331,20 @@ pub(crate) fn run_bounded_git(
/// the Unix version's signature and result semantics so every caller compiles on all
/// targets (#174).
#[cfg(not(unix))]
pub(crate) fn run_bounded_git_raw(
fn run_bounded_git_raw_impl(
git_bin: &str,
args: &[&str],
repo_path: &Path,
stdin_bytes: &[u8],
deadline: Instant,
isolated_https: bool,
isolated_config: &[(&str, &str)],
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
use std::io::{Read, Write};
use std::sync::mpsc::RecvTimeoutError;
let label = args.first().copied().unwrap_or("git");
let mut child = std::process::Command::new(git_bin)
.args(args)
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
let mut child = git_child_command(git_bin, args, repo_path, isolated_https, isolated_config)
.spawn()
.with_context(|| format!("failed to spawn git {label}"))?;
@@ -334,6 +421,111 @@ pub(crate) fn run_bounded_git(
Ok(out)
}
/// Fail closed unless every ref ultimately resolves to a commit (a ref pointing
/// directly at a blob or tree, or an annotated tag — even a nested one — of such
/// an object is refused). `git rev-list --all` silently *skips* such refs, but
/// `git upload-pack` (serve) and the whole-repo pin fallback
/// (`git cat-file --batch-all-objects`) still expose their target object, so a
/// tolerant walk would under-withhold. Refuse rather than leak.
///
/// Each ref is peeled fully with `<ref>^{}` through `git cat-file --batch-check`.
/// Full peeling is why this is not `for-each-ref %(*objecttype)`, which
/// dereferences only one tag level and so misclassifies a tag-of-a-tag-of-a-
/// commit as a non-commit.
///
/// Internal node-managed refs are exempt from this check since they point
/// to blobs by design, not repository content: request markers
/// (`refs/gitlawb/requests/*`, per-push marker blobs) and issue records
/// (`refs/gitlawb/issues/*`, JSON blobs). The exemption lists exactly
/// those two namespaces: any other `refs/gitlawb/*` ref or non-commit
/// content ref still fails closed. Pushes into `refs/gitlawb/*` are
/// refused at the push edge, so no new bypass ref can appear; the
/// visibility logic only cares about content refs, not these two
/// internal metadata namespaces whose binding and advertisement hiding
/// are verified on the push path.
fn assert_all_refs_are_commits(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<()> {
let refs_out = run_bounded_git(
git_bin,
&["for-each-ref", "--format=%(refname)"],
repo_path,
b"",
deadline,
)?;
let refs_stdout = String::from_utf8_lossy(&refs_out);
let refnames: Vec<&str> = refs_stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if refnames.is_empty() {
return Ok(());
}
// Filter out only the two node-managed blob namespaces before checking
// object types. Scoped to `refs/gitlawb/requests/` and
// `refs/gitlawb/issues/` — not all of `refs/gitlawb/*` — so any
// other internal or content ref pointing at a non-commit still
// fails closed.
let content_refs: Vec<&str> = refnames
.iter()
.filter(|r| {
!r.starts_with("refs/gitlawb/requests/") && !r.starts_with("refs/gitlawb/issues/")
})
.copied()
.collect();
if content_refs.is_empty() {
return Ok(());
}
// Peel every content ref in one `git cat-file --batch-check` pass: one `<refname>^{}`
// query per line, one output line per input line, in order. cat-file echoes the
// full query on a `<query> missing` line, so output scales with refname length;
// run_bounded_git drains stdout concurrently with the stdin write, so the pipe
// cannot deadlock, and the whole peel is bounded by the shared walk deadline.
let queries = content_refs
.iter()
.map(|r| format!("{r}^{{}}"))
.collect::<Vec<_>>()
.join("\n");
let peel_out = run_bounded_git(
git_bin,
&["cat-file", "--batch-check=%(objecttype)"],
repo_path,
queries.as_bytes(),
deadline,
)?;
let peel_stdout = String::from_utf8_lossy(&peel_out);
let types: Vec<&str> = peel_stdout.lines().map(str::trim).collect();
// A short read means at least one ref went unclassified — fail closed.
if types.len() != content_refs.len() {
anyhow::bail!(
"git cat-file returned {} lines for {} refs; \
refusing to produce a partial (under-withheld) set",
types.len(),
content_refs.len()
);
}
for (refname, kind) in content_refs.iter().zip(types.iter()) {
// git emits `<query> missing` (not the objecttype) when the peel target
// is absent; the status word is the last token.
if kind.split_ascii_whitespace().last() == Some("missing") {
anyhow::bail!(
"ref {refname} does not resolve to an object; \
refusing to produce a partial (under-withheld) set"
);
}
if *kind != "commit" {
anyhow::bail!(
"ref {refname} resolves to a {kind}, not a commit; \
refusing to produce a partial (under-withheld) set"
);
}
}
Ok(())
}
/// List every (blob_oid, "/repo/relative/path") pair reachable from any commit in
/// `repo_path` — every ref *and* every historical commit those refs reach, not just
/// the ref tips. `git upload-pack` (serve) and the whole-repo pin fallback
@@ -1424,84 +1616,17 @@ fn pair_decision(
}
}
/// Reachable blob AND withheld-subtree tree OIDs the anonymous (or `caller`)
/// replication audience must not receive. The single source for
/// `replication_withheld_set`: a blob-only set handed to [`replicable_objects`]
/// reintroduces #172 (withheld trees pin to public IPFS).
///
/// Strict where [`tree_paths`] is lenient. This feeds a withheld filter, so
/// absence means replicate; a missed reachable tree would leak. The walk
/// therefore matches [`blob_paths`]: [`assert_all_refs_are_commits`] first, then
/// a failing `rev-list --all [HEAD]` is an error, then [`object_paths`] (already
/// fail-closed on ls-tree / non-UTF-8) plus [`root_tree_pairs`]. [`tree_paths`]
/// stays the lenient allow-list walk for GET /ipfs/{cid} and the full-scan
/// allowed-tree set, where absence withholds.
fn assert_all_refs_are_commits(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<()> {
let refs_out = run_bounded_git(
git_bin,
&["for-each-ref", "--format=%(refname)"],
repo_path,
b"",
deadline,
)?;
let refs_stdout = String::from_utf8_lossy(&refs_out);
let refnames: Vec<&str> = refs_stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if refnames.is_empty() {
return Ok(());
}
// Peel every ref in one `git cat-file --batch-check` pass: one `<refname>^{}`
// query per line, one output line per input line, in order. cat-file echoes the
// full query on a `<query> missing` line, so output scales with refname length;
// run_bounded_git drains stdout concurrently with the stdin write, so the pipe
// cannot deadlock, and the whole peel is bounded by the shared walk deadline.
let queries = refnames
.iter()
.map(|r| format!("{r}^{{}}"))
.collect::<Vec<_>>()
.join("\n");
let peel_out = run_bounded_git(
git_bin,
&["cat-file", "--batch-check=%(objecttype)"],
repo_path,
queries.as_bytes(),
deadline,
)?;
let peel_stdout = String::from_utf8_lossy(&peel_out);
let types: Vec<&str> = peel_stdout.lines().map(str::trim).collect();
// A short read means at least one ref went unclassified — fail closed.
if types.len() != refnames.len() {
anyhow::bail!(
"git cat-file returned {} lines for {} refs; \
refusing to produce a partial (under-withheld) set",
types.len(),
refnames.len()
);
}
for (refname, kind) in refnames.iter().zip(types.iter()) {
// git emits `<query> missing` (not the objecttype) when the peel target
// is absent; the status word is the last token.
if kind.split_ascii_whitespace().last() == Some("missing") {
anyhow::bail!(
"ref {refname} does not resolve to an object; \
refusing to produce a partial (under-withheld) set"
);
}
if *kind != "commit" {
anyhow::bail!(
"ref {refname} resolves to a {kind}, not a commit; \
refusing to produce a partial (under-withheld) set"
);
}
}
Ok(())
}
/// Root tree oid of every reachable commit, at "/". `ls-tree` never emits a commit's
/// own root tree (it lists entries *under* a tree), so it is added explicitly here.
/// Resolved in ONE bounded `git log --no-walk --format=%T --stdin` pass over the
/// shared commit set — not a per-commit `rev-parse` — so a tree-set walk costs the
/// same subprocess order as the blob walk. The commit oids go on STDIN, not argv: a
/// long history has tens of thousands of reachable commits, and passing them all as
/// arguments overflows ARG_MAX so `git log` fails to spawn — which the caller treats
/// as a walk error and fail-closed 404s an authorized reader of a reachable/root
/// tree (#173 P2). `run_bounded_git` drains stdout concurrently with the stdin
/// write, so a large history cannot deadlock the pipes. A commit whose root tree git
/// cannot resolve fails the pass (bail), failing closed.
fn root_tree_pairs(
repo_path: &Path,
git_bin: &str,
@@ -1533,6 +1658,18 @@ fn root_tree_pairs(
Ok(set)
}
/// Reachable blob AND withheld-subtree tree OIDs the anonymous (or `caller`)
/// replication audience must not receive. The single source for
/// `replication_withheld_set`: a blob-only set handed to [`replicable_objects`]
/// reintroduces #172 (withheld trees pin to public IPFS).
///
/// Strict where [`tree_paths`] is lenient. This feeds a withheld filter, so
/// absence means replicate; a missed reachable tree would leak. The walk
/// therefore matches [`blob_paths`]: [`assert_all_refs_are_commits`] first, then
/// a failing `rev-list --all [HEAD]` is an error, then [`object_paths`] (already
/// fail-closed on ls-tree / non-UTF-8) plus [`root_tree_pairs`]. [`tree_paths`]
/// stays the lenient allow-list walk for GET /ipfs/{cid} and the full-scan
/// allowed-tree set, where absence withholds.
pub fn withheld_object_oids_bounded(
repo_path: &Path,
git_bin: &str,
@@ -5963,6 +6100,77 @@ esac\n";
);
}
/// A planted non-exempt `refs/gitlawb/*` blob ref fails the commit walk
/// closed, while the two exempt namespaces (`requests/`, `issues/`) pass.
/// The exemption narrowing removed a serving path, so this test pins the
/// denial: widening the exemption back to all of `refs/gitlawb/*` turns
/// the expect_err red. Real git, scratch bare repo.
#[test]
fn planted_non_exempt_gitlawb_blob_ref_fails_closed() {
use std::time::{Duration, Instant};
let td = TempDir::new().unwrap();
let bare = td.path().join("repo.git");
std::fs::create_dir_all(&bare).unwrap();
let run = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&bare)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
let run_out = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&bare)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
run(&["init", "-q", "--bare", "."]);
// A blob object to point refs at (node markers and issues are blobs).
std::fs::write(bare.join("marker-body"), b"marker\n").unwrap();
let blob = run_out(&["hash-object", "-w", "marker-body"]);
let check = |want_ok: bool, why: &str| {
let result =
assert_all_refs_are_commits(&bare, "git", Instant::now() + Duration::from_secs(30));
if want_ok {
result.expect(why);
} else {
let err = result.expect_err(why);
assert!(
err.to_string().contains("refs/gitlawb/evil"),
"the denial must name the offending ref, got: {err:#}"
);
}
};
// The bare `refs/gitlawb` name fails closed too (checked first: git
// forbids a bare ref coexisting with the subtree below, D/F conflict).
run(&["update-ref", "refs/gitlawb", &blob]);
let result =
assert_all_refs_are_commits(&bare, "git", Instant::now() + Duration::from_secs(30));
let err = result.expect_err("bare refs/gitlawb blob ref must fail closed");
assert!(
err.to_string().contains("refs/gitlawb"),
"the denial must name the offending ref, got: {err:#}"
);
run(&["update-ref", "-d", "refs/gitlawb", "--no-deref"]);
// Exempt namespaces with blob targets pass (node-managed by design).
run(&["update-ref", "refs/gitlawb/requests/r1", &blob]);
run(&["update-ref", "refs/gitlawb/issues/i1", &blob]);
check(true, "requests//issues/ blob refs are exempt and must pass");
// A non-exempt blob ref fails closed.
run(&["update-ref", "refs/gitlawb/evil", &blob]);
check(false, "a non-exempt refs/gitlawb blob ref must fail closed");
// Cleanup proves the denial is about that ref, not residue.
run(&["update-ref", "-d", "refs/gitlawb/evil", "--no-deref"]);
check(true, "removing the evil ref must restore the walk");
}
/// #218 review round 9 (guidance #1 — single fail-closed
/// classification contract): for every non-commit ref shape the
/// parser can produce, the FOUR consumers of `(oid, path)` —
+122 -12
View File
@@ -14,6 +14,11 @@ use subscription::SubscriptionRoot;
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
/// No mutations: the /graphql/ws transport keeps queries and subscriptions but
/// has no mutation root, so the socket can never be used to run a write and
/// sidestep the write-brake bucket the HTTP route pays.
pub type GitlawbWsSchema = Schema<QueryRoot, async_graphql::EmptyMutation, SubscriptionRoot>;
// Keep ordinary schema discovery and application queries usable while
// bounding validation work and the number of resolver selections per request.
const GRAPHQL_MAX_COMPLEXITY: usize = 400;
@@ -76,6 +81,7 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
// Curated client-safe variants — `Display` is intentional API text.
safe @ (crate::error::AppError::RepoNotFound(_)
| crate::error::AppError::RepoExists(_)
| crate::error::AppError::Conflict(_)
| crate::error::AppError::NotFound(_)
| crate::error::AppError::Unauthorized(_)
| crate::error::AppError::Forbidden(_)
@@ -92,18 +98,108 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
}
}
/// Classify a failed `claim_task` for GraphQL exactly as REST's claim handler
/// classifies it: a lost race is a client-safe conflict, a real sqlx fault
/// stays opaque. Lives here rather than at the three call sites so the
/// classification cannot drift between transports (#327 review), and so
/// `every_graphql_map_err_uses_opaque_helpers` can keep whitelisting by name.
pub(crate) fn graphql_claim_conflict(e: anyhow::Error) -> async_graphql::Error {
graphql_app_err(crate::api::tasks::task_write_conflict(
e,
"task not claimable: not found or already claimed",
))
}
/// The `finish_task` half of [`graphql_claim_conflict`], covering both
/// `completeTask` and `failTask`.
pub(crate) fn graphql_finish_conflict(e: anyhow::Error) -> async_graphql::Error {
graphql_app_err(crate::api::tasks::task_write_conflict(
e,
"task not found or not in claimed state",
))
}
pub struct TaskReadBrakeExtension;
impl async_graphql::extensions::ExtensionFactory for TaskReadBrakeExtension {
fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
Arc::new(TaskReadBrakeExtensionImpl)
}
}
use async_graphql::async_trait::async_trait;
struct TaskReadBrakeExtensionImpl;
#[async_trait]
impl async_graphql::extensions::Extension for TaskReadBrakeExtensionImpl {
async fn prepare_request(
&self,
ctx: &async_graphql::extensions::ExtensionContext<'_>,
mut request: async_graphql::Request,
next: async_graphql::extensions::NextPrepareRequest<'_>,
) -> async_graphql::ServerResult<async_graphql::Request> {
if let Some(session_brake) = ctx
.session_data
.get(&std::any::TypeId::of::<crate::rate_limit::TaskReadBrake>())
.and_then(|d| d.downcast_ref::<crate::rate_limit::TaskReadBrake>())
{
request = request.data(crate::rate_limit::TaskReadBrake {
limiter: session_brake.limiter.clone(),
key: session_brake.key.clone(),
request_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
});
}
if let Some(did) = ctx
.session_data
.get(&std::any::TypeId::of::<crate::auth::AuthenticatedDid>())
.and_then(|d| d.downcast_ref::<crate::auth::AuthenticatedDid>())
{
request = request.data(did.clone());
}
next.run(ctx, request).await
}
}
pub fn build_schema(
db: Arc<Db>,
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
task_cursor_key: crate::api::task_cursor::TaskCursorKey,
) -> GitlawbSchema {
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
.data(db)
.data(ref_update_tx)
.data(task_event_tx)
.data(task_cursor_key)
.extension(TaskReadBrakeExtension)
.finish()
}
/// Build the subscriptions-only schema served over /graphql/ws. Carries the
/// same broadcast senders the subscription resolvers consume. Mutations are
/// disabled — the schema's mutation root is `EmptyMutation` — but queries still
/// run via `QueryRoot` (they are reads and already ride the TaskReadBrake), so
/// "subscriptions-only" refers to the write surface, not the operation set.
pub fn build_ws_schema(
db: Arc<Db>,
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
task_cursor_key: crate::api::task_cursor::TaskCursorKey,
) -> GitlawbWsSchema {
apply_query_limits(Schema::build(
QueryRoot,
async_graphql::EmptyMutation,
SubscriptionRoot,
))
.data(db)
.data(ref_update_tx)
.data(task_event_tx)
.data(task_cursor_key)
.extension(TaskReadBrakeExtension)
.finish()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -192,6 +288,7 @@ mod tests {
db,
tokio::sync::broadcast::channel(1).0,
tokio::sync::broadcast::channel(1).0,
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
)
}
@@ -217,6 +314,7 @@ mod tests {
Arc::new(Db::for_testing(pool)),
ref_tx.clone(),
task_tx.clone(),
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
);
for count in [7, 8] {
let fields = (0..count)
@@ -313,7 +411,10 @@ mod tests {
assert_eq!(response.errors.len(), 1);
assert_eq!(response.errors[0].message, "Query is too complex.");
assert_eq!(
db.list_tasks(Some("pending"), None, 8).await.unwrap().len(),
db.list_tasks_keyset(Some("pending"), None, 8, None)
.await
.unwrap()
.len(),
8
);
continue;
@@ -330,7 +431,7 @@ mod tests {
7
);
assert_eq!(
db.list_tasks(Some("claimed"), Some(caller), 8)
db.list_tasks_keyset(Some("claimed"), Some(caller), 8, None)
.await
.unwrap()
.len(),
@@ -352,7 +453,7 @@ mod tests {
// documented maximums instead of going stale as literals.
for (field, selection, max) in [
("refUpdates", "repo", MAX_VISIBLE_REF_UPDATES),
("tasks", "id", MAX_VISIBLE_TASK_PAGE_SIZE),
("tasks", "items { id }", MAX_VISIBLE_TASK_PAGE_SIZE),
(
"reposPage",
"nodes { name }",
@@ -595,7 +696,12 @@ mod tests {
pool.close().await;
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
let (task_tx, _) = tokio::sync::broadcast::channel(16);
let schema = build_schema(Arc::new(Db::for_testing(pool)), ref_tx, task_tx);
let schema = build_schema(
Arc::new(Db::for_testing(pool)),
ref_tx,
task_tx,
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
);
// refUpdates(limit: MAX_VISIBLE_REF_UPDATES):
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
@@ -649,12 +755,12 @@ mod tests {
);
// tasks(limit: MAX_VISIBLE_TASK_PAGE_SIZE):
// Single-field selection has child_complexity 1, so the cost is
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 1 <= 400.
// Selection items { id } has child_complexity 2, so the cost is
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 400.
// It passes validation and reaches the resolver.
let single_task = schema
.execute(format!(
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }} }}"
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ items {{ id }} }} }}"
))
.await;
assert!(
@@ -674,14 +780,14 @@ mod tests {
single_task.errors
);
// Two-field selection has child_complexity 2, so the cost is
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 400.
// Selection items { id status } has child_complexity 3, so the cost is
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 3 <= 400.
// A request at the documented maximum stays reachable with multiple
// fields (the old multiplicative price rejected it at 450); it passes
// validation and reaches the resolver like the single-field case.
let multi_task = schema
.execute(format!(
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id status }} }}"
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ items {{ id status }} }} }}"
))
.await;
assert!(
@@ -843,8 +949,10 @@ mod tests {
}
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
/// through the opaque helpers, or discard the error (`|_|`). Same source-
/// scrape pattern as `api::authz_guard` (#255 review).
/// through one of the curated helpers in this module, or discard the error
/// (`|_|`). Same source-scrape pattern as `api::authz_guard` (#255
/// review). The list is deliberately a whitelist of names rather than a
/// prefix match, so adding a new mapper is a decision a reviewer sees.
#[test]
fn every_graphql_map_err_uses_opaque_helpers() {
for (file, src) in [
@@ -859,6 +967,8 @@ mod tests {
let after = code[idx + ".map_err(".len()..].trim_start();
let ok = after.starts_with("crate::graphql::graphql_db_err")
|| after.starts_with("crate::graphql::graphql_app_err")
|| after.starts_with("crate::graphql::graphql_claim_conflict")
|| after.starts_with("crate::graphql::graphql_finish_conflict")
|| after.starts_with("|_|")
|| after.starts_with("|_ ");
assert!(
+327 -43
View File
@@ -106,25 +106,37 @@ impl MutationRoot {
let assignee_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
crate::api::tasks::get_claimable_task(db, &id, caller)
.await
.map_err(crate::graphql::graphql_app_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
// Same classification REST's claim handler applies: a claim race is a
// client-safe conflict, a genuine sqlx failure stays opaque. Routing
// both transports through `task_write_conflict` is what stops a normal
// race from reaching a GraphQL caller as a generic database error
// (#327 review).
let task = db
.claim_task(&id, &assignee_did)
.await
.map_err(crate::graphql::graphql_db_err)?;
// Report the stored assignee form like REST, so subscribers see the
// same identity whichever API surface ran the transition.
.map_err(crate::graphql::graphql_claim_conflict)?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.unwrap_or(assignee_did.as_str())
.to_string();
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
});
crate::api::tasks::announce_task_event(
db,
tx,
TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
},
)
.await;
Ok(AgentTaskType::from(task))
}
@@ -145,12 +157,12 @@ impl MutationRoot {
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: binding by_did to the signer is necessary but not
// sufficient — only the task's assignee may finish it.
let existing = db
.get_task(&id)
// Authorize the actor: the task must be visible to the caller (returning
// not found for invisible tasks so existence is not leaked), and only
// the task's assignee may finish it.
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
.await
.map_err(crate::graphql::graphql_db_err)?
.map_err(crate::graphql::graphql_app_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(
caller,
@@ -163,20 +175,25 @@ impl MutationRoot {
let task = db
.finish_task(&id, "completed", input.result.as_deref(), caller)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(crate::graphql::graphql_finish_conflict)?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.unwrap_or(by_did.as_str())
.to_string();
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "completed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
});
crate::api::tasks::announce_task_event(
db,
tx,
TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "completed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
},
)
.await;
Ok(AgentTaskType::from(task))
}
@@ -197,11 +214,12 @@ impl MutationRoot {
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: only the task's assignee may fail it.
let existing = db
.get_task(&id)
// Authorize the actor: the task must be visible to the caller (returning
// not found for invisible tasks so existence is not leaked), and only
// the task's assignee may fail it.
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
.await
.map_err(crate::graphql::graphql_db_err)?
.map_err(crate::graphql::graphql_app_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(
caller,
@@ -215,20 +233,25 @@ impl MutationRoot {
let task = db
.finish_task(&id, "failed", Some(&reason), caller)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(crate::graphql::graphql_finish_conflict)?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.unwrap_or(by_did.as_str())
.to_string();
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "failed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
});
crate::api::tasks::announce_task_event(
db,
tx,
TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "failed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
},
)
.await;
Ok(AgentTaskType::from(task))
}
}
@@ -326,7 +349,7 @@ mod tests {
);
if *name == "claimTask" {
assert!(
errs.contains("task not claimable"),
errs.contains("task not found"),
"claim race / missing task must keep its business message: {errs}"
);
assert!(
@@ -370,6 +393,210 @@ mod tests {
);
}
/// #327 review: `claimTask`/`completeTask`/`failTask` called
/// `graphql_db_err` directly on db-layer business failures, so an ordinary
/// claim race or stale finish reached GraphQL clients as a generic
/// database error while REST clients got an actionable conflict. All three
/// now route through the shared `task_write_conflict` classifier.
///
/// Deleting either `map_err` from a mutation turns this red: the message
/// becomes the raw anyhow text instead of the fixed conflict form.
#[sqlx::test]
async fn task_write_races_surface_as_conflicts_not_db_errors(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let schema = state.graphql_schema.as_ref();
let assignee = "did:key:zGQLRACEASSIGNEEAAAAAAAAAAAAAAAAAAAAAAA";
let rival = "did:key:zGQLRACERIVALBBBBBBBBBBBBBBBBBBBBBBBBBB";
let delegator = "did:key:zGQLRACEDELEGATORCCCCCCCCCCCCCCCCCCCCC";
let now = chrono::Utc::now().to_rfc3339();
state
.db
.create_repo(&crate::db::RepoRecord {
id: "race-repo".into(),
name: "race".into(),
owner_did: delegator.into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/tmp/race-repo".into(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
state
.db
.create_task(&crate::db::AgentTask {
id: "race-task".into(),
repo_id: Some("race-repo".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: delegator.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
})
.await
.unwrap();
// The assignee wins the claim.
let claim = |did: &str| {
format!(r#"mutation {{ claimTask(id: "race-task", assigneeDid: "{did}") {{ id }} }}"#)
};
let resp = schema
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
.await;
assert!(resp.errors.is_empty(), "first claim: {}", errors(&resp));
// The rival loses the race: ineligible caller receives opaque not-found.
let resp = schema
.execute(Request::new(claim(rival)).data(AuthenticatedDid(rival.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not found"),
"ineligible caller after task is claimed must receive opaque not-found: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"ineligible claim is not a database failure: {errs}"
);
// A second claim that reaches the conditional SQL write (e.g. assignee claiming
// again on an already-claimed task) exercises the conflict mapper:
let resp = schema
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not claimable: not found or already claimed"),
"a lost claim race must render the fixed conflict message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a claim race is not a database failure: {errs}"
);
// A stale finish on an already-completed task is the same class.
let complete = format!(
r#"mutation {{ completeTask(id: "race-task", byDid: "{assignee}", input: {{ result: "ok" }}) {{ id }} }}"#
);
let resp = schema
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
.await;
assert!(resp.errors.is_empty(), "first complete: {}", errors(&resp));
let resp = schema
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not found or not in claimed state"),
"a stale complete must render the fixed conflict message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a stale complete is not a database failure: {errs}"
);
let fail = format!(
r#"mutation {{ failTask(id: "race-task", byDid: "{assignee}", input: {{ reason: "nope" }}) {{ id }} }}"#
);
let resp = schema
.execute(Request::new(&fail).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not found or not in claimed state"),
"a stale fail must render the fixed conflict message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a stale fail is not a database failure: {errs}"
);
}
/// The other half of the same classification: a genuine SQL fault on the
/// write itself must stay opaque. Without this, routing conflicts through
/// `task_write_conflict` could be "fixed" by making every db failure a
/// client-visible conflict message.
#[sqlx::test]
async fn task_write_sql_faults_stay_opaque(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let assignee = "did:key:zGQLOPAQUEASSIGNEEAAAAAAAAAAAAAAAAAAAAA";
let delegator = "did:key:zGQLOPAQUEDELEGATORBBBBBBBBBBBBBBBBBBB";
let now = chrono::Utc::now().to_rfc3339();
state
.db
.create_task(&crate::db::AgentTask {
id: "opaque-task".into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: delegator.into(),
assignee_did: Some(assignee.into()),
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
})
.await
.unwrap();
// Fault the write itself, not the schema. Dropping a column the
// visibility pre-check also SELECTs would fail in `get_visible_task`
// and never reach `graphql_claim_conflict`, so this test would pass
// against a claim mapper that leaks (#327 review). A BEFORE UPDATE
// trigger keeps every read valid and faults only inside
// `Db::claim_task`.
sqlx::raw_sql(
"CREATE FUNCTION gl_test_fault_task_update() RETURNS trigger
LANGUAGE plpgsql AS $fn$
BEGIN RAISE EXCEPTION 'gl_test_forced_update_fault'; END;
$fn$;
CREATE TRIGGER gl_test_fault_task_update
BEFORE UPDATE ON agent_tasks
FOR EACH ROW EXECUTE FUNCTION gl_test_fault_task_update();",
)
.execute(&pool)
.await
.unwrap();
let resp = state
.graphql_schema
.execute(
Request::new(format!(
r#"mutation {{ claimTask(id: "opaque-task", assigneeDid: "{assignee}") {{ id }} }}"#
))
.data(AuthenticatedDid(assignee.into())),
)
.await;
let errs = errors(&resp);
assert!(
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a real sqlx fault on the write must stay opaque: {errs}"
);
assert!(
!errs.contains("task not claimable"),
"a write-time sqlx fault must not be reclassified as a claim race: {errs}"
);
assert!(
!errs.contains("gl_test_forced_update_fault") && !errs.contains("trigger"),
"database text leaked through the conflict mapper: {errs}"
);
}
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
/// assignee, not merely signing as the by_did you pass. A signer who is not
/// the assignee is rejected even though the by_did binding passes; the
@@ -392,7 +619,7 @@ mod tests {
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
updated_at: now.clone(),
deadline: None,
};
state.db.create_task(&task).await.expect("seed task");
@@ -409,14 +636,69 @@ mod tests {
)
};
// Stranger signs as themselves and passes byDid=self (so the signer
// binding passes), but is not the assignee → rejected by authorization.
// Stranger signs as themselves on a repo-less task they cannot see:
// invisible task returns "task not found" so existence is not leaked.
let resp = schema
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).contains("task not found"),
"an invisible task must return not found, got: {}",
errors(&resp)
);
// Seed a task on a public repo that stranger CAN see, but is not assignee of:
let pub_repo = crate::db::RepoRecord {
id: "pub-r".into(),
name: "pub-r".into(),
owner_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/tmp/pub-r".into(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&pub_repo).await.expect("create repo");
let pub_task = crate::db::AgentTask {
id: "task-pub".into(),
repo_id: Some("pub-r".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
};
state
.db
.create_task(&pub_task)
.await
.expect("seed pub task");
state
.db
.claim_task("task-pub", assignee)
.await
.expect("claim pub task");
let q_pub = |actor: &str| {
format!(
r#"mutation {{ completeTask(id: "task-pub", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
)
};
let resp = schema
.execute(Request::new(q_pub(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).contains("assignee"),
"a non-assignee signer must be rejected: {}",
"a non-assignee signer on a visible task must be rejected: {}",
errors(&resp)
);
@@ -482,7 +764,7 @@ mod tests {
// repo id afterwards.
let stored = state
.db
.list_tasks(None, None, 50)
.list_tasks_keyset(None, None, 50, None)
.await
.expect("list tasks");
assert!(
@@ -644,9 +926,11 @@ mod tests {
.execute(Request::new(q(thief)).data(AuthenticatedDid(thief.into())))
.await;
let errs = errors(&resp);
// The claim gate hides existence: an ineligible claimer gets the same
// not-found as a missing task, not a reserved deny that would leak it.
assert!(
errs.contains("reserved"),
"stranger claim must surface reserved deny distinctly: {errs}"
errs.contains("task not found"),
"stranger claim must read as not-found, got: {errs}"
);
assert!(
!errs.contains("gql-ucan-secret"),
+481 -29
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
use crate::db::{Db, RepoRecord, MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
use super::types::{AgentTaskType, RefUpdateType, RepoPageType, RepoType};
use super::types::{AgentTaskReadType, RefUpdateType, RepoPageType, RepoType, TaskPageType};
fn repo_type(repo: RepoRecord) -> RepoType {
RepoType {
@@ -45,6 +45,23 @@ fn parse_repo_cursor(cursor: &str) -> Result<(String, String)> {
pub struct QueryRoot;
/// Debit the per-IP task-read brake for one task field, mirroring the
/// `rate_limit_by_ip` layer on `task_read_routes`. Both surfaces run the same
/// #268 visibility gate and pay the same queries before answering, so an
/// anonymous prober must not get an unbraked lane by asking over GraphQL
/// (#327 review).
///
/// A schema built without the brake (unit tests, subscriptions) is not braked,
/// exactly as `rate_limit_by_ip` is a no-op without its extension.
async fn task_read_brake(ctx: &Context<'_>, field: &str) -> Result<()> {
match ctx.data::<crate::rate_limit::TaskReadBrake>() {
Ok(brake) if !brake.check(field).await => Err(async_graphql::Error::new(
crate::rate_limit::RATE_LIMIT_MESSAGE,
)),
_ => Ok(()),
}
}
#[Object]
impl QueryRoot {
// DB-backed roots carry a base cost so aliases consume the request budget
@@ -217,30 +234,74 @@ impl QueryRoot {
assignee_did: Option<String>,
#[graphql(
default = 50,
desc = "Requests above the documented maximum are clamped to it (no error). Negative values clamp to 0."
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0. Use `nextCursor` to read past the clamp."
)]
limit: i64,
) -> Result<Vec<AgentTaskType>> {
#[graphql(
desc = "Opaque continuation token from a previous page's `nextCursor`. Must be presented with the same `status`/`assigneeDid` filter that issued it."
)]
cursor: Option<String>,
) -> Result<TaskPageType> {
use crate::api::task_cursor::{self, TaskCursorKey, TaskFilter};
task_read_brake(ctx, "tasks").await?;
let db = ctx.data_unchecked::<Arc<Db>>();
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
// rejects with 2201W, which would otherwise trip the opaque DB path
// and write an error-level log on every probe (#250 review).
let limit = limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE);
let tasks = db
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
.await
.map_err(crate::graphql::graphql_db_err)?;
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
// #268: gate rows via the same collector the REST list route uses (like
// `ref_updates` shares `collect_visible_ref_updates` with its REST feed),
// so the two surfaces cannot drift. The collector clamps `limit` itself,
// including the negative-LIMIT case #250 called out for this resolver.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let key = ctx.data_unchecked::<TaskCursorKey>();
let filter = TaskFilter {
status: status.as_deref(),
assignee_did: assignee_did.as_deref(),
};
let resume = cursor
.as_deref()
.map(|token| task_cursor::decode(key, filter, caller, token))
.transpose()
.map_err(crate::graphql::graphql_app_err)?;
let result = crate::api::tasks::collect_visible_tasks(
db,
status.as_deref(),
assignee_did.as_deref(),
limit,
resume.as_ref(),
caller,
)
.await
.map_err(crate::graphql::graphql_app_err)?;
Ok(TaskPageType {
next_cursor: result
.next_position
.as_ref()
.map(|pos| task_cursor::encode(key, filter, caller, pos)),
items: result
.tasks
.into_iter()
.map(AgentTaskReadType::from)
.collect(),
has_more: result.has_more,
incomplete: result.incomplete,
})
}
#[graphql(complexity = "50 + child_complexity")]
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskReadType>> {
task_read_brake(ctx, "task").await?;
let db = ctx.data_unchecked::<Arc<Db>>();
let t = db
.get_task(&id)
// #268: same gate as the REST get route, via the shared helper.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let t = crate::api::tasks::get_visible_task(db, &id, caller)
.await
.map_err(crate::graphql::graphql_db_err)?;
Ok(t.map(AgentTaskType::from))
.map_err(crate::graphql::graphql_app_err)?;
Ok(t.map(AgentTaskReadType::from))
}
}
@@ -889,10 +950,14 @@ mod tests {
Arc::new(db)
}
fn cursor_key() -> crate::api::task_cursor::TaskCursorKey {
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32])
}
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
let (task_tx, _) = tokio::sync::broadcast::channel(16);
super::super::build_schema(db, ref_tx, task_tx)
super::super::build_schema(db, ref_tx, task_tx, cursor_key())
}
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
@@ -1202,7 +1267,7 @@ mod tests {
async fn tasks_negative_limit_clamped(pool: PgPool) {
let db = db(pool).await;
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
let resp = anon(&schema, "{ tasks(limit: -1) { items { id } } }").await;
assert!(
resp.errors.is_empty(),
"negative limit must clamp, not fail: {:?}",
@@ -1237,12 +1302,12 @@ mod tests {
.unwrap();
}
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
assert_eq!(
count_tasks(&resp),
crate::db::MAX_VISIBLE_TASK_PAGE_SIZE as usize,
"limit above the maximum must clamp to MAX_VISIBLE_TASK_PAGE_SIZE"
);
// Queried as the delegator, not anonymously: since #268 the task read
// surface is visibility-gated, and an anonymous caller sees none of
// these repo-less tasks at all. The clamp is what this test pins, so it
// needs a caller who can legitimately see all 201 rows.
let resp = authed(&schema, "{ tasks(limit: 5000) { items { id } } }", OWNER).await;
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
}
/// A limit above MAX_VISIBLE_REF_UPDATES must reach the resolver clamped,
@@ -1283,14 +1348,401 @@ mod tests {
);
}
fn count_tasks(resp: &async_graphql::Response) -> usize {
/// Seed one repo-less task carrying a `ucan_token`, so a leak on any read
/// surface is visible in the response body.
async fn seed_task(db: &Db, id: &str, delegator: &str) {
let now = Utc::now().to_rfc3339();
db.create_task(&crate::db::AgentTask {
id: id.into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: delegator.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: Some("SECRET-UCAN-TOKEN".into()),
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
})
.await
.unwrap();
}
/// #268: the `tasks` resolver must delegate to the gated collector, not
/// query the DB directly. A repo-less task belonging to someone else is
/// invisible to an anonymous caller. `tasks_negative_limit_clamped` cannot
/// catch a resolver that stops calling `collect_visible_tasks` because it
/// seeds no rows — this seeds one, so the gate is load-bearing here.
#[sqlx::test]
async fn tasks_repo_less_task_hidden_from_anon(pool: PgPool) {
let db = db(pool).await;
seed_task(&db, "t1", OWNER).await;
let schema = schema(db);
let resp = anon(&schema, "{ tasks { items { id } } }").await;
assert_eq!(
count_tasks(&resp),
0,
"anon must not enumerate another party's repo-less task"
);
assert!(
!format!("{:?}", resp.data).contains("SECRET-UCAN-TOKEN"),
"no ucan token may reach an anonymous caller"
);
}
/// #268 sibling for the single-task resolver: an invisible task reads as
/// `null`, indistinguishable from one that does not exist.
#[sqlx::test]
async fn task_by_id_is_null_for_anon(pool: PgPool) {
let db = db(pool).await;
seed_task(&db, "t1", OWNER).await;
let schema = schema(db);
let resp = anon(&schema, r#"{ task(id: "t1") { id } }"#).await;
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not a list");
assert_eq!(
obj.get("task"),
Some(&async_graphql::Value::Null),
"an invisible task must read as null, got {:?}",
obj.get("task")
);
}
/// #268: `ucanToken` is absent from the read type's schema entirely, so the
/// delegator cannot request it either. Asking for it is a validation error,
/// which pins the redaction at the schema level rather than per-resolver.
#[sqlx::test]
async fn task_read_schema_has_no_ucan_token_field(pool: PgPool) {
let db = db(pool).await;
seed_task(&db, "t1", OWNER).await;
let schema = schema(db);
let resp = authed(&schema, r#"{ task(id: "t1") { id ucanToken } }"#, OWNER).await;
assert!(
!resp.errors.is_empty(),
"ucanToken must not exist on the task read type"
);
}
#[sqlx::test]
async fn tasks_find_older_visible_row_behind_denied_window(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("public-repo", OWNER, "public", true))
.await
.unwrap();
let visible = crate::db::AgentTask {
id: "visible".into(),
repo_id: Some("public-repo".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: OWNER.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
rows.len()
db.create_task(&visible).await.unwrap();
for i in 0..200 {
let mut hidden = visible.clone();
hidden.id = format!("hidden-{i:03}");
hidden.repo_id = None;
hidden.created_at = "2026-01-02T00:00:00Z".into();
hidden.updated_at = hidden.created_at.clone();
db.create_task(&hidden).await.unwrap();
}
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: 1) { items { id } incomplete } }").await;
assert_eq!(count_tasks(&resp), 1);
assert!(!task_incomplete(&resp));
assert!(format!("{:?}", resp.data).contains("visible"));
}
/// The GraphQL surface must page past a denied window on server-issued
/// cursors alone, exactly as REST does (#327 review). Before this, the
/// only cursor a caller could hold named the last row they saw, so the
/// query stalled at `incomplete: true` forever and never reached
/// `past-ceiling`.
#[sqlx::test]
async fn tasks_page_past_candidate_ceiling_on_server_cursors(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("public-repo", OWNER, "public", true))
.await
.unwrap();
let visible_newer = crate::db::AgentTask {
id: "newer-visible".into(),
repo_id: Some("public-repo".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: OWNER.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: "2026-01-03T00:00:00Z".into(),
updated_at: "2026-01-03T00:00:00Z".into(),
deadline: None,
};
db.create_task(&visible_newer).await.unwrap();
for i in 0..1500 {
let mut hidden = visible_newer.clone();
hidden.id = format!("hidden-{i:04}");
hidden.repo_id = None;
hidden.created_at = "2026-01-02T00:00:00Z".into();
hidden.updated_at = hidden.created_at.clone();
db.create_task(&hidden).await.unwrap();
}
let mut visible_older = visible_newer.clone();
visible_older.id = "past-ceiling".into();
visible_older.created_at = "2026-01-01T00:00:00Z".into();
visible_older.updated_at = visible_older.created_at.clone();
db.create_task(&visible_older).await.unwrap();
let schema = schema(db);
let mut seen: Vec<String> = Vec::new();
let mut cursor: Option<String> = None;
for request in 0..10 {
let query = match &cursor {
Some(c) => format!(
r#"{{ tasks(limit: 1, cursor: "{c}") {{ items {{ id }} hasMore incomplete nextCursor }} }}"#
),
None => {
"{ tasks(limit: 1) { items { id } hasMore incomplete nextCursor } }".to_string()
}
};
let resp = anon(&schema, &query).await;
let rendered = format!("{:?}", resp.data);
assert!(
!rendered.contains("hidden-"),
"request {request} disclosed a denied row: {rendered}"
);
for item in task_items(&resp) {
let async_graphql::Value::Object(row) = item else {
panic!("task item not an object");
};
seen.push(row.get("id").unwrap().to_string().replace('"', ""));
}
// Short page with more behind it is the scan wall, and says so.
if task_page_bool(&resp, "hasMore") && task_items(&resp).is_empty() {
assert!(
task_incomplete(&resp),
"request {request}: an empty page with rows behind it is a paused scan"
);
}
match task_page_cursor(&resp) {
Some(c) => cursor = Some(c),
None => {
assert!(!task_page_bool(&resp, "hasMore"));
assert!(!task_incomplete(&resp));
break;
}
}
}
assert_eq!(
seen,
vec!["newer-visible".to_string(), "past-ceiling".to_string()],
"both visible rows must be reachable using only server-issued cursors"
);
}
/// REST and GraphQL mint the same tokens from the same node key, so a
/// cursor must not be usable against a filter it was not issued for, and
/// must render the same single rejection every other bad cursor renders.
#[sqlx::test]
async fn tasks_rejects_unusable_cursor(pool: PgPool) {
use crate::api::task_cursor::{self, TaskFilter, TaskPosition};
let db = db(pool).await;
let schema = schema(db);
let wrong_filter = task_cursor::encode(
&cursor_key(),
TaskFilter {
status: Some("pending"),
assignee_did: None,
},
None,
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
);
let foreign = task_cursor::encode(
&crate::api::task_cursor::TaskCursorKey::derive(&[9u8; 32]),
TaskFilter {
status: None,
assignee_did: None,
},
None,
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
);
for (label, query) in [
(
"garbage",
r#"{ tasks(cursor: "not-a-cursor") { items { id } } }"#.to_string(),
),
(
"another node's key",
format!(r#"{{ tasks(cursor: "{foreign}") {{ items {{ id }} }} }}"#),
),
(
"different filter",
format!(r#"{{ tasks(cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#),
),
] {
let resp = anon(&schema, &query).await;
assert_eq!(
resp.errors.len(),
1,
"{label}: must be rejected, not treated as page one"
);
assert!(
resp.errors[0].message.contains("invalid or expired cursor"),
"{label}: unexpected message {:?}",
resp.errors[0].message
);
}
let resp = anon(
&schema,
&format!(
r#"{{ tasks(status: "pending", cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#
),
)
.await;
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
}
#[sqlx::test]
async fn tasks_anonymous_denial_hides_repoless_task_and_leaks_no_token(pool: PgPool) {
let db = db(pool).await;
let task = crate::db::AgentTask {
id: "t1".into(),
repo_id: None,
kind: "code-review".into(),
status: "pending".into(),
delegator_did: "did:key:z6MkDelegator".into(),
assignee_did: None,
capability: "agent:task".into(),
ucan_token: Some("secret-ucan-token".into()),
payload: Some("payload".into()),
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
db.create_task(&task).await.unwrap();
let schema = schema(db);
let query = "{ tasks { items { id } } }";
let resp = anon(&schema, query).await;
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
assert_eq!(count_tasks(&resp), 0);
let rendered = format!("{:?}", resp.data);
assert!(!rendered.contains("secret-ucan-token"));
}
#[sqlx::test]
async fn task_anonymous_denial_returns_null_and_leaks_no_token(pool: PgPool) {
let db = db(pool).await;
let task = crate::db::AgentTask {
id: "t1".into(),
repo_id: None,
kind: "code-review".into(),
status: "pending".into(),
delegator_did: "did:key:z6MkDelegator".into(),
assignee_did: None,
capability: "agent:task".into(),
ucan_token: Some("secret-ucan-token".into()),
payload: Some("payload".into()),
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
db.create_task(&task).await.unwrap();
let schema = schema(db);
let query = r#"{ task(id: "t1") { id } }"#;
let resp = anon(&schema, query).await;
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
assert_eq!(obj.get("task"), Some(&async_graphql::Value::Null));
let rendered = format!("{:?}", resp.data);
assert!(!rendered.contains("secret-ucan-token"));
}
fn task_page_bool(resp: &async_graphql::Response, field: &str) -> bool {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
let async_graphql::Value::Boolean(value) = page.get(field).expect("field present") else {
panic!("{field} not a bool");
};
*value
}
fn task_page_cursor(resp: &async_graphql::Response) -> Option<String> {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
match page.get("nextCursor").expect("nextCursor key") {
async_graphql::Value::Null => None,
async_graphql::Value::String(c) => Some(c.clone()),
other => panic!("nextCursor not a string: {other:?}"),
}
}
fn task_items(resp: &async_graphql::Response) -> &Vec<async_graphql::Value> {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
let async_graphql::Value::List(rows) = page.get("items").expect("items key") else {
panic!("items not a list");
};
rows
}
fn count_tasks(resp: &async_graphql::Response) -> usize {
task_items(resp).len()
}
fn task_incomplete(resp: &async_graphql::Response) -> bool {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
let async_graphql::Value::Boolean(incomplete) =
page.get("incomplete").expect("incomplete key")
else {
panic!("incomplete not a bool");
};
*incomplete
}
}
@@ -11,9 +11,9 @@ pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
/// Live ref-update stream. `/graphql/ws` is mounted outside the
/// `optional_signature` layer, so this resolver has NO caller identity and
/// cannot gate per-subscriber — it relays whatever enters the broadcast
/// Live ref-update stream. `/graphql/ws` is mounted under the
/// `optional_signature` layer and carries optional caller identity, but this
/// resolver does not inspect it — it relays whatever enters the broadcast
/// channel to any anonymous client. Its visibility safety therefore rests
/// entirely on the WRITE side: the push handler only sends a
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
+59
View File
@@ -56,6 +56,65 @@ impl From<AgentTask> for AgentTaskType {
}
}
/// Read-only projection of `AgentTask` for the `tasks`/`task` queries, as
/// opposed to `AgentTaskType`, which the task mutations (`createTask`,
/// `claimTask`, `completeTask`, `failTask` — all `require_signer`-gated)
/// return. Identical except for the missing `ucan_token` (#268): a read
/// surface never needs to echo it back, since the assignee already received it
/// at delegation/claim time via the mutation response.
#[derive(SimpleObject, Clone)]
pub struct AgentTaskReadType {
pub id: String,
pub repo_id: Option<String>,
pub kind: String,
pub status: String,
pub delegator_did: String,
pub assignee_did: Option<String>,
pub capability: String,
pub payload: Option<String>,
pub result: Option<String>,
pub created_at: String,
pub updated_at: String,
pub deadline: Option<String>,
}
/// Wraps a `tasks` page with the same completion signals the REST list route
/// exposes, so the two surfaces answer pagination identically.
///
/// `hasMore` and `incomplete` are separate because they are separate facts
/// (#327 review): `hasMore` says more candidates remain, `incomplete` says
/// this page is short *only* because the authorization scan hit its safety
/// wall. `nextCursor` is present exactly when `hasMore` is true and is an
/// opaque MAC'd token — it can name the last examined candidate, denied or
/// not, without disclosing it, which is what lets a caller page past a denied
/// window instead of stalling on it.
#[derive(SimpleObject, Clone)]
pub struct TaskPageType {
pub items: Vec<AgentTaskReadType>,
pub has_more: bool,
pub incomplete: bool,
pub next_cursor: Option<String>,
}
impl From<AgentTask> for AgentTaskReadType {
fn from(t: AgentTask) -> Self {
Self {
id: t.id,
repo_id: t.repo_id,
kind: t.kind,
status: t.status,
delegator_did: t.delegator_did,
assignee_did: t.assignee_did,
capability: t.capability,
payload: t.payload,
result: t.result,
created_at: t.created_at,
updated_at: t.updated_at,
deadline: t.deadline,
}
}
}
#[derive(SimpleObject, Clone)]
pub struct RefUpdateType {
pub repo: String,
+166 -1
View File
@@ -126,15 +126,55 @@ fn decode_key(b64url: &str) -> Option<VerifyingKey> {
VerifyingKey::from_bytes(&arr).ok()
}
/// Upper bound on the iCaptcha `/v1/pubkey` response body. The document is a
/// handful of keys at most; anything larger is refused rather than buffered
/// into memory (the unbounded `.json()` read this replaces, #507).
const KEY_MAX_BODY_BYTES: usize = 8 * 1024;
/// Read at most [`KEY_MAX_BODY_BYTES`] bytes of a key response. Returns `None`
/// when the body exceeds the cap, so an oversized document is treated as absent
/// exactly like a network error or a timeout (fail safe, never a partial key).
async fn read_key_body(mut resp: reqwest::Response) -> Option<Vec<u8>> {
let mut buf = Vec::with_capacity(1024);
loop {
match resp.chunk().await {
Ok(Some(chunk)) => {
let take = (KEY_MAX_BODY_BYTES - buf.len()).min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if take < chunk.len() {
return None; // exceeded the cap mid-chunk
}
}
Ok(None) => return Some(buf),
Err(_) => return None,
}
}
}
async fn fetch_key(url: &str) -> Option<VerifyingKey> {
let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/'));
// Bounded request: a hung /v1/pubkey must never block node startup. On
// timeout/error we return None and the gate stays inert (fail safe).
//
// No redirects and a capped body read: the key this fetch produces is the
// TRUST ANCHOR for the whole proof gate, so a 3xx must not be allowed to move
// where it comes from (anchor substitution / SSRF, matching the node's other
// outbound clients — `build_http_client` in main.rs, the sync worker), and
// the body is bounded rather than handed to `.json()`'s unbounded buffering.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.ok()?;
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
let resp = client.get(&endpoint).send().await.ok()?;
// A non-2xx response body is not a key: with Policy::none() a 3xx is
// surfaced rather than followed, but its own body must not be parsed
// either, or the redirect would move the anchor after all.
if !resp.status().is_success() {
return None;
}
let body = read_key_body(resp).await?;
let jwks: Jwks = serde_json::from_slice(&body).ok()?;
decode_key(&jwks.keys.first()?.x)
}
@@ -647,4 +687,129 @@ mod tests {
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
}
/// `{"keys":[{"x":"<base64url ed25519 point>"}]}` for the live `PUBKEY_X`.
fn jwks_body() -> Vec<u8> {
format!(r#"{{"keys":[{{"x":"{PUBKEY_X}"}}]}}"#).into_bytes()
}
/// The no-redirect / bounded-body client still parses a valid JWKS, so the
/// hardening did not break the happy path the gate depends on at startup.
#[tokio::test]
async fn fetch_key_parses_a_valid_jwks() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(jwks_body())
.create_async()
.await;
let key = fetch_key(&server.url()).await.expect("valid JWKS parses");
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
}
/// A 302 must not move the trust anchor. The redirect target serves a REAL
/// key, so following the redirect would make `fetch_key` adopt it; instead
/// `Policy::none()` leaves the 302's (non-JWKS) body as the answer → None.
#[tokio::test]
async fn fetch_key_does_not_follow_a_redirect() {
let mut target = mockito::Server::new_async().await;
let _t = target
.mock("GET", "/v1/pubkey")
.expect(0)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(jwks_body())
.create_async()
.await;
let mut redirector = mockito::Server::new_async().await;
// Location points at the target's /v1/pubkey, so a client that DID
// follow the redirect would fetch the real key and return Some: the
// assertion below only holds while Policy::none() is in place.
let _r = redirector
.mock("GET", "/v1/pubkey")
.with_status(302)
.with_header("Location", &format!("{}/v1/pubkey", target.url()))
.create_async()
.await;
assert!(
fetch_key(&redirector.url()).await.is_none(),
"a redirect must not be followed to a host serving a valid key"
);
}
/// A body past the cap is refused rather than buffered, so a broken
/// endpoint cannot make the node materialize an arbitrarily large document.
/// The body is a VALID JWKS (`jwks_body()`) padded with JSON whitespace past
/// the cap, so an unbounded read would still parse it; this test only passes
/// because the byte cap refuses it.
#[tokio::test]
async fn fetch_key_rejects_an_oversized_body() {
let mut server = mockito::Server::new_async().await;
let mut oversized = jwks_body();
oversized.resize(KEY_MAX_BODY_BYTES + 1, b' ');
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(oversized)
.create_async()
.await;
assert!(
fetch_key(&server.url()).await.is_none(),
"an oversized body must be refused even when it is a valid JWKS"
);
}
/// A body of exactly the cap still parses: the contract refuses bodies that
/// EXCEED it, not bodies that reach it (the padded JWKS is exactly
/// KEY_MAX_BODY_BYTES bytes of otherwise-valid JSON).
#[tokio::test]
async fn fetch_key_accepts_a_body_at_the_cap() {
let mut server = mockito::Server::new_async().await;
let mut at_cap = jwks_body();
at_cap.resize(KEY_MAX_BODY_BYTES, b' ');
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(at_cap)
.create_async()
.await;
let key = fetch_key(&server.url()).await.expect("at-cap JWKS parses");
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
}
/// A non-2xx response never supplies the anchor, even when its body is a
/// perfectly valid JWKS: the redirect pin only stops the hop, and the
/// status gate is what stops the 3xx's own body from being adopted.
#[tokio::test]
async fn fetch_key_rejects_a_non_success_status() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/v1/pubkey")
.with_status(500)
.with_header("Content-Type", "application/json")
.with_body(jwks_body())
.create_async()
.await;
assert!(
fetch_key(&server.url()).await.is_none(),
"a non-2xx body must not be adopted as the anchor even when it parses"
);
}
/// An unreachable endpoint is the same fail-safe None as every other fetch
/// error: the gate stays inert rather than the node failing to start.
#[tokio::test]
async fn fetch_key_returns_none_when_the_endpoint_is_unreachable() {
assert!(fetch_key("http://127.0.0.1:1").await.is_none());
}
}
+29
View File
@@ -187,6 +187,11 @@ where
F: std::future::Future<Output = Result<T>>,
{
let left = deadline.saturating_duration_since(Instant::now());
// `timeout` polls the future before it checks the timer, so a zero budget
// would still start the work. A spent deadline must not.
if left.is_zero() {
return Err(BoundedDbError::Elapsed);
}
match tokio::time::timeout(left, fut).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(BoundedDbError::Db(e)),
@@ -3433,6 +3438,30 @@ mod tests {
);
}
/// A spent deadline must not start the work at all. `tokio::time::timeout`
/// polls the inner future before it checks the timer, so a future that is
/// ready on its first poll would otherwise slip through a zero budget and
/// return a value. Deterministic: the future has no await point.
#[tokio::test]
async fn db_bounded_spent_deadline_does_not_poll_a_ready_future() {
let spent = Instant::now() - Duration::from_secs(5);
let polled = std::sync::atomic::AtomicBool::new(false);
let out: Result<u8, BoundedDbError> = db_bounded(spent, async {
polled.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(7u8)
})
.await;
assert!(
matches!(out, Err(BoundedDbError::Elapsed)),
"a spent deadline must yield the timeout arm even for a ready future: {out:?}"
);
assert!(
!polled.load(std::sync::atomic::Ordering::SeqCst),
"a spent deadline must not start the work"
);
}
/// U5 (#173): the elapsed arm of the discovery record leaves neither row behind.
///
/// What this proves and what it does NOT: it shows the wrapper composition
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+112 -5
View File
@@ -5,6 +5,8 @@
//!
//! * is push traffic flowing? — `gitlawb_pushes_total`
//! * is fetch traffic flowing? — `gitlawb_fetches_total`
//! * how long do successful push and fetch operations take? —
//! `gitlawb_push_duration_seconds` / `gitlawb_fetch_duration_seconds`
//! * are signature checks passing or failing? —
//! `gitlawb_auth_successes_total` / `gitlawb_auth_failures_total`
//! * is the sync worker making progress? —
@@ -18,6 +20,8 @@
//! `gitlawb_gossip_replay_guard_saturated_total`
//! * how big are the packs we're sending and receiving? —
//! `gitlawb_pack_size_bytes`
//! * how long after an origin lands a push is it visible on this mirror? —
//! `gitlawb_sync_lag_seconds`
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
//! * currently-connected peer count — `gitlawb_peers_connected`
//! * reconciliation sweep gaps found and filled —
@@ -34,11 +38,10 @@
//! free.
//!
//! Follow-ups (not in this module):
//! * per-route latency histograms (TraceLayer already gives us spans)
//! * per-peer p2p counters
//! * ipfs / pinata counters
use std::sync::OnceLock;
use std::{sync::OnceLock, time::Duration};
use prometheus::{
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
@@ -60,6 +63,9 @@ static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
static GOSSIP_REPLAY_GUARD_SATURATED: OnceLock<IntCounter> = OnceLock::new();
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
static PUSH_DURATION: OnceLock<Histogram> = OnceLock::new();
static FETCH_DURATION: OnceLock<Histogram> = OnceLock::new();
static SYNC_LAG: OnceLock<Histogram> = OnceLock::new();
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
static RECONCILIATION_GAPS_FOUND: OnceLock<IntCounter> = OnceLock::new();
static RECONCILIATION_GAPS_FILLED: OnceLock<IntCounter> = OnceLock::new();
@@ -125,6 +131,51 @@ fn init_inner(version: &str, node_did: &str) {
.expect("register gitlawb_fetches_total");
FETCHES.set(fetches).expect("set FETCHES once");
let push_duration = Histogram::with_opts(
HistogramOpts::new(
"gitlawb_push_duration_seconds",
"Wall time of a successful git push (receive-pack) request in seconds, from handler entry to its response, including claim-loser returns where another worker owns the effects",
)
.buckets(operation_duration_buckets()),
)
.expect("gitlawb_push_duration_seconds definition");
registry
.register(Box::new(push_duration.clone()))
.expect("register gitlawb_push_duration_seconds");
PUSH_DURATION
.set(push_duration)
.expect("set PUSH_DURATION once");
let fetch_duration = Histogram::with_opts(
HistogramOpts::new(
"gitlawb_fetch_duration_seconds",
"Duration of successful git fetch (upload-pack) completion requests in seconds",
)
.buckets(operation_duration_buckets()),
)
.expect("gitlawb_fetch_duration_seconds definition");
registry
.register(Box::new(fetch_duration.clone()))
.expect("register gitlawb_fetch_duration_seconds");
FETCH_DURATION
.set(fetch_duration)
.expect("set FETCH_DURATION once");
let sync_lag = Histogram::with_opts(
HistogramOpts::new(
"gitlawb_sync_lag_seconds",
"Lag from the origin push-land timestamp (lag_origin) to successful local mirror visibility in seconds",
)
.buckets(vec![
1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1_800.0, 3_600.0,
]),
)
.expect("gitlawb_sync_lag_seconds definition");
registry
.register(Box::new(sync_lag.clone()))
.expect("register gitlawb_sync_lag_seconds");
SYNC_LAG.set(sync_lag).expect("set SYNC_LAG once");
let auth_successes = IntCounterVec::new(
Opts::new(
"gitlawb_auth_successes_total",
@@ -302,6 +353,35 @@ pub fn record_fetch(repo: &str) {
}
}
/// Observe the elapsed time of a successful git push handler.
pub fn observe_push_duration(duration: Duration) {
observe_duration(&PUSH_DURATION, duration);
}
/// Observe the elapsed time of a successful git fetch completion request.
pub fn observe_fetch_duration(duration: Duration) {
observe_duration(&FETCH_DURATION, duration);
}
/// Observe end-to-end lag until an origin ref update is visible on this mirror.
pub fn observe_sync_lag(duration: Duration) {
observe_duration(&SYNC_LAG, duration);
}
fn observe_duration(histogram: &OnceLock<Histogram>, duration: Duration) {
if let Some(h) = histogram.get() {
h.observe(duration.as_secs_f64());
}
}
/// Test-only: current `gitlawb_sync_lag_seconds` sample count (0 if the
/// registry is not initialized). Lets the sync lag-gate tests assert that a
/// skipped observation really never reached the histogram.
#[cfg(test)]
pub fn sync_lag_sample_count_for_test() -> u64 {
SYNC_LAG.get().map(|h| h.get_sample_count()).unwrap_or(0)
}
/// Test-only: current `gitlawb_fetches_total` value for `repo` (0 if the registry
/// is not initialized). Lets api-layer tests assert the completed-fetch count with
/// a unique label instead of scraping the encoded text.
@@ -342,9 +422,11 @@ pub fn record_sync_processed(status: &str) {
}
/// Record one webhook delivery attempt outcome. `result` ∈
/// {ok, http_error, network_error, skipped, invalid_url}.
/// {ok, http_error, network_error, skipped, invalid_url, secret_unopenable}.
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
/// check at delivery time and the POST was never sent.
/// check at delivery time and the POST was never sent. `secret_unopenable` is
/// a withheld delivery: the hook carries a secret the node could not open, so
/// the POST was never sent rather than going out unsigned.
pub fn record_webhook_delivery(result: &str) {
if let Some(c) = WEBHOOK_DELIVERIES.get() {
c.with_label_values(&[result]).inc();
@@ -418,6 +500,12 @@ pub fn observe_pack_size(bytes: f64) {
}
}
fn operation_duration_buckets() -> Vec<f64> {
vec![
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
]
}
/// Update the currently-connected peer count gauge.
pub fn set_peers_connected(count: i64) {
if let Some(g) = PEERS_CONNECTED.get() {
@@ -483,6 +571,11 @@ mod tests {
.expect("PUSHES set after init")
.with_label_values(&["alice/repo"])
.inc();
observe_push_duration(Duration::from_millis(42));
observe_fetch_duration(Duration::from_millis(24));
// `observe_sync_lag` is deliberately NOT called. The sync lag-gate
// test asserts a before/after delta on the process-wide histogram, so
// a sample from this test running concurrently would flake it.
record_reconciliation_gaps_found(7);
record_reconciliation_gaps_filled(3);
@@ -499,6 +592,16 @@ mod tests {
body.contains("gitlawb_pushes_total{repo=\"alice/repo\"} 1"),
"expected the incremented counter to be visible in: {body}"
);
for metric in [
"gitlawb_push_duration_seconds",
"gitlawb_fetch_duration_seconds",
"gitlawb_sync_lag_seconds",
] {
assert!(
body.contains(&format!("# TYPE {metric} histogram")),
"expected {metric} histogram in: {body}"
);
}
assert!(
body.contains("gitlawb_reconciliation_gaps_found_total 7"),
"expected the reconciliation gaps-found counter to be visible in: {body}"
@@ -541,9 +644,13 @@ mod tests {
// here. It is a label-less process-wide counter and the seen-set test
// that asserts it asserts a before/after delta, so an increment from a
// second test running concurrently in this binary would be a flake with
// no diagnostic value.
// no diagnostic value. `observe_sync_lag` is withheld for the same
// reason: the sync lag-gate test asserts a sample-count delta on the
// process-wide histogram.
record_gossip_ingest("accepted");
observe_pack_size(1024.0);
observe_push_duration(Duration::from_secs(1));
observe_fetch_duration(Duration::from_secs(1));
set_peers_connected(0);
}
+87 -22
View File
@@ -306,8 +306,9 @@ impl IngestLimiters {
///
/// 0 is the versionless form: the field set this struct shipped with, before
/// `v` existed. It is not a placeholder for "unset", it is a real version whose
/// wire encoding happens to omit the key.
pub(crate) const CURRENT_REF_UPDATE_VERSION: u32 = 0;
/// wire encoding happens to omit the key. 1 adds `lag_origin`, the first
/// version-gated field: it is emitted and verified only under v1.
pub(crate) const CURRENT_REF_UPDATE_VERSION: u32 = 1;
/// True for the version whose key is omitted from the wire form. Free function
/// because `skip_serializing_if` takes a path, not a closure.
@@ -376,8 +377,15 @@ pub struct RefUpdateEvent {
pub old_sha: String,
/// SHA after the push
pub new_sha: String,
/// RFC-3339 timestamp
/// RFC-3339 timestamp of the publish, freshness-checked at ingest
pub timestamp: String,
/// RFC-3339 instant the origin push landed, for sync-lag measurement only.
/// Kept out of `timestamp` so `check_freshness` still sees the publish
/// instant: a push whose durability wait ran long would otherwise sign an
/// already-stale event. Optional for older peers; ingest falls back to
/// `timestamp` when it is absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lag_origin: Option<String>,
/// Certificate ID (from the ref certificate, if issued)
pub cert_id: Option<String>,
/// IPFS CID of the latest commit object (set after pinning completes)
@@ -1192,6 +1200,14 @@ pub(crate) async fn ingest_ref_update(
event.v, CURRENT_REF_UPDATE_VERSION
));
}
// `lag_origin` is a v1 field: a v0 event carrying it is a protocol
// violation, not a newer peer's wire form, and refusing it keeps the field
// set inside each version's signing bytes exactly what that version means.
if event.v < 1 && event.lag_origin.is_some() {
return IngestOutcome::Rejected(
"v0 ref-update event must not carry the v1 field lag_origin".to_string(),
);
}
// The cheapest denial under enforcement, ahead of every gate that reads a
// field: an unsigned event is refused before its `node_did` is resolved or
@@ -1498,6 +1514,12 @@ pub(crate) async fn ingest_ref_update(
&event.ref_name,
&event.new_sha,
event.cid.as_deref(),
Some(
event
.lag_origin
.as_deref()
.unwrap_or(event.timestamp.as_str()),
),
)
.await
{
@@ -7193,7 +7215,7 @@ mod tests {
#[test]
fn ref_update_event_round_trip_with_owner_did() {
let event = RefUpdateEvent {
v: 0,
v: CURRENT_REF_UPDATE_VERSION,
node_did: "did:key:zNode".into(),
pusher_did: "did:key:zPusher".into(),
repo: "zOwner/myrepo".into(),
@@ -7202,6 +7224,7 @@ mod tests {
old_sha: "0000000000000000000000000000000000000000".into(),
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
timestamp: "2026-07-02T12:00:00Z".into(),
lag_origin: Some("2026-07-02T11:59:30Z".into()),
cert_id: None,
cid: None,
sig: None,
@@ -7213,6 +7236,7 @@ mod tests {
let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into()));
assert_eq!(deserialized.lag_origin, Some("2026-07-02T11:59:30Z".into()));
}
#[test]
@@ -7255,7 +7279,7 @@ mod tests {
/// field is Some so the serialized form exercises the widest field set.
fn populated_event() -> RefUpdateEvent {
RefUpdateEvent {
v: 0,
v: CURRENT_REF_UPDATE_VERSION,
node_did: "did:key:zNode".into(),
pusher_did: "did:key:zPusher".into(),
repo: "zOwner/myrepo".into(),
@@ -7264,6 +7288,7 @@ mod tests {
old_sha: "0000000000000000000000000000000000000000".into(),
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
timestamp: "2026-07-02T12:00:00Z".into(),
lag_origin: Some("2026-07-02T11:59:30Z".into()),
cert_id: Some("cert-1".into()),
cid: Some("bafycid".into()),
sig: None,
@@ -7318,30 +7343,53 @@ mod tests {
cid: Option<String>,
}
let mut event = populated_event();
event.sig = Some("c2lnbmF0dXJl".into());
let bytes = serde_json::to_vec(&event).unwrap();
// The fleet rule this version split is built around:
//
// * A v0 event is the pre-change wire form: no `v` key, no `lag_origin`,
// and it must still parse under the OLD struct verbatim.
// * A v1 event is for nodes that bumped with us. An un-upgraded peer
// rejects it on the `v` key itself, which is the LOUD version drop the
// ingest ceiling was designed to name, not a silent signature
// mismatch against bytes it could never compute.
let mut v0_event = populated_event();
v0_event.v = 0;
v0_event.lag_origin = None;
v0_event.sig = Some("c2lnbmF0dXJl".into());
let bytes = serde_json::to_vec(&v0_event).unwrap();
let legacy: LegacyRefUpdateEvent = serde_json::from_slice(&bytes)
.expect("new-code bytes must deserialize under the old field set");
.expect("v0 bytes must deserialize under the old field set");
assert_eq!(legacy.repo, "zOwner/myrepo");
assert_eq!(legacy.owner_did, Some("did:key:zOwner".into()));
// An UNSIGNED event carries no `sig` key at all, so it is byte-identical
// An UNSIGNED v0 event carries no `sig` key at all, so it is byte-identical
// in shape to the pre-change wire form and parses even under the strict
// reader. This is what `skip_serializing_if` buys; drop it and a
// `"sig": null` key appears here and this goes red.
let unsigned_bytes = serde_json::to_vec(&populated_event()).unwrap();
v0_event.sig = None;
let unsigned_bytes = serde_json::to_vec(&v0_event).unwrap();
let strict: StrictLegacyRefUpdateEvent = serde_json::from_slice(&unsigned_bytes)
.expect("an unsigned event must carry no field the pre-change reader did not know");
.expect("an unsigned v0 event must carry no field the pre-change reader did not know");
assert_eq!(strict.repo, "zOwner/myrepo");
// A SIGNED event does carry the new key, and that is intentional, not a
// compatibility bug: the permissive reader above is what makes it
// harmless. Pinning the refusal here documents `sig` as the one added
// field, so a SECOND addition cannot slip in unnoticed.
serde_json::from_slice::<StrictLegacyRefUpdateEvent>(&bytes)
.expect_err("a signed event must be visibly carrying the added `sig` key");
// A SIGNED v0 event does carry the added `sig` key, and that is
// intentional, not a compatibility bug: the permissive reader above is
// what makes it harmless. Pinning the refusal here documents `sig` as
// an added field, so a SECOND unsigned-field addition cannot slip in
// unnoticed.
v0_event.sig = Some("c2lnbmF0dXJl".into());
serde_json::from_slice::<StrictLegacyRefUpdateEvent>(
&serde_json::to_vec(&v0_event).unwrap(),
)
.expect_err("a signed v0 event must be visibly carrying the added `sig` key");
// A v1 event is refused by the pre-change reader on the `v` key:
// refusing to parse IS the version drop old nodes perform, the same
// failure the ingest ceiling produces in the other direction.
serde_json::from_slice::<StrictLegacyRefUpdateEvent>(
&serde_json::to_vec(&populated_event()).unwrap(),
)
.expect_err("a v1 event must fail loudly on the `v` key for a pre-change reader");
}
#[test]
@@ -7398,12 +7446,13 @@ mod tests {
/// this failure, and the emit-to-ingest round trip is structurally blind to
/// it because both sides re-serialize the same new declaration order.
const GOLDEN_SIGNING_BYTES: &str = concat!(
r#"{"node_did":"did:key:zNode","pusher_did":"did:key:zPusher","#,
r#"{"v":1,"node_did":"did:key:zNode","pusher_did":"did:key:zPusher","#,
r#""repo":"zOwner/myrepo","owner_did":"did:key:zOwner","#,
r#""ref_name":"refs/heads/main","#,
r#""old_sha":"0000000000000000000000000000000000000000","#,
r#""new_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","#,
r#""timestamp":"2026-07-02T12:00:00Z","cert_id":"cert-1","cid":"bafycid"}"#,
r#""timestamp":"2026-07-02T12:00:00Z","lag_origin":"2026-07-02T11:59:30Z","#,
r#""cert_id":"cert-1","cid":"bafycid"}"#,
);
#[test]
@@ -7448,9 +7497,15 @@ mod tests {
/// The all-`None` optional shape: what the production publish site emits.
fn all_none_optionals_event() -> RefUpdateEvent {
RefUpdateEvent {
// v0 pins the versionless form: the key stays off the wire and the
// signing bytes are byte-identical to what a pre-version build emits.
v: 0,
owner_did: None,
cert_id: None,
cid: None,
// None skips the key entirely, so the production-shape golden stays
// byte-identical even though lag_origin exists.
lag_origin: None,
..populated_event()
}
}
@@ -7675,8 +7730,11 @@ mod tests {
let keypair = Keypair::generate();
// Signed at v0, where the key is absent from the signing bytes.
// Raising it on the received copy makes the key appear.
// Raising it on the received copy makes the key appear. The v0 shape
// drops lag_origin too, since v0 may not carry the v1 field.
let mut at_zero = event_for(&keypair);
at_zero.v = 0;
at_zero.lag_origin = None;
sign_ref_update(&keypair, &mut at_zero).unwrap();
verify_ref_update(&at_zero).expect("baseline must verify before tampering");
let mut raised = at_zero.clone();
@@ -7981,7 +8039,14 @@ mod tests {
&bytes_of(&event),
"v1 event",
Some((
"unsupported ref-update event version 1; this build understands version 0",
Box::leak(
format!(
"unsupported ref-update event version {}; this build understands version {}",
CURRENT_REF_UPDATE_VERSION + 1,
CURRENT_REF_UPDATE_VERSION
)
.into_boxed_str(),
),
"an unknown version must be named as one, never reported as a signature failure",
)),
)
+365 -20
View File
@@ -70,10 +70,21 @@ impl RateLimiter {
self.window.min(Duration::from_secs(1))
}
/// Returns `true` if the request is allowed. Thin wrapper over
/// [`check_retry`](Self::check_retry) for callers that only need the
/// allow/deny decision, not the rejection's Retry-After delay.
pub(crate) async fn check(&self, key: &str) -> bool {
self.check_retry(key).await.is_none()
}
/// Record a request against `key`. Returns `None` when it is allowed, or
/// `Some(retry_after)` when it is rejected — the delay a client should wait
/// before retrying, so a 429 can advertise a value consistent with the
/// actual window instead of a constant.
pub(crate) async fn check_retry(&self, key: &str) -> Option<Duration> {
// max_requests == 0 means the limiter is disabled, not "block all".
if self.max_requests == 0 {
return true;
return None;
}
let now = Instant::now();
let mut state = self.state.lock().await;
@@ -85,10 +96,25 @@ impl RateLimiter {
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
// Insertion order is preserved, so after the retain the first
// element is the oldest live request. It ages out (freeing a
// slot) one window after it landed, so that remaining time is
// the delay to advertise. Clamp to >= 1 so a 429 never tells a
// client to retry immediately.
let retry = window
.timestamps
.first()
.map(|oldest| {
self.window
.as_secs()
.saturating_sub(now.duration_since(*oldest).as_secs())
})
.unwrap_or(0)
.max(1);
return Some(Duration::from_secs(retry));
}
window.timestamps.push(now);
return true;
return None;
}
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
@@ -109,7 +135,11 @@ impl RateLimiter {
}
drop(last_sweep);
if state.len() >= self.max_keys {
return false;
// The key is untracked (never inserted), so there is no oldest
// entry to derive a delay from. Advertise the full window as a
// conservative bound: capacity frees as other keys expire, at
// the latest one window from now.
return Some(self.window.max(Duration::from_secs(1)));
}
}
state.insert(
@@ -118,7 +148,23 @@ impl RateLimiter {
timestamps: vec![now],
},
);
true
None
}
/// Give back one slot a prior [`check`](Self::check) took for `key`, for a
/// caller that debits two buckets and must undo the first when the second
/// refuses. Drops the newest timestamp: the count is what the cap reads, so
/// that is equivalent to dropping the caller's own even if another request
/// landed in between. An unknown key is a no-op, and a key left with no
/// timestamps is removed so a refund never holds a map entry.
pub(crate) async fn refund(&self, key: &str) {
let mut state = self.state.lock().await;
if let Some(window) = state.get_mut(key) {
window.timestamps.pop();
if window.timestamps.is_empty() {
state.remove(key);
}
}
}
/// Non-consuming check: is this key ALREADY at its limit for the current window?
@@ -170,6 +216,19 @@ impl RateLimiter {
});
}
/// Test-only: insert a fully-expired entry (no live timestamps) that the next
/// `cleanup()` must reclaim. Models a key whose window has aged out, without
/// depending on wall-clock sleeps or a short window. Used cross-module by the
/// AppState cleanup guard test (H2).
#[cfg(test)]
pub(crate) async fn insert_reclaimable_for_test(&self, key: &str) {
self.state
.lock()
.await
.insert(key.to_string(), Window { timestamps: vec![] });
}
/// Test-only: number of distinct keys currently tracked.
/// Number of distinct keys currently tracked. Test-only introspection so a
/// cross-module test can assert that a sweep actually evicted expired entries
/// and observe what it reclaimed. There is no production reader.
@@ -177,6 +236,23 @@ impl RateLimiter {
pub(crate) async fn tracked_keys(&self) -> usize {
self.state.lock().await.len()
}
/// Test-only: seed a key's bucket with entries of the given ages (how long
/// ago each request landed), so a test can drive the Retry-After computation
/// off a controlled oldest-entry age without wall-clock sleeps. Ages that
/// would underflow the monotonic clock collapse to `now`.
#[cfg(test)]
pub(crate) async fn seed_aged_entry_for_test(&self, key: &str, ages: &[Duration]) {
let now = Instant::now();
let timestamps = ages
.iter()
.map(|a| now.checked_sub(*a).unwrap_or(now))
.collect();
self.state
.lock()
.await
.insert(key.to_string(), Window { timestamps });
}
}
/// Per-source concurrency cap derived from the write-pool size: one resolved client
@@ -292,13 +368,8 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
.map(|a| a.0.clone());
if let (Some(limiter), Some(did)) = (limiter, did) {
if !limiter.check(&did).await {
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response();
if let Some(retry_after) = limiter.check_retry(&did).await {
return too_many_requests(retry_after);
}
}
@@ -401,18 +472,75 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
}
}
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
/// middleware now serves the push path AND the peer-sync routes, so the message
/// stays generic (the offending path is recorded in the warn log below).
pub fn too_many_requests() -> Response {
/// The shared 429 response for the rate-limit middlewares (per-DID and per-IP
/// alike). Route-agnostic: the message stays generic (the offending path, when
/// logged, is recorded at the call site).
/// The message every per-IP brake answers with, shared so the GraphQL surface
/// (which cannot return a 429 status inside a 200 GraphQL envelope) says the
/// same thing as the REST routes.
pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later";
/// The shared 429 response for the rate-limit middlewares (per-DID and per-IP
/// alike). Route-agnostic: the message stays generic (the offending path, when
/// logged, is recorded at the call site).
pub fn too_many_requests(retry_after: Duration) -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
[("retry-after", retry_after.as_secs().to_string())],
RATE_LIMIT_MESSAGE,
)
.into_response()
}
/// The per-IP task-read brake carried as GraphQL request data: the limiter plus
/// the client key the transport already resolved.
///
/// `/graphql` is a single POST endpoint, so this brake cannot sit in middleware
/// the way `task_read_routes` mounts `rate_limit_by_ip`: that would charge every
/// unrelated query and every mutation against the task-read bucket. The two task
/// resolvers debit it instead, which also prices an aliased query honestly — ten
/// aliased `tasks` fields run the visibility gate ten times and pay ten slots,
/// where one request-scoped debit would pay one (#327 review).
/// Default cap on the number of task read fields (across all aliases) permitted
/// in a single GraphQL request before rejecting further field executions (#327 review).
pub const MAX_GRAPHQL_TASK_READS_PER_REQUEST: usize = 5;
#[derive(Clone)]
pub struct TaskReadBrake {
pub limiter: RateLimiter,
/// `None` when no key could be resolved at all (no trusted header and no
/// `ConnectInfo`, e.g. a synthetic test request). Never braked, matching
/// [`rate_limit_by_ip`].
pub key: Option<String>,
pub request_count: Arc<std::sync::atomic::AtomicUsize>,
}
impl TaskReadBrake {
/// Debit one slot for one task-read field. `true` = proceed.
pub async fn check(&self, field: &str) -> bool {
let Some(key) = self.key.as_deref() else {
return true;
};
let count = self
.request_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count >= MAX_GRAPHQL_TASK_READS_PER_REQUEST {
tracing::warn!(
key = %key,
field = %field,
count = count + 1,
"per-request GraphQL task field limit exceeded"
);
return false;
}
if self.limiter.check(key).await {
return true;
}
tracing::warn!(key = %key, field = %field, "per-IP rate limit exceeded");
false
}
}
/// Throttle the git push path by resolved client IP. The socket peer address is
/// read from `ConnectInfo` (see `into_make_service_with_connect_info` in
/// `main`). Only skips the limiter when no key can be resolved at all.
@@ -425,9 +553,9 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
if let Some(limiter) = limiter {
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
if !limiter.limiter.check(&key).await {
if let Some(retry_after) = limiter.limiter.check_retry(&key).await {
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
return too_many_requests();
return too_many_requests(retry_after);
}
}
}
@@ -813,4 +941,221 @@ mod tests {
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
);
}
// ── Adversarial TrustedProxy verification through the middleware (U5) ──
// These complement the client_key unit tests above by driving hostile
// headers through rate_limit_by_ip on a real router: the property under test
// is that the *bucket key* cannot be rotated by a spoofer.
async fn post_with(
router: &axum::Router,
peer: SocketAddr,
hdrs: &[(&str, &str)],
) -> StatusCode {
use tower::ServiceExt;
let mut b = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/api/v1/repos");
for (k, v) in hdrs {
b = b.header(*k, *v);
}
let mut req = b.body(axum::body::Body::empty()).unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn xff_spoofed_leftmost_hop_cannot_rotate_bucket() {
// XForwardedFor mode, budget 1. A spoofer varies the client-controlled
// leftmost hop every request but the trusted proxy always appends the
// same rightmost hop. All requests must key to the rightmost hop, so the
// second is braked despite a fresh leftmost value.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::XForwardedFor,
});
let p1: SocketAddr = "10.0.0.1:1".parse().unwrap();
let p2: SocketAddr = "10.0.0.2:1".parse().unwrap();
// First request passes the brake, reaches (failing) auth.
assert_eq!(
post_with(&router, p1, &[("x-forwarded-for", "9.9.9.1, 203.0.113.5")]).await,
StatusCode::UNAUTHORIZED
);
// Different leftmost + different socket peer, SAME rightmost trusted hop:
// braked, because the key is the rightmost hop, not the spoofed value.
assert_eq!(
post_with(&router, p2, &[("x-forwarded-for", "9.9.9.2, 203.0.113.5")]).await,
StatusCode::TOO_MANY_REQUESTS,
"a spoofer varying the leftmost XFF hop must not rotate its bucket key"
);
}
#[tokio::test]
async fn xff_distinct_real_clients_get_distinct_buckets() {
// Distinct trusted (rightmost) hops are distinct clients: neither throttles
// the other, so a busy legitimate client cannot starve a different one.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::XForwardedFor,
});
let peer: SocketAddr = "10.0.0.9:1".parse().unwrap();
assert_eq!(
post_with(
&router,
peer,
&[("x-forwarded-for", "1.1.1.1, 203.0.113.5")]
)
.await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
post_with(
&router,
peer,
&[("x-forwarded-for", "1.1.1.1, 203.0.113.6")]
)
.await,
StatusCode::UNAUTHORIZED,
"a different trusted client hop must get its own bucket"
);
}
#[tokio::test]
async fn absent_trusted_header_collapses_to_socket_peer() {
// The documented fallback: in a trusted-proxy mode, a request with no
// forwarded header keys on the socket peer. Behind a real edge every
// request shares the proxy's socket, so they collapse onto one bucket —
// asserted here by name so any change to this behavior is deliberate.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::XForwardedFor,
});
let proxy: SocketAddr = "172.16.0.1:1".parse().unwrap();
assert_eq!(
post_with(&router, proxy, &[]).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
post_with(&router, proxy, &[]).await,
StatusCode::TOO_MANY_REQUESTS,
"with no trusted header, requests fall back to the socket peer key (collapse)"
);
}
// ── Retry-After derived from the window, not a constant 60 (U5) ───────
// A 429 must advertise the delay until the oldest live request ages out of
// the limiter's window, so the value tracks the real window instead of the
// hardcoded 60 that contradicted every limiter's 3600s window.
async fn resp_from(router: &axum::Router, peer: SocketAddr) -> Response {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/o/r/git-receive-pack")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap()
}
fn retry_after_secs(resp: &Response) -> u64 {
resp.headers()
.get("retry-after")
.expect("429 must carry a retry-after header")
.to_str()
.unwrap()
.parse()
.expect("retry-after must be an integer number of seconds")
}
#[tokio::test]
async fn retry_after_reflects_full_window_for_freshly_filled_bucket() {
// A just-filled bucket's oldest entry is ~now, so the advertised delay
// must be close to the whole window (100s), NOT the old constant 60.
let router = ip_limited_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(100)),
trust: TrustedProxy::None,
});
let peer: SocketAddr = "203.0.113.50:6000".parse().unwrap();
assert_eq!(resp_from(&router, peer).await.status(), StatusCode::OK);
let resp = resp_from(&router, peer).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry = retry_after_secs(&resp);
assert!(
(95..=100).contains(&retry),
"freshly-filled bucket must advertise ~window (95..=100s), got {retry} (constant-60 bug if 60)"
);
}
#[tokio::test]
async fn retry_after_shrinks_as_oldest_entry_ages() {
// Seed the sole slot with an entry already 95s into a 100s window: the
// next request is rejected and must advertise only the ~5s remaining,
// not 60.
let limiter = RateLimiter::new(1, Duration::from_secs(100));
let peer: SocketAddr = "203.0.113.51:6000".parse().unwrap();
limiter
.seed_aged_entry_for_test(&peer.ip().to_string(), &[Duration::from_secs(95)])
.await;
let router = ip_limited_router(IpRateLimiter {
limiter,
trust: TrustedProxy::None,
});
let resp = resp_from(&router, peer).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry = retry_after_secs(&resp);
assert!(
(1..=10).contains(&retry),
"an aged bucket must advertise the small remaining delay (1..=10s), got {retry}"
);
}
#[tokio::test]
async fn retry_after_clamps_to_at_least_one_second() {
// Oldest entry is 99s into a 100s window: the raw remainder rounds toward
// 1s and must never be advertised as 0 (a 0/blank retry lets a rejected
// client hammer immediately).
let limiter = RateLimiter::new(1, Duration::from_secs(100));
let peer: SocketAddr = "203.0.113.52:6000".parse().unwrap();
limiter
.seed_aged_entry_for_test(&peer.ip().to_string(), &[Duration::from_secs(99)])
.await;
let router = ip_limited_router(IpRateLimiter {
limiter,
trust: TrustedProxy::None,
});
let resp = resp_from(&router, peer).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let retry = retry_after_secs(&resp);
assert!(retry >= 1, "retry-after must clamp to >= 1, got {retry}");
assert!(
retry <= 2,
"with 99s of a 100s window elapsed, the remaining delay is ~1s, got {retry}"
);
}
/// `refund` gives back exactly one slot a prior `check` took, so a caller
/// that debits two buckets can undo the first when the second refuses.
#[tokio::test]
async fn refund_returns_one_slot_and_is_a_no_op_for_an_unknown_key() {
let limiter = RateLimiter::new(2, Duration::from_secs(60));
assert!(limiter.check("k").await);
assert!(limiter.check("k").await);
assert!(!limiter.check("k").await, "bucket is full");
limiter.refund("k").await;
assert!(limiter.check("k").await, "one slot came back");
assert!(!limiter.check("k").await, "and only one");
// An unknown key must neither panic nor create an entry.
limiter.refund("never-seen").await;
assert!(limiter.state.lock().await.get("never-seen").is_none());
// Refunding the only slot drops the key, so a refunded key does not
// hold a map entry (the limiter's key cap bounds its own state).
let single = RateLimiter::new(1, Duration::from_secs(60));
assert!(single.check("solo").await);
single.refund("solo").await;
assert!(single.state.lock().await.get("solo").is_none());
}
}
+471 -18
View File
@@ -1,5 +1,6 @@
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
use axum::extract::DefaultBodyLimit;
use async_graphql::http::ALL_WEBSOCKET_PROTOCOLS;
use async_graphql_axum::{GraphQLProtocol, GraphQLRequest, GraphQLResponse, GraphQLWebSocket};
use axum::extract::{DefaultBodyLimit, WebSocketUpgrade};
use axum::{
extract::State,
middleware,
@@ -23,6 +24,8 @@ use crate::state::AppState;
async fn graphql_handler(
State(state): State<AppState>,
auth: Option<axum::Extension<crate::auth::AuthenticatedDid>>,
headers: axum::http::HeaderMap,
rate_limit::PeerAddr(peer): rate_limit::PeerAddr,
req: GraphQLRequest,
) -> GraphQLResponse {
// `optional_signature` attaches the verified DID when a signature is present.
@@ -32,9 +35,49 @@ async fn graphql_handler(
if let Some(axum::Extension(did)) = auth {
inner = inner.data(did);
}
// The anonymous `tasks`/`task` resolvers run the same #268 visibility gate
// as the REST read routes and cost the node the same queries, so they carry
// the same per-IP brake. It rides as request data rather than a router layer
// because /graphql is one endpoint for every operation — see `TaskReadBrake`
// (#327 review). It debits before the gate runs, but unlike the REST layer
// it sits inside `optional_signature`, so it brakes the gate's query cost
// and not signature verification.
inner = inner.data(rate_limit::TaskReadBrake {
limiter: state.task_read_rate_limiter.clone(),
key: rate_limit::client_key(&headers, peer, state.push_limiter_trust),
request_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
});
state.graphql_schema.execute(inner).await.into()
}
async fn graphql_ws_handler(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
rate_limit::PeerAddr(peer): rate_limit::PeerAddr,
auth: Option<axum::Extension<auth::AuthenticatedDid>>,
protocol: GraphQLProtocol,
upgrade: WebSocketUpgrade,
) -> axum::response::Response {
let mut data = async_graphql::Data::default();
if let Some(axum::Extension(did)) = auth {
data.insert(did);
}
data.insert(rate_limit::TaskReadBrake {
limiter: state.task_read_rate_limiter.clone(),
key: rate_limit::client_key(&headers, peer, state.push_limiter_trust),
request_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
});
let schema = state.graphql_ws_schema.as_ref().clone();
upgrade
.protocols(ALL_WEBSOCKET_PROTOCOLS)
.on_upgrade(move |stream| {
GraphQLWebSocket::new(stream, schema, protocol)
.with_data(data)
.serve()
})
.into_response()
}
async fn graphql_playground() -> impl IntoResponse {
axum::response::Html(async_graphql::http::playground_source(
async_graphql::http::GraphQLPlaygroundConfig::new("/graphql")
@@ -75,21 +118,57 @@ fn add_auth_layers(
))
}
/// Attach the shared per-IP write brake to an authenticated write-route group,
/// outermost so a flood is rejected before signature verification. Every
/// authenticated non-creation write group MUST call this or it silently ships
/// unbraked with no compile error. Deliberately a separate call, NOT folded into
/// `add_auth_layers`: the creation routes use a different bucket
/// (`create_ip_rate_limiter`) and the graphql group is not built through
/// `add_auth_layers`, so a helper keyed on auth would attach the wrong limiter.
trait WriteBraked {
fn write_braked(self, limiter: &rate_limit::IpRateLimiter) -> Self;
}
impl WriteBraked for Router<AppState> {
fn write_braked(self, limiter: &rate_limit::IpRateLimiter) -> Self {
self.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(limiter.clone()))
}
}
pub fn build_router(state: AppState) -> Router {
// ── GraphQL routes ─────────────────────────────────────────────────────
let schema = state.graphql_schema.as_ref().clone();
let graphql_routes = Router::new()
// Per-IP write brake, shared across every authenticated non-creation write
// group (see `AppState::write_rate_limiter`). Built once here so each group
// clones the same bucket; attached outermost on each group so a flood is
// rejected before auth runs. Separate bucket from the creation brake.
let write_ip_limiter = rate_limit::IpRateLimiter {
limiter: state.write_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let graphql_http = Router::new()
.route("/graphql", get(graphql_playground).post(graphql_handler))
// Attach the verified DID to /graphql when a signature is present. The
// layer covers only routes added before it, so /graphql/ws (added after,
// read-only subscriptions) stays open.
// layer covers only routes added before it, so /graphql/ws (mounted on
// its own router below, read-only subscriptions) gets no brake. The
// per-IP write brake covers /graphql (KTD-5: an HTTP-layer brake cannot
// tell a query POST from a mutation POST, so it counts every /graphql
// request — acceptable, both cost work); /graphql/ws stays unbraked.
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
.write_braked(&write_ip_limiter);
let graphql_ws = Router::new()
.route("/graphql/ws", get(graphql_ws_handler))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
let graphql_routes = graphql_http.merge(graphql_ws);
// ── Task routes (write — require HTTP Signature) ───────────────────────
// ── Task routes (write — require HTTP Signature + per-IP write brake) ──
let task_write_routes = add_auth_layers(
Router::new()
.route("/api/v1/tasks", post(tasks::create_task))
@@ -98,12 +177,34 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
state.clone(),
SIGNED_BODY_LIMIT,
);
)
.write_braked(&write_ip_limiter);
// ── Task routes (read — open) ──────────────────────────────────────────
// ── Task routes (read — open, but scoped) ──────────────────────────────
// `optional_signature` attaches the verified DID when a signature is present
// so the handlers can identify the caller; the routes stay anonymous-reachable,
// but each task/row is gated to its delegator, its assignee, or (for a
// repo-scoped task) whoever can read that repo (#268 — these routes previously
// carried no gate and no identity at all).
// Both routes also carry a per-IP flood brake, mirroring `/ipfs/{cid}`: they are
// anon-reachable and the gate above costs a task lookup plus deduped-repo and
// visibility-rule queries *before* the opaque 404, so a prober pays nothing and
// the node pays per request. The limiter is the outermost layer so a flood is
// rejected before signature verification and the visibility queries run. The
// extension MUST be attached or `rate_limit_by_ip` is a silent no-op.
let task_read_limiter = rate_limit::IpRateLimiter {
limiter: state.task_read_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let task_read_routes = Router::new()
.route("/api/v1/tasks", get(tasks::list_tasks))
.route("/api/v1/tasks/{id}", get(tasks::get_task));
.route("/api/v1/tasks/{id}", get(tasks::get_task))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(task_read_limiter));
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
@@ -136,7 +237,13 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(create_ip_limiter));
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
// ── Write routes — require HTTP Signature + per-IP write brake ────────
// Same per-IP flood defense as the creation routes, on its own `write`
// bucket (see `AppState::write_rate_limiter`). Per-DID is deliberately not
// paired (a DID farm never trips it; busy legitimate agents would false-
// positive). The bundled visibility GET is a read that also draws from the
// write bucket — harmless, since the bucket is sized far above any legit
// per-IP read rate.
let write_routes = add_auth_layers(
Router::new()
.route(
@@ -207,7 +314,8 @@ pub fn build_router(state: AppState) -> Router {
),
state.clone(),
SIGNED_BODY_LIMIT,
);
)
.write_braked(&write_ip_limiter);
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
// routes only — all other API routes keep axum's default 2 MB cap.
@@ -320,7 +428,8 @@ pub fn build_router(state: AppState) -> Router {
),
state.clone(),
SIGNED_BODY_LIMIT,
);
)
.write_braked(&write_ip_limiter);
// ── Bounty routes (read — open) ──────────────────────────────────────
let bounty_read_routes = Router::new()
@@ -345,9 +454,15 @@ pub fn build_router(state: AppState) -> Router {
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
SIGNED_BODY_LIMIT,
);
)
.write_braked(&write_ip_limiter);
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
// ── Issue routes (write — require HTTP Signature; NO outer write brake) ──
// Both handlers are read-gated before they mutate, so an outer per-IP
// brake would answer 429 before authorize_repo_read could return the
// opaque 404 a hidden repo owes the caller — the brake's status would
// distinguish a real repo from a missing one (INV-12). The write bucket
// is debited inside each handler, right after the read gate.
let issue_write_routes = add_auth_layers(
Router::new()
.route(
@@ -715,13 +830,351 @@ async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::test_state;
use sqlx::PgPool;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn ws_send_text(stream: &mut tokio::net::TcpStream, text: &str) {
let payload = text.as_bytes();
let len = payload.len();
let mut frame = Vec::new();
frame.push(0x81);
let mask = [0x12, 0x34, 0x56, 0x78];
if len <= 125 {
frame.push(0x80 | (len as u8));
} else if len <= 65535 {
frame.push(0x80 | 126);
frame.extend_from_slice(&(len as u16).to_be_bytes());
} else {
frame.push(0x80 | 127);
frame.extend_from_slice(&(len as u64).to_be_bytes());
}
frame.extend_from_slice(&mask);
for (i, b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
stream.write_all(&frame).await.unwrap();
stream.flush().await.unwrap();
}
async fn ws_recv_text(stream: &mut tokio::net::TcpStream) -> String {
let mut header = [0u8; 2];
stream.read_exact(&mut header).await.unwrap();
let b1 = header[1];
let masked = (b1 & 0x80) != 0;
let mut len = (b1 & 0x7f) as usize;
if len == 126 {
let mut ext = [0u8; 2];
stream.read_exact(&mut ext).await.unwrap();
len = u16::from_be_bytes(ext) as usize;
} else if len == 127 {
let mut ext = [0u8; 8];
stream.read_exact(&mut ext).await.unwrap();
len = u64::from_be_bytes(ext) as usize;
}
let mask = if masked {
let mut m = [0u8; 4];
stream.read_exact(&mut m).await.unwrap();
Some(m)
} else {
None
};
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await.unwrap();
if let Some(m) = mask {
for (i, b) in payload.iter_mut().enumerate() {
*b ^= m[i % 4];
}
}
String::from_utf8(payload).unwrap()
}
async fn ws_recv_op_frames(stream: &mut tokio::net::TcpStream, op_id: &str) -> Vec<String> {
let mut frames = Vec::new();
loop {
let text = ws_recv_text(stream).await;
frames.push(text.clone());
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if v.get("id").and_then(|id| id.as_str()) == Some(op_id) {
let msg_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if msg_type == "complete" || msg_type == "error" {
break;
}
}
}
}
frames
}
async fn connect_ws(addr: std::net::SocketAddr) -> tokio::net::TcpStream {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let req = format!(
"GET /graphql/ws HTTP/1.1\r\n\
Host: {}\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Protocol: graphql-transport-ws\r\n\r\n",
addr
);
stream.write_all(req.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await.unwrap();
let resp = String::from_utf8_lossy(&buf[..n]);
assert!(resp.starts_with("HTTP/1.1 101 Switching Protocols"));
// Init connection
ws_send_text(&mut stream, r#"{"type":"connection_init"}"#).await;
let ack = ws_recv_text(&mut stream).await;
assert!(ack.contains("connection_ack"));
stream
}
#[sqlx::test]
async fn graphql_ws_task_query_enforces_per_request_field_limit(pool: PgPool) {
let state = test_state(pool).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = build_router(state);
tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.unwrap();
});
let mut stream = connect_ws(addr).await;
// Query with 6 aliased task fields (exceeding MAX_GRAPHQL_TASK_READS_PER_REQUEST = 5).
// limit: 1 keeps the document under GRAPHQL_MAX_COMPLEXITY so the 6th
// field reaches the brake instead of failing complexity first.
let query = r#"{"id":"1","type":"subscribe","payload":{"query":"query { f1: tasks(limit: 1) { items { id } } f2: tasks(limit: 1) { items { id } } f3: tasks(limit: 1) { items { id } } f4: tasks(limit: 1) { items { id } } f5: tasks(limit: 1) { items { id } } f6: tasks(limit: 1) { items { id } } }"}}"#;
ws_send_text(&mut stream, query).await;
let frames = ws_recv_op_frames(&mut stream, "1").await;
let resp = frames.join("\n");
assert!(
resp.contains("rate limit exceeded"),
"6th task field over WS must be braked: {resp}"
);
}
#[sqlx::test]
async fn graphql_ws_task_query_enforces_per_ip_rate_limit(pool: PgPool) {
let mut state = test_state(pool).await;
// Restrict task read rate limiter to 1 request per 60s
state.task_read_rate_limiter =
crate::rate_limit::RateLimiter::new(1, std::time::Duration::from_secs(60));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = build_router(state);
tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.unwrap();
});
let mut stream = connect_ws(addr).await;
// First task query succeeds (or returns valid data)
let query1 = r#"{"id":"1","type":"subscribe","payload":{"query":"query { tasks { items { id } } }"}}"#;
ws_send_text(&mut stream, query1).await;
let frames1 = ws_recv_op_frames(&mut stream, "1").await;
let resp1 = frames1.join("\n");
assert!(!resp1.contains("rate limit exceeded"));
// Second task query on the same connection hits per-IP limiter
let query2 = r#"{"id":"2","type":"subscribe","payload":{"query":"query { tasks { items { id } } }"}}"#;
ws_send_text(&mut stream, query2).await;
let frames2 = ws_recv_op_frames(&mut stream, "2").await;
let resp2 = frames2.join("\n");
assert!(
resp2.contains("rate limit exceeded"),
"exceeded per-IP limiter over WS must return rate limit message: {resp2}"
);
}
#[sqlx::test]
async fn graphql_ws_task_query_resets_field_budget_per_operation(pool: PgPool) {
let state = test_state(pool).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = build_router(state);
tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.unwrap();
});
let mut stream = connect_ws(addr).await;
// Execute 6 sequential 1-field queries on the same connection.
// Each operation must receive its own 5-field budget, so none are braked by the per-request limit.
for i in 1..=6 {
let id = i.to_string();
let query = format!(
r#"{{"id":"{id}","type":"subscribe","payload":{{"query":"query {{ tasks {{ items {{ id }} }} }}"}}}}"#
);
ws_send_text(&mut stream, &query).await;
let frames = ws_recv_op_frames(&mut stream, &id).await;
let resp = frames.join("\n");
assert!(
!resp.contains("rate limit exceeded"),
"operation {id} on the same WS connection must have a fresh field budget: {resp}"
);
}
}
async fn connect_ws_signed(
addr: std::net::SocketAddr,
keypair: &gitlawb_core::identity::Keypair,
) -> tokio::net::TcpStream {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let signed = gitlawb_core::http_sig::sign_request(keypair, "GET", "/graphql/ws", b"");
let req = format!(
"GET /graphql/ws HTTP/1.1\r\n\
Host: {}\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Protocol: graphql-transport-ws\r\n\
content-digest: {}\r\n\
signature-input: {}\r\n\
signature: {}\r\n\r\n",
addr, signed.content_digest, signed.signature_input, signed.signature
);
stream.write_all(req.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await.unwrap();
let resp = String::from_utf8_lossy(&buf[..n]);
assert!(resp.starts_with("HTTP/1.1 101 Switching Protocols"));
// Init connection
ws_send_text(&mut stream, r#"{"type":"connection_init"}"#).await;
let ack = ws_recv_text(&mut stream).await;
assert!(ack.contains("connection_ack"));
stream
}
#[sqlx::test]
async fn graphql_ws_authenticated_query_accesses_private_task(pool: PgPool) {
let state = test_state(pool).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = build_router(state.clone());
tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.unwrap();
});
let keypair = gitlawb_core::identity::Keypair::generate();
let delegator_did = keypair.did().to_string();
let task = crate::db::AgentTask {
id: "ws-auth-task-01".into(),
repo_id: None,
delegator_did: delegator_did.clone(),
kind: "test".into(),
capability: "read".into(),
status: "pending".into(),
assignee_did: Some(delegator_did.clone()),
ucan_token: None,
payload: None,
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
state.db.create_task(&task).await.unwrap();
// 1. Unauthenticated WS connection cannot see the private task
let mut unauth_stream = connect_ws(addr).await;
let query = r#"{"id":"1","type":"subscribe","payload":{"query":"query { tasks { items { id } } }"}}"#;
ws_send_text(&mut unauth_stream, query).await;
let unauth_frames = ws_recv_op_frames(&mut unauth_stream, "1").await;
let unauth_resp = unauth_frames.join("\n");
assert!(
!unauth_resp.contains("ws-auth-task-01"),
"unauthenticated WS must not disclose private task: {unauth_resp}"
);
// 2. Forged signature claiming the delegator DID must be rejected at handshake
let attacker_keypair = gitlawb_core::identity::Keypair::generate();
let attacker_signed =
gitlawb_core::http_sig::sign_request(&attacker_keypair, "GET", "/graphql/ws", b"");
let forged_sig_input = attacker_signed
.signature_input
.replace(&attacker_keypair.did().to_string(), &delegator_did);
let mut forged_stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let forged_req = format!(
"GET /graphql/ws HTTP/1.1\r\n\
Host: {}\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Protocol: graphql-transport-ws\r\n\
content-digest: {}\r\n\
signature-input: {}\r\n\
signature: {}\r\n\r\n",
addr, attacker_signed.content_digest, forged_sig_input, attacker_signed.signature
);
forged_stream
.write_all(forged_req.as_bytes())
.await
.unwrap();
forged_stream.flush().await.unwrap();
let mut forged_bytes = Vec::new();
let mut chunk = [0u8; 1024];
while !forged_bytes.windows(4).any(|w| w == b"\r\n\r\n") {
let n = forged_stream.read(&mut chunk).await.unwrap();
if n == 0 {
break;
}
forged_bytes.extend_from_slice(&chunk[..n]);
}
let forged_resp = String::from_utf8_lossy(&forged_bytes);
assert!(
forged_resp.starts_with("HTTP/1.1 401 Unauthorized"),
"forged WS signature must be rejected with 401: {forged_resp}"
);
// 3. Signed WS connection authenticates caller and retrieves the private task
let mut auth_stream = connect_ws_signed(addr, &keypair).await;
ws_send_text(&mut auth_stream, query).await;
let auth_frames = ws_recv_op_frames(&mut auth_stream, "1").await;
let auth_resp = auth_frames.join("\n");
assert!(
auth_resp.contains("ws-auth-task-01"),
"signed WS must authenticate delegator and return private task: {auth_resp}"
);
}
use axum::body::Body;
use axum::http::{Request, StatusCode};
use sqlx::PgPool;
use tower::ServiceExt;
use crate::db::RepoRecord;
use crate::test_support::test_state;
/// Regression: anonymous callers must not see the pin/anchor index (#121, #134).
#[sqlx::test]
+133 -25
View File
@@ -21,6 +21,19 @@ const SCAN_TOKEN_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/ipfs-scan-token";
/// front) and needs no migration, no config, and no change to this function.
const SCAN_TOKEN_KEY_INFO: &[u8] = b"gitlawb/ipfs-scan-token/v1";
/// HKDF salt for [`AppState::derive_webhook_secret_key`]. A constant, not a
/// secret: the confidentiality of the derived key rests on the node's private
/// seed being the input keying material, exactly as for the scan-token key.
const WEBHOOK_SECRET_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/webhook-secret";
/// HKDF `info` for [`AppState::derive_webhook_secret_key`]: the domain
/// separation from the scan-token key that shares the same input keying
/// material. Unlike the scan-token key, bumping the version is NOT a safe
/// rotation: sealed secrets persist, `open` has no old-key fallback, and a new
/// version renders every stored ciphertext permanently unopenable until a
/// re-seal path exists.
const WEBHOOK_SECRET_KEY_INFO: &[u8] = b"gitlawb/webhook-secret/v1";
#[derive(Clone, Debug)]
pub struct RefUpdateBroadcast {
pub repo: String,
@@ -59,6 +72,11 @@ pub struct AppState {
pub task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
/// GraphQL schema (queries + mutations + subscriptions)
pub graphql_schema: Arc<crate::graphql::GitlawbSchema>,
pub graphql_ws_schema: Arc<crate::graphql::GitlawbWsSchema>,
/// MAC key for task-list continuation tokens, derived from the node
/// keypair seed. Held here (and handed to the GraphQL schema) so REST and
/// GraphQL mint and accept the same tokens.
pub task_cursor_key: crate::api::task_cursor::TaskCursorKey,
/// Fly.io machine ID — used for fly-replay routing in multi-machine deployments
pub machine_id: Option<String>,
/// Centralized repo storage: local disk cache + optional Tigris backend
@@ -75,6 +93,21 @@ pub struct AppState {
/// resolved client IP is what actually stops a single-source flood (same
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
pub create_ip_rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for the authenticated write surface that is
/// not repo/agent creation: issue/PR comments, labels, stars, merges,
/// protect/unprotect, replicas, webhook create/delete, visibility (its GET
/// shares the group), tasks, bounties, profile, and every HTTP `/graphql`
/// request (queries and the playground GET as well as mutations; the
/// WebSocket route is excluded). Issue comment and issue close debit it in
/// the handler after the read gate, not via router middleware; close also
/// draws on `close_issue_rate_limiter` and spends neither bucket when
/// either refuses. Separate bucket from `create_ip_rate_limiter`
/// (its own budget, per the `sync_trigger`/`peer_write` precedent) so a
/// write flood cannot drain the creation budget and vice versa. Per-DID is
/// deliberately NOT paired here: a DID farm never trips it, and busy
/// legitimate agents would false-positive. `GITLAWB_WRITE_RATE_LIMIT`
/// overrides the default, 0 disables. Keyed by `push_limiter_trust`.
pub write_rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
/// push path throttles on the resolved client IP instead.
@@ -169,6 +202,11 @@ pub struct AppState {
/// already has on disk, so derivation is also the smaller mechanism. Rotation is a
/// bump of the version in [`SCAN_TOKEN_KEY_INFO`].
pub ipfs_scan_token_key: Arc<[u8; 32]>,
/// Key sealing webhook HMAC secrets at rest (#506), derived from the node's
/// persistent identity by [`AppState::derive_webhook_secret_key`]. The
/// webhook delivery path opens a secret with it only at signing time; the
/// `webhooks.secret` column stores the ciphertext (see `webhook_secret`).
pub webhook_secret_key: Arc<[u8; 32]>,
/// Hard ceiling on the byte size of an object `GET /ipfs/{cid}` will buffer and
/// serve (default `api::ipfs::MAX_SERVED_OBJECT_BYTES`). The serve reads via a
/// blocking `git cat-file` and buffers the whole object; without a bound a large
@@ -326,6 +364,16 @@ pub struct AppState {
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
/// it (INV-15).
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
/// Per-client-IP rate limiter for the task read routes. `GET /api/v1/tasks`
/// and `GET /api/v1/tasks/{id}` are anonymously reachable and run the #268
/// visibility gate (a task lookup plus deduped-repo and visibility-rule
/// queries) before returning the opaque 404, so an unauthenticated prober
/// costs the node real work per request whether or not anything is visible.
/// Keyed on the resolved client IP via `push_limiter_trust`. Layered on
/// `task_read_routes` via `rate_limit_by_ip`.
/// The same per-IP hourly budget applies to GraphQL `tasks` / `task`
/// queries and WebSocket task queries via `TaskReadBrake`.
pub task_read_rate_limiter: RateLimiter,
/// The `git` executable the served-git withheld-blob walk spawns. Production is
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
/// process-group teardown in handler tests without mutating the process-global
@@ -340,23 +388,6 @@ impl AppState {
self.shutdown_tx.subscribe()
}
/// Sweep expired entries from every per-IP/DID rate limiter. Driven by the
/// periodic cleanup task so a bounded limiter's key map sheds stale entries
/// instead of sitting near its cap until an inline capacity sweep reclaims
/// them. Every limiter on the state is swept here; adding a new limiter means
/// adding it to this list.
pub(crate) async fn sweep_rate_limiters(&self) {
self.rate_limiter.cleanup().await;
self.create_ip_rate_limiter.cleanup().await;
self.push_rate_limiter.cleanup().await;
self.close_issue_rate_limiter.cleanup().await;
self.ipfs_rate_limiter.cleanup().await;
self.ipfs_work_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
self.peer_write_rate_limiter.cleanup().await;
self.federated_rate_limiter.cleanup().await;
}
/// Trigger graceful shutdown. Idempotent — calling more than once
/// has no effect. Returns `true` if this call was the one that
/// flipped the signal.
@@ -378,6 +409,26 @@ impl AppState {
*self.shutdown_tx.borrow()
}
/// Reclaim expired entries from EVERY rate limiter. Single source of truth for
/// the periodic cleanup loop (main.rs), co-located with the limiter fields so a
/// newly-added limiter is cleaned here rather than silently omitted from a
/// hand-maintained list in main() (H2: `write_rate_limiter` was the omitted
/// one). Every limiter field above MUST be swept here; the guard test
/// `cleanup_rate_limiters_reaps_the_write_limiter` fails if it is not.
pub(crate) async fn cleanup_rate_limiters(&self) {
self.rate_limiter.cleanup().await;
self.create_ip_rate_limiter.cleanup().await;
self.write_rate_limiter.cleanup().await;
self.push_rate_limiter.cleanup().await;
self.close_issue_rate_limiter.cleanup().await;
self.ipfs_rate_limiter.cleanup().await;
self.ipfs_work_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
self.peer_write_rate_limiter.cleanup().await;
self.task_read_rate_limiter.cleanup().await;
self.federated_rate_limiter.cleanup().await;
}
/// Legacy-probe budget wired from the `GITLAWB_IPFS_MAX_LEGACY_PROBES` operator
/// knob. The knob seeds `ipfs_max_legacy_probes` at construction so it controls the
/// per-request legacy (NULL-provenance) probe fan-out it advertises. It deliberately
@@ -416,20 +467,48 @@ impl AppState {
/// identity, the token format, or any caller, so rotation is a constant change
/// rather than a fork of this derivation.
pub(crate) fn derive_scan_token_key(keypair: &Keypair) -> [u8; 32] {
Self::hkdf_sha256(
SCAN_TOKEN_KEY_SALT,
SCAN_TOKEN_KEY_INFO,
keypair.to_seed().as_slice(),
)
}
/// Derive the at-rest webhook-secret sealing key from the node identity.
///
/// Same HKDF construction as [`AppState::derive_scan_token_key`] under its
/// own [`WEBHOOK_SECRET_KEY_INFO`]/[`WEBHOOK_SECRET_KEY_SALT`] pair, so it
/// is derived (a restart re-derives the same key, and old secrets remain
/// readable) yet domain-separated from the signing key and the scan-token
/// key. The doc on [`SCAN_TOKEN_KEY_INFO`] argues why derivation (not a
/// boot-random key) is the right trade: the secret's confidentiality still
/// rests on the node's private seed. Rotation is NOT a version bump here,
/// unlike for the scan-token key: [`WEBHOOK_SECRET_KEY_INFO`] explains that
/// bumping renders every stored ciphertext unopenable until a re-seal path
/// exists.
pub(crate) fn derive_webhook_secret_key(keypair: &Keypair) -> [u8; 32] {
Self::hkdf_sha256(
WEBHOOK_SECRET_KEY_SALT,
WEBHOOK_SECRET_KEY_INFO,
keypair.to_seed().as_slice(),
)
}
/// HKDF-Extract-then-Expand (RFC 5869) over the node seed: PRK =
/// HMAC(salt, ikm), then one expand block T(1) = HMAC(PRK, info || 0x01).
/// One 32-byte output needs exactly one block of SHA-256, so there is no
/// counter loop to get wrong. Salt and info are the caller's domain
/// separation; shared by every identity-derived key in this module.
fn hkdf_sha256(salt: &[u8], info: &[u8], ikm: &[u8]) -> [u8; 32] {
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<sha2::Sha256>;
let seed = keypair.to_seed();
// HKDF-Extract: PRK = HMAC(salt, ikm).
let mut extract =
HmacSha256::new_from_slice(SCAN_TOKEN_KEY_SALT).expect("HMAC takes a key of any size");
extract.update(seed.as_slice());
let mut extract = HmacSha256::new_from_slice(salt).expect("HMAC takes a key of any size");
extract.update(ikm);
let prk = extract.finalize().into_bytes();
// HKDF-Expand, one block: T(1) = HMAC(PRK, info || 0x01). One 32-byte output
// needs exactly one block of SHA-256, so there is no counter loop to get wrong.
let mut expand =
HmacSha256::new_from_slice(prk.as_slice()).expect("HMAC takes a key of any size");
expand.update(SCAN_TOKEN_KEY_INFO);
expand.update(info);
expand.update(&[0x01]);
expand.finalize().into_bytes().into()
}
@@ -1212,6 +1291,35 @@ mod repo_identity_key_tests {
}
}
#[cfg(test)]
mod tests {
/// H2 guard: the write-surface limiter must be reaped by the shared cleanup
/// routine. Seeds a reclaimable entry into `write_rate_limiter` and asserts
/// `cleanup_rate_limiters` removes it. Goes RED if `write_rate_limiter` is
/// dropped from `cleanup_rate_limiters` — the exact H2 omission, now guarded.
#[tokio::test]
async fn cleanup_rate_limiters_reaps_the_write_limiter() {
let state = crate::test_support::test_state_lazy();
state
.write_rate_limiter
.insert_reclaimable_for_test("some-ip")
.await;
assert_eq!(
state.write_rate_limiter.tracked_keys().await,
1,
"precondition: the reclaimable entry is tracked"
);
state.cleanup_rate_limiters().await;
assert_eq!(
state.write_rate_limiter.tracked_keys().await,
0,
"write_rate_limiter must be reaped by cleanup_rate_limiters (H2)"
);
}
}
#[cfg(test)]
mod repo_write_lease_tests {
use super::RepoWriteLeases;
File diff suppressed because it is too large. Load diff
+3
View File
@@ -6,6 +6,7 @@ use std::sync::OnceLock;
/// The fixture delays or hangs in its own process, so the production watchdog
/// can terminate it without depending on a shell or descendant process support.
#[allow(dead_code)]
pub(super) enum Behavior<'a> {
Delay(u64),
Hang,
@@ -14,6 +15,7 @@ pub(super) enum Behavior<'a> {
LogTypes(&'a Path),
}
#[allow(dead_code)]
pub(super) struct GitShim {
_directory: tempfile::TempDir,
executable: PathBuf,
@@ -27,6 +29,7 @@ impl Deref for GitShim {
}
}
#[allow(dead_code)]
pub(super) fn create(name: &str, behavior: Behavior<'_>) -> GitShim {
static COMPILED: OnceLock<tempfile::TempDir> = OnceLock::new();
let compiled = COMPILED.get_or_init(|| {
+609
View File
@@ -0,0 +1,609 @@
//! 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,
/// The serve task panicked. The payload is carried out so the caller can
/// resume unwinding only AFTER the pool is closed and the temp dir removed.
Panicked(Box<dyn std::any::Any + Send>),
}
/// 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 {
// A panic in the serve task is carried, not unwound here: the caller
// must close the pool and remove the repos dir before resuming it.
Ok(join_result) => match join_result {
Ok(result) => ServeTeardown::Graceful(result),
Err(e) => ServeTeardown::Panicked(e.into_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,
Err(e) => ServeTeardown::Panicked(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 webhook_secret_key = crate::state::AppState::derive_webhook_secret_key(&keypair);
let task_cursor_key = crate::api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
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(),
task_cursor_key.clone(),
));
let ws_schema = Arc::new(crate::graphql::build_ws_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
task_cursor_key.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,
graphql_ws_schema: ws_schema,
task_cursor_key,
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)),
write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
close_issue_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
federated_rate_limiter: RateLimiter::new(12, Duration::from_secs(60)),
task_read_rate_limiter: RateLimiter::new(1200, 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),
webhook_secret_key: Arc::new(webhook_secret_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)"
);
}
Some(ServeTeardown::Panicked(payload)) => std::panic::resume_unwind(payload),
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
+968
View File
@@ -0,0 +1,968 @@
//! Scheduled external-forge fetches for canonical INBOUND mirrors.
//!
//! This worker is deliberately separate from `sync`, which mirrors repositories
//! between Gitlawb peers. External upstream URLs are owner-controlled data that
//! eventually reach `git fetch`, so each cycle resolves DNS itself, validates
//! every address, and pins the approved answers into Git/libcurl with
//! `http.curloptResolve`. Redirects, proxies, credential helpers, submodule
//! recursion, and non-HTTPS protocols are disabled at the command boundary.
use anyhow::{Context, Result};
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::config::Config;
use crate::db::{Db, InboundMirrorTarget, MirrorStatus, MirrorTransitionPhase};
use crate::git::repo_store::RepoStore;
use crate::state::{repo_identity_key, RepoWriteLeases};
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_PINNED_ADDRESSES: usize = 32;
const MIN_CURL_OPT_RESOLVE_GIT: (u64, u64) = (2, 37);
#[derive(Debug, Clone, Copy)]
struct EgressPolicy;
#[derive(Debug, Clone, PartialEq, Eq)]
struct PinnedUpstream {
url: String,
curlopt_resolve: Option<String>,
}
impl EgressPolicy {
async fn resolve(&self, url: reqwest::Url) -> Result<PinnedUpstream> {
let raw_host = url.host_str().context("mirror upstream URL has no host")?;
let connect_host = raw_host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
let policy_host = normalize_policy_host(&connect_host);
reject_always_local_hostname(&policy_host)?;
let port = url
.port_or_known_default()
.context("mirror upstream URL has no known port")?;
if let Ok(ip) = connect_host.parse::<IpAddr>() {
self.validate_addresses(&policy_host, &[ip])?;
return Ok(PinnedUpstream {
url: url.to_string(),
// An IP literal performs no DNS lookup, so there is no rebinding
// window to pin. TLS still verifies the literal from the URL.
curlopt_resolve: None,
});
}
let resolved = tokio::time::timeout(
DNS_TIMEOUT,
tokio::net::lookup_host((connect_host.as_str(), port)),
)
.await
.context("mirror upstream DNS lookup timed out")?
.context("resolving mirror upstream host")?;
let addresses: BTreeSet<IpAddr> = resolved.map(|addr| addr.ip()).collect();
if addresses.is_empty() {
anyhow::bail!("mirror upstream host resolved to no addresses");
}
let addresses: Vec<IpAddr> = addresses.into_iter().collect();
self.validate_addresses(&policy_host, &addresses)?;
let pinned = addresses
.iter()
.map(|ip| match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!("[{v6}]"),
})
.collect::<Vec<_>>()
.join(",");
Ok(PinnedUpstream {
url: url.to_string(),
curlopt_resolve: Some(format!("+{connect_host}:{port}:{pinned}")),
})
}
fn validate_addresses(&self, host: &str, addresses: &[IpAddr]) -> Result<()> {
if addresses.is_empty() || addresses.len() > MAX_PINNED_ADDRESSES {
anyhow::bail!(
"mirror upstream host must resolve to 1..={MAX_PINNED_ADDRESSES} addresses"
);
}
for &ip in addresses {
if !address_is_permitted(ip) {
anyhow::bail!("mirror upstream host {host:?} resolved to disallowed address {ip}");
}
}
Ok(())
}
}
fn normalize_policy_host(host: &str) -> String {
host.trim_end_matches('.').to_ascii_lowercase()
}
fn reject_always_local_hostname(host: &str) -> Result<()> {
if host.is_empty()
|| host == "localhost"
|| host.ends_with(".localhost")
|| host.ends_with(".local")
|| host.ends_with(".internal")
{
anyhow::bail!("mirror upstream host is local-only and cannot be fetched");
}
Ok(())
}
fn address_is_permitted(ip: IpAddr) -> bool {
crate::api::peers::mirror_upstream_ip_permitted(ip)
}
fn parse_git_version(output: &str) -> Option<(u64, u64)> {
let version = output.split_whitespace().find(|part| {
part.as_bytes()
.first()
.is_some_and(|byte| byte.is_ascii_digit())
})?;
let mut parts = version.split('.');
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
}
fn ensure_safe_git_runtime(git_bin: &str) -> Result<()> {
// Bounded runner like every other git spawn on this path: --version does
// no I/O of its own, but the child still takes a permit and the deadline.
let (status, stdout, stderr) = crate::git::visibility_pack::run_bounded_git_raw_isolated_https(
git_bin,
&["--version"],
&std::env::temp_dir(),
b"",
Instant::now() + Duration::from_secs(10),
)?;
if !status.success() {
anyhow::bail!(
"{git_bin} --version failed: {}",
String::from_utf8_lossy(&stderr)
.chars()
.take(256)
.collect::<String>()
);
}
let stdout = String::from_utf8_lossy(&stdout);
let version = parse_git_version(&stdout).context("parsing git version")?;
if version < MIN_CURL_OPT_RESOLVE_GIT {
anyhow::bail!(
"upstream mirroring requires Git 2.37+ for DNS pinning; found {}.{}",
version.0,
version.1
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FetchCommand {
config: Vec<(String, String)>,
args: Vec<String>,
}
fn fetch_command(upstream: &PinnedUpstream) -> FetchCommand {
let http_scope = |name: &str| format!("http.{}.{name}", upstream.url);
let credential_scope = |name: &str| format!("credential.{}.{name}", upstream.url);
let mut config = vec![
("http.proxy".to_string(), "".to_string()),
("http.followRedirects".to_string(), "false".to_string()),
// Reset any inherited multi-valued resolver entries before adding the
// address set validated for this exact fetch.
("http.curloptResolve".to_string(), "".to_string()),
// Never forward operator or repository-scoped HTTP credentials to an
// owner-selected forge. Empty values reset Git's multi-valued headers
// and disable cookie state for this invocation.
("http.extraHeader".to_string(), "".to_string()),
("http.cookieFile".to_string(), "".to_string()),
("http.saveCookies".to_string(), "false".to_string()),
("http.sslVerify".to_string(), "true".to_string()),
// URL-specific configuration outranks generic `http.*` values even
// when the generic value is command-scoped. Repeat every security
// control at the exact validated URL so a repository-local scoped
// setting cannot re-enable redirects/proxies/headers/cookies or disable
// TLS. Key/value environment entries avoid `git -c` splitting a valid
// URL path that contains `=`.
(http_scope("proxy"), "".to_string()),
(http_scope("followRedirects"), "false".to_string()),
(http_scope("curloptResolve"), "".to_string()),
(http_scope("extraHeader"), "".to_string()),
(http_scope("cookieFile"), "".to_string()),
(http_scope("saveCookies"), "false".to_string()),
(http_scope("sslVerify"), "true".to_string()),
];
if let Some(resolve) = &upstream.curlopt_resolve {
config.extend([
("http.curloptResolve".to_string(), resolve.clone()),
(http_scope("curloptResolve"), resolve.clone()),
]);
}
config.extend([
("credential.helper".to_string(), "".to_string()),
(credential_scope("helper"), "".to_string()),
("credential.interactive".to_string(), "false".to_string()),
("core.askPass".to_string(), "false".to_string()),
("fetch.recurseSubmodules".to_string(), "false".to_string()),
// Do not let a smart server or repository-local config introduce a
// second, unvalidated pack/bundle URL outside the pinned upstream.
("fetch.uriprotocols".to_string(), "".to_string()),
("fetch.bundleURI".to_string(), "".to_string()),
("protocol.allow".to_string(), "never".to_string()),
("protocol.https.allow".to_string(), "always".to_string()),
]);
let args = vec![
"fetch".to_string(),
"--atomic".to_string(),
"--force".to_string(),
"--prune".to_string(),
"--prune-tags".to_string(),
"--no-auto-maintenance".to_string(),
"--no-recurse-submodules".to_string(),
"--no-write-fetch-head".to_string(),
upstream.url.clone(),
"+refs/heads/*:refs/heads/*".to_string(),
"+refs/tags/*:refs/tags/*".to_string(),
];
FetchCommand { config, args }
}
fn reject_local_url_rewrite(
git_bin: &str,
repo_path: &Path,
upstream_url: &str,
deadline: Instant,
) -> Result<()> {
let args = ["ls-remote", "--get-url", upstream_url];
let (status, stdout, stderr) = crate::git::visibility_pack::run_bounded_git_raw_isolated_https(
git_bin, &args, repo_path, b"", deadline,
)?;
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr);
anyhow::bail!(
"checking mirror upstream URL rewrite failed: {}",
stderr.chars().take(4_096).collect::<String>()
);
}
let expanded = std::str::from_utf8(&stdout)
.context("Git returned a non-UTF-8 expanded mirror upstream URL")?
.trim_end_matches(['\r', '\n']);
if expanded != upstream_url {
anyhow::bail!("repository-local Git config attempted to rewrite the mirror upstream URL");
}
Ok(())
}
fn reject_sensitive_local_config(git_bin: &str, repo_path: &Path, deadline: Instant) -> Result<()> {
let args = [
"config",
"--local",
"--includes",
"--get-regexp",
r"^(http|credential|url)\.",
];
let (status, stdout, stderr) = crate::git::visibility_pack::run_bounded_git_raw_isolated_https(
git_bin, &args, repo_path, b"", deadline,
)?;
match status.code() {
Some(1) if stdout.is_empty() => Ok(()),
Some(0) => anyhow::bail!(
"repository-local network or credential Git config is not allowed for upstream mirrors"
),
_ => {
let stderr = String::from_utf8_lossy(&stderr);
anyhow::bail!(
"checking repository-local Git config failed: {}",
stderr.chars().take(4_096).collect::<String>()
)
}
}
}
/// Budget for each preflight probe inside [`run_fetch`] (the URL-rewrite
/// check and the sensitive-local-config scan). Both run local git children
/// against the on-disk repo, so a short bound is generous; keeping them on
/// their own deadlines means `upstream_mirror_fetch_timeout_secs` covers
/// only the external pack transfer it documents.
const PREFLIGHT_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
fn run_fetch(
git_bin: &str,
repo_path: &Path,
upstream: &PinnedUpstream,
timeout: Duration,
) -> Result<()> {
let command = fetch_command(upstream);
let args: Vec<&str> = command.args.iter().map(String::as_str).collect();
let config: Vec<(&str, &str)> = command
.config
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let rewrite_deadline = Instant::now()
.checked_add(PREFLIGHT_PROBE_TIMEOUT)
.context("mirror URL-rewrite probe timeout is not representable")?;
reject_local_url_rewrite(git_bin, repo_path, &upstream.url, rewrite_deadline)?;
let config_deadline = Instant::now()
.checked_add(PREFLIGHT_PROBE_TIMEOUT)
.context("mirror local-config probe timeout is not representable")?;
reject_sensitive_local_config(git_bin, repo_path, config_deadline)?;
let deadline = Instant::now()
.checked_add(timeout)
.context("upstream mirror fetch timeout is not representable")?;
let (status, _stdout, stderr) =
crate::git::visibility_pack::run_bounded_git_raw_isolated_https_with_config(
git_bin, &args, repo_path, b"", deadline, &config,
)?;
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr);
let stderr = stderr.chars().take(4_096).collect::<String>();
anyhow::bail!("git upstream fetch failed: {stderr}");
}
Ok(())
}
#[derive(Clone)]
struct Worker {
db: Arc<Db>,
config: Arc<Config>,
repo_store: RepoStore,
repo_write_leases: RepoWriteLeases,
git_write_semaphore: Arc<tokio::sync::Semaphore>,
policy: EgressPolicy,
git_bin: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FetchOutcome {
Updated,
SkippedStale,
}
impl Worker {
async fn fetch_target(&self, target: InboundMirrorTarget) -> Result<FetchOutcome> {
let url = crate::db::validate_mirror_upstream_url(&target.upstream_url)?;
let upstream = self.policy.resolve(url).await?;
let repo_identity = repo_identity_key(&target.owner_did, &target.name);
let steal_after = Duration::from_secs(
self.config
.upstream_mirror_fetch_timeout_secs
.saturating_mul(2)
.saturating_add(60),
);
let _lease = self
.repo_write_leases
.acquire(&repo_identity, steal_after)
.await
.context("upstream mirror write-lease waiter cap reached")?;
let _write_permit = Arc::clone(&self.git_write_semaphore)
.acquire_owned()
.await
.context("upstream mirror write semaphore closed")?;
let acquire_timeout = Duration::from_secs(self.config.git_acquire_timeout_secs);
let guard = tokio::time::timeout(
acquire_timeout,
self.repo_store
.acquire_write(&target.owner_did, &target.name),
)
.await
.context("upstream mirror write-lock acquisition timed out")??;
let repo_path = guard.path().to_path_buf();
// A row can leave INBOUND state after selection. Recheck only after
// both the process-local lease and the cluster-wide advisory write lock
// are held. Every future transition path must take these same locks
// before changing authority, so no other node can commit a transition
// between this check and the fetch.
let current = match self.db.get_repo_mirror_state(&target.repo_id).await {
Ok(Some(current)) => current,
Ok(None) => {
let _ = guard.release(false).await;
anyhow::bail!("inbound mirror state disappeared before fetch");
}
Err(error) => {
let _ = guard.release(false).await;
return Err(error);
}
};
// Both stable shapes the CHECK admits: never transitioned, or a
// completed transition whose metadata is retained as audit. Only an
// in-flight transition makes the target stale.
let stable = (current.transition_id.is_none() && current.transition_phase.is_none())
|| (current.transition_id.is_some()
&& current.transition_phase == Some(MirrorTransitionPhase::Completed));
if current.status != MirrorStatus::Inbound
|| !stable
|| current.upstream_url != target.upstream_url
{
let _ = guard.release(false).await;
info!(repo_id = %target.repo_id, "upstream mirror state changed before fetch; skipping stale target");
return Ok(FetchOutcome::SkippedStale);
}
let git_bin = self.git_bin.clone();
let fetch_timeout = Duration::from_secs(self.config.upstream_mirror_fetch_timeout_secs);
let fetch = tokio::task::spawn_blocking(move || {
run_fetch(&git_bin, &repo_path, &upstream, fetch_timeout)
})
.await
.context("upstream mirror fetch task panicked")?;
let success = fetch.is_ok();
let outcome = guard.release(success).await;
// A refused publish (fenced or unknowable upload) must not report
// Updated; surface it as the failure it is before mapping the fetch.
outcome.into_result()?;
fetch.map(|()| FetchOutcome::Updated)
}
async fn scan_once(&self) {
let mut cursor: Option<String> = None;
loop {
let targets = match self
.db
.list_inbound_mirror_targets(
cursor.as_deref(),
self.config.upstream_mirror_page_size as i64,
)
.await
{
Ok(targets) => targets,
Err(error) => {
warn!(err = %error, "failed to list inbound mirror targets");
return;
}
};
if targets.is_empty() {
return;
}
let page_len = targets.len();
cursor = targets.last().map(|target| target.repo_id.clone());
for target in targets {
let repo_id = target.repo_id.clone();
let upstream_url = target.upstream_url.clone();
match self.fetch_target(target).await {
Ok(FetchOutcome::Updated) => {
info!(repo_id, upstream = %upstream_url, "upstream mirror fetch completed")
}
Ok(FetchOutcome::SkippedStale) => {}
Err(error) => {
warn!(repo_id, upstream = %upstream_url, err = %error, "upstream mirror fetch failed")
}
}
}
if page_len < self.config.upstream_mirror_page_size {
return;
}
}
}
}
/// Validate the opt-in runtime and spawn the scheduled worker. The worker runs
/// one scan immediately, sleeps only after a complete scan, and exits between
/// scans when graceful shutdown is signalled. An in-flight Git child is bounded
/// and reaped before the worker observes shutdown.
pub fn start(
db: Arc<Db>,
config: Arc<Config>,
repo_store: RepoStore,
repo_write_leases: RepoWriteLeases,
git_write_semaphore: Arc<tokio::sync::Semaphore>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> Result<()> {
ensure_safe_git_runtime("git")?;
let policy = EgressPolicy;
let worker = Worker {
db,
config: Arc::clone(&config),
repo_store,
repo_write_leases,
git_write_semaphore,
policy,
git_bin: "git".to_string(),
};
tokio::spawn(async move {
info!(
interval_secs = config.upstream_mirror_interval_secs,
"upstream mirror worker started"
);
loop {
worker.scan_once().await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(config.upstream_mirror_interval_secs)) => {}
changed = shutdown_rx.changed() => {
if changed.is_err() || *shutdown_rx.borrow() {
info!("upstream mirror worker stopped");
return;
}
}
}
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::path::PathBuf;
async fn test_worker(
pool: sqlx::PgPool,
git_bin: PathBuf,
page_size: usize,
) -> (Worker, Arc<Db>, tempfile::TempDir) {
let db = Arc::new(Db::for_testing(pool.clone()));
db.run_migrations().await.unwrap();
let repos = tempfile::tempdir().unwrap();
let mut config = Config::parse_from([
"gitlawb-node".to_string(),
"--upstream-mirror-enabled".to_string(),
"--enforce-owner-push".to_string(),
"--upstream-mirror-fetch-timeout-secs".to_string(),
"5".to_string(),
"--upstream-mirror-page-size".to_string(),
page_size.to_string(),
]);
config.validate().unwrap();
let config = Arc::new(config);
let worker = Worker {
db: Arc::clone(&db),
config,
repo_store: RepoStore::for_testing(repos.path().to_path_buf(), pool),
repo_write_leases: RepoWriteLeases::new(8),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
policy: EgressPolicy,
git_bin: git_bin.to_string_lossy().into_owned(),
};
(worker, db, repos)
}
async fn add_inbound_mirror(
worker: &Worker,
db: &Db,
id: &str,
name: &str,
upstream_url: &str,
) -> InboundMirrorTarget {
let now = chrono::Utc::now();
let record = crate::db::RepoRecord {
id: id.to_string(),
name: name.to_string(),
owner_did: "did:key:z6MkMirrorWorker".to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/test/{id}.git"),
forked_from: None,
machine_id: None,
};
db.create_repo(&record).await.unwrap();
worker
.repo_store
.init(&record.owner_did, &record.name)
.await
.unwrap();
let state = db
.configure_inbound_mirror(&record.id, upstream_url)
.await
.unwrap();
InboundMirrorTarget {
repo_id: record.id,
owner_did: record.owner_did,
name: record.name,
upstream_url: state.upstream_url,
}
}
#[cfg(unix)]
fn fake_git(repos: &tempfile::TempDir) -> (PathBuf, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let git = repos.path().join("fake-git");
let calls = repos.path().join("fetch-calls");
let script = format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\n' "$3"
exit 0
fi
if [ "$1" = "config" ]; then
# Match real Git when no sensitive repository-local keys are configured.
exit 1
fi
case "$*" in
*fail.git*) printf '%s\n' fail >> '{}'; exit 7 ;;
*) printf '%s\n' ok >> '{}'; exit 0 ;;
esac
"#,
calls.display(),
calls.display()
);
std::fs::write(&git, script).unwrap();
let mut permissions = std::fs::metadata(&git).unwrap().permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&git, permissions).unwrap();
(git, calls)
}
#[test]
fn git_version_gate_requires_curlopt_resolve_support() {
assert_eq!(parse_git_version("git version 2.37.0"), Some((2, 37)));
assert_eq!(
parse_git_version("git version 2.39.5 (Apple Git-154)"),
Some((2, 39))
);
assert_eq!(parse_git_version("not git"), None);
assert!((2, 36) < MIN_CURL_OPT_RESOLVE_GIT);
}
#[test]
fn every_resolved_address_must_be_public() {
let policy = EgressPolicy;
let public_v4: IpAddr = "8.8.8.8".parse().unwrap();
let private_v4: IpAddr = "10.2.3.4".parse().unwrap();
let private_v6: IpAddr = "fd00::20".parse().unwrap();
assert!(policy
.validate_addresses("public.example", &[public_v4])
.is_ok());
assert!(policy
.validate_addresses("private.example", &[private_v4, private_v6])
.is_err());
assert!(policy
.validate_addresses("mixed.example", &[public_v4, private_v4])
.is_err());
let too_many = (1..=MAX_PINNED_ADDRESSES + 1)
.map(|last| IpAddr::V4(std::net::Ipv4Addr::new(8, 8, 0, last as u8)))
.collect::<Vec<_>>();
assert!(policy
.validate_addresses("oversized.example", &too_many)
.is_err());
for never in [
"127.0.0.1",
"169.254.169.254",
"192.0.2.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.10",
"::1",
"::ffff:198.18.0.1",
"2002:c612:1::",
"64:ff9b::c612:1",
"100::1",
"100:0:0:1::1",
"2001::1",
"2001:2::1",
"2001:db8::1",
"fec0::1",
"fe80::1",
"3fff::1",
"5f00::1",
] {
let ip = never.parse().unwrap();
assert!(!address_is_permitted(ip), "{never} must stay denied");
}
}
#[test]
fn local_only_hostnames_are_always_rejected() {
for invalid in [
"",
"localhost",
"forge.localhost",
"forge.local",
"ghe.internal",
] {
assert!(
reject_always_local_hostname(invalid).is_err(),
"{invalid:?}"
);
}
assert!(reject_always_local_hostname("github.example").is_ok());
}
#[test]
fn fetch_command_pins_dns_and_only_updates_branches_and_tags() {
let upstream = PinnedUpstream {
url: "https://github.example/org/repo.git".to_string(),
curlopt_resolve: Some("+github.example:443:8.8.8.8,[2606:4700:4700::1111]".to_string()),
};
let command = fetch_command(&upstream);
let has_config = |key: &str, value: &str| {
command
.config
.contains(&(key.to_string(), value.to_string()))
};
assert!(has_config("http.followRedirects", "false"));
assert!(has_config("http.proxy", ""));
assert!(has_config("http.extraHeader", ""));
assert!(has_config("http.cookieFile", ""));
assert!(has_config("http.saveCookies", "false"));
assert!(has_config("http.sslVerify", "true"));
assert!(has_config(
"http.curloptResolve",
"+github.example:443:8.8.8.8,[2606:4700:4700::1111]"
));
assert!(has_config(
"http.https://github.example/org/repo.git.followRedirects",
"false"
));
assert!(has_config(
"http.https://github.example/org/repo.git.proxy",
""
));
assert!(has_config(
"http.https://github.example/org/repo.git.extraHeader",
""
));
assert!(has_config(
"http.https://github.example/org/repo.git.curloptResolve",
"+github.example:443:8.8.8.8,[2606:4700:4700::1111]"
));
assert!(has_config(
"credential.https://github.example/org/repo.git.helper",
""
));
assert!(has_config("protocol.allow", "never"));
assert!(has_config("protocol.https.allow", "always"));
assert!(has_config("fetch.uriprotocols", ""));
assert!(has_config("fetch.bundleURI", ""));
assert!(!command
.config
.iter()
.any(|(key, _)| key.ends_with("sslCert") || key.ends_with("sslKey")));
assert!(command.args.contains(&"--atomic".to_string()));
assert!(command
.args
.contains(&"+refs/heads/*:refs/heads/*".to_string()));
assert!(command
.args
.contains(&"+refs/tags/*:refs/tags/*".to_string()));
assert!(!command.args.iter().any(|arg| arg == "+refs/*:refs/*"));
}
/// Spawn `git` inside a fixture repo with inherited repository-location
/// and config environment scrubbed, so a test run inside an outer Git
/// checkout or custom config can never leak into the fixture.
fn fixture_git(dir: &std::path::Path) -> std::process::Command {
let mut cmd = std::process::Command::new("git");
cmd.current_dir(dir);
for var in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_CONFIG",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_NOSYSTEM",
"GIT_CONFIG_COUNT",
"GIT_EXEC_PATH",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_ASKPASS",
"SSH_ASKPASS",
] {
cmd.env_remove(var);
}
cmd
}
#[test]
fn repository_local_instead_of_rewrite_is_rejected() {
let repo = tempfile::tempdir().unwrap();
let init = fixture_git(repo.path())
.args(["init", "--bare"])
.output()
.unwrap();
assert!(init.status.success());
let config = fixture_git(repo.path())
.args([
"config",
"url.https://evil.example/.insteadOf",
"https://github.example/",
])
.output()
.unwrap();
assert!(config.status.success());
let error = reject_local_url_rewrite(
"git",
repo.path(),
"https://github.example/org/repo.git",
Instant::now() + Duration::from_secs(5),
)
.unwrap_err();
assert!(error.to_string().contains("attempted to rewrite"));
}
#[test]
fn repository_local_network_and_credential_config_is_rejected() {
let repo = tempfile::tempdir().unwrap();
let init = fixture_git(repo.path())
.args(["init", "--bare"])
.output()
.unwrap();
assert!(init.status.success());
reject_sensitive_local_config("git", repo.path(), Instant::now() + Duration::from_secs(5))
.unwrap();
let config = fixture_git(repo.path())
.args(["config", "http.sslCert", "/tmp/untrusted-client.pem"])
.output()
.unwrap();
assert!(config.status.success());
let error = reject_sensitive_local_config(
"git",
repo.path(),
Instant::now() + Duration::from_secs(5),
)
.unwrap_err();
assert!(error.to_string().contains("network or credential"));
}
#[cfg(unix)]
#[test]
fn hardened_real_git_reaches_https_transport_for_equals_path() {
let repo = tempfile::tempdir().unwrap();
let init = fixture_git(repo.path())
.args(["init", "--bare"])
.output()
.unwrap();
assert!(init.status.success());
// This is intentionally a loopback test seam around `run_fetch`, below
// the egress-policy layer. Reaching the listener proves the generated
// Git config neither dies on an empty client certificate nor splits the
// valid `=` path at the command-config boundary. The listener closes
// immediately, so the expected final result is a TLS transport error.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
listener.set_nonblocking(true).unwrap();
let accepted = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
match listener.accept() {
Ok((_stream, _address)) => return true,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("accepting real-Git transport probe: {error}"),
}
}
false
});
let upstream = PinnedUpstream {
url: format!("https://127.0.0.1:{port}/org/repo=mirror.git"),
curlopt_resolve: None,
};
let error = run_fetch("git", repo.path(), &upstream, Duration::from_secs(5)).unwrap_err();
assert!(
accepted.join().unwrap(),
"real Git must reach the HTTPS transport; got {error:#}"
);
let error = error.to_string();
assert!(!error.contains("credential missing host field"));
assert!(!error.contains("invalid key"));
}
#[tokio::test]
async fn literal_addresses_are_checked_without_dns() {
let policy = EgressPolicy;
let allowed = policy
.resolve(reqwest::Url::parse("https://8.8.8.8/org/repo.git").unwrap())
.await
.unwrap();
assert_eq!(allowed.curlopt_resolve, None);
for denied in [
"127.0.0.1",
"10.2.3.4",
"169.254.169.254",
"192.0.2.1",
"198.18.0.1",
"203.0.113.10",
"[::1]",
"[100::1]",
"[100:0:0:1::1]",
"[2001::1]",
"[2001:db8::1]",
"[fec0::1]",
"[fd00::20]",
"[3fff::1]",
"[5f00::1]",
] {
let url = reqwest::Url::parse(&format!("https://{denied}/org/repo.git")).unwrap();
assert!(
policy.resolve(url).await.is_err(),
"{denied} must stay denied"
);
}
}
#[sqlx::test]
async fn stale_target_is_rechecked_under_the_write_locks(pool: sqlx::PgPool) {
let missing_git = PathBuf::from("/definitely/missing/git");
let (worker, db, _repos) = test_worker(pool, missing_git, 1).await;
let target = add_inbound_mirror(
&worker,
&db,
"00000000-0000-0000-0000-000000000001",
"stale",
"https://8.8.8.8/org/stale.git",
)
.await;
sqlx::query(
"UPDATE repos
SET mirror_status = 'outbound'
WHERE id = $1",
)
.bind(&target.repo_id)
.execute(db.pool())
.await
.unwrap();
let outcome = worker.fetch_target(target).await.unwrap();
assert_eq!(outcome, FetchOutcome::SkippedStale);
assert!(worker.repo_write_leases.is_empty());
}
#[cfg(unix)]
#[sqlx::test]
async fn scan_pages_past_one_failure_and_fetches_the_next_repo(pool: sqlx::PgPool) {
let bootstrap = tempfile::tempdir().unwrap();
let (git, calls) = fake_git(&bootstrap);
let (worker, db, _repos) = test_worker(pool, git, 1).await;
add_inbound_mirror(
&worker,
&db,
"00000000-0000-0000-0000-000000000001",
"fails",
"https://8.8.8.8/org/fail.git",
)
.await;
add_inbound_mirror(
&worker,
&db,
"00000000-0000-0000-0000-000000000002",
"succeeds",
"https://8.8.8.8/org/ok.git",
)
.await;
worker.scan_once().await;
let calls = std::fs::read_to_string(calls).unwrap();
assert_eq!(calls.lines().collect::<Vec<_>>(), ["fail", "ok"]);
assert!(worker.repo_write_leases.is_empty());
}
}
+221
View File
@@ -0,0 +1,221 @@
//! At-rest protection for webhook HMAC secrets (#506).
//!
//! Webhook secrets are signing keys: whoever holds one can forge delivery
//! payloads to the receiver the hook points at. They are persisted in the
//! `webhooks.secret` column and used to compute the `X-Gitlawb-Signature-256`
//! header on each delivery. That secret needs two properties:
//!
//! * It must never reach a log line or an API response. [`WebhookSecret`]
//! redacts both `Debug` and `Serialize`, so no derived `{:?}` and no JSON
//! body ever renders the value, whatever it contains.
//! * It must not rest as plaintext where a database dump or backup can read
//! it. The node seals the secret with a key derived from its persistent
//! identity (see `AppState::derive_webhook_secret_key`), so a dump alone
//! reveals nothing.
//!
//! Storage format: `enc:v1:<base64url(nonce || ciphertext)>` — a 24-byte
//! XChaCha20-Poly1305 nonce followed by the ciphertext (which carries the tag).
//! Anything without the `enc:v1:` prefix is a legacy plaintext value, written
//! before this protection shipped; the delivery path signs with those unchanged
//! and they are re-encrypted once at startup by
//! `Db::reencrypt_legacy_webhook_secrets`.
use std::fmt;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine};
use chacha20poly1305::{
aead::{Aead, AeadCore, KeyInit, OsRng},
XChaCha20Poly1305, XNonce,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Prefix marking a sealed-at-rest secret. Legacy plaintext values lack it.
pub const SEAL_PREFIX: &str = "enc:v1:";
const NONCE_LEN: usize = 24;
/// An HMAC signing secret that never renders its contents in debug or JSON
/// output. Used as `Webhook.secret`, so that field is safe to `Debug`-print and
/// `Serialize` by construction rather than by discipline at each call site.
#[derive(Clone, PartialEq, Eq)]
pub struct WebhookSecret(String);
impl WebhookSecret {
pub fn new(inner: String) -> Self {
Self(inner)
}
pub fn as_str(&self) -> &str {
&self.0
}
/// Whether the value is a sealed ciphertext (as opposed to legacy plaintext).
pub fn is_sealed(&self) -> bool {
self.0.starts_with(SEAL_PREFIX)
}
}
impl fmt::Debug for WebhookSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("\"***\"")
}
}
impl Serialize for WebhookSecret {
/// Emit a fixed marker, never the contents. On the wire a set secret is
/// `"secret": "***"` and an unset one is `"secret": null` (`Option`), which
/// the CLI already reads as "is a secret set".
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("***")
}
}
impl<'de> Deserialize<'de> for WebhookSecret {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(WebhookSecret(String::deserialize(deserializer)?))
}
}
/// Seal a plaintext secret under a node-held key. A fresh `OsRng` nonce on every
/// seal guarantees repeated seals of the same secret never collide.
pub fn seal(key: &[u8; 32], plaintext: &str) -> String {
let cipher = XChaCha20Poly1305::new_from_slice(key).expect("webhook secret key is 32 bytes");
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.expect("XChaCha20-Poly1305 encryption is infallible for in-memory inputs");
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
format!("{SEAL_PREFIX}{}", B64URL.encode(out))
}
/// Open a sealed secret. Returns `None` for anything that is not a well-formed
/// sealed value — legacy plaintext, a foreign key, tampered or truncated bytes —
/// so a caller that needs the legacy path checks [`WebhookSecret::is_sealed`]
/// first (see [`reveal`]).
pub fn open(key: &[u8; 32], stored: &str) -> Option<String> {
let raw = B64URL.decode(stored.strip_prefix(SEAL_PREFIX)?).ok()?;
if raw.len() <= NONCE_LEN {
return None;
}
let (nonce, ciphertext) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new_from_slice(key).ok()?;
let plaintext = cipher.decrypt(XNonce::from_slice(nonce), ciphertext).ok()?;
String::from_utf8(plaintext).ok()
}
/// Whether a stored value has the structural shape of a sealed payload:
/// [`SEAL_PREFIX`] plus a base64url blob long enough to hold a nonce and
/// ciphertext. This is a shape check, not an authentication check: a
/// well-formed blob sealed under a different key satisfies it while [`open`]
/// still fails. The re-encryption pass uses the distinction to leave
/// well-formed-but-foreign ciphertext alone instead of re-wrapping it as
/// plaintext, which would let `reveal` return the outer blob as a signing key.
pub fn has_seal_shape(stored: &str) -> bool {
stored
.strip_prefix(SEAL_PREFIX)
.and_then(|rest| B64URL.decode(rest).ok())
.is_some_and(|raw| raw.len() > NONCE_LEN)
}
/// Resolve a stored secret to its plaintext for signing. Sealed values are opened
/// with the node key; legacy plaintext is returned unchanged so a hook created
/// before this fix keeps signing until the startup re-encryption pass runs.
pub fn reveal(key: &[u8; 32], stored: &WebhookSecret) -> Option<String> {
if stored.is_sealed() {
open(key, stored.as_str())
} else {
Some(stored.as_str().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::Rng;
/// Test keys need no secrecy, but they still come from the OS CSPRNG so no
/// hard-coded key material sits in the tree. A wrong key fails XChaCha20-Poly1305
/// authentication with overwhelming probability, so the "wrong key" case stays
/// deterministic in practice.
fn new_key() -> [u8; 32] {
OsRng.gen()
}
fn other_key() -> [u8; 32] {
OsRng.gen()
}
#[test]
fn seal_open_round_trip() {
let k = new_key();
let sealed = seal(&k, "s3cr3t-value");
assert!(sealed.starts_with(SEAL_PREFIX));
assert_eq!(open(&k, &sealed).as_deref(), Some("s3cr3t-value"));
}
#[test]
fn sealed_value_is_never_the_plaintext() {
let k = new_key();
let sealed = seal(&k, "s3cr3t-value");
assert!(!sealed.contains("s3cr3t-value"));
}
#[test]
fn wrong_key_does_not_open() {
let sealed = seal(&new_key(), "s3cr3t-value");
assert_eq!(open(&other_key(), &sealed), None);
}
#[test]
fn tampered_or_truncated_value_does_not_open() {
let k = new_key();
let sealed = seal(&k, "s3cr3t-value");
// Flip a byte inside the base64 payload.
let mut chars: Vec<char> = sealed.chars().collect();
let idx = SEAL_PREFIX.len();
chars[idx] = if chars[idx] == 'A' { 'B' } else { 'A' };
let tampered: String = chars.into_iter().collect();
assert_eq!(open(&k, &tampered), None);
// A value that is just the prefix (no nonce/ciphertext) is malformed.
assert_eq!(open(&k, SEAL_PREFIX), None);
}
#[test]
fn reveal_opens_a_sealed_value() {
let k = new_key();
let sealed = WebhookSecret::new(seal(&k, "s3cr3t-value"));
assert_eq!(reveal(&k, &sealed).as_deref(), Some("s3cr3t-value"));
}
#[test]
fn legacy_plaintext_is_revealed_unchanged() {
let k = new_key();
let legacy = WebhookSecret::new("plaintext-secret".into());
assert!(!legacy.is_sealed());
assert_eq!(reveal(&k, &legacy).as_deref(), Some("plaintext-secret"));
}
#[test]
fn debug_is_redacted() {
let s = WebhookSecret::new("s3cr3t-value".into());
let rendered = format!("{s:?}");
assert!(!rendered.contains("s3cr3t-value"));
assert_eq!(rendered, "\"***\"");
}
#[test]
fn serialize_is_redacted() {
let s = WebhookSecret::new("s3cr3t-value".into());
let json = serde_json::to_string(&s).unwrap();
assert!(!json.contains("s3cr3t-value"));
assert_eq!(json, "\"***\"");
}
#[test]
fn deserialize_reads_the_underlying_string() {
let s: WebhookSecret = serde_json::from_str("\"s3cr3t-value\"").unwrap();
assert_eq!(s.as_str(), "s3cr3t-value");
}
}
+280 -9
View File
@@ -30,34 +30,200 @@ fn sign_payload(secret: &str, payload: &[u8]) -> String {
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
/// What the signing step decided for one hook. Keeping this a decision (not a
/// bare `Option`) lets the delivery path tell "hook has no secret" (send
/// unsigned) apart from "hook has a secret that could not be opened" (withhold:
/// an unsigned POST would present a broken hook as working while the ledger
/// marks the event delivered).
enum SigningOutcome {
NoSecret,
Signed(String),
Withhold,
}
fn signing_decision(
secret_key: &[u8; 32],
secret: Option<&crate::webhook_secret::WebhookSecret>,
bytes: &[u8],
) -> SigningOutcome {
match secret {
None => SigningOutcome::NoSecret,
Some(s) => match crate::webhook_secret::reveal(secret_key, s) {
Some(plain) => SigningOutcome::Signed(sign_payload(&plain, bytes)),
None => SigningOutcome::Withhold,
},
}
}
/// Fire webhooks for `event` on `repo_id`. Spawns background tasks — never blocks.
pub fn fire_event(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
) {
fire_event_occurrence(
db,
http_client,
secret_key,
repo_id,
event,
payload,
None,
None,
);
}
/// Occurrence-keyed variant: at most one delivery per
/// `(request_id, ref_name, hook)` is spawned; concurrent executors
/// collapse via the `webhook_deliveries` ledger. Best-effort on crash
/// (ledger is claimed before spawn), which the PR scope allows.
///
/// For durable outbox usage, call `claim_webhook_delivery_before_spawn` first
/// to claim deliveries synchronously, then call
/// `fire_event_occurrence_with_claimed_hooks` with the claimed hook IDs to
/// spawn HTTP POST only for claimed hooks.
#[allow(clippy::too_many_arguments)]
pub fn fire_event_occurrence(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
request_id: Option<&str>,
ref_name: Option<&str>,
) {
let repo_id = repo_id.to_string();
let event = event.to_string();
let request_id = request_id.map(|s| s.to_string());
let ref_name = ref_name.map(|s| s.to_string());
tokio::spawn(async move {
fire_event_async(db, http_client, &repo_id, &event, payload).await;
fire_event_async_occurrence(
db,
http_client,
secret_key,
&repo_id,
&event,
payload,
request_id.as_deref(),
ref_name.as_deref(),
None,
)
.await;
});
}
async fn fire_event_async(
/// Variant for durable outbox: only spawn HTTP POST for previously claimed hooks.
#[allow(clippy::too_many_arguments)]
pub fn fire_event_occurrence_with_claimed_hooks(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
request_id: Option<&str>,
ref_name: Option<&str>,
claimed_hooks: Vec<crate::db::Webhook>,
) {
let hooks = match db.list_webhooks_for_event(repo_id, event).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(err = %e, "failed to list webhooks for event {event}");
return;
let repo_id = repo_id.to_string();
let event = event.to_string();
let request_id = request_id.map(|s| s.to_string());
let ref_name = ref_name.map(|s| s.to_string());
tokio::spawn(async move {
fire_event_async_occurrence(
db,
http_client,
secret_key,
&repo_id,
&event,
payload,
request_id.as_deref(),
ref_name.as_deref(),
Some(claimed_hooks),
)
.await;
});
}
/// Claim webhook delivery before spawning HTTP POST.
/// Returns `Some(hook ids)` successfully claimed, or `None` when the hook
/// list itself could not be read (transient DB error, unknown membership).
/// A `None` caller must fall back to best-effort send rather than dropping
/// the delivery: children are still deleted downstream, so an empty claim
/// set on DB error would lose the webhook permanently.
/// This synchronous claim phase must be awaited before child deletion to prevent
/// permanent webhook loss on crash between deletion and claim.
pub async fn claim_webhook_delivery_before_spawn(
db: Arc<Db>,
repo_id: &str,
event: &str,
request_id: Option<&str>,
ref_name: Option<&str>,
) -> Option<Vec<crate::db::Webhook>> {
let mut claimed_hooks = Vec::new();
// Only claim when we have occurrence context (request_id + ref_name)
if let (Some(req), Some(r)) = (request_id, ref_name) {
let hooks = match db.list_webhooks_for_event(repo_id, event).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(err = %e, "failed to list webhooks for event {event}");
return None;
}
};
for hook in hooks {
let delivery_id = crate::db::deterministic_id(&["webhook", req, r, &hook.id]);
match db
.claim_webhook_delivery(&delivery_id, req, repo_id, event)
.await
{
Ok(true) => claimed_hooks.push(hook),
Ok(false) => continue, // Already claimed
// Send fallback (legacy best-effort): a transient claim
// error must not drop the delivery while children are
// still deleted. The in-task claimer this replaced sent
// anyway on claim error; keep that here.
Err(e) => {
tracing::warn!(err = %e, hook_id = %hook.id, "webhook claim failed; sending anyway");
claimed_hooks.push(hook);
}
}
}
}
Some(claimed_hooks)
}
#[allow(clippy::too_many_arguments)]
async fn fire_event_async_occurrence(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
request_id: Option<&str>,
ref_name: Option<&str>,
claimed_hooks: Option<Vec<crate::db::Webhook>>,
) {
// A caller that already claimed deliveries hands the claimed hook rows
// through; the send path must not re-list, because a list failure here
// would silently drop deliveries already claimed in the ledger.
let already_claimed = claimed_hooks.is_some();
let hooks = match claimed_hooks {
Some(h) => h,
None => match db.list_webhooks_for_event(repo_id, event).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(err = %e, "failed to list webhooks for event {event}");
return;
}
},
};
if hooks.is_empty() {
@@ -76,8 +242,30 @@ async fn fire_event_async(
let client = Arc::clone(&http_client);
let event_name = event.to_string();
let bytes = payload_bytes.clone();
let delivery_id = uuid::Uuid::new_v4().to_string();
// Stable occurrence delivery key when request context exists;
// random UUID otherwise (legacy callers without occurrence).
let delivery_id = match (request_id, ref_name) {
(Some(req), Some(r)) => crate::db::deterministic_id(&["webhook", req, r, &hook.id]),
_ => uuid::Uuid::new_v4().to_string(),
};
// Claim before spawn so concurrent executors collapse to one
// delivery per occurrence. Failures to record fall back to
// sending (legacy best-effort) rather than dropping.
// Skip claim if already done by caller (durable_outbox path).
if !already_claimed && request_id.is_some() {
match db
.clone()
.claim_webhook_delivery(&delivery_id, request_id.unwrap_or(""), repo_id, event)
.await
{
Ok(true) => {}
Ok(false) => continue,
Err(_) => {}
}
}
let db_for_mark = db.clone();
let secret_key = Arc::clone(&secret_key);
tokio::spawn(async move {
// Policy skip, not a delivery failure: the stored URL predates the
// create-time gate. The "invalid_url" label is documented on
@@ -92,7 +280,29 @@ async fn fire_event_async(
return;
}
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
let delivery_for_mark = delivery_id.clone();
// Open the secret only at signing time; at rest it stays sealed.
let signature =
match signing_decision(secret_key.as_ref(), hook.secret.as_ref(), &bytes) {
SigningOutcome::NoSecret => None,
SigningOutcome::Signed(sig) => Some(sig),
// A hook that HAS a secret must sign with it or not deliver at
// all: `reveal` returning None means the stored value could
// not be opened (a foreign or rotated key, a truncated
// ciphertext), and sending unsigned would present a broken
// hook as a working one while the ledger marks the event
// delivered. The row stays pending, like a network error, so
// a fixed key can still land it.
SigningOutcome::Withhold => {
crate::metrics::record_webhook_delivery("secret_unopenable");
tracing::warn!(
hook_id = %hook.id,
delivery_id = %delivery_for_mark,
"webhook secret could not be opened; delivery withheld"
);
return;
}
};
let mut req = client
.post(&hook.url)
@@ -113,6 +323,10 @@ async fn fire_event_async(
"http_error"
};
crate::metrics::record_webhook_delivery(result_label);
// Any HTTP response proves the delivery fired; mark
// sent so the ledger never claims an unsent delivery.
// Network errors stay pending for stale-reclaim.
let _ = db_for_mark.mark_webhook_sent(&delivery_for_mark).await;
tracing::info!(
url = %hook.url,
event = %event_name,
@@ -133,3 +347,60 @@ async fn fire_event_async(
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::Rng;
fn key() -> [u8; 32] {
chacha20poly1305::aead::OsRng.gen()
}
/// A hook with no secret delivers unsigned, as before.
#[test]
fn signing_decision_no_secret_is_unsigned() {
assert!(matches!(
signing_decision(&key(), None, b"{}"),
SigningOutcome::NoSecret
));
}
/// A sealed secret that opens signs with the revealed plaintext.
#[test]
fn signing_decision_sealed_secret_signs() {
let k = key();
let sealed =
crate::webhook_secret::WebhookSecret::new(crate::webhook_secret::seal(&k, "topsecret"));
match signing_decision(&k, Some(&sealed), b"{}") {
SigningOutcome::Signed(sig) => {
assert_eq!(sig, sign_payload("topsecret", b"{}"))
}
_ => panic!("an openable sealed secret must produce a signature"),
}
}
/// A stored value that carries the sealed prefix but cannot be opened
/// (foreign key, truncated ciphertext) must withhold, never send unsigned.
#[test]
fn signing_decision_unopenable_secret_withholds() {
let garbage =
crate::webhook_secret::WebhookSecret::new("enc:v1:bm90LWNoaXBoZXJ0ZXh0".to_string());
assert!(matches!(
signing_decision(&key(), Some(&garbage), b"{}"),
SigningOutcome::Withhold
));
}
/// A legacy plaintext value signs with itself unchanged.
#[test]
fn signing_decision_legacy_plaintext_signs() {
let legacy = crate::webhook_secret::WebhookSecret::new("plainsecret".to_string());
match signing_decision(&key(), Some(&legacy), b"{}") {
SigningOutcome::Signed(sig) => {
assert_eq!(sig, sign_payload("plainsecret", b"{}"))
}
_ => panic!("legacy plaintext must keep signing"),
}
}
}
File diff suppressed because it is too large. Load diff
+595 -30
View File
@@ -416,8 +416,8 @@ fn f3_second_writer_leased_until_reap() {
NOT RepoWriteGuard (which drops at the disconnect instant, reopening F3).",
);
let receive = repos_production
.find("smart_http::receive_pack(")
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack");
.find("smart_http::receive_pack_raw")
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack_raw");
assert!(
lease_acquire < with_lease && with_lease < receive,
"F3 gate bypassed: the write lease must be acquired, then carried by the \
@@ -509,17 +509,19 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
/// too, so an ungated spawn would fire for a push git rejected, pinning and announcing
/// a half-applied repo. Above `release` the `?` on `receive_result` can no longer be
/// what gates it, so the success check is explicit and this gate binds it: the spawn
/// must sit inside `if push_succeeded`, and `release` must consume the same flag so
/// the two cannot drift apart.
/// must sit inside `if disposition.spawn_tail`, and `release` must consume the same
/// disposition (`disposition.release_ok`) so the two cannot drift apart.
///
/// This is an ordering check rather than a cancellation-race test on purpose: it is
/// the companion to `receive_pack_tail_survives_a_disconnect_during_release`, which
/// drives the actual disconnect through a parked `release`. Same instrument the F3
/// gate above uses.
///
/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below
/// MUTATION (RED): move the `post_receive_replication_tail(` call below
/// `guard.release(` and the ordering assertion fails; take it out of the
/// `if push_succeeded` block and the failed-push assertion fails.
/// `if disposition.spawn_tail` block and the failed-push assertion fails; gate
/// `spawn_tail`/`release_ok` on the outcome commit and
/// `post_git_disposition_replication_carve_out` fails with it.
#[test]
fn inv22_replication_tail_spawns_at_the_durability_boundary() {
let repos = src("api/repos.rs");
@@ -534,41 +536,604 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() {
.expect("split always yields a first chunk");
let success_flag = production
.find("let push_succeeded = receive_result.is_ok();")
.expect(
"U5 gate missing: the tail's success gate must be bound from receive_result.is_ok()",
.find("post_git_disposition(")
.expect("U5 gate missing: the tail's disposition must be bound before the gate");
// One tail per push, on the proven-landed ref set. Either spawn
// position (carve-out early, or durability-bound at release) must sit
// inside a `disposition.spawn_tail` gate and feed `landed_refs` —
// never the unfiltered `ref_updates`.
// Match the tail CALL, not the `tokio::spawn(...)` wrapper: production
// currently wraps it in `tokio::spawn(async move { ... })`, and keying on
// the wrapper's exact shape is what let this gate go blind on the refactor.
// The `pub(crate) async fn` definition is the only other text occurrence,
// filtered by the `fn ` that must precede it.
let spawn_positions: Vec<usize> = production
.match_indices("post_receive_replication_tail(")
.map(|(p, _)| p)
.filter(|&p| !production[..p].trim_end().ends_with("fn"))
.collect();
assert!(
!spawn_positions.is_empty(),
"U5 gate missing: the replication tail must be spawned by git_receive_pack"
);
for (i, pos) in spawn_positions.iter().copied().enumerate() {
let gate = production[..pos].rfind("if disposition.spawn_tail").expect(
"U5 gate missing: every tail spawn must be gated on `if disposition.spawn_tail`",
);
let gate_open = production
.find("if push_succeeded {")
.expect("U5 gate missing: the tail spawn must be gated on the push having succeeded");
let spawn = production
.find("tokio::spawn(post_receive_replication_tail(")
// The spawn must sit inside the gate's block: brace depth at the
// spawn position must still be positive measured from the `if`.
// An ungated spawn after the block closes fails this, as does a
// stray `disposition.spawn_tail` mention in a comment.
let mut depth = 0i32;
for ch in production[gate..pos].chars() {
match ch {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
}
assert!(
depth >= 1,
"U5 gate bypassed: tail #{i} sits outside the `if disposition.spawn_tail` block"
);
assert!(
!production[gate..pos].contains("post_receive_replication_tail"),
"U5 gate bypassed: a second tail spawn sits between the gate and its spawn"
);
let call_end = production[pos..].find(");").unwrap() + pos;
let call = &production[pos..call_end];
// Match the ref-set ARGUMENT (third positional), not a substring of the
// call: `landed` is the in-scope alias for `landed_refs` in the
// carve-out spawn and either name is the proven-landed set, but a bare
// `call.contains("landed")` is also satisfied by the `push_landed_at`
// timestamp argument, which would let a call feeding unfiltered
// `ref_updates` pass.
let refset_arg = call
.split(',')
.nth(2)
.map(str::trim)
.expect("U5 gate stale: tail call no longer takes a third argument");
assert!(
refset_arg == "landed" || refset_arg == "landed_refs",
"U5 gate bypassed: tail #{i} must carry the proven-landed ref set, not unfiltered ref_updates"
);
}
let spawn = *spawn_positions
.first()
.expect("U5 gate missing: the replication tail must be spawned by git_receive_pack");
let release = production
.find(".release(push_succeeded)")
.expect("U5 gate stale: release must consume the same success flag as the tail gate");
.find(".release(disposition.release_ok)")
.expect("U5 gate stale: release must consume the same disposition as the tail gate");
let touch = production
.find("state.db.touch_repo(")
.expect("U5 gate stale: git_receive_pack no longer calls touch_repo");
let webhook = production
.find("webhooks::fire_event(")
.expect("U5 gate stale: git_receive_pack no longer fires push webhooks");
// #26 Split PR 1 step 3 — the webhook fan-out moved into
// `durable_outbox::apply_request_effects`, which the live
// handler calls inline (the recovery drain calls the same
// function on the next startup). The gate now pins that the
// handler is wired to the executor, not to a per-ref inline
// webhook call.
let effects_executor = production
.find("apply_request_effects(&state, &request_id)")
.expect("U5 gate stale: git_receive_pack no longer calls apply_request_effects inline");
assert!(
success_flag < gate_open && gate_open < spawn,
"U5 gate bypassed: the tail must be spawned inside `if push_succeeded`, or a \
rejected push spawns a tail that pins and announces a half-applied repo"
);
// Still inside that block: no `}` may close it between the gate and the spawn.
assert!(
!production[gate_open + "if push_succeeded {".len()..spawn].contains('}'),
"U5 gate bypassed: the tail spawn left the `if push_succeeded` block, so a \
rejected push now spawns a tail"
success_flag < spawn,
"U5 gate bypassed: the disposition must be bound before any tail spawn"
);
assert!(
spawn < release && spawn < touch && spawn < webhook,
spawn < release && spawn < touch && spawn < effects_executor,
"U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \
and the webhook fan-out, so a disconnect in any of those windows cannot drop \
and the effect executor, so a disconnect in any of those windows cannot drop \
this push's pins, recovery copy, and announcements"
);
}
/// #26 Split PR 1 step 3 — drain + handler share a single effect
/// executor. The live handler (api/repos.rs) and the recovery
/// drain (`durable_outbox::drain_receive_pack_requests_with`) both
/// call `apply_request_effects`, so the per-ref effects fan-out
/// lives in exactly one place. The v29 per-ref walk
/// (`derive_one`, `drain_pending_ref_transitions_all`,
/// `lookup_accepted_ordinal`) is dead code: any caller reintroduced
/// would be the per-ref walk the step-3 PR removed. This gate
/// fails if a call site slips back in or the new seam is bypassed.
#[test]
fn inv26_step3_live_and_drain_share_apply_request_effects() {
let repos = src("api/repos.rs");
let outbox = src("durable_outbox.rs");
// The live handler calls `apply_request_effects`. Split the
// file at the test attribute so test code can't satisfy the
// gate by itself.
let production_repos = repos
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
assert!(
production_repos.contains("apply_request_effects(&state, &request_id)"),
"live handler must call `apply_request_effects(&state, &request_id)`; \
reverting to a per-ref inline fan-out splits live and recovery"
);
// The durable intent insert closes the pre-outbox crash window this
// PR exists to fix. Wrapping it in `if false` must turn this gate
// red; drain-only tests cannot prove the handler creates the rows.
assert!(
production_repos.contains("insert_receive_pack_request_with_children("),
"live handler must persist durable intent via \
`insert_receive_pack_request_with_children`; disabling it must fail this gate"
);
// The drain's per-request seam calls the same executor. Test
// code lives below `mod drain_tests`, so split there too.
let production_outbox = outbox
.split("\nmod drain_tests {")
.next()
.expect("split always yields a first chunk");
assert!(
production_outbox.contains("drain_receive_pack_requests_with"),
"drain seam `drain_receive_pack_requests_with` must exist; \
removing it forces a per-ref walk back into the drain"
);
assert!(
production_outbox.contains("apply_request_effects"),
"durable_outbox production code must define `apply_request_effects`; \
removing it splits the executor between live and recovery"
);
// The drain's per-request walker is wired to the executor.
// The closure body of `drain_receive_pack_requests_with` is the
// only call site — if a future change calls `derive_one`
// instead, this assertion fires.
let drain_seam_open = production_outbox
.find("pub async fn drain_receive_pack_requests_with<F, Fut>")
.expect("drain seam must be defined in the production half");
let drain_seam_close = production_outbox[drain_seam_open..]
.find("\n}\n")
.expect("drain seam body must close");
let drain_seam_body = &production_outbox[drain_seam_open..drain_seam_open + drain_seam_close];
assert!(
drain_seam_body.contains("apply_request_effects"),
"drain_receive_pack_requests_with must call apply_request_effects; \
wiring it to a per-ref helper reintroduces the v29 walk"
);
// The deleted per-ref drain must have zero call sites in the
// production half. A regression that re-adds a caller would
// bring back the per-ref fan-out.
assert!(
!production_outbox.contains("drain_pending_ref_transitions_all("),
"deleted `drain_pending_ref_transitions_all` must have zero production call sites; \
a per-ref walk is reintroduced"
);
assert!(
!production_outbox.contains("derive_one("),
"deleted `derive_one` must have zero production call sites; \
the per-ref fan-out is reintroduced"
);
assert!(
!production_outbox.contains("lookup_accepted_ordinal("),
"deleted `lookup_accepted_ordinal` must have zero production call sites; \
the per-ref ordinal lookup is reintroduced"
);
assert!(
!production_repos.contains("derive_one("),
"deleted `derive_one` must have zero live-handler call sites"
);
assert!(
!production_repos.contains("drain_pending_ref_transitions"),
"deleted per-ref drain functions must have zero live-handler call sites"
);
}
/// #26 Split PR 1 step 4 — the periodic queue-lifecycle purge is
/// wired in `lib.rs` and the contract is enforced by the `idx_receive_pack_requests_completed_at`
/// partial index from v41.
///
/// Assertions:
/// 1. `lib.rs` calls `purge_request_queue` once at boot (well, on
/// the spawn-task interval) and the spawn function exists.
/// 2. The DB helpers `purge_completed_receive_pack_requests` and
/// `purge_completed_pending_ref_transitions` exist in `db/mod.rs`.
/// 3. The config knobs `queue_retention_days` and `queue_purge_batch` exist
/// on `Config`.
/// 4. `quarantined` is NOT in the purge WHERE clause (step 4 does not
/// introduce the state, but the invariant holds for the future).
#[test]
fn inv26_step4_queue_lifecycle_purge_is_wired() {
let boot_src = src("lib.rs");
assert!(
boot_src.contains("spawn_queue_lifecycle_sweep"),
"lib.rs must spawn the periodic queue-lifecycle purge"
);
assert!(
boot_src.contains("purge_request_queue"),
"lib.rs must call purge_request_queue on the periodic sweep"
);
let db_src = src("db/mod.rs");
assert!(
db_src.contains("purge_completed_receive_pack_requests"),
"db/mod.rs must expose purge_completed_receive_pack_requests"
);
assert!(
db_src.contains("purge_completed_pending_ref_transitions"),
"db/mod.rs must expose purge_completed_pending_ref_transitions"
);
let config_src = src("config.rs");
assert!(
config_src.contains("queue_retention_days"),
"Config must expose queue_retention_days"
);
assert!(
config_src.contains("queue_purge_batch"),
"Config must expose queue_purge_batch"
);
}
/// #26 Split PR 1 step 5 — the marker gate and the retry-bound
/// quarantine are wired end-to-end. A missing or hash-mismatched
/// marker on disk quarantines the request; a Retry over the bound
/// does too. This gate pins every load-bearing seam, against the
/// production half of each file (the test modules name the same
/// identifiers in their own harnesses).
///
/// Assertions:
/// 1. `reconcile_prepared_page` calls `mark_request_quarantined`
/// and `mark_children_rejected_for_quarantined_parent` on
/// marker-gate failure.
/// 2. The drain's `EffectsOutcome::Retry` arm checks
/// `effects_max_attempts`.
/// 3. `git::store::read_ref` exists in `git/store.rs`.
/// 4. `db::mark_request_quarantined`,
/// `db::mark_children_rejected_for_quarantined_parent`,
/// `db::get_receive_pack_requests_by_ids`, and
/// `db::request_state::QUARANTINED` all exist.
/// 5. The handler writes the marker ref BEFORE calling
/// `smart_http::receive_pack_raw` (the durability window).
#[test]
fn inv26_step5_marker_quarantine_and_bound_are_wired() {
let outbox = src("durable_outbox.rs");
let store = src("git/store.rs");
let db = src("db/mod.rs");
let repos = src("api/repos.rs");
// Split at the TEST MODULE for the production-only assertions.
let production_outbox = outbox
.split("\nmod drain_tests {")
.next()
.expect("split always yields a first chunk");
let production_repos = repos
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
// (1) The reconcile's marker gate quarantines via the DB helpers.
assert!(
production_outbox.contains("mark_request_quarantined"),
"reconcile_prepared_page must call mark_request_quarantined on marker-gate failure"
);
assert!(
production_outbox.contains("mark_children_rejected_for_quarantined_parent"),
"reconcile_prepared_page must call mark_children_rejected_for_quarantined_parent \
so quarantined parents cancel their children"
);
// The gate reads the marker ref and compares against the parent's
// `request_bytes_hash` via `git::store::read_ref` /
// `git::store::marker_value_for`. Reverting either reintroduces
// the DoS window the marker gate exists to close.
assert!(
production_outbox.contains("git::store::read_ref"),
"reconcile_prepared_page must read the marker ref via git::store::read_ref"
);
assert!(
production_outbox.contains("marker_value_for"),
"reconcile_prepared_page must compute the expected marker value via \
git::store::marker_value_for"
);
// (2) The drain's `EffectsOutcome::Retry` arm checks the bound.
assert!(
production_outbox.contains("effects_max_attempts"),
"drain_receive_pack_requests_with must consult effects_max_attempts on Retry"
);
// (3) `git::store::read_ref` is the read seam the gate depends on.
assert!(
store.contains("pub fn read_ref("),
"git::store::read_ref must exist; the marker gate reads through it"
);
assert!(
store.contains("pub fn marker_value_for("),
"git::store::marker_value_for must exist; the marker gate computes the expected \
value with it (and the live handler writes the value via the same helper)"
);
// (4) DB-side seams the gate depends on.
assert!(
db.contains("pub async fn mark_request_quarantined"),
"Db::mark_request_quarantined must exist"
);
assert!(
db.contains("pub async fn mark_children_rejected_for_quarantined_parent"),
"Db::mark_children_rejected_for_quarantined_parent must exist"
);
assert!(
db.contains("pub async fn get_receive_pack_requests_by_ids"),
"Db::get_receive_pack_requests_by_ids must exist (avoids N+1 in the marker gate)"
);
assert!(
db.contains("pub const QUARANTINED: &str = \"quarantined\""),
"request_state::QUARANTINED must be defined"
);
// (5) The handler writes the marker ref BEFORE the durability
// boundary (smart_http::receive_pack_raw). Severing the
// ordering re-opens the marker-gate DoS window for live pushes.
let marker_write = production_repos
.find("git::store::marker_value_for")
.expect("U5 gate stale: the live handler no longer computes the marker value");
let receive_raw = production_repos
.find("smart_http::receive_pack_raw")
.expect("U5 gate stale: git_receive_pack no longer calls smart_http::receive_pack_raw");
assert!(
marker_write < receive_raw,
"U5 gate bypassed: the marker ref must be written BEFORE receive_pack_raw so the \
reconcile's gate has evidence of the live push"
);
}
/// Issue #26 split 1 — namespace gate and pre-git refusal lifecycle.
///
/// The push gate (repos.rs) must deny:
/// 1. Any ref-update line that fails UTF-8 decode (the raw body is passed verbatim
/// to git receive-pack, and git accepts refnames with bytes >= 0x80; the gate
/// never sees these refs, no durable child row exists, and under requests/issues/
/// the lossy for-each-ref decode still matches the narrowed exemption).
/// 2. The bare `refs/gitlawb` name (the `starts_with("refs/gitlawb/")` check misses it).
///
/// Every explicit pre-git refusal must call `refuse_receive_pack_before_git` to
/// terminalize the intent aggregate in one transaction, or a dropped future between
/// the intent insert and `receive_pack` strands a `received` + `prepared` aggregate
/// that is never purged, never promotable, and poisons its tuple.
///
/// The visibility exemption (visibility_pack.rs) was narrowed to `requests/` and
/// `issues/` only. The push gate is the premise that narrowing relies on (the comment
/// at visibility_pack.rs:340), so tests must prove:
/// - A `refs/gitlawb/<other>` ref pointing at a blob is denied on push.
/// - A stray `refs/gitlawb/<other>` blob ref planted in the pack path fails closed.
///
/// Source gates below pin the wiring; behavioral proofs live next to the code:
/// `parse_ref_updates_refuses_non_utf8_command_line` and
/// `internal_namespace_gate_covers_bare_name_and_subtree` (api/repos.rs),
/// `planted_non_exempt_gitlawb_blob_ref_fails_closed` (git/visibility_pack.rs),
/// and the mirror import prune
/// `prune_removes_non_exempt_gitlawb_refs_and_keeps_exempt` (sync.rs).
#[test]
fn issue_26_namespace_gate_and_refusal_wiring() {
let repos = src("api/repos.rs");
let vis = src("git/visibility_pack.rs");
// P1: fail the push on ref-update lines the handler cannot decode.
// The gate at parse_ref_updates must refuse when any command line in the
// ref-update section is undecodable UTF-8. The check is an Err arm on
// `from_utf8(data)` that returns BadRequest, so removing it allows non-UTF-8
// refs to bypass the namespace gate.
assert!(
repos.contains("ref-update line is not valid UTF-8; push refused"),
"P1 gate missing: parse_ref_updates must refuse when any ref-update line is \
non-UTF-8 (git accepts bytes >= 0x80; the gate never sees them, no durable \
child exists, and the lossy for-each-ref decode still matches the exemption)"
);
// P1 (cont): the bare `refs/gitlawb` name must also be caught. The
// predicate is extracted as `ref_is_internal_namespace` so it is
// unit-testable; pin the bare-name arm inside it and its use at the
// push gate (a `starts_with("refs/gitlawb/")`-only check misses the
// bare name, and it lands on repos with no internal refs yet).
assert!(
repos.contains("ref_name == \"refs/gitlawb\"")
&& repos.contains(".find(|r| ref_is_internal_namespace(r))"),
"P1 gate missing: ref_is_internal_namespace must match the bare \
'refs/gitlawb' name and the push gate must consult it \
(starts_with('refs/gitlawb/') misses it, and it lands on repos with \
no internal refs yet)"
);
// P1 (cont): every explicit pre-git refusal must call refuse_receive_pack_before_git
// to terminalize the intent. Exactly 6 occurrences: the five inline call
// sites (lease cap, per-source cap, write pool, acquire timeout, acquire
// error) plus the drop-guard's own pre-git spawn. The post-git guard phase
// calls mark_receive_pack_interrupted (a different name), so deleting any
// one inline site drops the count to 5 and turns this red. The parse and
// namespace refusals correctly carry no call: they return before the
// intent insert, so there is no aggregate to terminalize.
assert_eq!(
repos.matches("refuse_receive_pack_before_git(").count(),
6,
"P1 wiring: exactly 6 refuse_receive_pack_before_git calls expected (5 \
inline refusal sites + guard pre-git spawn); deleting any inline site \
must turn this red"
);
// Pin the five inline sites by their reason literals, so a site that stops
// terminalizing (or is renumbered away) fails by name, not just by count.
for reason in [
"repo write-lease waiter cap reached",
"admission per-source cap reached",
"git write pool saturated",
"acquire_write timed out",
"acquire_write failed",
"handler dropped before git started",
] {
assert!(
repos.contains(reason),
"P1 wiring: refusal reason literal missing: {reason}"
);
}
// P1 (cont): the intent drop-guard must be phase-aware. It is armed before
// the insert resolves (covering the cancellation window), flipped to
// git-started immediately before the receive_pack await (a drop from there
// is a mid-git disconnect, terminalized via mark_receive_pack_interrupted
// because the Err arm never runs on a drop), and disarmed once the outcome
// commit resolves. Assert the ordering, not just presence: the phase flip
// must precede the receive_pack call.
assert!(
repos.contains("IntentDropGuard::new") && repos.contains("notify_git_started()"),
"P1 gate missing: IntentDropGuard must exist and flip to git-started phase \
before receive_pack (a dropped future between intent insert and git start \
strands a received+prepared aggregate)"
);
let flip_pos = repos
.find("notify_git_started()")
.expect("notify_git_started call must exist");
let git_pos = repos
.find("receive_pack_raw_with_reflog(")
.expect("receive_pack call must exist");
assert!(
flip_pos < git_pos,
"P1 ordering: notify_git_started() must precede the receive_pack call, \
or a mid-git drop is misclassified as a pre-git shed"
);
assert!(
repos.contains("_intent_guard.disarm()"),
"P1 gate missing: the guard must disarm once the outcome commit resolves, \
or every push spawns a wasteful cleanup txn at scope end"
);
// A sync Drop cannot await: cleanup is spawned via the current runtime
// handle with an off-runtime fallback. A bare tokio::spawn panics
// mid-unwind with no runtime (off-runtime drop, process teardown).
assert!(
repos.contains("Handle::try_current()"),
"P1 gate missing: guard Drop must spawn via Handle::try_current with an \
off-runtime arm, not a bare tokio::spawn"
);
// P2: the post-git drop arm must exist and be wired. The guard's
// GIT_STARTED phase spawns `mark_receive_pack_interrupted` (parent to
// `rejected_at_git`, children to `uncertain` — the Err arm never runs on
// a drop). Deleting that spawn keeps every count above green, so pin the
// method name; its behavior is proved by
// `post_git_interruption_marks_uncertain_and_terminalizes_parent`.
assert!(
repos.contains("mark_receive_pack_interrupted("),
"P2 wiring: the guard's post-git phase must spawn \
mark_receive_pack_interrupted (a mid-git drop otherwise strands \
received+prepared with no Err arm to mark them uncertain)"
);
// The guard must commit computed fates rather than a blanket uncertain
// once the report is parsed, and only disarm on a resolved commit: pin
// both the stash call and the `outcome_commit_ok` gate. Making disarm()
// unconditional strands the commit-failure path and misfiles proven
// rejections as uncertain.
assert!(
repos.contains("set_fates(ComputedFates"),
"P2 wiring: computed fates must be stashed on the guard once parsed, \
or a post-commit drop can only apply a context-free uncertain"
);
assert!(
repos.contains("if outcome_commit_ok"),
"P2 wiring: disarm must be gated on outcome_commit_ok; unconditional \
disarm strands the persistent-commit-failure path"
);
// The live-handler `AwaitingSiblings` arm is unreachable through the
// current outcome builder (documented at the arm itself) and therefore
// has no behavioral test; pin its presence so removing it is conscious.
// The drain consumer IS behaviorally driven (sibling-wait tests).
assert_eq!(
repos.matches("EffectsOutcome::AwaitingSiblings").count(),
1,
"live AwaitingSiblings arm must exist (defensive symmetry with the \
drain consumer); deleting it must turn this red"
);
// P2: pin the mirror prune call sites. The prune helper is called from
// both `clone_repo` and `fetch_repo` (plus its own definition) — deleting
// either call leaves the direct-helper test green while mirrors import
// evil refs again. Count occurrences of the call with its argument.
let sync_src = src("sync.rs");
assert_eq!(
sync_src
.matches("prune_non_exempt_gitlawb_refs(local_path)")
.count(),
2,
"P2 wiring: prune must be called from both clone_repo and fetch_repo; \
deleting either call must turn this red"
);
// Both call sites must route through `wrap_prune_error`, which preserves
// deterministic `PruneInvalid` refusals instead of stringifying every
// error into transient `PruneFailed` (an every-tick deferral spin for
// permanently invalid mirrors). Pin the exact call shape at both sites.
assert_eq!(
sync_src.matches(".map_err(wrap_prune_error)").count(),
2,
"P2 wiring: both prune call sites must use wrap_prune_error; inline \
rewrap must turn this red"
);
// P2: the visibility exemption narrowing removed a serving path. The exemption
// is now scoped to `requests/` and `issues/` only (visibility_pack.rs:371), and
// the comment at :340 states the push gate is the premise. Tests must prove the
// gate works, which the P1 findings above covered (non-UTF-8 line, bare name,
// refusal wiring). This check verifies the exemption narrowing landed.
assert!(
vis.contains(
r#"!r.starts_with("refs/gitlawb/requests/") && !r.starts_with("refs/gitlawb/issues/")"#
),
"P2 gate missing: the visibility exemption must be narrowed to requests/ and \
issues/ only (the push gate is the premise; any other refs/gitlawb/* ref is \
denied on push and must fail closed in the pack path)"
);
}
/// Claimed-webhook delivery must not re-list hooks: the durable path claims
/// synchronously, hands the claimed hook rows to the sender, and a list
/// failure in the send task would silently drop already-claimed deliveries.
#[test]
fn inv26_claimed_hook_delivery_never_relists() {
let webhooks = src("webhooks.rs");
let production = webhooks
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
// The claimed entry point exists and passes hook ROWS (not ids) so the
// sender never needs the hook table again.
let entry = production
.find("fire_event_occurrence_with_claimed_hooks(")
.expect("claimed-hooks sender missing");
let claimed_rows = production
.find("claimed_hooks: Vec<crate::db::Webhook>")
.expect("claimed path must carry Webhook rows, not ids");
let list_call = production
.find("db.list_webhooks_for_event(repo_id, event)")
.expect("unclaimed path still lists hooks");
assert!(
entry < claimed_rows && claimed_rows < list_call,
"claimed sender must hand rows through before any list fallback"
);
// Inside the async worker, the claimed branch must bypass the list.
// Inside the async worker (which starts after the claim helper), the
// claimed branch must be selected before the list fallback can run.
let worker = production
.find("async fn fire_event_async_occurrence(")
.expect("occurrence worker missing");
let already_claimed = production[worker..]
.find("let already_claimed = claimed_hooks.is_some();")
.map(|i| worker + i)
.expect("claimed path must short-circuit before re-listing");
let worker_list = production[worker..]
.find("db.list_webhooks_for_event(repo_id, event)")
.map(|i| worker + i)
.expect("unclaimed path still lists hooks");
assert!(
already_claimed < worker_list,
"the claimed branch must be selected before list_webhooks_for_event can run"
);
}
+235
View File
@@ -0,0 +1,235 @@
//! 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> {
check_denied(status, body, expected, withheld)?;
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)
}
+1
View File
@@ -29,6 +29,7 @@ uuid = { workspace = true }
url = { workspace = true }
percent-encoding = { workspace = true }
urlencoding = "2"
libc = "0.2"
alloy = { version = "1", default-features = false, features = [
"contract",
"provider-http",
+31 -2
View File
@@ -267,7 +267,31 @@ async fn fetch_withheld(node: &str, owner: &str, name: &str) -> Result<(Vec<Stri
};
let resp = match resp {
Ok(r) if r.status().is_success() => r,
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => return Ok((Vec::new(), Vec::new())),
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => {
// Two 404s share this status and must be told apart before the
// legacy empty-glob fallback is taken: a route miss on a node that
// predates withheld-paths has an empty body, while a current node
// returns the JSON AppError body for absent/denied/quarantined
// repos. The JSON body proves the route exists, so the repo is
// unavailable to this caller and cloning cannot proceed.
if r.status().as_u16() == 404 {
let status = r.status();
let body = crate::http::read_body_capped(r, 4096).await;
if body.read_failed {
// The read died part-way: we cannot classify the denial,
// and a failed read must not collapse into the legacy
// route-miss arm and start a clone.
bail!("withheld-paths 404 body from this node could not be read");
}
if !body.text.trim().is_empty() {
bail!(
"repository {owner}/{name} is not available on this node ({status}): {}",
sanitize_node_msg(&body.text)
);
}
}
return Ok((Vec::new(), Vec::new()));
}
Ok(r) => bail!("withheld-paths lookup failed: {}", r.status()),
Err(err) => return Err(err).context("fetching withheld paths"),
};
@@ -327,7 +351,12 @@ async fn recover_encrypted_blobs(
.await
{
Ok(r) if r.status().is_success() => r,
_ => return Ok(vec![]),
// Discovery failure is not an empty inventory: a denied, quarantined,
// erroring, or unreachable node must surface so the caller can warn
// and still run the Arweave fallback rather than report a clean clone
// with authorized blobs silently missing.
Ok(r) => bail!("encrypted-blobs inventory unavailable: {}", r.status()),
Err(err) => return Err(err).context("fetching encrypted-blobs inventory"),
};
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
if body.blobs.is_empty() {
+98 -37
View File
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -69,22 +69,63 @@ impl Check {
}
}
/// Resolve the config dir and key path without aborting on a refused resolver.
/// The whole point of doctor is to explain a broken setup, so a `GITLAWB_KEY`
/// or `--dir` the resolvers reject becomes `Check::fail` rows for the checks
/// that depend on them, while node/git checks still run.
fn resolve_config_paths(
args_dir: Option<PathBuf>,
) -> (Option<PathBuf>, Option<PathBuf>, Vec<Check>) {
let dir = crate::identity::gitlawb_dir(args_dir.clone());
let mut checks = Vec::new();
let dir = match dir {
Ok(d) => Some(d),
Err(e) => {
checks.push(Check::fail(
"identity",
format!("could not resolve config dir: {e}"),
"fix GITLAWB_KEY or --dir",
));
checks.push(Check::fail(
"registration",
"skipped: config dir unresolved",
"fix GITLAWB_KEY or --dir",
));
None
}
};
let pem_path = match crate::identity::key_path_for(args_dir.as_deref()) {
Ok(p) => Some(p),
Err(e) => {
if dir.is_some() {
checks.push(Check::fail(
"identity",
format!("could not resolve key path: {e}"),
"fix GITLAWB_KEY or --dir",
));
}
None
}
};
(dir, pem_path, checks)
}
pub async fn run(args: DoctorArgs) -> Result<()> {
println!("gl doctor — checking your Twigpine setup");
println!();
let dir = args.dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let mut checks = Vec::new();
// The same resolver every other command uses. `doctor` reporting on
// `~/.gitlawb` while `gl register` writes to the parent of `GITLAWB_KEY` would
// make the one command whose job is to explain a broken setup the one that
// misreports it.
let args_dir = args.dir.clone();
let (dir, pem_path, resolver_rows) = resolve_config_paths(args_dir.clone());
let mut checks = resolver_rows;
// ── 1. Identity ───────────────────────────────────────────────────────
let pem_path = dir.join("identity.pem");
if pem_path.exists() {
match std::fs::read_to_string(&pem_path)
if pem_path.as_ref().is_some_and(|p| p.exists()) {
let pem_path = pem_path.as_ref().unwrap();
match std::fs::read_to_string(pem_path)
.ok()
.and_then(|pem| gitlawb_core::identity::Keypair::from_pem(&pem).ok())
{
@@ -101,7 +142,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
));
}
}
} else {
} else if let Some(pem_path) = &pem_path {
checks.push(Check::fail(
"identity",
format!("not found at {}", pem_path.display()),
@@ -110,33 +151,35 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
// ── 2. Registration ───────────────────────────────────────────────────
let ucan_path = dir.join("ucan.json");
if ucan_path.exists() {
match std::fs::read_to_string(&ucan_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
{
Some(v) => {
let node = v["node"].as_str().unwrap_or("unknown");
checks.push(Check::pass(
"registration",
format!("registered with {node}"),
));
}
None => {
checks.push(Check::fail(
"registration",
"ucan.json exists but is malformed",
"gl register",
));
if let Some(dir) = &dir {
let ucan_path = dir.join("ucan.json");
if ucan_path.exists() {
match std::fs::read_to_string(&ucan_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
{
Some(v) => {
let node = v["node"].as_str().unwrap_or("unknown");
checks.push(Check::pass(
"registration",
format!("registered with {node}"),
));
}
None => {
checks.push(Check::fail(
"registration",
"ucan.json exists but is malformed",
"gl register",
));
}
}
} else {
checks.push(Check::fail(
"registration",
"not registered with any node",
"gl register",
));
}
} else {
checks.push(Check::fail(
"registration",
"not registered with any node",
"gl register",
));
}
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
@@ -605,6 +648,24 @@ mod tests {
assert!(check_shell_alias_shadowing(None).is_none());
}
#[test]
fn refused_resolver_becomes_check_rows_not_abort() {
// A relative GITLAWB_KEY makes both resolvers Err; doctor must turn
// that into fail rows for the dependent checks and keep going, not
// exit before the node/git checks ever run.
crate::identity::test_env::with_key(Some("relative/path"), || {
let (dir, pem_path, rows) = resolve_config_paths(None);
assert!(dir.is_none() && pem_path.is_none());
let fails: Vec<_> = rows
.iter()
.filter(|c| matches!(c.state, CheckState::Fail))
.map(|c| c.label)
.collect();
assert!(fails.contains(&"identity"), "rows: {fails:?}");
assert!(fails.contains(&"registration"), "rows: {fails:?}");
});
}
#[test]
fn test_is_newer_minor_bump() {
assert!(is_newer("0.2.0", "0.1.0"));
+32 -3
View File
@@ -148,6 +148,18 @@ impl NodeClient {
self.send_signed("POST", path, body).await
}
/// POST with JSON body + RFC 9421 signing and an optional user-provided
/// iCaptcha proof, sent on the initial request.
pub async fn post_with_proof(
&self,
path: &str,
body: &[u8],
initial_proof: Option<&str>,
) -> Result<reqwest::Response> {
self.send_signed_with_proof("POST", path, body, initial_proof)
.await
}
/// PUT with RFC 9421 signing + transparent iCaptcha solve/retry.
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("PUT", path, body).await
@@ -162,16 +174,32 @@ impl NodeClient {
/// `x-icaptcha-*` headers) solve it and retry the same signed request with
/// the proof header, up to [`MAX_ICAPTCHA_RETRIES`]. Emits an actionable
/// hint on a 401 "not an agent" (the old-CLI / unregistered failure mode).
/// Existing call path: no user-supplied proof on the first request.
async fn send_signed(
&self,
method: &str,
path: &str,
body: &[u8],
) -> Result<reqwest::Response> {
let mut proof: Option<String> = None;
self.send_signed_with_proof(method, path, body, None).await
}
/// Sign + send a write. `initial_proof`, if provided, is attached to the
/// first request. On a 403 iCaptcha challenge advertised via
/// `x-icaptcha-*` headers, solve and retry up to MAX_ICAPTCHA_RETRIES.
async fn send_signed_with_proof(
&self,
method: &str,
path: &str,
body: &[u8],
initial_proof: Option<&str>,
) -> Result<reqwest::Response> {
let mut proof = initial_proof.map(str::to_owned);
let mut attempts = 0;
loop {
let resp = self.send_once(method, path, body, proof.as_deref()).await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED
@@ -195,6 +223,7 @@ impl NodeClient {
continue;
}
}
return Ok(resp);
}
}
@@ -324,8 +353,8 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
/// `char::is_control` does not cover — they can reorder the displayed line).
pub(crate) fn sanitize_node_msg(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
gitlawb_core::sanitize::strip_terminal_controls(s)
.chars()
.take(200)
.collect()
}
+308 -53
View File
@@ -9,7 +9,8 @@ use std::path::{Path, PathBuf};
pub enum IdentityCmd {
/// Generate a new Ed25519 keypair and DID
New {
/// Output directory for key files (default: ~/.gitlawb)
/// Output directory for key files
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Overwrite existing keys if present
@@ -63,16 +64,51 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
}
}
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
/// Resolve the identity directory, honouring an explicit override.
/// Public so sibling commands (`gl ucan import`, `gl doctor`) look in the same
/// place the identity itself lives.
///
/// Without an override this is [`gitlawb_core::identity_path::identity_dir`] — the
/// parent of `GITLAWB_KEY`, else `~/.gitlawb`. The rules live in `gitlawb-core`
/// because `git-remote-gitlawb` needs the identical answer: an operator who moved
/// their key (`GITLAWB_KEY=/data/keys/identity.pem`, the shape `.env.example`
/// documents) would otherwise have `gl ucan import` write the delegation to
/// `~/.gitlawb/delegations` while the helper reads `/data/keys/delegations` and
/// finds it empty. The push then goes out with no `X-Ucan` and the delegate is
/// refused, with nothing on either side to indicate why.
pub fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
if let Some(d) = override_dir {
return Ok(d);
}
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".gitlawb"))
// `home_dir()` is passed as an Option rather than demanded here: an absolute
// GITLAWB_KEY resolves without a home directory, and a host that has none is a
// normal container shape, not a reason to refuse a correctly-configured key.
let home = dirs::home_dir();
gitlawb_core::identity_path::identity_dir(home.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))
}
fn key_path(dir: &Path) -> PathBuf {
dir.join("identity.pem")
/// The identity PEM to read or write.
///
/// With an explicit `--dir` this is `<dir>/identity.pem`, the conventional layout.
/// Without one it is [`gitlawb_core::identity_path::identity_key_path`] — the whole
/// of `GITLAWB_KEY`, basename included.
///
/// Taking only the parent and re-appending `identity.pem` was a real divergence,
/// not a tidy-up: `GITLAWB_KEY` is documented as a path to a PEM, and
/// `git-remote-gitlawb` opens exactly that path. With
/// `GITLAWB_KEY=/data/keys/ci-agent.pem`, `gl identity new` wrote
/// `/data/keys/identity.pem` while every push loaded `/data/keys/ci-agent.pem`, so
/// the two either disagreed on identity or the helper found no key at all — and
/// owner enforcement and the delegation proof both key off that identity.
pub(crate) fn key_path_for(dir: Option<&Path>) -> Result<PathBuf> {
match dir {
Some(d) => Ok(d.join(gitlawb_core::identity_path::KEY_FILE_NAME)),
None => {
let home = dirs::home_dir();
gitlawb_core::identity_path::identity_key_path(home.as_deref())
.map_err(|e| anyhow::anyhow!("{e}"))
}
}
}
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
@@ -81,15 +117,14 @@ fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
/// Load keypair from an optional directory override.
/// Used by other modules (register, repo, mcp).
///
/// Routed through [`gitlawb_dir`] rather than reaching for `~/.gitlawb` directly:
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so a second
/// resolver here would have every other command read a different file than the one
/// just created — `gl ucan delegate` would either fail to find an identity or sign
/// with a stale DID that is not the repo owner.
pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
let base = if let Some(d) = dir {
d.to_path_buf()
} else {
dirs::home_dir()
.context("could not determine home directory")?
.join(".gitlawb")
};
let path = key_path(&base);
let path = key_path_for(dir)?;
let pem = fs::read_to_string(&path).with_context(|| {
format!(
"no identity found at {}\nRun `gl identity new` to create one",
@@ -99,6 +134,36 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
}
/// Load keypair from an optional directory override, distinguishing missing default
/// identity (clean anonymous) from an explicit directory error or corrupt PEM.
pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Keypair>> {
if let Some(d) = dir {
return load_keypair_from_dir(Some(d)).map(Some);
}
// Route the default lookup through key_path_for(None), not a hand-built
// ~/.gitlawb/identity.pem: that path pinned the conventional basename and
// ignored GITLAWB_KEY, so callers on this helper (task list/view, the MCP
// identity fallback) signed as a stale DID or went anonymous while every
// sibling command read the configured key. A host with no home and no
// GITLAWB_KEY still resolves to anonymous rather than refusing.
// An empty GITLAWB_KEY counts as unconfigured, matching identity_key_path:
// set-but-empty should not force an anonymous resolution through a path
// that cannot resolve.
let key_configured = std::env::var_os("GITLAWB_KEY").is_some_and(|v| !v.is_empty());
if !key_configured && dirs::home_dir().is_none() {
return Ok(None);
}
let path = key_path_for(None)?;
match fs::read_to_string(&path) {
Ok(pem) => Keypair::from_pem(&pem)
.context("failed to load keypair from PEM")
.map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("failed to read identity from {}", path.display()))),
}
}
async fn cmd_new(dir: Option<PathBuf>, force: bool) -> Result<()> {
cmd_new_with_reader(dir, force, &mut std::io::stdin().lock()).await
}
@@ -108,8 +173,11 @@ async fn cmd_new_with_reader(
force: bool,
reader: &mut impl std::io::BufRead,
) -> Result<()> {
let dir = gitlawb_dir(dir)?;
let path = key_path(&dir);
let path = key_path_for(dir.as_deref())?;
let dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if path.exists() {
if force {
@@ -131,23 +199,13 @@ async fn cmd_new_with_reader(
}
}
fs::create_dir_all(&dir)
crate::secret_file::create_dir(&dir)
.with_context(|| format!("failed to create directory {}", dir.display()))?;
let keypair = Keypair::generate();
let pem = keypair.to_pem()?;
// Write with restricted permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::write(&path, pem.as_bytes())?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
fs::write(&path, pem.as_bytes())?;
}
crate::secret_file::write(&path, pem.as_bytes())?;
let did = keypair.did();
println!("✓ Generated new identity");
@@ -183,8 +241,7 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
}
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
let base = gitlawb_dir(dir)?;
let src = key_path(&base);
let src = key_path_for(dir.as_deref())?;
let pem = fs::read_to_string(&src).with_context(|| {
format!(
@@ -202,16 +259,7 @@ async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
.join("identity.pem.bak")
});
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::write(&dest, pem.as_bytes())?;
fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
fs::write(&dest, pem.as_bytes())?;
}
crate::secret_file::write(&dest, pem.as_bytes())?;
println!("✓ Identity backed up");
println!(" DID: {}", keypair.did());
@@ -239,8 +287,11 @@ async fn cmd_restore_with_reader(
// Verify it's a valid keypair before writing anything
let keypair = Keypair::from_pem(&pem).context("backup file is not a valid identity PEM")?;
let base = gitlawb_dir(dir)?;
let dest = key_path(&base);
let dest = key_path_for(dir.as_deref())?;
let base = dest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if dest.exists() {
if force {
@@ -262,19 +313,10 @@ async fn cmd_restore_with_reader(
}
}
fs::create_dir_all(&base)
crate::secret_file::create_dir(&base)
.with_context(|| format!("failed to create directory {}", base.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::write(&dest, pem.as_bytes())?;
fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
fs::write(&dest, pem.as_bytes())?;
}
crate::secret_file::write(&dest, pem.as_bytes())?;
println!("✓ Identity restored");
println!(" DID: {}", keypair.did());
@@ -288,6 +330,36 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn load_optional_keypair_honors_gitlawb_key_basename() {
let dir = TempDir::new().unwrap();
let kp = Keypair::generate();
let custom = dir.path().join("ci-agent.pem");
std::fs::write(&custom, kp.to_pem().unwrap()).unwrap();
crate::identity::test_env::with_key(Some(custom.as_os_str()), || {
let loaded = load_optional_keypair(None).unwrap();
assert_eq!(
loaded.map(|k| k.did().to_string()),
Some(kp.did().to_string()),
"the configured basename must be read, not ~/.gitlawb/identity.pem"
);
});
}
#[test]
fn load_optional_keypair_none_when_unset_and_missing() {
let dir = TempDir::new().unwrap();
crate::identity::test_env::with_key(
Some(dir.path().join("absent.pem").as_os_str()),
|| {
assert!(
load_optional_keypair(None).unwrap().is_none(),
"a configured-but-absent key resolves anonymous, not an error"
);
},
);
}
#[tokio::test]
async fn test_cmd_new_creates_pem() {
let dir = TempDir::new().unwrap();
@@ -509,3 +581,186 @@ mod tests {
assert_eq!(original_did, dst_kp.did());
}
}
/// Scoped `GITLAWB_KEY` for tests, shared crate-wide.
///
/// The process environment is global and more than one suite in this crate
/// depends on it — the resolver's own cases here, and `gl register`'s check that
/// the bootstrap token lands beside the key. They take one lock rather than each
/// declaring its own, which would not serialise them against each other.
#[cfg(test)]
pub(crate) mod test_env {
use std::ffi::{OsStr, OsString};
use std::sync::{Mutex, MutexGuard};
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Restores the previous value and releases the lock on drop.
pub(crate) struct KeyEnv {
_guard: MutexGuard<'static, ()>,
restore: Option<OsString>,
}
impl Drop for KeyEnv {
fn drop(&mut self) {
match self.restore.take() {
Some(v) => std::env::set_var("GITLAWB_KEY", v),
None => std::env::remove_var("GITLAWB_KEY"),
}
}
}
/// Run `f` with `GITLAWB_KEY` set, restoring it afterwards.
pub(crate) fn with_key<T, V: AsRef<OsStr>>(value: Option<V>, f: impl FnOnce() -> T) -> T {
let _guard = set_key(value);
f()
}
/// Set `GITLAWB_KEY` (or remove it, for `None`) until the guard drops.
pub(crate) fn set_key<V: AsRef<OsStr>>(value: Option<V>) -> KeyEnv {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let restore = std::env::var_os("GITLAWB_KEY");
match value {
Some(v) => std::env::set_var("GITLAWB_KEY", v),
None => std::env::remove_var("GITLAWB_KEY"),
}
KeyEnv {
_guard: guard,
restore,
}
}
}
#[cfg(test)]
mod gitlawb_dir_tests {
use super::{gitlawb_dir, load_keypair_from_dir};
use std::ffi::OsString;
use std::path::PathBuf;
/// Run `f` with `GITLAWB_KEY` set to `value` (or removed for `None`), restoring
/// whatever was there before.
fn with_key_env<T>(value: Option<OsString>, f: impl FnOnce() -> T) -> T {
let _env = crate::identity::test_env::set_key(value);
f()
}
/// An explicit --dir always wins and is never validated against GITLAWB_KEY.
#[test]
fn explicit_override_wins() {
let d = PathBuf::from("/tmp/explicit");
assert_eq!(gitlawb_dir(Some(d.clone())).unwrap(), d);
}
/// A relative GITLAWB_KEY must fail loudly. `gl` and `git-remote-gitlawb` run
/// from different working directories, so resolving one relatively sends the
/// import and the lookup to different stores; a one-component value yields an
/// empty parent and puts the store in `./delegations`.
#[test]
fn relative_key_paths_are_refused() {
for raw in ["identity.pem", "keys/identity.pem"] {
let result = with_key_env(Some(OsString::from(raw)), || gitlawb_dir(None));
assert!(result.is_err(), "{raw} is relative and must be refused");
}
}
/// An empty value is what a shell leaves behind for `FOO=` and for an unset
/// variable expanded into a wrapper script. Treated as unset, not as an error.
#[test]
fn an_empty_key_path_is_treated_as_unset() {
// A host without a home dir is a legitimate CI shape; the empty-is-unset
// rule resolves to anonymous there, so this assertion has nothing to
// compare against and is a pass, not a panic.
let Some(home) = dirs::home_dir() else { return };
let result = with_key_env(Some(OsString::new()), || gitlawb_dir(None));
assert_eq!(
result.unwrap(),
home.join(".gitlawb"),
"an empty value selects the default directory"
);
}
/// The reason `gitlawb_dir` reads through `var_os`: `env::var` folds a non-UTF-8
/// value into the same `Err` as unset, so a bad path would silently resolve to
/// `~/.gitlawb` instead of being reported. Byte 0xFF is not valid UTF-8 in any
/// position, so this value is unreachable through `env::var`.
#[cfg(unix)]
#[test]
fn a_non_utf8_key_path_is_not_mistaken_for_unset() {
use std::os::unix::ffi::OsStringExt;
let raw = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
let result = with_key_env(Some(raw), || gitlawb_dir(None));
let err = result.expect_err("a non-UTF-8 relative path must be refused");
assert!(
err.to_string().contains("absolute"),
"the error must name the real problem, not fall back to the default: {err}"
);
let mut absolute = OsString::from("/data/");
absolute.push(OsString::from_vec(vec![0xFF]));
absolute.push("/identity.pem");
let resolved = with_key_env(Some(absolute.clone()), || gitlawb_dir(None)).unwrap();
assert_eq!(
resolved,
PathBuf::from(&absolute).parent().unwrap(),
"an absolute non-UTF-8 path resolves to its own parent, not to ~/.gitlawb"
);
}
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so every
/// other command has to read it back from there. `load_keypair_from_dir(None)`
/// used to hardcode `~/.gitlawb`, which made `gl ucan delegate` sign with a
/// stale DID — or fail outright — for exactly the operators who moved the key.
#[test]
fn load_keypair_from_dir_honours_the_key_env() {
let dir = tempfile::tempdir().unwrap();
let key = dir.path().join("identity.pem");
let expected = gitlawb_core::identity::Keypair::generate();
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
let loaded = with_key_env(Some(key.into_os_string()), || load_keypair_from_dir(None))
.expect("the identity beside GITLAWB_KEY must be found");
assert_eq!(loaded.did(), expected.did());
}
}
#[cfg(test)]
mod key_basename_tests {
use super::*;
/// `GITLAWB_KEY` names a FILE. `gl` used to keep only its parent and re-append
/// `identity.pem`, while `git-remote-gitlawb` opened the configured path — so
/// with `GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI and the push path loaded
/// different files, and owner enforcement and the delegation proof both key off
/// whichever identity that was.
#[test]
fn a_non_default_key_basename_is_honoured() {
let dir = tempfile::tempdir().unwrap();
let key = dir.path().join("ci-agent.pem");
let expected = gitlawb_core::identity::Keypair::generate();
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
let (resolved, loaded) = crate::identity::test_env::with_key(Some(key.clone()), || {
(key_path_for(None).unwrap(), load_keypair_from_dir(None))
});
assert_eq!(resolved, key, "the configured basename must survive");
assert_eq!(
loaded.expect("the key at GITLAWB_KEY must load").did(),
expected.did(),
"gl must load the same file the helper opens"
);
}
/// An explicit --dir keeps the conventional layout.
#[test]
fn an_explicit_dir_still_uses_identity_pem() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
key_path_for(Some(dir.path())).unwrap(),
dir.path().join("identity.pem")
);
}
}
+22 -28
View File
@@ -26,7 +26,7 @@ pub struct InitArgs {
)]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -105,20 +105,17 @@ pub async fn run(args: InitArgs) -> Result<()> {
// Save UCAN if returned
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
if !ucan.is_empty() {
let ucan_dir = args
.dir
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
std::fs::create_dir_all(&ucan_dir)?;
let ucan_dir = crate::identity::gitlawb_dir(args.dir.clone())?;
crate::secret_file::create_dir(&ucan_dir)?;
let record = json!({
"ucan": ucan,
"node": args.node,
"did": did.to_string(),
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(
ucan_dir.join("ucan.json"),
serde_json::to_string_pretty(&record)?,
crate::secret_file::write(
&ucan_dir.join("ucan.json"),
serde_json::to_string_pretty(&record)?.as_bytes(),
)?;
}
}
@@ -225,29 +222,26 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
let base = if let Some(d) = dir {
d.to_path_buf()
} else {
dirs::home_dir()
.context("could not determine home directory")?
.join(".gitlawb")
};
std::fs::create_dir_all(&base)?;
let path = crate::identity::key_path_for(dir)?;
let base = path
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
crate::secret_file::create_dir(&base)?;
let keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
let path = base.join("identity.pem");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&path, pem.as_bytes())?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, pem.as_bytes())?;
}
crate::secret_file::write_new(&path, pem.as_bytes()).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
anyhow::anyhow!(
"identity key already exists at {} — refusing to overwrite; pass --dir for a fresh location or move the key",
path.display()
)
} else {
anyhow::anyhow!(e)
}
})?;
Ok(keypair)
}
+2 -2
View File
@@ -28,7 +28,7 @@ pub enum IpfsCmd {
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
@@ -79,7 +79,7 @@ pub enum IpfsCmd {
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Resume token from a scan that stopped at a bound, as printed by a
+1
View File
@@ -26,6 +26,7 @@ mod protect;
mod quickstart;
mod register;
mod repo;
mod secret_file;
mod star;
mod status;
mod sync;
+951 -70
View File
File diff suppressed because it is too large. Load diff
+2 -10
View File
@@ -168,16 +168,8 @@ pub async fn run(args: NameArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────────
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
})
}
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let path = identity_dir(dir).join("identity.pem");
let path = crate::identity::key_path_for(dir.as_deref())?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
@@ -190,7 +182,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
}
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
let path = identity_dir(dir).join("identity.pem");
let path = crate::identity::key_path_for(dir.as_deref())?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+1 -1
View File
@@ -26,7 +26,7 @@ pub enum NodeCmd {
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
+4 -6
View File
@@ -328,12 +328,10 @@ pub async fn cmd_unstake(
// ── Helpers ─────────────────────────────────────────────────────────────────
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let base = dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let path = base.join("identity.pem");
// The shared resolver, not a local `~/.gitlawb`: `gl identity new` writes the
// key wherever `GITLAWB_KEY` points, and the old fallback to `.` on a missing
// home made the answer depend on the working directory.
let path = crate::identity::key_path_for(dir.as_deref())?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+70 -37
View File
@@ -12,7 +12,7 @@ use serde_json::{json, Value};
use std::io::{self, Write};
use std::path::PathBuf;
use crate::http::NodeClient;
use crate::http::{sanitize_node_msg, NodeClient};
use crate::identity::load_keypair_from_dir;
const PUBLIC_NODE: &str = "https://node.twigpine.com";
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -39,19 +39,25 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!("and create your first repository.");
println!();
let dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
// The wizard generates the identity AND stores the bootstrap UCAN, so it has to
// land where every later command reads from — the parent of `GITLAWB_KEY`, not
// an unconditional `~/.gitlawb`.
let dir = crate::identity::gitlawb_dir(args.dir.clone())?;
// ── Step 1: Identity ──────────────────────────────────────────────────
println!("── Step 1: Identity ─────────────────────────────────────────────────");
println!();
let pem_path = dir.join("identity.pem");
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
let keypair = if pem_path.exists() {
match load_keypair_from_dir(Some(&dir)) {
// `args.dir.as_deref()`, not `Some(&dir)`. Passing the DIRECTORY made the
// loader re-derive the basename as `identity.pem`, so with
// `GITLAWB_KEY=/data/keys/ci-agent.pem` the check above found `ci-agent.pem`
// while the load looked for a sibling that need not exist. The load must
// target the file that was actually selected, and a file that exists but
// fails to load is refused below, never regenerated over: `pem_path` can
// name any file, and repository ownership and delegations are tied to its DID.
match load_keypair_from_dir(args.dir.as_deref()) {
Ok(kp) => {
let did = kp.did();
println!(" ✓ Identity already exists");
@@ -59,14 +65,17 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!();
kp
}
Err(_) => {
println!(" Identity file exists but is unreadable. Regenerating...");
generate_identity(&dir)?
Err(e) => {
anyhow::bail!(
"identity at {} could not be loaded: {e}. Fix or move the file, \
or run `gl identity new --force` to replace it deliberately.",
pem_path.display()
);
}
}
} else {
println!(" No identity found. Generating a new Ed25519 keypair...");
generate_identity(&dir)?
generate_identity(&dir, &pem_path)?
};
let did = keypair.did().to_string();
@@ -99,26 +108,40 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
Ok(resp) if resp.status().is_success() => {
let payload: Value = resp.json().await.unwrap_or_default();
let ucan = payload["ucan"].as_str().unwrap_or("");
if !ucan.is_empty() {
std::fs::create_dir_all(&dir)?;
// The messaging below derives from what was actually persisted, not
// from the 2xx: a success response carrying no `ucan` skips the write,
// and saying "UCAN saved" anyway sends the operator looking for a file
// that was never created - then resurfaces later as a push rejection
// with nothing pointing back here.
let saved = if ucan.is_empty() {
false
} else {
crate::secret_file::create_dir(&dir)?;
let record = json!({
"ucan": ucan,
"node": args.node,
"did": did,
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
crate::secret_file::write(
&ucan_path,
serde_json::to_string_pretty(&record)?.as_bytes(),
)?;
true
};
if saved {
// Success belongs to the saved branch: a 2xx without a
// usable ucan is not a registration — the response may not
// even be JSON, and saying success would also leave a
// stale ucan.json from another node in place.
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
println!(" ✓ Registered successfully");
println!(" Trust score: {trust:.2}");
println!(" UCAN saved to {}", ucan_path.display());
} else {
// A 2xx without a usable ucan is not a registration; the
// response may not even be JSON. Saying success here would
// also leave a stale ucan.json from another node in place.
println!(" ✗ Registration returned no UCAN (unexpected response body)");
// No retry hint here: a `gl register` instruction without
// this invocation's --dir would load a different identity.
println!(
" \u{00d7} Registration returned no UCAN (unexpected response body)"
);
}
println!();
}
@@ -126,7 +149,10 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
let msg = body["message"].as_str().unwrap_or("unknown");
println!(" ✗ Registration failed ({status}): {msg}");
println!(
" ✗ Registration failed ({status}): {}",
sanitize_node_msg(msg)
);
println!(" You can retry with: gl register --node {}", args.node);
println!();
// Non-fatal — continue to repo creation
@@ -212,7 +238,10 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
let status = resp.status();
let payload: Value = resp.json().await.unwrap_or_default();
let msg = payload["message"].as_str().unwrap_or("unknown");
println!(" ✗ Repo creation failed ({status}): {msg}");
println!(
" ✗ Repo creation failed ({status}): {}",
sanitize_node_msg(msg)
);
println!(
" Try manually: gl repo create {repo_name} --node {}",
args.node
@@ -233,23 +262,27 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
fn generate_identity(
dir: &std::path::Path,
path: &std::path::Path,
) -> Result<gitlawb_core::identity::Keypair> {
crate::secret_file::create_dir(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
let keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
let path = dir.join("identity.pem");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&path, pem.as_bytes())?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, pem.as_bytes())?;
}
crate::secret_file::write_new(path, pem.as_bytes()).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
anyhow::anyhow!(
"identity key already exists at {}: refusing to overwrite; \
pass --dir for a fresh location or move the existing key",
path.display()
)
} else {
anyhow::anyhow!(e)
}
})?;
let did = keypair.did();
println!(" ✓ Generated new identity");
+120 -21
View File
@@ -1,14 +1,15 @@
//! `gl register` — register this agent identity with a Twigpine node.
//!
//! Sends a signed POST /api/register request and saves the returned bootstrap
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
//! UCAN token as `ucan.json` beside the identity key, where the other commands
//! look for it.
use anyhow::{Context, Result};
use clap::Args;
use serde_json::json;
use std::path::PathBuf;
use crate::http::NodeClient;
use crate::http::{sanitize_node_msg, NodeClient};
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
@@ -21,6 +22,11 @@ pub struct RegisterArgs {
)]
pub node: String,
/// A pre-obtained iCaptcha proof token, forwarded on the register request
/// instead of making the client solve a fresh challenge.
#[arg(long, env = "GITLAWB_ICAPTCHA_PROOF")]
pub icaptcha_proof: Option<String>,
/// Capabilities to advertise (comma-separated)
#[arg(
long,
@@ -33,7 +39,7 @@ pub struct RegisterArgs {
#[arg(long)]
pub model: Option<String>,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -55,7 +61,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
}))?;
let resp = client
.post("/api/register", &body)
.post_with_proof("/api/register", &body, args.icaptcha_proof.as_deref())
.await
.context("failed to connect to node")?;
@@ -64,17 +70,20 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
// Save bootstrap UCAN
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
if !ucan.is_empty() {
let ucan_path = ucan_path(args.dir.as_deref())?;
let saved_to = if ucan.is_empty() {
None
} else {
let path = ucan_path(args.dir.as_deref())?;
let record = json!({
"ucan": ucan,
"node": args.node,
"did": did.to_string(),
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
tracing::debug!("saved UCAN to {}", ucan_path.display());
}
crate::secret_file::write(&path, serde_json::to_string_pretty(&record)?.as_bytes())?;
tracing::debug!("saved UCAN to {}", path.display());
Some(path)
};
let trust = payload
.get("trust_score")
@@ -90,25 +99,39 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
.unwrap_or("");
println!();
println!(" {message}");
println!(" {}", sanitize_node_msg(message));
println!(" Trust score: {trust:.2}");
println!(" UCAN expires: {expires}");
println!(" UCAN expires: {}", sanitize_node_msg(expires));
println!();
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
println!(" You are now a verified agent on the Twigpine network.");
// The real path, not the default one: `GITLAWB_KEY` moves it, and an operator
// told to look in `~/.gitlawb` would find nothing there.
// Registration without a stored token means the registration-gated
// capabilities never arrived, so the closing line must not claim they did.
match &saved_to {
Some(path) => {
println!(" Bootstrap UCAN saved to {}", path.display());
println!(" You are now a verified agent on the gitlawb network.");
}
None => {
println!(" The node returned no bootstrap UCAN, so this identity is not");
println!(" a verified agent yet. Re-run `gl register` once the node issues one.");
}
}
Ok(())
}
/// Where the bootstrap UCAN is stored: beside the identity key, always.
///
/// Routed through `gitlawb_dir` rather than resolving `~/.gitlawb` locally. The
/// key is READ from the parent of `GITLAWB_KEY`, so writing the token anywhere
/// else splits the two: registration succeeds, and `gl doctor`, `gl ucan show`,
/// `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the key's
/// directory and report an unregistered identity.
fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
let base = if let Some(d) = dir {
d.to_path_buf()
} else {
dirs::home_dir()
.context("could not determine home directory")?
.join(".gitlawb")
};
std::fs::create_dir_all(&base)?;
let base = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?;
crate::secret_file::create_dir(&base)
.with_context(|| format!("failed to create {}", base.display()))?;
Ok(base.join("ucan.json"))
}
@@ -142,6 +165,7 @@ mod tests {
capabilities: vec!["git:push".to_string(), "git:fetch".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await
.unwrap();
@@ -155,6 +179,79 @@ mod tests {
assert_eq!(content["node"].as_str().unwrap(), server.url());
}
/// A supplied `--icaptcha-proof` must ride the register request as the
/// `x-icaptcha-proof` header so an enforce-mode node accepts it without a
/// fresh solve round-trip.
#[tokio::test]
async fn register_forwards_icaptcha_proof_header() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.match_header("x-icaptcha-proof", "test.supplied.proof")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#)
.create_async()
.await;
run(RegisterArgs {
node: server.url(),
capabilities: vec!["git:push".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: Some("test.supplied.proof".to_string()),
})
.await
.unwrap();
m.assert();
}
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
/// silent split-brain: registration prints success, and every command that later
/// reads `ucan.json` — `gl doctor`, `gl ucan show`, `gl init`, `gl mcp` — looks
/// beside the key, finds nothing, and reports an unregistered identity.
#[tokio::test]
async fn register_saves_the_bootstrap_ucan_beside_the_key() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
// No --dir: the destination has to come from GITLAWB_KEY alone.
let _env = crate::identity::test_env::set_key(Some(dir.path().join("identity.pem")));
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/register")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#,
)
.create_async()
.await;
run(RegisterArgs {
node: server.url(),
capabilities: vec!["git:push".to_string()],
model: None,
dir: None,
icaptcha_proof: None,
})
.await
.unwrap();
let beside_the_key = dir.path().join("ucan.json");
assert!(
beside_the_key.exists(),
"the bootstrap UCAN must land beside GITLAWB_KEY, not in ~/.gitlawb"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(beside_the_key).unwrap()).unwrap();
assert_eq!(content["ucan"].as_str().unwrap(), "eyJhbGci.test.token");
}
#[tokio::test]
async fn test_register_server_error() {
let dir = TempDir::new().unwrap();
@@ -175,6 +272,7 @@ mod tests {
capabilities: vec!["git:push".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await;
@@ -196,6 +294,7 @@ mod tests {
capabilities: vec![],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await;
+212
View File
@@ -0,0 +1,212 @@
//! Writes and directories that hold key material or bearer tokens.
use std::io::Write;
use std::path::Path;
/// Write `contents` to `path` with owner-only permissions.
///
/// On unix the mode is pinned at creation, so the file never exists with a
/// permissive mode between open and chmod. `O_NOFOLLOW` refuses to write
/// through a pre-planted symlink. `mode()` applies only when the file is
/// created, so the mode is pinned on the descriptor before the file is
/// truncated: a pre-existing loose file is tightened before, not after, the
/// new contents land in it.
pub(crate) fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
f.set_len(0)?;
f.write_all(contents)?;
}
#[cfg(not(unix))]
std::fs::write(path, contents)?;
Ok(())
}
/// Write `contents` to a `path` that must not already exist.
///
/// `create_new` makes the existence check and the create one atomic open, so
/// there is no check-then-write window where an attacker or a second `gl init`
/// could slip a file in between. Same owner-only mode and `O_NOFOLLOW` as
/// [`write`]. `AlreadyExists` means the key was present, never that it was
/// touched.
pub(crate) fn write_new(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
f.write_all(contents)?;
}
#[cfg(not(unix))]
{
if path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"refusing to overwrite existing file",
));
}
std::fs::write(path, contents)?;
}
Ok(())
}
/// Create `path` (and missing parents) as an owner-only directory.
///
/// An existing directory is also re-pinned: a `~/.gitlawb` created before
/// this helper stays group- and world-listable otherwise. A symlinked
/// `path` is left alone rather than chmodded through, which would strip
/// group and world access from whatever the link points at.
pub(crate) fn create_dir(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)?;
// Re-pin through the opened directory, not the path: O_NOFOLLOW
// refuses a swapped-in symlink and fchmod acts on the checked inode,
// so a final-component swap between check and chmod cannot retarget
// the mode change at whatever the link points at.
match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
{
Ok(dir) => {
use std::os::unix::io::AsRawFd;
let fd = dir.as_raw_fd();
// Re-pin only a leaf this process owns. A --dir the caller
// can write but cannot chmod (foreign-owned 0775 leaf, ACL
// grant) is adopted unchanged: create_dir_all allowed the
// write before, and aborting turns a usable directory into
// a command failure. An owned directory that still refuses
// chmod (EPERM) is adopted the same way.
let owned = unsafe {
let mut st: libc::stat = std::mem::zeroed();
libc::fstat(fd, &mut st) == 0 && st.st_uid == libc::geteuid()
};
if owned && unsafe { libc::fchmod(fd, 0o700) } != 0 {
let e = std::io::Error::last_os_error();
if e.raw_os_error() != Some(libc::EPERM) {
return Err(e);
}
}
}
// ELOOP: the path is a symlink and is left alone per contract.
Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {}
Err(e) => return Err(e),
}
}
#[cfg(not(unix))]
std::fs::create_dir_all(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
#[test]
#[cfg(unix)]
fn create_dir_adopts_writable_leaf_it_cannot_chmod() {
use std::os::unix::fs::PermissionsExt;
// /tmp is root-owned 1777: writable by this euid, not chmod-able.
// The pre-helper create_dir_all allowed the write; the re-pin must
// adopt the leaf unchanged rather than abort on EPERM.
let before = std::fs::metadata("/tmp").unwrap().permissions().mode();
super::create_dir(std::path::Path::new("/tmp")).unwrap();
let after = std::fs::metadata("/tmp").unwrap().permissions().mode();
assert_eq!(before, after);
}
#[test]
#[cfg(unix)]
fn write_creates_file_with_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = dir.path().join("key.pem");
super::write(&path, b"secret").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
#[cfg(unix)]
fn write_tightens_preexisting_loose_file() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = dir.path().join("key.pem");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
super::write(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
#[cfg(unix)]
fn write_refuses_symlink() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.pem");
std::fs::write(&target, b"planted").unwrap();
let link = dir.path().join("link.pem");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(super::write(&link, b"secret").is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"planted");
}
#[test]
#[cfg(unix)]
fn create_dir_modes_0700_and_tightens_existing() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = dir.path().join("a").join("b");
super::create_dir(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o700
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
super::create_dir(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o700
);
}
#[test]
#[cfg(unix)]
fn create_dir_does_not_chmod_through_symlink() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let target = dir.path().join("shared");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
super::create_dir(&link).unwrap();
assert_eq!(
std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o755
);
}
}
+1364 -34
View File
File diff suppressed because it is too large. Load diff
+1885 -41
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct WhoamiArgs {
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Node URL to query for registration info
+110 -11
View File
@@ -145,9 +145,11 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
## Owner-only push
The node requires the authenticated pusher to be the repo owner on **every**
branch. A push whose authenticated DID is not the repo owner is rejected with
HTTP 403 before any ref update is applied. The owner is matched in both the full
On the `git-receive-pack` POST — the push itself — the node requires the
authenticated pusher to be the repo owner, or to present a UCAN whose proof chain
roots at the owner and carries `git/push` for this repository (see *Delegating
push to a CI agent* below). Anyone else is rejected with HTTP 403 before any ref
update is applied, on **every** branch. The owner is matched in both the full
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
This is on by default, and the default is the point. The node authenticates every
@@ -157,6 +159,25 @@ Authentication is not authorization, so without this gate every signed caller ca
push to every repository — private ones included — on any branch that is not
explicitly protected.
A `git push` crosses two independent gates, and this section is about the second:
1. **Read visibility**, enforced twice on a push: on the `info/refs` advertisement
that precedes every fetch *and* every push, and again on the `git-receive-pack`
POST itself, because a client can skip the advertisement entirely. The
owner always passes. A visibility rule that matches `/` is an allow-list:
only the DIDs in its reader list pass, on a public repository as much as on
a private one. With no matching rule the repository's own flag decides:
public admits everyone, private denies every non-reader with the same 404 a
missing repository gets. A UCAN plays no part here — a delegation grants
no read access.
2. **Push authorization**, on the `git-receive-pack` POST: the owner, or an
owner-rooted, bounded, repository-scoped push delegation.
Both must pass. With no `/` rule on a public repository the first is open to
everyone, so a delegation alone is enough; with a `/` rule, or on a private
repository, the delegate must also be a reader — see *Private repositories and
`/` rules* below.
### Turning it off
```bash
@@ -170,16 +191,94 @@ Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
treat it as temporary: while it is off, your node accepts a push to any repository
from anyone who can generate a keypair.
### Caution: delegated and CI keys are non-owners
- **When `true`** — a push whose authenticated DID is not the repo owner is
rejected (HTTP 403) before any ref update is applied, unless it carries a
valid delegation (the next bullet). The owner is matched in
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
- **A delegated key can still push.** A non-owner clears this gate by presenting a
UCAN whose proof chain roots at the repo owner and which carries `git/push` for
this repository. See *Delegating push to a CI agent* below.
Push authorization is owner-only today. A UCAN `git/push` capability is verified
but **not yet honored for authorization**, so a delegated key cannot push while
this gate is on, even when it holds a valid capability for the repo.
### Delegating push to a CI agent
If your automation pushes under its own DID rather than the owner's, it will start
getting 403s. Either have it push as the owner, or set
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
rights land — that work is what removes this trade-off.
The owner issues a capability, the agent stores it, and the git helper presents it
automatically on every push:
Before anything below, set `GITLAWB_KEY` to an **absolute** path to the
identity PEM (`gl identity new` prints it). `gl ucan import` and
`git-remote-gitlawb` both resolve the delegation store from that key's parent
directory, so do not pass `import --dir` pointing anywhere else: an import that
succeeds elsewhere leaves the push unsigned. The README and `.env.example`
cover the layout.
```bash
# Owner, once per agent per repo:
gl ucan delegate --to did:key:z6MkAgent… \
--cap gitlawb://repos/<owner-did>/<repo> --can git/push --expiry 168
# Agent:
gl ucan import <token-or-path>
git push origin main # git-remote-gitlawb attaches it as X-Ucan
```
`--expiry` is measured in hours: 168 is 7 days, 720 is 30.
`<repo>` is the repository's canonical name: the record name without a `.git`
suffix. A trailing `.git` in the capability (or pasted from the remote URL) is
stripped the same way the git remote strips it, at most once, so
`gitlawb://repos/<owner-did>/myrepo.git` delegates to `myrepo`.
What the node requires, and why:
| Requirement | Reason |
|---|---|
| The chain's **root issuer** is the repo owner | A `did:key` is self-certifying, so anyone can mint a chain. The owner is the only anchor the node holds independently of the token. |
| **Every link** names this repository | A delegation's scope is fixed when it is issued. The node walks the whole proof chain, so a `*` resource authorizes nothing — not in the leaf and not in any proof behind it. Refusing only the leaf would not have been enough: attenuation permits narrowing a `*` parent to a concrete child, so one wildcard grant could otherwise reach every repository the owner has or later creates. `gl ucan delegate` therefore refuses to issue a push-class `*` at all. |
| **Every link carries an expiry** | There is no revocation path yet. An unbounded delegation could never be withdrawn once leaked, so the node refuses one outright. `gl ucan delegate` defaults to 30 days. |
| No `nb` constraints | Constraints are reserved but not yet interpreted, so a capability carrying them authorizes nothing rather than silently granting more than the owner intended. |
| The token is addressed to the pusher | The node requires the proof's audience to equal the invocation's issuer, and `gl ucan import` refuses a token addressed to another identity rather than storing one that can only fail. |
**Private repositories and `/` rules.** A delegation clears the push gate
only. The `info/refs` advertisement that `git push` fetches first is read-gated,
and a UCAN grants no read access, so on a private repository the delegate must
also be a reader — and the same holds on a public repository that has a `/`
rule, because a matching rule is an allow-list that replaces the public flag:
```bash
# Owner: check the existing rules first — a `/` rule you already set has a mode
# you want to keep, and `set` replaces its reader list wholesale.
gl visibility list --repo <repo>
# Then grant read to the agent AND every existing reader on `/`. The mode
# (`a`/`b`) must match what `list` showed; only reach for `b` when no `/`
# rule exists yet.
gl visibility set / --repo <repo> --mode <a|b> --readers did:key:z6MkReader1,did:key:z6MkAgent…
```
Without it the agent's push fails with a 404 — the same answer a stranger gets,
because a private repository is not confirmed to exist for a non-reader. A
normal `git push` sees it at the `info/refs` advertisement; a client that skips
the advertisement sees the identical denial on the `git-receive-pack` POST
itself. Either way the delegation is valid and would clear the push gate: the
read gate is what refused it. On a public repository with no `/` rule this
step is unnecessary. The two gates are
independent in the other direction as well: a reader with no delegation is still
refused on the push.
**One repository per token.** `gl ucan delegate` issues one capability per token,
and `gl ucan import` stores one repository per token. A hand-built token that
names several repositories is refused at import rather than applied to some of
them; ask the owner for one delegation per repository.
**A delegation does not override branch protection.** A protected branch is your
explicit marker that even routine writes should stop, so a delegate is still
refused there and only the owner may push. That is deliberate: if a delegation
overrode it, issuing any capability would weaken every protection you had set.
**Withdrawal is by expiry only.** There is no revocation today. If a delegated
token leaks, it remains valid until its `exp`, and the only faster remedy is
rotating the owner DID the repository is keyed on. Choose `--expiry` accordingly —
short lifetimes reissued often are safer than one long-lived grant.
---
+124
View File
@@ -0,0 +1,124 @@
# Storage and pinning
How a gitlawb node's **optional post-push pinning tiers** work. This documents
the behavior implemented in `crates/gitlawb-node/src/ipfs_pin.rs` and
`crates/gitlawb-node/src/pinata.rs`; it does not change any behavior. Primary
git storage for clone/fetch lives in bare repos under `GITLAWB_REPOS_DIR`
(with optional `GITLAWB_TIGRIS_BUCKET` backing), covered in the README
Configuration table.
## Two-tier model
After a push lands, eligible new git objects can be pinned to up to two
independent sinks. Both are **opt-in and independent**: a node runs fine with
neither, either, or both configured.
Pinning does not run on every push. The post-push tail only reaches the pin
steps when the push passed publish-durability confirmation and the repository
replicates at root (`announce_at_root`): private repositories and other repos
that are not anonymously readable at the root skip both public pin paths, and
path-scoped rules exclude withheld objects. The warm path additionally checks
whether this push ran its own withheld walk: an admitted, non-coalesced push
whose tail walk failed spawns no warm worker. A push that coalesced onto an
in-flight encrypt/pin task has no walk of its own, so it still runs warm
pinning; the detached worker re-derives the withheld set inside its pin permit
(`pinata_object_list_for_refs`) and fails closed on that result instead. A
separate best-effort path can pin encrypted withheld blobs to Kubo for
authorized readers; it is not a two-tier backup and is outside this page's
scope.
| Tier | Sink | Module | Enabled by | Purpose |
|------|------|--------|-----------|---------|
| Hot | Local Kubo (IPFS) | `ipfs_pin.rs` | `GITLAWB_IPFS_API` set | Node-local availability; the node is an HTTP client of a co-located Kubo daemon |
| Warm | Pinata (IPFS) | `pinata.rs` | `GITLAWB_PINATA_JWT` set | Off-node durability + public IPFS gateway reachability |
If a sink's config value is empty, its **upload** step is a no-op, so leaving
`GITLAWB_PINATA_JWT` unset disables warm-tier *uploads*. The tail's cost is
conditional on the same admission gates above: only when the push was admitted
to replication does the warm worker still take a global pin permit and run the
full object-list re-derivation walk before the upload step returns empty,
because its gate is the announce/walk state, not the JWT. On a push that was
not admitted, no warm worker runs at all. Note the no-op applies to pinning
only, not reads: the hot-tier read path (`ipfs_pin::cat`) returns an error rather
than a no-op when `GITLAWB_IPFS_API` is unset, so a node that serves the
encrypted-blob read endpoint needs Kubo configured. An empty `GITLAWB_IPFS_API`
can still briefly touch the pin pool: the hot worker may take a permit when the
built object list is non-empty even though the upload call returns immediately.
## Configuration
| Env var | Default | Meaning |
|---------|---------|---------|
| `GITLAWB_IPFS_API` | `""` (disabled) | Base URL of the local Kubo HTTP API, e.g. `http://127.0.0.1:5001` |
| `GITLAWB_PINATA_JWT` | `""` (disabled) | Pinata bearer JWT enabling the warm tier |
| `GITLAWB_PINATA_UPLOAD_URL` | `https://uploads.pinata.cloud/v3/files` | Pinata v3 upload endpoint |
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | `8` | Cap on concurrent post-push pin loops across all repos |
## How pinning runs
Pinning happens **after** a push is accepted, not on the push's critical path:
- The **hot** tier pins inline in the post-push encrypt/pin task.
- The **warm** (Pinata) tier runs in a spawned replication tail, so a slow or
unreachable Pinata never blocks the pusher.
Both tiers share a single global **pin admission semaphore**
(`max_concurrent_pin_tasks`). One admitted push that starts its own hot
encrypt/pin task with both backends configured can hold up to **two** loops on
this pool at once, so the default of eight caps loops, not pushes: roughly four
concurrent all-dual-tier pushes fit before new loops wait. A push that coalesced
onto an in-flight task starts no hot task of its own, so it adds at most one new
loop (the warm worker) to that count. The pool **defers rather than sheds**: when it is
saturated, a pin loop waits for a slot instead of dropping the pin. Each batch is
bounded by `PIN_BATCH_BUDGET` (120s) so the pin batch itself cannot hold a slot
indefinitely. The Pinata tail re-derives its object list only *after* acquiring a
slot, which bounds *its* outstanding memory to O(refs) rather than
O(pushes × objects), but that bound is the warm tail's alone. A **hot-tier** pin
loop takes an owned object list materialized by its caller *before* it waits for
a slot, so every hot loop parked on a saturated pool is holding its full list:
the hot side of the parked-list memory is O(pushes × objects), capped per repo by
`EncryptInflight` but not across repos. Do not size a node's memory from the
semaphore knob (README says the same).
Note the budget covers the pin batch, not the preceding object-list re-derivation
walk: that walk holds the slot too, and bounds only each child git process
individually (no aggregate deadline).
De-duplication is per sink and best-effort, backed by the single `pinned_cids`
table: the hot tier skips an object when `has_ipfs_cid` reports true, which is
`local_ipfs_provenance = TRUE` (a row that records a real local Kubo CID), and
the warm tier on the nullable `pinata_cid` column, so later pushes normally skip
objects whose successful pin is already recorded for that sink. The isolation is
one-directional: a hot-only pin does not suppress the warm tier (`pinata_cid`
stays null), and a warm-tier pin does not suppress the hot tier either: a row
with only `pinata_cid` set has `local_ipfs_provenance = FALSE`, so an object
warm-pinned while `GITLAWB_IPFS_API` was unset can still enter the hot upload
path once Kubo is configured, provided a later push selects it (the hot walk
covers objects new to that push) or the enabled reconciliation sweep picks it
up among eligible public objects, subject to visibility checks, budgets, and
failure handling. The check-upload-record sequence is not atomic,
so concurrent post-push tasks for the same object, or a failure to record after a
successful upload, can still cause a repeat upload attempt.
## Durability notes
- With only the **hot** tier, availability depends on the node (and any IPFS
peers that have fetched the CIDs). If the node is down and no peer holds the
objects, they are unreachable until it returns.
- The **warm** tier adds off-node durability via Pinata's IPFS storage and makes objects reachable through the public IPFS gateway.
- Running **both** can give a node-local hot copy plus an off-node warm copy:
each copy exists only when its own sink's pin succeeded, so a sink failure or
a truncated batch can leave an object single-copied (the enabled
reconciliation sweep can fill gaps for public objects later). An object
warm-pinned before Kubo was configured can still enter the hot path when a
later push or the sweep selects it (the dedup paragraph above).
> The two sinks are currently invoked as separate call paths. Unifying them
> behind a single pluggable backend interface (to add providers such as direct
> Filecoin deals or self-hosted clusters without touching push logic) is tracked
> separately.
## See also
- [RUN-A-NODE.md](RUN-A-NODE.md): provisioning and running a node
- `crates/gitlawb-node/src/ipfs_pin.rs`: hot-tier implementation
- `crates/gitlawb-node/src/pinata.rs`: warm-tier implementation
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
#
# Map a pull request's changed paths to the CI surfaces they touch, so each
# check in pr-checks.yml and codeql.yml runs only when its inputs changed and
# Rust jobs test only the affected crates.
#
# Usage:
# git diff -z --name-only --no-renames BASE HEAD | scripts/ci-surfaces.sh
# scripts/ci-surfaces.sh --all # every surface, whole workspace
#
# Input is NUL-terminated paths (`git diff -z`). Without -z, git C-quotes any
# path with non-ASCII, tab, quote or newline characters ("crates/gl/\303\251.rs"),
# which would match no surface and skip every job; newline-delimited input
# also cannot carry a path that contains a newline. Input that does not end in
# NUL (e.g. a plain `git diff --name-only`) is rejected rather than misread.
# scripts/test-ci-surfaces.sh pins the mapping.
#
# Prints GITHUB_OUTPUT lines (key=value). Each surface runs only for the paths
# listed for it below, so a path no surface lists (docs, README, infra/, any
# other workflow) runs no PR Checks job. Within the Rust surface, a change this
# script cannot attribute to specific crates widens rather than narrows: a
# global Rust input or an unknown crate directory runs the Rust jobs on the
# whole workspace. The CI wiring itself (pr-checks.yml, codeql.yml, this
# script) runs every surface.
# Non-PR events (push to main, merge_group, workflow_dispatch, schedule) are
# expected to pass --all, so main and the merge queue keep full coverage.
#
# Runnable from anywhere in the repo.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
all=false
if [ "${1:-}" = "--all" ]; then
all=true
elif [ "$#" -ne 0 ]; then
echo "usage: $0 [--all] < NUL-terminated-changed-paths" >&2
exit 2
fi
changed=()
if [ "$all" = false ]; then
path=""
while IFS= read -r -d '' path; do
[ -n "$path" ] && changed+=("$path")
done
# read leaves any unterminated tail in $path.
if [ -n "$path" ]; then
echo "ci-surfaces.sh: input is not NUL-terminated; pipe \`git diff -z --name-only\`" >&2
exit 2
fi
fi
# Workspace crates, derived from crates/*/Cargo.toml so a new crate is picked
# up without editing this script. dir -> package name, and dir -> the crate
# dirs it depends on through `path = "../<dir>"` (normal, dev, or build deps).
declare -A pkg_of=() deps_of=()
for manifest in "$ROOT"/crates/*/Cargo.toml; do
dir="$(basename "$(dirname "$manifest")")"
pkg_of[$dir]="$(sed -n 's/^name *= *"\([^"]*\)".*/\1/p' "$manifest" | head -n1)"
deps_of[$dir]="$(sed -n 's#.*path *= *"\.\./\([^"/]*\)".*#\1#p' "$manifest" | tr '\n' ' ')"
done
# Files outside crates/ that a crate compiles in (include_str!/include_bytes!),
# path -> crate dir. A change to one is a change to that crate, so its tests
# run. Candidates (keep those that resolve outside crates/<dir>/):
# git grep -nE 'include_(str|bytes)!\("(\.\./){2,}' crates
declare -A embedded_in=(
[bootstrap-peers.json]=gitlawb-node
)
# Mark a crate dir as touched; a dir this checkout does not know (removed or
# renamed crate) widens the Rust jobs to the whole workspace.
touch_crate() {
if [ -n "${pkg_of[$1]:-}" ]; then
touched[$1]=1
else
full_rust=true
fi
}
full_rust=false
rust=false
docker=false
audit=false
core_deps=false
release_versioning=false
release_tag=false
cq_actions=false
cq_js=false
cq_rust=false
cq_swift=false
declare -A touched=()
for path in "${changed[@]}"; do
case "$path" in
# The CI wiring itself: validate everything it gates.
.github/workflows/pr-checks.yml | .github/workflows/codeql.yml | scripts/ci-surfaces.sh)
all=true ;;
# Workspace-wide Rust inputs.
Cargo.toml | Cargo.lock | .cargo/* | rust-toolchain | rust-toolchain.toml \
| rustfmt.toml | .rustfmt.toml | clippy.toml | .clippy.toml)
full_rust=true ;;
crates/*/*)
dir="${path#crates/}"
touch_crate "${dir%%/*}" ;;
*)
if [ -n "${embedded_in[$path]:-}" ]; then
touch_crate "${embedded_in[$path]}"
fi ;;
esac
case "$path" in
Dockerfile | .dockerignore | bootstrap-peers.json | Cargo.toml | Cargo.lock \
| .cargo/* | rust-toolchain | rust-toolchain.toml | crates/*/Cargo.toml)
docker=true ;;
esac
case "$path" in
Cargo.lock | .cargo/audit.toml) audit=true ;;
esac
case "$path" in
Cargo.toml | Cargo.lock | crates/gitlawb-core/Cargo.toml \
| ci/gitlawb-core-allowed-deps.txt | scripts/check-gitlawb-core-deps.sh)
core_deps=true ;;
esac
case "$path" in
Cargo.toml | crates/*/Cargo.toml | release-please-config.json \
| .release-please-manifest.json | scripts/check-release-versioning.sh)
release_versioning=true ;;
esac
case "$path" in
scripts/resolve-release-tag.sh | scripts/test-resolve-release-tag.sh)
release_tag=true ;;
esac
case "$path" in
.github/workflows/* | .github/actions/*) cq_actions=true ;;
esac
case "$path" in
npm/* | *.js | *.mjs | *.cjs | *.jsx | *.ts | *.mts | *.cts | *.tsx) cq_js=true ;;
esac
case "$path" in
macos-app/* | *.swift) cq_swift=true ;;
esac
done
if [ "$all" = true ]; then
full_rust=true
docker=true
audit=true
core_deps=true
release_versioning=true
release_tag=true
cq_actions=true
cq_js=true
cq_swift=true
fi
# Affected crates: the touched ones plus everything that depends on them,
# transitively, so a gitlawb-core change still tests every consumer.
declare -A affected=()
for dir in "${!touched[@]}"; do affected[$dir]=1; done
grew=true
while [ "$grew" = true ]; do
grew=false
for dir in "${!deps_of[@]}"; do
[ -n "${affected[$dir]:-}" ] && continue
for dep in ${deps_of[$dir]}; do
if [ -n "${affected[$dep]:-}" ]; then
affected[$dir]=1
grew=true
break
fi
done
done
done
if [ "$full_rust" = true ] || [ "${#affected[@]}" -gt 0 ]; then
rust=true
cq_rust=true
fi
rust_packages=""
windows_packages=""
if [ "$full_rust" = true ]; then
rust_packages="--workspace"
windows_packages="-p gl -p git-remote-gitlawb"
elif [ "$rust" = true ]; then
for dir in $(printf '%s\n' "${!affected[@]}" | sort); do
rust_packages+="${rust_packages:+ }-p ${pkg_of[$dir]}"
case "${pkg_of[$dir]}" in
gl | git-remote-gitlawb) windows_packages+="${windows_packages:+ }-p ${pkg_of[$dir]}" ;;
esac
done
fi
windows=false
[ -n "$windows_packages" ] && windows=true
languages=()
[ "$cq_actions" = true ] && languages+=('{"language":"actions","build-mode":"none","runner":"ubuntu-latest"}')
[ "$cq_js" = true ] && languages+=('{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"}')
[ "$cq_rust" = true ] && languages+=('{"language":"rust","build-mode":"none","runner":"ubuntu-latest"}')
[ "$cq_swift" = true ] && languages+=('{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}')
codeql_matrix="{\"include\":[$(IFS=,; echo "${languages[*]}")]}"
codeql=false
[ "${#languages[@]}" -gt 0 ] && codeql=true
cat <<EOF
rust=$rust
rust_packages=$rust_packages
windows=$windows
windows_packages=$windows_packages
docker=$docker
audit=$audit
core_deps=$core_deps
release_versioning=$release_versioning
release_tag=$release_tag
codeql=$codeql
codeql_matrix=$codeql_matrix
EOF
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Fixture test for scripts/ci-surfaces.sh, which alone decides which PR Checks
# jobs and CodeQL languages run. Pins at least one must-not-skip case per
# surface row, the crate fan-out and widening rules, and paths that git would
# C-quote without -z. Run from pr-checks.yml before the mapper is used.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
mapper="$repo_root/scripts/ci-surfaces.sh"
failures=0
# map PATH... : run the mapper on the paths as `git diff -z` would emit them.
map() {
if [ "$#" -gt 0 ]; then printf '%s\0' "$@"; fi | "$mapper"
}
# check CASE OUTPUT KEY=VALUE... : each KEY must have exactly VALUE.
check() {
local name="$1" out="$2" want key
shift 2
for want in "$@"; do
key="${want%%=*}"
if ! grep -qxF -- "$want" <<<"$out"; then
printf 'FAIL %s: want %s, got %s\n' "$name" "$want" \
"$(grep -E "^${key}=" <<<"$out" || echo '<missing>')" >&2
failures=$((failures + 1))
fi
done
}
# has_packages CASE OUTPUT PKG... : rust_packages must include each -p PKG.
has_packages() {
local name="$1" out="$2" pkgs pkg
shift 2
pkgs=" $(sed -n 's/^rust_packages=//p' <<<"$out") "
for pkg in "$@"; do
if [[ "$pkgs" != *" -p $pkg "* ]]; then
printf 'FAIL %s: rust_packages lacks -p %s (got%s)\n' "$name" "$pkg" "$pkgs" >&2
failures=$((failures + 1))
fi
done
}
nothing=(rust=false rust_packages= windows=false windows_packages= docker=false
audit=false core_deps=false release_versioning=false release_tag=false
codeql=false 'codeql_matrix={"include":[]}')
everything=(rust=true rust_packages=--workspace windows=true
'windows_packages=-p gl -p git-remote-gitlawb' docker=true audit=true
core_deps=true release_versioning=true release_tag=true codeql=true)
all_languages='codeql_matrix={"include":[{"language":"actions","build-mode":"none","runner":"ubuntu-latest"},{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"},{"language":"rust","build-mode":"none","runner":"ubuntu-latest"},{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
rust_only='codeql_matrix={"include":[{"language":"rust","build-mode":"none","runner":"ubuntu-latest"}]}'
# Docs-only and empty input run nothing.
check "docs only" "$(map README.md docs/x.md infra/x.tf)" "${nothing[@]}"
check "empty input" "$(map)" "${nothing[@]}"
# A crate change tests that crate and its reverse deps, nothing else.
check "gl source" "$(map crates/gl/src/main.rs)" \
rust=true 'rust_packages=-p gl' windows=true 'windows_packages=-p gl' \
docker=false audit=false "$rust_only"
out="$(map crates/gitlawb-core/src/lib.rs)"
check "gitlawb-core source" "$out" rust=true windows=true \
'windows_packages=-p git-remote-gitlawb -p gl' "$rust_only"
has_packages "gitlawb-core fan-out" "$out" gitlawb-core icaptcha-client \
gitlawb-attest git-remote-gitlawb gl gitlawb-node
check "icaptcha-client source" "$(map crates/icaptcha-client/src/lib.rs)" \
'rust_packages=-p gl -p icaptcha-client' 'windows_packages=-p gl'
# Anything the Rust surface cannot attribute to known crates widens.
check "unknown crate dir" "$(map crates/no-such-crate/src/lib.rs)" \
rust=true rust_packages=--workspace docker=false
check "root Cargo.lock" "$(map Cargo.lock)" \
rust=true rust_packages=--workspace docker=true audit=true core_deps=true \
release_versioning=false
check "toolchain file" "$(map rust-toolchain.toml)" rust=true rust_packages=--workspace
# Files compiled into a crate from outside crates/.
check "bootstrap-peers.json" "$(map bootstrap-peers.json)" \
rust=true 'rust_packages=-p gitlawb-node' windows=false docker=true
# One must-not-skip trigger per non-Rust row.
check "Dockerfile" "$(map Dockerfile)" docker=true rust=false
check "crate manifest" "$(map crates/gl/Cargo.toml)" \
docker=true release_versioning=true 'rust_packages=-p gl'
check "audit config" "$(map .cargo/audit.toml)" audit=true rust_packages=--workspace
check "core allowlist" "$(map ci/gitlawb-core-allowed-deps.txt)" core_deps=true rust=false
check "release-please config" "$(map release-please-config.json)" \
release_versioning=true rust=false
check "release tag resolver" "$(map scripts/resolve-release-tag.sh)" release_tag=true rust=false
check "other workflow" "$(map .github/workflows/release.yml)" "${nothing[@]:0:9}" \
codeql=true 'codeql_matrix={"include":[{"language":"actions","build-mode":"none","runner":"ubuntu-latest"}]}'
check "npm" "$(map npm/package.json)" codeql=true \
'codeql_matrix={"include":[{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"}]}'
check "swift" "$(map macos-app/Sources/App.swift)" rust=false codeql=true \
'codeql_matrix={"include":[{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
# Editing the CI wiring runs every surface, exactly like --all.
for wiring in .github/workflows/pr-checks.yml .github/workflows/codeql.yml scripts/ci-surfaces.sh; do
check "wiring $wiring" "$(map "$wiring")" "${everything[@]}" "$all_languages"
done
check "--all" "$("$mapper" --all </dev/null)" "${everything[@]}" "$all_languages"
# Names git C-quotes without -z (non-ASCII, tab, quote, newline) still map.
for name in $'\xc3\xa9.rs' $'a\tb.rs' 'a"b.rs' $'a\nb.rs'; do
check "quoted name $(printf %q "$name")" "$(map "crates/gl/src/$name")" \
rust=true 'rust_packages=-p gl'
done
check "non-ASCII swift" "$(map $'macos-app/Sources/Caf\xc3\xa9.swift')" codeql=true \
'codeql_matrix={"include":[{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
# Newline-delimited input (git diff without -z) is rejected, not misread.
if printf 'crates/gl/src/main.rs\n' | "$mapper" >/dev/null 2>&1; then
echo "FAIL newline-delimited input: mapper accepted it" >&2
failures=$((failures + 1))
fi
if [ "$failures" -ne 0 ]; then
echo "$failures ci-surfaces mapping check(s) failed" >&2
exit 1
fi
echo "ci-surfaces mapping: all checks passed"