Compare commits

..
Author SHA1 Message Date
beardthelion e5d9af3998 ci: retrigger codeql scan after alert 86 dismissal 2026-11-09 12:19:00 -06:00
beardthelion 9a6496c945 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.
2026-11-09 11:24:00 -06:00
beardthelion d8ddbc8bc6 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.
2026-11-09 10:55:28 -06:00
beardthelion 6c0926de89 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.
2026-11-09 10:24:59 -06:00
beardthelion 70ea6b03cd Merge remote-tracking branch 'origin/main' into pr-464
# Conflicts:
#	crates/gitlawb-node/src/db/mod.rs
#	crates/gitlawb-node/src/graphql/mutation.rs
#	crates/gitlawb-node/src/graphql/query.rs
2026-11-09 10:07:44 -06:00
beardthelion 29b51d7264 fix(node): trim stored assignee on every task gate; align SECURITY.md task-read wording 2026-11-08 20:46:16 -06:00
beardthelion 5a6d2f4fb2 Merge remote-tracking branch 'origin/main' into pr-464
# Conflicts:
#	crates/gitlawb-node/src/api/tasks.rs
#	crates/gitlawb-node/src/db/mod.rs
#	crates/gitlawb-node/src/graphql/mutation.rs
#	crates/gitlawb-node/src/server.rs
#	crates/gitlawb-node/src/state.rs
2026-11-08 17:02:27 -06:00
cairn-intern 1514f3d806 Merge branch 'main' into recreate-396-fix-task-read-auth-gate-v2
# Conflicts:
#	crates/gitlawb-node/src/db/mod.rs
#	crates/gitlawb-node/src/graphql/mutation.rs
#	crates/gitlawb-node/src/main.rs
#	crates/gitlawb-node/src/server.rs
#	crates/gl/src/mcp.rs
#	crates/gl/src/task.rs
2026-11-07 06:13:12 +00:00
Marlowe 7a1f644859 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.
2026-11-03 17:45:32 -08:00
Marlowe 408d588aaa 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
2026-11-03 17:45:32 -08:00
cairn-intern daa3e982ec 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.
2026-11-03 17:45:32 -08:00
Marlowe 7ad01befb7 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.
2026-11-03 17:45:32 -08:00
cairn-intern 3d4ac67ab4 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.
2026-11-03 17:45:32 -08:00
Marlowe 4f12e15e17 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.
2026-11-03 17:45:32 -08:00
Marlowe 96726a33ee 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.
2026-11-03 17:45:32 -08:00
Marlowe e394f90e61 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)
2026-11-03 17:45:32 -08:00
cairn-intern 7a98ab1558 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.
2026-11-03 17:45:32 -08:00
euxarist 29fbec5962 docs(db): remove stray dedup comment escapes
Restore readable root-path notation in the deduplicated listing documentation.

Fixup for a0ea4c78e4.
Refs #396
2026-11-03 17:45:32 -08:00
euxarist 7527be870b 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
2026-11-03 17:45:32 -08:00
euxarist 6ebf754e09 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
2026-11-03 17:45:32 -08:00
euxarist 8b0a8cb9e6 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
2026-11-03 17:45:32 -08:00
euxarist b7bd1083c0 fix(node): Batch task quarantine checks to bound read costs.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist ac0cac12e3 docs: Distinguish task list and get behavior in rate-limit guidance.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist 43b34ad3e7 docs: Align task-read documentation with enforced visibility and rate limits.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist e864f92dbb Withhold quarantined-repo tasks on all read surfaces before party checks.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist 16d2466164 Accumulate forged WebSocket handshake response until headers terminate.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist fc4fa033b1 Reject forged signatures during WebSocket authentication upgrade.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist 36a2748557 Address review feedback on quarantined task claims, WebSocket auth, and MCP limit parsing.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist a0eac9947e Address review feedback on task-read authorization gating, WebSocket budgets, and test framing.
Refs #395
2026-11-03 17:45:32 -08:00
euxarist 7c3969f7c0 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
2026-11-03 17:45:32 -08:00
euxarist deaee40df0 Enforce task page limits on legacy responses and drop redundant single-column indexes.
Refs #327
2026-11-03 17:45:32 -08:00
euxarist f0a001a4de 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
2026-11-03 17:45:32 -08:00
euxarist eebcfd8881 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
2026-11-03 17:45:32 -08:00
euxarist 1c6d67ae0f 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
2026-11-03 17:45:32 -08:00
euxarist a60272fb10 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
2026-11-03 17:45:32 -08:00
euxarist 585b33534d 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
2026-11-03 17:45:32 -08:00
euxarist 58bce06cc2 fix(gl,node): parse typed task pages, track cursor cycles, and normalize assignee filter MAC 2026-11-03 17:45:32 -08:00
euxarist 2a646d1e9e 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
2026-11-03 17:45:32 -08:00
euxarist 2445f7869e 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
2026-11-03 17:45:32 -08:00
euxarist 8f202b660e 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
2026-11-03 17:45:32 -08:00
euxarist b175fdb5df 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
2026-11-03 17:45:32 -08:00
euxarist 7c2741ecf4 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
2026-11-03 17:45:32 -08:00
euxarist 1a89c0eed6 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
2026-11-03 17:45:32 -08:00
euxarist ffe87c6f6b 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
2026-11-03 17:45:32 -08:00
euxarist b054189453 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
2026-11-03 17:45:32 -08:00
euxarist 86c1dc7a75 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
2026-11-03 17:45:32 -08:00
euxaristandcairn-code f15ca136f2 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]>
2026-11-03 17:45:32 -08:00
euxarist e6efc9e3bb Remove unused mut binding on visible_newer in GraphQL query test.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist 536ffb4c32 Match on Value::Object to extract task ID in GraphQL continuation test.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist 7d481660f3 Preserve timestamp fractional width and anchor continuation tests on legitimate cursors.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist 21b576a413 Bind task to repo id in complete_task test in test_support.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist 4f58cddce5 Update complete_task test in test_support to exercise both 404 on unreadable and 403 on non-assignee tasks.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist d4b5e99c99 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
2026-11-03 17:45:32 -08:00
euxarist c268c03b26 Stop disclosing denied task rows in the scan-wall cursor and add GraphQL pagination state.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist f0f031f73c Signal incomplete task scans with recoverable cursors and map read errors to AppError.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist 7149a37d77 Bound denied task history scans.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist 8d5adeeb06 Preserve authorized task reads across shipped clients.
Refs #268
2026-11-03 17:45:32 -08:00
euxarist f22ebacf79 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
2026-11-03 17:45:31 -08:00
euxarist 171a4342ec 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
2026-11-03 17:45:31 -08:00
euxarist e1ca1b6d10 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
2026-11-03 17:45:31 -08:00
62 changed files with 1151 additions and 26296 deletions

No files matched your search

-12
View File
@@ -58,16 +58,4 @@ ignore = [
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
# libsqlite3-sys 0.30.1 (bundled SQLite WAL uninitialized-memory disclosure,
# CVE-2026-119375). Present in Cargo.lock because sqlx resolves sqlx-sqlite
# unconditionally; sqlx is built postgres-only
# (crates/gitlawb-node/Cargo.toml), so sqlx-sqlite and its bundled SQLite C
# source are never compiled or linked (`cargo tree -i sqlx-sqlite` prints
# nothing) and there is no reachable WAL open to attack. The fix requires
# libsqlite3-sys >= 0.39, but sqlx-sqlite 0.8.6 pins ^0.30.1; a compatible
# bump needs a sqlx upgrade, not a lockfile refresh. REMOVE this ignore if
# sqlx is ever built with the `sqlite` feature or any other libsqlite3-sys
# consumer enters the build.
"RUSTSEC-2026-0459", # libsqlite3-sys bundled SQLite CVE (not linked in our build)
]
+4 -36
View File
@@ -6,16 +6,8 @@
# 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, 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.
# home after expansion. Bare `identity.pem` 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
@@ -160,10 +152,9 @@ 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. 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.
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
# verified but not yet honored for authorization, so they cannot push while this
# is on. See docs/RUN-A-NODE.md.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Comma-separated libp2p multiaddrs.
@@ -356,14 +347,6 @@ GITLAWB_TASK_READ_RATE_LIMIT=1200
# 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
# ── 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
@@ -389,21 +372,6 @@ 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
-100
View File
@@ -1,100 +0,0 @@
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 }}"
+3 -98
View File
@@ -30,62 +30,8 @@ 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:
@@ -111,8 +57,6 @@ 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:
@@ -142,8 +86,6 @@ jobs:
test:
name: test (${{ matrix.toolchain }})
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
@@ -188,17 +130,10 @@ jobs:
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
# `--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
run: cargo test --locked --workspace
build-release:
name: build --release
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
@@ -218,20 +153,10 @@ jobs:
key: release
- name: cargo build --release
env:
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
run: |
# shellcheck disable=SC2086 # PACKAGES is a word list by design
cargo build --locked --release $PACKAGES
run: cargo build --locked --release --workspace
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:
@@ -291,8 +216,6 @@ jobs:
msrv:
name: MSRV (Rust 1.91)
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -316,12 +239,6 @@ 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:
@@ -338,8 +255,6 @@ 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`
@@ -367,13 +282,7 @@ jobs:
key: windows
- name: cargo test (shipped Windows crates)
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
run: cargo test --locked -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
@@ -383,8 +292,6 @@ 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:
@@ -417,8 +324,6 @@ 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'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.
- 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.
- 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
+3 -7
View File
@@ -3085,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3473,13 +3473,10 @@ name = "git-remote-gitlawb"
version = "0.7.1"
dependencies = [
"anyhow",
"chrono",
"dirs",
"gitlawb-core",
"libc",
"mockito",
"reqwest",
"serde_json",
"tempfile",
"tracing",
"tracing-subscriber",
@@ -3604,7 +3601,6 @@ dependencies = [
"ed25519-dalek 2.2.0",
"gitlawb-core",
"icaptcha-client",
"libc",
"mockito",
"percent-encoding",
"reqwest",
@@ -6232,7 +6228,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7166,7 +7162,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5 -5
View File
@@ -67,8 +67,8 @@ Good today:
Known limitations:
- 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.
- 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.
- 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.
- 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.
@@ -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 | 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). |
| UCAN | Delegation token for future capability-based workflows. |
| 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 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_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_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. |
+1 -9
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-10-02",
"updated": "2026-06-30",
"peers": [
{
"name": "gitlawb",
@@ -42,14 +42,6 @@
"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,17 +14,8 @@ 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
+11 -228
View File
@@ -41,30 +41,6 @@ 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 {
@@ -162,15 +138,9 @@ impl RefUpdateCert {
Ok(())
}
/// Verify all signatures on this certificate, failing closed.
/// Verify all signatures on this certificate.
///
/// 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.
/// Returns the list of DIDs whose signatures are valid.
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()?;
@@ -199,65 +169,21 @@ 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. 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).
/// signature from the same maintainer counts once.
///
/// 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.
/// `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.
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();
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)
let valid = self.verify_all()?;
let distinct_signers: HashSet<&Did> =
valid.iter().filter(|d| maintainers.contains(d)).collect();
Ok(distinct_signers.len() >= threshold)
}
/// Validate the certificate structure (not signatures).
@@ -490,149 +416,6 @@ 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
@@ -1,299 +0,0 @@
//! 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,7 +5,6 @@ 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,30 +30,10 @@ 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
+25 -781
View File
@@ -16,19 +16,6 @@ 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 {
@@ -62,32 +49,11 @@ 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;
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
resource_ok && action_ok
}
}
@@ -105,157 +71,6 @@ 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 {
@@ -331,37 +146,6 @@ 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 {
@@ -464,33 +248,8 @@ impl Ucan {
/// 3. Check the proof is not expired
/// 4. Recursively verify the proof's own chain
///
/// 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"
)));
}
/// A UCAN with no proofs (root capability) passes trivially.
pub fn verify_chain(&self) -> Result<()> {
// First verify our own signature
self.verify_signature()?;
@@ -502,44 +261,34 @@ impl Ucan {
return Err(Error::Ucan("token is not yet valid".to_string()));
}
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(),
));
}
for proof_token in &self.payload.prf {
let proof = Self::decode(proof_token)
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
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 {
// The proof's audience must be this UCAN's issuer
if proof.payload.aud != self.payload.iss {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
"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()?;
}
// Recurse; the root of the proof's chain is the root of ours.
proof.verify_chain_at(depth + 1)
Ok(())
}
}
@@ -915,509 +664,4 @@ 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 -10
View File
@@ -6,24 +6,29 @@ 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 (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
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 rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
@@ -40,9 +45,17 @@ 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 (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
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 cid = state
.db
.encrypted_blob_cid(&record.id, &oid)
@@ -68,9 +81,17 @@ 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 (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
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 rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
+40 -75
View File
@@ -238,32 +238,49 @@ pub async fn close_issue(
headers: axum::http::HeaderMap,
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
) -> Result<Json<serde_json::Value>> {
// 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.
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.
//
// 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 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?;
// carries none of the owner's visibility rules, so for such a row this check
// can only return allow. That is deliberate rather than overlooked, and it is
// the same verdict every other read gate in this API reaches for one. Refusing
// instead would deny the repo's real owner and the issue's real author on any
// node whose only copy of the repo is a synced one, which is an ordinary state
// here, and it would not buy the protection it appears to, because a synced
// row's recorded owner comes from the peer that sent it. The expensive work
// this check guards is bounded ahead of it by the per-IP limiter and the read
// pool slot taken below, and the authoritative owner-or-author decision still
// runs below and again under the write lock.
{
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.0.as_str();
if crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
Some(caller),
"/",
) == crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
}
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
// policy as the push advertisement. The pre-lock snapshot downloads the
@@ -485,15 +502,6 @@ 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(
@@ -695,15 +703,6 @@ 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
@@ -729,40 +728,6 @@ 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.
+10 -4
View File
@@ -44,8 +44,11 @@ pub async fn add_label(
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
let added = state.db.add_label(&record.id, &label).await?;
@@ -68,8 +71,11 @@ pub async fn remove_label(
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
let removed = state.db.remove_label(&record.id, &label).await?;
+17 -77
View File
@@ -69,9 +69,16 @@ 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 {
// 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)
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
}
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
@@ -170,28 +177,16 @@ 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());
// 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]
rest[..end]
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.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
.join("\n")
}
#[test]
@@ -214,14 +209,9 @@ mod authz_guard {
// (source, handler, expected gate marker)
let rows: &[(&str, &str, &str)] = &[
// 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("),
// Bucket A — owner-gate (require_repo_owner -> 403)
(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
@@ -230,15 +220,10 @@ 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),
// also behind the shared read gate first.
(pulls, "close_pr", "authorize_repo_read("),
// Bucket A' — owner OR author (did_matches against the author)
(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("),
@@ -284,66 +269,21 @@ mod authz_guard {
(bounties, "dispute_bounty", "did_matches("),
(replicas, "register_replica", "did_matches("),
(replicas, "unregister_replica", "replica_did = &auth.0"),
// PRE-GATED — owner-gated behind the shared read gate; guard both
(protect, "protect_branch", "authorize_repo_read("),
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
(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("),
];
// Rows above prove each visibility handler calls authorize_repo_read and
// require_owner; this proves the helper itself does DID-safe matching.
// The visibility rows prove require_owner is CALLED; this proves the helper
// itself does DID-safe matching, not a raw/trailing-segment compare.
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).
+55 -116
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.
pub(crate) fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
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,116 +104,6 @@ pub(crate) 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
@@ -247,9 +137,60 @@ 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>() {
// 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);
// 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;
}
}
}
} 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
@@ -675,8 +616,6 @@ 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");
}
+10 -4
View File
@@ -17,8 +17,11 @@ 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, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Only the repo owner can protect branches (DID-safe match, shared idiom).
let caller = &auth.0;
@@ -48,8 +51,11 @@ pub async fn unprotect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
+10 -4
View File
@@ -199,8 +199,11 @@ pub async fn merge_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// 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
@@ -271,8 +274,11 @@ pub async fn close_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
File diff suppressed because it is too large. Load diff
-54
View File
@@ -1296,60 +1296,6 @@ mod visible_tasks_tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// An over-ceiling limit caps at MAX_VISIBLE_TASKS rather than scanning or
/// serializing the whole table (#405, adapted to the cursor handler).
#[sqlx::test]
async fn limit_ceiling_caps_at_200(pool: PgPool) {
let state = test_state(pool).await;
for i in 0..201 {
state
.db
.create_task(&task(&format!("ceil-{i}"), None, DELEGATOR))
.await
.unwrap();
}
let resp = list_router(state)
.oneshot(signed_request_as(
DELEGATOR,
Method::GET,
"/api/v1/tasks?limit=9223372036854775807",
Body::empty(),
))
.await
.unwrap();
let body = body_json(resp).await;
assert_eq!(
body["count"], 200,
"i64::MAX must clamp to the 200 ceiling, not scan the table"
);
}
/// An omitted limit falls back to the 50-row default (#405).
#[sqlx::test]
async fn omitted_limit_uses_default_50(pool: PgPool) {
let state = test_state(pool).await;
for i in 0..60 {
state
.db
.create_task(&task(&format!("def-{i}"), None, DELEGATOR))
.await
.unwrap();
}
let resp = list_router(state)
.oneshot(signed_request_as(
DELEGATOR,
Method::GET,
"/api/v1/tasks",
Body::empty(),
))
.await
.unwrap();
let body = body_json(resp).await;
assert_eq!(body["count"], 50, "omitted limit must default to 50");
}
/// A negative limit must clamp to zero through `collect_visible_tasks`,
/// not fall through to the visible set.
#[sqlx::test]
+32 -16
View File
@@ -85,10 +85,11 @@ pub async fn set_visibility(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<SetVisibilityRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
// 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?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
require_owner(&record, &auth.0)?;
validate_path_glob(&req.path_glob)?;
@@ -140,8 +141,11 @@ pub async fn remove_visibility(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<RemoveVisibilityRequest>,
) -> Result<Json<serde_json::Value>> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
require_owner(&record, &auth.0)?;
state
@@ -167,12 +171,14 @@ pub async fn list_visibility(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
// 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?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
require_owner(&record, &auth.0)?;
let rules = state.db.list_visibility_rules(&record.id).await?;
let rules_json: Vec<_> = rules
.into_iter()
.map(|r| {
@@ -199,18 +205,28 @@ 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());
let (record, rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
// 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 withheld =
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
+10 -4
View File
@@ -33,8 +33,11 @@ pub async fn create_webhook(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<(StatusCode, Json<Webhook>)> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Gate the target through the same hardened public-host validator the peer
@@ -115,8 +118,11 @@ pub async fn delete_webhook(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Verify the webhook belongs to this repo
+14 -542
View File
@@ -17,74 +17,16 @@ 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`.
///
/// 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 {
/// 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 {
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::{
@@ -413,7 +355,7 @@ fn validate_ucan_chain(
token: &str,
expected_aud: &Did,
signer_did: &Did,
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let ucan = Ucan::decode(token).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
@@ -441,14 +383,14 @@ fn validate_ucan_chain(
)
})?;
let root = ucan.verify_chain().map_err(|e| {
ucan.verify_chain().map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
Ok(VerifiedUcan { ucan, root })
Ok(())
}
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
@@ -501,18 +443,11 @@ pub async fn require_ucan_chain(
}
};
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
Ok(v) => v,
Err((status, body)) => return (status, body).into_response(),
};
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
return (status, body).into_response();
}
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);
tracing::debug!(did = %signer_did, "UCAN chain validated");
next.run(request).await
}
@@ -548,37 +483,6 @@ 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,
@@ -950,435 +854,3 @@ 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");
}
}
}
+8 -124
View File
@@ -11,8 +11,6 @@ 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
@@ -83,36 +81,8 @@ pub(crate) fn v2_signing_payload(
/// Issue a signed ref-update certificate for a successful push.
///
/// `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
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
/// persists the certificate, and returns it.
pub async fn issue_ref_certificate(
state: &AppState,
repo_id: &str,
@@ -120,92 +90,9 @@ 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();
// 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());
let issued_at = 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
@@ -217,9 +104,8 @@ async fn build_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,
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
@@ -490,16 +376,14 @@ 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, cert_id,
)
.await
.expect("issue_ref_certificate must succeed");
let cert =
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
.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.
-178
View File
@@ -152,46 +152,6 @@ 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.
@@ -284,29 +244,6 @@ pub struct Config {
#[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
@@ -837,45 +774,6 @@ pub struct Config {
)]
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`
@@ -960,13 +858,6 @@ 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!(
@@ -1166,51 +1057,6 @@ 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!(
@@ -1439,30 +1285,6 @@ 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
-465
View File
@@ -220,7 +220,6 @@ 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,
@@ -245,219 +244,6 @@ 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`.
@@ -792,121 +578,6 @@ 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> {
@@ -1197,86 +868,6 @@ 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!(
@@ -3050,62 +2641,6 @@ 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
+92 -300
View File
@@ -43,53 +43,6 @@ 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,
@@ -128,22 +81,24 @@ fn child_terminated_without_reaping(pid: i32) -> bool {
}
#[cfg(unix)]
fn run_bounded_git_raw_impl(
pub(crate) fn run_bounded_git_raw(
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 command = git_child_command(git_bin, args, repo_path, isolated_https, isolated_config);
let mut child = command
let mut child = std::process::Command::new(git_bin)
.args(args)
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.with_context(|| format!("failed to spawn git {label}"))?;
@@ -253,51 +208,6 @@ fn run_bounded_git_raw_impl(
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
@@ -331,20 +241,23 @@ 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))]
fn run_bounded_git_raw_impl(
pub(crate) fn run_bounded_git_raw(
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 = git_child_command(git_bin, args, repo_path, isolated_https, isolated_config)
let mut child = std::process::Command::new(git_bin)
.args(args)
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn git {label}"))?;
@@ -421,111 +334,6 @@ 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
@@ -1616,17 +1424,84 @@ fn pair_decision(
}
}
/// 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.
/// 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(())
}
fn root_tree_pairs(
repo_path: &Path,
git_bin: &str,
@@ -1658,18 +1533,6 @@ 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,
@@ -6100,77 +5963,6 @@ 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)` —
+1 -166
View File
@@ -126,55 +126,15 @@ 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 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()?;
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
decode_key(&jwks.keys.first()?.x)
}
@@ -687,129 +647,4 @@ 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());
}
}
+52 -268
View File
@@ -7,7 +7,6 @@ mod bootstrap;
mod cert;
mod config;
mod db;
mod durable_outbox;
mod encrypted_pin;
mod error;
mod git;
@@ -25,7 +24,6 @@ mod state;
mod sync;
#[cfg(test)]
mod test_support;
mod upstream_mirror;
mod visibility;
mod webhooks;
@@ -358,14 +356,9 @@ async fn main() -> Result<()> {
);
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
// DID (attacker-varied), so bound its key set to cap memory. Max and window
// are operator-configurable via GITLAWB_RATE_LIMIT_MAX /
// GITLAWB_RATE_LIMIT_WINDOW_SECS; both reject 0 at parse time.
let rate_limiter = rate_limit::RateLimiter::new_bounded(
config.rate_limit_max_requests as usize,
std::time::Duration::from_secs(config.rate_limit_window_secs),
200_000,
);
// DID (attacker-varied), so bound its key set to cap memory.
let rate_limiter =
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
@@ -631,13 +624,6 @@ async fn main() -> Result<()> {
}
let _legacy_cid_sweep = spawn_legacy_cid_sweep(&state, &config);
let _queue_lifecycle_sweep = spawn_queue_lifecycle_sweep(&state, &config);
// NOTE: the due-request worker is spawned AFTER the one-shot startup
// reconcile/drain below, not here: its 5s interval would otherwise tick
// during operator/PoS setup and interleave with boot recovery on backlog
// rows (state gates prevent data loss, but the interleave costs recovery
// latency and widens the reconcile/drain race window).
let _marker_cleanup_worker = spawn_marker_cleanup_worker(&state);
let router = server::build_router(state.clone());
// Re-register the socket bound at startup — same fd, so there was never a
@@ -695,22 +681,6 @@ async fn main() -> Result<()> {
info!("auto-sync worker started");
}
// External-forge mirroring is a separate, default-off worker. Startup
// validation already requires owner-only pushes when it is enabled; the
// worker additionally verifies the Git runtime can pin DNS answers before
// any URL reaches `git fetch`.
if config.upstream_mirror_enabled {
upstream_mirror::start(
Arc::clone(&state.db),
Arc::clone(&state.config),
state.repo_store.clone(),
state.repo_write_leases.clone(),
Arc::clone(&state.git_write_semaphore),
state.subscribe_shutdown(),
)
.context("starting upstream mirror worker")?;
}
// Periodic reconciliation sweep: re-derives pin/seal sets and fills gaps
// so a dropped replication job never means data loss.
{
@@ -769,63 +739,6 @@ async fn main() -> Result<()> {
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
// #26 Split PR 1: drain any `applied` rows left by a previous
// process that crashed after Git applied a ref but before the
// bookkeeping landed. Runs once, BEFORE the server accepts new
// pushes, so a recovery re-derivation does not race a fresh push.
// Non-fatal: a transient drain failure is logged and the rows
// remain `applied` for the next startup to pick up.
//
// P1-A: the reconcile step runs FIRST and promotes any `prepared`
// or `uncertain` row whose target SHA actually landed on disk.
// This is the path that recovers a ref when the post-receive
// `mark_pending_ref_transitions_applied` call errored or was
// interrupted after `receive_pack` returned Ok, or when
// receive-pack returned Err but some refs may have landed
// (the `uncertain` state). Without this step, the drain (gated
// on `state = 'applied'`) would never see those rows.
//
// P2 (reviewer-1/2 round 3): use the multi-pass reconcile so
// prepared/uncertain rows beyond the first 1000-row page are
// processed in the same startup, rather than waiting for the
// next restart (where they might age out of MAX_RECONCILE_AGE).
match durable_outbox::reconcile_prepared_from_disk_all(
state.clone(),
durable_outbox::DRAIN_PER_PASS_LIMIT,
durable_outbox::DRAIN_MAX_PASSES,
)
.await
{
Ok(0) => {}
Ok(n) => info!(
n,
"reconciled prepared/uncertain -> applied via on-disk ref match"
),
Err(e) => warn!(
err = %e,
"pending ref transition reconcile failed at startup (non-fatal; will retry on next start)"
),
}
match durable_outbox::drain_receive_pack_requests_all(
state.clone(),
durable_outbox::DRAIN_PER_PASS_LIMIT,
durable_outbox::DRAIN_MAX_PASSES,
)
.await
{
Ok(0) => {}
Ok(n) => info!(n, "drained pending ref transitions from prior run"),
Err(e) => warn!(
err = %e,
"pending ref transition drain failed at startup (non-fatal; will retry on next start)"
),
}
// Start the due-request worker only now that boot recovery is done (see
// the note at the other spawns above): its first tick must not interleave
// with the one-shot reconcile/drain on backlog rows.
let _due_request_worker = spawn_due_request_worker(&state);
// `into_make_service_with_connect_info` exposes the socket peer address as
// `ConnectInfo<SocketAddr>` so the push limiter can key on the real client
// when no trusted proxy header applies (see `rate_limit::client_key`).
@@ -858,158 +771,6 @@ async fn main() -> Result<()> {
Ok(())
}
/// U4 (#173): spawn the periodic legacy provider-CID repair sweep. Releases before this
/// version stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`,
/// and this version's `/ipfs/{cid}` resolver withholds any row whose stored key is not
/// the raw-content CID. The opportunistic repair on the pin path only fires when a push
/// re-carries the object, which normal git negotiation makes it not do, so those rows
/// need a walk. DETACHED, never on the boot path: the caller keeps serving while this
/// runs, and the sweep's own batch bound plus inter-batch delay keep it off the DB's
/// critical path. Its cursor is durable, so a restart mid-walk resumes instead of
/// rewinding. It re-arms after every run, including a failed one, so it never returns
/// and there is no awaited value to log here; the shutdown watcher below is what ends
/// it.
///
/// A named function rather than an inline block in `main` so the WIRING has a seam a
/// test can call: that the task is spawned at all, that it reads its batch and delay
/// from the config knobs rather than some other field, that the caller is not blocked
/// on it, and that the shutdown watcher actually ends it mid-walk. The sweep's own
/// behavior is covered elsewhere; this is the boot-path half.
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
let db = state.db.clone();
let repos_dir = config.repos_dir.clone();
let git_bin = state.git_bin.clone();
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
let batch = config.pin_repair_sweep_batch;
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
tokio::select! {
_ = ipfs_pin::run_sweep_rearmed(
&repos_dir, &git_bin, git_timeout, batch, delay,
ipfs_pin::SWEEP_REARM_DELAY, &db,
) => {}
// Shutdown mid-walk simply drops the run; the persisted cursor means the
// next boot picks up where this one stopped.
_ = shutdown_rx.changed() => {}
}
})
}
/// #26 Split PR 1 step 4 — periodic queue-lifecycle purge. Runs on
/// the same detached task pattern as `spawn_legacy_cid_sweep`:
/// tokio::spawn with a shutdown watcher, never on the boot path. The
/// interval is fixed at 24 hours (the spec calls for "one per
/// cluster per day"); the inter-batch delay is implicit in the
/// batch size plus the wall-clock cost of each pass. The drain
/// (`drain_receive_pack_requests_all`) and the purge
/// (`purge_request_queue`) share the same `DRAIN_PER_PASS_LIMIT`
/// budget so a 1000-row purge pass takes roughly the same time as
/// a 1000-row drain pass.
fn spawn_queue_lifecycle_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
let app_state = state.clone();
let retention_days = config.queue_retention_days;
let batch = config.queue_purge_batch;
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs.min(30));
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Consume the immediate first tick: Tokio intervals fire
// immediately on creation, which would run the purge
// concurrently with startup reconcile/drain. The child purge
// is terminal-parent-gated in SQL, but delaying the first
// sweep a full period removes the race entirely.
interval.tick().await;
loop {
tokio::select! {
_ = interval.tick() => {
if let Err(e) = durable_outbox::purge_request_queue(
&app_state.db,
retention_days,
batch,
).await {
tracing::warn!(err = %e, "queue lifecycle purge failed; will retry on next tick");
}
// Drain marker tombstones with the bounded deleter;
// failures retain the tombstone for the next tick.
if let Err(e) = durable_outbox::drain_marker_cleanup_queue(
&app_state,
batch,
git_timeout,
).await {
tracing::warn!(err = %e, "marker cleanup drain failed");
}
}
_ = shutdown_rx.changed() => {
break;
}
}
}
})
}
/// Shutdown-aware background due-request loop. The startup drain runs
/// once; without this, `effects_pending` rows with a future
/// `next_attempt_at` would wait until the next process restart.
/// Polls the indexed due query with bounded batches and failure
/// isolation (one request's error never aborts the batch).
fn spawn_due_request_worker(state: &AppState) -> tokio::task::JoinHandle<()> {
let app_state = state.clone();
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
match durable_outbox::drain_receive_pack_requests(
app_state.clone(),
durable_outbox::DRAIN_PER_PASS_LIMIT,
)
.await
{
Ok((0, _)) => {}
Ok((n, _)) => tracing::info!(n, "due request worker drained"),
Err(e) => tracing::warn!(err = %e, "due request worker drain failed"),
}
}
_ = shutdown_rx.changed() => break,
}
}
})
}
/// Dedicated marker-tombstone worker at a shorter cadence than the
/// daily purge, so external Git refs do not linger a full period
/// after their SQL owner is gone.
fn spawn_marker_cleanup_worker(state: &AppState) -> tokio::task::JoinHandle<()> {
let app_state = state.clone();
let git_timeout =
std::time::Duration::from_secs(app_state.config.git_service_timeout_secs.min(30));
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = interval.tick() => {
if let Err(e) = durable_outbox::drain_marker_cleanup_queue(
&app_state,
durable_outbox::DRAIN_PER_PASS_LIMIT,
git_timeout,
)
.await
{
tracing::warn!(err = %e, "marker cleanup worker failed");
}
}
_ = shutdown_rx.changed() => break,
}
}
})
}
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
tokio::spawn(async move {
#[cfg(unix)]
@@ -1497,6 +1258,55 @@ mod rate_limiter_sweep_tests {
}
/// it.
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
let db = state.db.clone();
let repos_dir = config.repos_dir.clone();
let git_bin = state.git_bin.clone();
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
let batch = config.pin_repair_sweep_batch;
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
tokio::select! {
_ = ipfs_pin::run_sweep_rearmed(
&repos_dir, &git_bin, git_timeout, batch, delay,
ipfs_pin::SWEEP_REARM_DELAY, &db,
) => {}
_ = shutdown_rx.changed() => {}
}
})
}
async fn gossip_ping_round(
db: &Db,
client: &reqwest::Client,
failed_once: &mut HashSet<String>,
peers: Vec<db::PeerRecord>,
) {
{
let current_dids: HashSet<&str> = peers.iter().map(|peer| peer.did.as_str()).collect();
failed_once.retain(|did| current_dids.contains(did.as_str()));
}
for peer in peers {
let ok = ping_peer_readiness(client, &peer.http_url).await;
match peer_ping_db_update(failed_once, &peer.did, ok) {
Some(reachable) => {
if let Err(error) = db.mark_peer_ping(&peer.did, reachable).await {
warn!(did = %peer.did, err = %error, "failed to persist peer readiness");
}
}
None => warn!(
did = %peer.did,
"peer readiness probe failed once; preserving previous federation status"
),
}
}
}
/// Decide whether one readiness sample should update the persisted federation
/// gate. A success is authoritative immediately. A failure must repeat on the
/// next gossip tick before it can mark a peer unreachable, so one transient
/// database hiccup does not hide the peer's repositories for five minutes.
fn peer_ping_db_update(failed_once: &mut HashSet<String>, did: &str, ready: bool) -> Option<bool> {
if ready {
failed_once.remove(did);
@@ -1673,32 +1483,6 @@ fn load_or_create_keypair_at(key_path: &std::path::Path) -> Result<Keypair> {
Ok(kp)
}
async fn gossip_ping_round(
db: &Db,
client: &reqwest::Client,
failed_once: &mut HashSet<String>,
peers: Vec<db::PeerRecord>,
) {
{
let current_dids: HashSet<&str> = peers.iter().map(|peer| peer.did.as_str()).collect();
failed_once.retain(|did| current_dids.contains(did.as_str()));
}
for peer in peers {
let ok = ping_peer_readiness(client, &peer.http_url).await;
match peer_ping_db_update(failed_once, &peer.did, ok) {
Some(reachable) => {
if let Err(error) = db.mark_peer_ping(&peer.did, reachable).await {
warn!(did = %peer.did, err = %error, "failed to persist peer readiness");
}
}
None => warn!(
did = %peer.did,
"peer readiness probe failed once; preserving previous federation status"
),
}
}
}
#[cfg(test)]
mod gossip_ssrf_tests {
use super::{
+9 -12
View File
@@ -70,14 +70,6 @@ impl RateLimiter {
self.window.min(Duration::from_secs(1))
}
/// The configured window, exposed so a 429 can carry an honest
/// `retry-after`: a hardcoded value lies when the operator retunes the
/// window, and a client that honors the header would retry into another
/// 429 until the real window elapses.
pub fn window(&self) -> Duration {
self.window
}
pub(crate) async fn check(&self, key: &str) -> bool {
// max_requests == 0 means the limiter is disabled, not "block all".
if self.max_requests == 0 {
@@ -301,7 +293,12 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
if let (Some(limiter), Some(did)) = (limiter, did) {
if !limiter.check(&did).await {
return too_many_requests(limiter.window().as_secs());
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
RATE_LIMIT_MESSAGE,
)
.into_response();
}
}
@@ -412,10 +409,10 @@ pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later";
/// 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(retry_after_secs: u64) -> Response {
pub fn too_many_requests() -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", retry_after_secs.to_string())],
[("retry-after", "60")],
RATE_LIMIT_MESSAGE,
)
.into_response()
@@ -484,7 +481,7 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
if !limiter.limiter.check(&key).await {
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
return too_many_requests(limiter.limiter.window().as_secs());
return too_many_requests();
}
}
}
+6 -773
View File
@@ -14,7 +14,6 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use gitlawb_core::identity::Keypair;
@@ -436,10 +435,9 @@ async fn process_batch(
.await;
}
Err(e) => {
// Prune vs terminal classification lives in
// `handle_sync_item_error` (defer = stays pending for the
// next tick; anything else = failed).
handle_sync_item_error(db, &item, &origin_url, e).await;
warn!(repo = %item.repo, origin = %origin_url, err = %e, "repo sync failed");
let _ = db.mark_sync_failed(&item.id).await;
crate::metrics::record_sync_processed("failed");
}
}
}
@@ -578,18 +576,7 @@ async fn replicate_encrypted_blobs(
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate");
let resp = match client.get(&url).send().await {
Ok(r) if r.status().is_success() => r,
// A peer that predates the replicate route answers 404: expected and
// quiet. Anything else is the route existing and refusing or failing,
// which should be observable rather than read as route-absent.
Ok(r) if r.status().as_u16() == 404 => return,
Ok(r) => {
tracing::warn!(repo = %repo, status = %r.status(), "encrypted-blobs replicate refused; skipping");
return;
}
Err(e) => {
tracing::warn!(repo = %repo, error = %e, "encrypted-blobs replicate request failed; skipping");
return;
}
_ => return,
};
let parsed: ReplicateResponse = match resp.json().await {
Ok(p) => p,
@@ -704,284 +691,6 @@ async fn existing_promisor_state(repo: &str) -> PromisorProbe {
}
}
/// Byte-prefix check for the two node-managed blob namespaces exempt from
/// the fail-closed commit walk (`visibility_pack.rs`): request markers
/// (`refs/gitlawb/requests/`) and issue records (`refs/gitlawb/issues/`).
/// Bytewise so non-UTF-8 refnames (which git accepts) are classified, not
/// lossy-decoded into a false exempt match. Extracted so the predicate is
/// unit-testable; [`prune_non_exempt_gitlawb_refs`] enforces it on import.
fn gitlawb_ref_is_exempt(raw_refname: &[u8]) -> bool {
raw_refname.starts_with(b"refs/gitlawb/requests/")
|| raw_refname.starts_with(b"refs/gitlawb/issues/")
}
/// Parse one `git show-ref` output line into its refname bytes: `<hex-sha>`
/// (40-hex SHA-1 or 64-hex SHA-256, which a sha256 mirror inherits from its
/// origin), one space, then the refname. Empty lines (trailing newline) yield
/// `Ok(None)`; anything else-shaped is an anomaly the caller fails on rather
/// than skips — silently passing an unclassifiable line could leave an evil
/// ref imported. Extracted so malformed input is unit-testable without git.
fn show_ref_refname(line: &[u8]) -> anyhow::Result<Option<&[u8]>> {
if line.is_empty() {
return Ok(None);
}
let Some(sp) = line.iter().position(|b| *b == b' ') else {
return Err(anyhow::anyhow!("git show-ref emitted an unparsable line"));
};
let (sha, raw) = (&line[..sp], &line[sp + 1..]);
let sha_ok = (sha.len() == 40 || sha.len() == 64) && sha.iter().all(|b| b.is_ascii_hexdigit());
if !sha_ok || raw.is_empty() {
return Err(anyhow::anyhow!("git show-ref emitted an unparsable line"));
}
Ok(Some(raw))
}
/// Delete imported `refs/gitlawb/*` refs outside the two exempt subtrees.
///
/// Mirrors import whatever the origin advertises (`+refs/*:refs/*`), and
/// `transfer.hideRefs` is a serve-side knob: a repo's own `git fetch` never
/// consults local hideRefs, and origins hide only `requests/` anyway. So a
/// planted `refs/gitlawb/<other>` blob ref — creatable on any pre-gate node —
/// lands on every mirror and then trips `assert_all_refs_are_commits` closed,
/// wedging serving on each one. Prune at import instead: after clone/fetch,
/// list `refs/gitlawb` and delete every ref outside the exempt subtrees
/// (negative refspecs cannot re-include them, so post-import deletion is the
/// mechanism that works).
///
/// Callers map a transient prune error to [`PruneFailed`] so the batch
/// defers the row (stays `pending`, retried next tick) instead of failing
/// it: a failed row would leave a possibly-wedged mirror unretried until
/// the next peer announcement. Deterministic refusals (identity mismatch,
/// malformed enumeration) map to [`PruneInvalid`], which fails terminally
/// since no retry can clear them.
///
/// Enumeration is `git show-ref` (no pattern), parsed as raw bytes: refnames
/// cannot contain newlines, so one line per ref is lossless even for
/// non-UTF-8 names, which a lossy decode would miss. The `<sha> SP <refname>`
/// structure (not `for-each-ref --format=%(refname)`, which carries no object
/// id) is what lets the parser width-check 40-hex SHA-1 vs 64-hex SHA-256
/// and reject anything else-shaped instead of skipping it.
///
/// Fail-closed throughout: the exit code is classified (only `1` with empty
/// output means "no refs"; anything else non-zero, e.g. 128 on a missing
/// repo, is fatal, since a silent skip would leave an imported evil ref in
/// place while the sync reports success), and any line that is not
/// `<hex-sha> SP <refname>` — 40-hex SHA-1 or 64-hex SHA-256, which a sha256
/// mirror inherits from its origin — fails the sync rather than being
/// skipped into an unpruned wedge.
/// Bounded git spawn for the prune below: `kill_on_drop` plus a hard timeout
/// so a wedged child cannot stall a mirror import or sweep forever. The
/// refname delete runs on exact bytes, so args arrive as `OsStr`, not `str`.
/// Non-PruneInvalid failures (including a timeout) wrap as PruneFailed and
/// defer for retry, matching the transient classification below.
async fn prune_git(
local_path: &Path,
args: &[&std::ffi::OsStr],
label: &str,
) -> anyhow::Result<std::process::Output> {
let fut = async {
// allow-unbounded-git: bounded by the tokio::time::timeout wrapping
// this future below, plus kill_on_drop.
let mut cmd = tokio::process::Command::new("git");
cmd.kill_on_drop(true);
cmd.arg("-C").arg(local_path).args(args);
cmd.output().await
};
tokio::time::timeout(std::time::Duration::from_secs(30), fut)
.await
.map_err(|_| anyhow::anyhow!("{label} timed out"))?
.map_err(|e| anyhow::anyhow!("{label} failed to spawn: {e}"))
}
async fn prune_non_exempt_gitlawb_refs(local_path: &Path) -> anyhow::Result<()> {
let local_str = local_path.to_str().unwrap_or(".");
// Prove the path is itself a repo before trusting the enumeration:
// `git -C` walks up to the nearest ancestor repo, so inside a non-repo
// child (a partially-created mirror) `show-ref` would exit 0 with the
// ANCESTOR's refs and `update-ref -d` would then run against that repo.
// Require `rev-parse --absolute-git-dir` to resolve to the path itself
// (mirrors are bare repos, where the git dir IS the path).
let git_dir_out = prune_git(
local_path,
&[
std::ffi::OsStr::new("rev-parse"),
std::ffi::OsStr::new("--absolute-git-dir"),
],
"git rev-parse",
)
.await?;
if !git_dir_out.status.success() {
let stderr = String::from_utf8_lossy(&git_dir_out.stderr);
return Err(anyhow::anyhow!(PruneInvalid(format!(
"mirror path is not a git repo (rev-parse failed): {stderr}"
))));
}
let actual_dir = PathBuf::from(
String::from_utf8_lossy(&git_dir_out.stdout)
.trim()
.to_string(),
);
let canonical_path = local_path
.canonicalize()
.map_err(|e| anyhow::anyhow!(PruneInvalid(format!("mirror path does not resolve: {e}"))))?;
let canonical_git_dir = actual_dir
.canonicalize()
.map_err(|e| anyhow::anyhow!(PruneInvalid(format!("git dir does not resolve: {e}"))))?;
if canonical_git_dir != canonical_path {
return Err(anyhow::anyhow!(PruneInvalid(format!(
"mirror path {local_str} is not itself a repo (git dir resolves elsewhere); refusing to enumerate"
))));
}
let out = prune_git(
local_path,
&[std::ffi::OsStr::new("show-ref")],
"git show-ref",
)
.await?;
// Classify on the exit code (the `existing_promisor_state` precedent):
// 0 parses, 1 with empty output is an empty repo, anything else is a
// fatal enumeration failure that must not read as "nothing to prune".
match out.status.code() {
Some(0) => {}
Some(1) if out.stdout.is_empty() => return Ok(()),
_ => {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow::anyhow!(
"git show-ref failed with code {:?}: {stderr}",
out.status.code()
));
}
}
for line in out.stdout.split(|b| *b == b'\n') {
// Malformed lines are deterministic (same git, same output every
// tick): fail terminally via PruneInvalid, not transiently.
let Some(raw) =
show_ref_refname(line).map_err(|e| anyhow::anyhow!(PruneInvalid(e.to_string())))?
else {
continue;
};
if !(raw == b"refs/gitlawb" || raw.starts_with(b"refs/gitlawb/"))
|| gitlawb_ref_is_exempt(raw)
{
continue;
}
// Delete by exact bytes (not the lossy display string below): a
// non-UTF-8 refname would otherwise survive the prune.
let refname_os = os_from_raw_refname(raw);
let shown = refname_os.to_string_lossy().into_owned();
let delete = prune_git(
local_path,
&[
std::ffi::OsStr::new("update-ref"),
std::ffi::OsStr::new("-d"),
refname_os.as_os_str(),
std::ffi::OsStr::new("--no-deref"),
],
"git update-ref -d",
)
.await?;
if !delete.status.success() {
let stderr = String::from_utf8_lossy(&delete.stderr);
return Err(anyhow::anyhow!(
"git update-ref -d {shown} failed (mirror still carries a non-exempt refs/gitlawb ref): {stderr}"
));
}
tracing::info!(
gitlawb_ref = %shown,
"mirror: pruned non-exempt refs/gitlawb ref on import"
);
}
Ok(())
}
/// Lossless bytes → OsString for `update-ref -d`: non-UTF-8 refnames must be
/// deleted by exact bytes. Unix is the serving platform; elsewhere fall back
/// to a lossy decode (best effort where exact bytes are unrepresentable).
#[cfg(unix)]
fn os_from_raw_refname(raw: &[u8]) -> std::ffi::OsString {
use std::os::unix::ffi::OsStringExt;
std::ffi::OsString::from_vec(raw.to_vec())
}
#[cfg(not(unix))]
fn os_from_raw_refname(raw: &[u8]) -> std::ffi::OsString {
String::from_utf8_lossy(raw).into_owned().into()
}
/// Marker for mirror-prune failures: the clone/fetch succeeded, so the
/// mirror is on disk, but a non-exempt `refs/gitlawb/*` ref may still be
/// imported. Local-git conditions are transient (a retry can succeed), while
/// `mark_sync_failed` is terminal until the next peer announcement — and a
/// failed row would leave a possibly-wedged mirror unretried in the
/// meantime. The batch error arm downcasts to this and defers the row
/// (stays `pending`, retried on the next 30s tick, where fetch+prune are
/// idempotent) instead of failing it.
#[derive(Debug)]
struct PruneFailed(String);
impl std::fmt::Display for PruneFailed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "mirror prune failed: {}", self.0)
}
}
impl std::error::Error for PruneFailed {}
/// Marker for deterministic mirror-prune refusals: the identity check (path
/// is not itself a repo) and malformed `show-ref` output cannot clear on
/// retry — re-running fetch+prune every tick would spin forever on a
/// permanently bad path. Unlike [`PruneFailed`], these fail the row
/// terminally so the condition surfaces instead of looping silently.
#[derive(Debug)]
struct PruneInvalid(String);
/// Preserve a deterministic prune refusal through the clone/fetch call
/// sites: `prune_non_exempt_gitlawb_refs` already returns `PruneInvalid`
/// for identity mismatches and malformed enumeration, and re-wrapping
/// every error as `PruneFailed` would stringify that type away before
/// `handle_sync_item_error` downcasts it — leaving a permanently invalid
/// mirror deferred (fetch+prune every tick) instead of failed. Transient
/// failures keep the `PruneFailed` deferral.
fn wrap_prune_error(e: anyhow::Error) -> anyhow::Error {
match e.downcast::<PruneInvalid>() {
Ok(invalid) => anyhow::Error::new(invalid),
Err(other) => anyhow::anyhow!(PruneFailed(other.to_string())),
}
}
impl std::fmt::Display for PruneInvalid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "mirror prune refused: {}", self.0)
}
}
impl std::error::Error for PruneInvalid {}
/// Classify a mirror sync failure for one queue row. Transient prune
/// failures ([`PruneFailed`]) defer: the mirror is on disk but may still
/// carry a non-exempt refs/gitlawb ref, a transient local-git condition a
/// retry can clear — failing the row would leave it unretried until the
/// next peer announcement, wedging serving in the meantime. Deterministic
/// refusals ([`PruneInvalid`]) and everything else fail the row terminally:
/// retrying a permanently bad path every tick would spin forever instead of
/// surfacing. Extracted so the deferral (not just the prune itself) is
/// unit-testable.
async fn handle_sync_item_error(
db: &Db,
item: &crate::db::SyncQueueItem,
origin_url: &str,
err: anyhow::Error,
) {
if err.downcast_ref::<PruneFailed>().is_some() {
warn!(repo = %item.repo, origin = %origin_url, err = %err, "mirror prune failed; leaving sync row pending for retry");
crate::metrics::record_sync_processed("deferred");
return;
}
warn!(repo = %item.repo, origin = %origin_url, err = %err, "repo sync failed");
let _ = db.mark_sync_failed(&item.id).await;
crate::metrics::record_sync_processed("failed");
}
/// Mirror-clone a repo from a remote URL into a local bare repo.
/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
@@ -1006,13 +715,6 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow::anyhow!("git clone --mirror failed: {stderr}"));
}
// A fresh mirror imports everything the origin advertises, including any
// planted non-exempt `refs/gitlawb/*` ref; prune before serving from it.
// Prune outcome flows through `wrap_prune_error`: transient failures
// defer (stays pending), deterministic refusals fail terminally.
prune_non_exempt_gitlawb_refs(local_path)
.await
.map_err(wrap_prune_error)?;
Ok(())
}
@@ -1033,11 +735,7 @@ async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> an
])
.await?;
// The fetch imports whatever the origin advertises via the stored
// `+refs/*:refs/*` refspec; prune non-exempt `refs/gitlawb/*` refs after
// every fetch so one planted origin ref cannot wedge serving on the
// mirror (see `prune_non_exempt_gitlawb_refs`).
let fetch_result = match mode {
match mode {
MirrorMode::Promisor => {
git_run(&["-C", local_str, "config", "remote.origin.promisor", "true"]).await?;
git_run(&[
@@ -1077,13 +775,7 @@ async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> an
git_run(&["-C", local_str, "fetch", "--prune", "origin"]).await
}
}
};
fetch_result?;
// Prune after every fetch (see above); outcome flows through
// `wrap_prune_error` like the clone path.
prune_non_exempt_gitlawb_refs(local_path)
.await
.map_err(wrap_prune_error)
}
}
#[cfg(test)]
@@ -2416,463 +2108,4 @@ mod tests {
assert!(object_count(&mirror) > 0, "mirror has no objects");
assert_eq!(sync_status(&pool, "z6Mkfoo/hello").await, "done");
}
/// The import-exemption predicate is bytewise over both node-managed
/// namespaces and nothing else. Non-UTF-8 bytes never match (git accepts
/// them, so a lossy decode could false-exempt). Narrowing either prefix
/// turns these red.
#[test]
fn gitlawb_import_exemption_covers_both_namespaces_by_bytes() {
for exempt in [
&b"refs/gitlawb/requests/550e8400-e29b-41d4-a716-446655440000"[..],
&b"refs/gitlawb/issues/abc123"[..],
] {
assert!(
gitlawb_ref_is_exempt(exempt),
"import must keep {}",
String::from_utf8_lossy(exempt)
);
}
for pruned in [
&b"refs/gitlawb/evil"[..],
&b"refs/gitlawb"[..],
&b"refs/gitlawbfoo"[..],
&b"refs/heads/main"[..],
&b""[..],
// Raw 0xFF byte: never an exempt prefix, always pruned.
&b"refs/gitlawb/\xffvil"[..],
] {
assert!(
!gitlawb_ref_is_exempt(pruned),
"import must prune {}",
String::from_utf8_lossy(pruned)
);
}
}
/// End-to-end prune on a scratch bare repo: a planted non-exempt
/// `refs/gitlawb/*` blob ref is deleted, exempt `requests/`/`issues/`
/// blob refs and content refs survive. Removing the prune call from
/// `clone_repo`/`fetch_repo` leaves the evil ref in place, which is what
/// wedges serving on mirrors — this test plants exactly that state and
/// proves the prune removes it.
#[test]
fn prune_removes_non_exempt_gitlawb_refs_and_keeps_exempt() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime");
rt.block_on(async {
let td = TempDir::new().unwrap();
let work = td.path().join("work");
let bare = td.path().join("m.git");
std::fs::create_dir_all(&work).unwrap();
// A real commit for the content branch (branch refs must point
// at commits; the evil/exempt refs below point at blobs).
std::fs::write(work.join("f.txt"), b"x\n").unwrap();
let run_work = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&work)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
run_work(&["init", "-q"]);
run_work(&["config", "user.email", "t@t"]);
run_work(&["config", "user.name", "t"]);
run_work(&["add", "f.txt"]);
run_work(&["commit", "-qm", "init"]);
assert!(
Command::new("git")
.args([
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap()
])
.current_dir(td.path())
.status()
.unwrap()
.success(),
"git clone --bare failed"
);
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");
out.stdout
};
run(&["init", "-q", "--bare", "."]);
std::fs::write(bare.join("blob-body"), b"marker\n").unwrap();
let blob = String::from_utf8_lossy(&run_out(&["hash-object", "-w", "blob-body"]))
.trim()
.to_string();
// The clone carries the content branch; plant the evil ref and
// both exempt refs (all blob-pointing, like node metadata).
run(&["update-ref", "refs/gitlawb/evil", &blob]);
run(&["update-ref", "refs/gitlawb/requests/r1", &blob]);
run(&["update-ref", "refs/gitlawb/issues/i1", &blob]);
prune_non_exempt_gitlawb_refs(&bare)
.await
.expect("prune succeeds");
let raw_refs = run_out(&["for-each-ref", "--format=%(refname)"]);
let refs = String::from_utf8_lossy(&raw_refs);
let set: std::collections::HashSet<&str> = refs
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
assert!(
!set.contains("refs/gitlawb/evil"),
"non-exempt blob ref must be pruned, still present: {set:?}"
);
for keep in ["refs/gitlawb/requests/r1", "refs/gitlawb/issues/i1"] {
assert!(set.contains(keep), "{keep} must survive the prune: {set:?}");
}
assert!(
set.iter().any(|r| r.starts_with("refs/heads/")),
"content branch ref must survive the prune: {set:?}"
);
});
}
/// `show-ref` line parser: 40-hex SHA-1 and 64-hex SHA-256 lines yield
/// the refname, empty lines yield None, and anything else-shaped errors
/// (the caller fails the sync rather than skipping into an unpruned
/// wedge). Reverting to a 40-only width check turns the sha256 case red.
#[test]
fn show_ref_line_parser_accepts_both_hash_widths_and_rejects_junk() {
// 40-hex SHA-1 line.
let sha1_line = format!("{} refs/gitlawb/evil", "a".repeat(40));
assert_eq!(
show_ref_refname(sha1_line.as_bytes()).unwrap(),
Some(b"refs/gitlawb/evil".as_slice())
);
// 64-hex SHA-256 line (a sha256 mirror inherits the width).
let sha256_line = format!("{} refs/gitlawb/evil", "b".repeat(64));
assert_eq!(
show_ref_refname(sha256_line.as_bytes()).unwrap(),
Some(b"refs/gitlawb/evil".as_slice())
);
// Non-UTF-8 refname passes through as exact bytes.
let mut raw = vec![b'c'; 40];
raw.extend_from_slice(b" refs/gitlawb/\xffvil");
assert_eq!(
show_ref_refname(&raw).unwrap(),
Some(b"refs/gitlawb/\xffvil".as_slice())
);
// Trailing newline residue.
assert_eq!(show_ref_refname(b"").unwrap(), None);
// No space, short sha, non-hex sha, empty refname: all errors.
let bads: Vec<Vec<u8>> = vec![
b"nospaceshere".to_vec(),
format!("{}refs/x", "a".repeat(40)).into_bytes(),
[b'z'; 40]
.iter()
.chain(b" refs/x".iter())
.copied()
.collect(),
format!("{} ", "a".repeat(40)).into_bytes(),
format!("{} refs/x", "a".repeat(41)).into_bytes(),
];
for bad in &bads {
assert!(
show_ref_refname(bad).is_err(),
"malformed show-ref line must error, not skip"
);
}
}
/// Pruning a path that is not a repo errors. Two shapes: a missing path
/// fails `rev-parse` outright (exit 128), while a plain subdirectory of
/// a repo would otherwise enumerate the ancestor's refs — covered by the
/// identity test below. Either way the prune refuses instead of pruning
/// the wrong repo or silently skipping an evil ref.
#[test]
fn prune_on_non_repo_path_errors() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime");
rt.block_on(async {
let err =
prune_non_exempt_gitlawb_refs(Path::new("/tmp/gitlawb-definitely-not-a-repo-xyz"))
.await
.expect_err("non-repo path must fail the prune, not skip it");
assert!(
err.to_string().contains("not a git repo")
|| err.to_string().contains("does not resolve"),
"unexpected error shape: {err:#}"
);
});
}
/// Identity check: pruning a plain subdirectory of a repo refuses instead
/// of enumerating the ancestor. `git -C` on the child exits 0 from
/// `show-ref` with the ANCESTOR's refs, so without the
/// `rev-parse --absolute-git-dir` identity check the delete loop would
/// enumerate and delete the wrong repository's refs. Deleting the check
/// makes the prune succeed and removes the ancestor's evil ref — both
/// assertions go red.
#[test]
fn prune_on_non_repo_child_of_repo_refuses_and_touches_nothing() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime");
rt.block_on(async {
let td = TempDir::new().unwrap();
let ancestor = td.path().join("ancestor.git");
std::fs::create_dir_all(&ancestor).unwrap();
let run_ancestor = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&ancestor)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
run_ancestor(&["init", "-q", "--bare", "."]);
// Blob target for the planted ref (mirrors the node-metadata shape).
std::fs::write(ancestor.join("blob-body"), b"marker\n").unwrap();
let out = Command::new("git")
.args(["hash-object", "-w", "blob-body"])
.current_dir(&ancestor)
.output()
.unwrap();
assert!(out.status.success(), "git hash-object failed");
let blob = String::from_utf8_lossy(&out.stdout).trim().to_string();
run_ancestor(&["update-ref", "refs/gitlawb/evil", &blob]);
// A plain child directory: not itself a repo.
let child = ancestor.join("not-a-repo");
std::fs::create_dir_all(&child).unwrap();
let err = prune_non_exempt_gitlawb_refs(&child)
.await
.expect_err("pruning a non-repo child must refuse, not enumerate the ancestor");
assert!(
err.to_string().contains("not itself a repo"),
"refusal must name the identity mismatch, got: {err:#}"
);
// The ancestor's evil ref is untouched.
let refs_out = Command::new("git")
.args(["for-each-ref", "--format=%(refname)"])
.current_dir(&ancestor)
.output()
.unwrap();
let refs = String::from_utf8_lossy(&refs_out.stdout);
assert!(
refs.lines().any(|l| l.trim() == "refs/gitlawb/evil"),
"ancestor evil ref must survive the refused prune"
);
});
}
/// sha256 mirror: a 64-hex `show-ref` line parses and the planted ref is
/// pruned. A 40-only parser skips every line and leaves the evil ref.
#[test]
fn prune_parses_sha256_show_ref_and_prunes() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime");
rt.block_on(async {
let td = TempDir::new().unwrap();
let bare = td.path().join("m256.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");
out.stdout
};
run(&["init", "-q", "--bare", "--object-format=sha256", "."]);
std::fs::write(bare.join("blob-body"), b"marker\n").unwrap();
let blob = String::from_utf8_lossy(&run_out(&["hash-object", "-w", "blob-body"]))
.trim()
.to_string();
assert_eq!(
blob.len(),
64,
"sanity: sha256 repo must mint 64-hex objects"
);
run(&["update-ref", "refs/gitlawb/evil", &blob]);
run(&["update-ref", "refs/gitlawb/issues/i1", &blob]);
prune_non_exempt_gitlawb_refs(&bare)
.await
.expect("prune succeeds on a sha256 repo");
let raw_refs = run_out(&["for-each-ref", "--format=%(refname)"]);
let refs = String::from_utf8_lossy(&raw_refs);
let set: std::collections::HashSet<&str> = refs
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
assert!(
!set.contains("refs/gitlawb/evil"),
"evil ref must be pruned from sha256 mirror: {set:?}"
);
assert!(
set.contains("refs/gitlawb/issues/i1"),
"exempt ref must survive: {set:?}"
);
});
}
/// Prune failures defer the queue row (stays `pending` for the next
/// tick); deterministic prune refusals and any other sync error fail it
/// terminally. Deleting the `downcast_ref::<PruneFailed>` arm restores
/// terminal failure and turns the first assertion red.
/// Prune failures defer the queue row (stays `pending` for the next
/// tick); deterministic prune refusals and any other sync error fail it
/// terminally. Deleting the `downcast_ref::<PruneFailed>` arm restores
/// terminal failure and turns the first assertion red.
///
/// The wrapper below is what the clone/fetch call sites run through:
/// a `PruneInvalid` from the helper must still downcast as
/// `PruneInvalid` afterwards (not stringified into `PruneFailed`),
/// and anything else must arrive as `PruneFailed`.
#[test]
fn wrap_prune_error_preserves_deterministic_type() {
let invalid = wrap_prune_error(anyhow::anyhow!(PruneInvalid("x".to_string())));
assert!(
invalid.downcast_ref::<PruneInvalid>().is_some(),
"deterministic refusal must survive the call-site wrap"
);
assert!(
invalid.downcast_ref::<PruneFailed>().is_none(),
"deterministic refusal must not also read as transient"
);
let transient = wrap_prune_error(anyhow::anyhow!("boom"));
assert!(
transient.downcast_ref::<PruneFailed>().is_some(),
"generic failure must arrive as transient"
);
assert!(
transient.downcast_ref::<PruneInvalid>().is_none(),
"generic failure must not read as deterministic"
);
}
#[sqlx::test]
async fn prune_failure_defers_row_other_failures_fail_it(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let item = |id: &str| crate::db::SyncQueueItem {
id: id.to_string(),
repo: "z6Mkfoo/hello".to_string(),
node_did: "did:key:z6MkOrigin".to_string(),
ref_name: "refs/heads/main".to_string(),
new_sha: "0".repeat(40),
cid: None,
status: "pending".to_string(),
enqueued_at: chrono::Utc::now().to_rfc3339(),
};
async fn status_of(pool: &sqlx::PgPool, id: &str) -> String {
let row: (String,) = sqlx::query_as("SELECT status FROM sync_queue WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await
.unwrap();
row.0
}
// Seed three pending rows directly (enqueue_sync mints random ids).
for id in ["sync-prune-defer", "sync-prune-invalid", "sync-other-fail"] {
sqlx::query(
"INSERT INTO sync_queue (id, repo, node_did, ref_name, new_sha, cid, status, enqueued_at)
VALUES ($1, $2, $3, $4, $5, NULL, 'pending', $6)",
)
.bind(id)
.bind("z6Mkfoo/hello")
.bind("did:key:z6MkOrigin")
.bind("refs/heads/main")
.bind("0".repeat(40))
.bind(chrono::Utc::now().to_rfc3339())
.execute(state.db.pool())
.await
.unwrap();
}
handle_sync_item_error(
&state.db,
&item("sync-prune-defer"),
"http://origin.example",
anyhow::anyhow!(PruneFailed("boom".to_string())),
)
.await;
assert_eq!(
status_of(state.db.pool(), "sync-prune-defer").await,
"pending",
"prune failure must leave the row pending for retry"
);
handle_sync_item_error(
&state.db,
&item("sync-other-fail"),
"http://origin.example",
anyhow::anyhow!("boom"),
)
.await;
assert_eq!(
status_of(state.db.pool(), "sync-other-fail").await,
"failed",
"non-prune failure must fail the row"
);
// Deterministic refusal: no retry can clear it, so it fails
// terminally instead of spinning every tick forever.
handle_sync_item_error(
&state.db,
&item("sync-prune-invalid"),
"http://origin.example",
anyhow::anyhow!(PruneInvalid("not itself a repo".to_string())),
)
.await;
assert_eq!(
status_of(state.db.pool(), "sync-prune-invalid").await,
"failed",
"deterministic prune refusal must fail the row, not defer forever"
);
}
}
-920
View File
@@ -5969,566 +5969,6 @@ mod tests {
authorization"
);
}
/// Delegated push, end to end through both auth layers.
///
/// A non-owner presenting an invocation whose chain roots at the repo owner
/// clears the owner-push gate; the same signer without one, and with one that
/// names a different repository, are both refused. This is the regression the
/// owner-push default introduced: a CI or delegated key holding a valid
/// `git/push` capability was refused exactly like a stranger.
///
/// Status codes are the discriminators. 500 means the request passed
/// `require_signature` (not 401), passed `require_ucan_chain` (not 401), and
/// cleared the owner gate (not 403), then reached git on a repo with no disk
/// backing — the same shape `git_upload_pack_post_is_read_gated_on_private_repo`
/// relies on. A bare `!= 403` would let a 401 regression pass.
///
/// Not `#[cfg(unix)]`: no fake-git shim is involved, only HTTP and the gate.
#[sqlx::test]
async fn delegated_push_clears_the_owner_gate(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::sync::Arc;
let owner = Keypair::generate();
let agent = Keypair::generate();
let owner_did = owner.did().to_string();
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
// Explicit rather than relying on the shipped default, so this test states
// the configuration it is about.
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = true;
state.config = Arc::new(cfg);
state
.db
.create_repo(&seed_repo(&owner_did, "deleg"))
.await
.expect("seed repo");
let router = || {
Router::new()
.route(
"/{owner}/{repo}/git-receive-pack",
axum::routing::post(crate::api::repos::git_receive_pack),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
crate::auth::require_ucan_chain,
))
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::require_signature,
))
.with_state(state.clone())
};
let path = format!("/{short}/deleg.git/git-receive-pack");
let body = b"0000".to_vec();
// owner -> agent delegation, then agent -> node invocation carrying it.
// Both links carry a finite expiry: a write capability that never lapses is
// refused, since there is no revocation path to withdraw a leaked one.
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let invocation_with_exp = |resource: String, exp: Option<chrono::DateTime<chrono::Utc>>| {
let delegation = Ucan::issue(
&owner,
agent.did(),
vec![Capability::new(resource, caps::GIT_PUSH)],
exp,
)
.expect("issue delegation");
Ucan::delegate(
&agent,
state.node_did.clone(),
delegation.payload.att.clone(),
exp,
&delegation,
)
.expect("wrap invocation")
.encode()
.expect("encode invocation")
};
let invocation_for = |resource: String| invocation_with_exp(resource, Some(hour));
let signed_push = |ucan: Option<String>| {
let signed = sign_request(&agent, "POST", &path, &body);
let mut req = Request::builder()
.method(Method::POST)
.uri(&path)
.header("content-type", "application/x-git-receive-pack-request")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature);
if let Some(token) = ucan {
req = req.header("x-ucan", token);
}
req.body(Body::from(body.clone())).expect("request")
};
// 1. Valid delegation for THIS repo: clears the gate.
let resp = router()
.oneshot(signed_push(Some(invocation_for(format!(
"gitlawb://repos/{owner_did}/deleg"
)))))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"an owner-rooted git/push delegation must clear the owner gate and reach git"
);
// 2. Delegation naming a DIFFERENT repo: refused.
let other = router()
.oneshot(signed_push(Some(invocation_for(format!(
"gitlawb://repos/{owner_did}/someotherrepo"
)))))
.await
.unwrap();
assert_eq!(
other.status(),
StatusCode::FORBIDDEN,
"a delegation for another repository must not authorize this push"
);
let other_body = axum::body::to_bytes(other.into_body(), 4096).await.unwrap();
// 3. No delegation at all: refused, with a byte-identical body. A caller
// must not be able to tell a non-applicable delegation from none, or the
// denial becomes an oracle for which capabilities exist.
let none = router().oneshot(signed_push(None)).await.unwrap();
assert_eq!(
none.status(),
StatusCode::FORBIDDEN,
"a non-owner with no delegation must still be refused"
);
let none_body = axum::body::to_bytes(none.into_body(), 4096).await.unwrap();
assert_eq!(
other_body, none_body,
"an inapplicable delegation and no delegation must be indistinguishable"
);
// 4. A delegation that never expires: refused, however otherwise valid.
// Owner-rooted, right repo, right action — but with no revocation path an
// unbounded grant cannot be withdrawn once the token leaks.
let perpetual = router()
.oneshot(signed_push(Some(invocation_with_exp(
format!("gitlawb://repos/{owner_did}/deleg"),
None,
))))
.await
.unwrap();
assert_eq!(
perpetual.status(),
StatusCode::FORBIDDEN,
"a delegation with no expiry must not authorize a push"
);
}
/// The two-gate contract on a PRIVATE repository, end to end.
///
/// A `git push` crosses two independent gates. Gate 1 is read visibility on
/// the `info/refs` advertisement (both services): the owner, anyone on a public
/// repository, or a caller a rule names in `reader_dids`. Gate 2 is push
/// authorization on the `git-receive-pack` POST, the owner gate a delegation
/// clears. A UCAN speaks only to gate 2 and grants no read, so a delegate on a
/// private repository needs BOTH a push delegation AND a reader rule. That
/// composition — not a POST defect — is what "delegated push does not work on
/// my private repo" turns out to be; the sibling test above runs on a public
/// repository and never meets gate 1.
///
/// Status codes discriminate as in the sibling: 404 is gate 1 withholding the
/// advertisement, 403 is gate 2 refusing the push, 500 is a request that cleared
/// every gate and reached git on a repo with no disk backing.
///
/// Each gate is mounted under the middleware production gives it (`server.rs`):
/// `optional_signature` alone on `info/refs`, `require_signature` plus
/// `require_ucan_chain` on the receive-pack POST.
#[sqlx::test]
async fn delegated_push_on_a_private_repo_needs_read_visibility_too(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::sync::Arc;
let owner = Keypair::generate();
let agent = Keypair::generate();
let owner_did = owner.did().to_string();
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = true;
state.config = Arc::new(cfg);
let mut repo = seed_repo(&owner_did, "priv-deleg");
repo.is_public = false;
state
.db
.create_repo(&repo)
.await
.expect("seed private repo");
let advertisement = || {
Router::new()
.route(
"/{owner}/{repo}/info/refs",
axum::routing::get(crate::api::repos::git_info_refs),
)
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::optional_signature,
))
.with_state(state.clone())
};
let receive_pack = || {
Router::new()
.route(
"/{owner}/{repo}/git-receive-pack",
axum::routing::post(crate::api::repos::git_receive_pack),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
crate::auth::require_ucan_chain,
))
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::require_signature,
))
.with_state(state.clone())
};
// A delegation that is valid in every respect the push gate checks.
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let delegation = Ucan::issue(
&owner,
agent.did(),
vec![Capability::new(
format!("gitlawb://repos/{owner_did}/priv-deleg"),
caps::GIT_PUSH,
)],
Some(hour),
)
.expect("issue delegation");
let invocation = Ucan::delegate(
&agent,
state.node_did.clone(),
delegation.payload.att.clone(),
Some(hour),
&delegation,
)
.expect("wrap invocation")
.encode()
.expect("encode invocation");
let advert_path = format!("/{short}/priv-deleg.git/info/refs?service=git-receive-pack");
let signed_advert = |ucan: Option<&str>| {
let signed = sign_request(&agent, "GET", &advert_path, b"");
let mut req = Request::builder()
.method(Method::GET)
.uri(&advert_path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature);
if let Some(token) = ucan {
req = req.header("x-ucan", token);
}
req.body(Body::empty()).expect("request")
};
let push_path = format!("/{short}/priv-deleg.git/git-receive-pack");
let push_body = b"0000".to_vec();
let signed_push = |ucan: Option<&str>| {
let signed = sign_request(&agent, "POST", &push_path, &push_body);
let mut req = Request::builder()
.method(Method::POST)
.uri(&push_path)
.header("content-type", "application/x-git-receive-pack-request")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature);
if let Some(token) = ucan {
req = req.header("x-ucan", token);
}
req.body(Body::from(push_body.clone())).expect("request")
};
// 1. Delegation, no reader rule. Gate 1 withholds the advertisement even
// though the delegate presents the token there too: a UCAN grants no read.
let resp = advertisement()
.oneshot(signed_advert(Some(&invocation)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a push delegation must not open the advertisement of a private repo"
);
// Gate 2, taken on its own, is satisfied by the same delegation: the two
// gates are independent, which is exactly why both must be arranged.
let resp = receive_pack()
.oneshot(signed_push(Some(&invocation)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a non-reader delegate is refused on the POST itself, not just the advertisement: the receive-pack route runs the same visibility gate because a caller can skip info/refs entirely"
);
// 2. The owner makes the agent a reader of the whole repository — what
// `gl visibility set / --repo <repo> --readers <agent>` does.
state
.db
.set_visibility_rule(
&repo.id,
"/",
crate::db::VisibilityMode::B,
&[agent.did().to_string()],
&owner_did,
)
.await
.expect("reader rule");
let resp = advertisement()
.oneshot(signed_advert(Some(&invocation)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a reader clears the advertisement gate and reaches git"
);
let resp = receive_pack()
.oneshot(signed_push(Some(&invocation)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"with both gates arranged the push reaches git"
);
// 3. Reader rule, no delegation: reading is not pushing.
let resp = advertisement().oneshot(signed_advert(None)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"the reader rule alone still opens the advertisement"
);
let resp = receive_pack().oneshot(signed_push(None)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"a reader with no delegation must still be refused on the push"
);
}
/// A valid delegation clears the owner gate but is still refused on a branch
/// the owner has explicitly protected.
///
/// Two predicates deliberately disagree: the owner gate accepts a delegate, the
/// branch-protection loop is owner-only. A protected branch is the owner's
/// marker that even routine writes should stop, so a `git/push` delegation must
/// not silently override it — otherwise issuing any capability would weaken
/// every protection the owner had already set.
///
/// The 403 body is the discriminator: it must name the branch, proving the
/// request reached branch protection rather than being turned away by the owner
/// gate for lacking a delegation.
#[sqlx::test]
async fn delegated_push_is_still_refused_on_a_protected_branch(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::sync::Arc;
const ZERO: &str = "0000000000000000000000000000000000000000";
let new_sha = "1111111111111111111111111111111111111111";
let owner = Keypair::generate();
let agent = Keypair::generate();
let owner_did = owner.did().to_string();
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = true;
state.config = Arc::new(cfg);
let rec = seed_repo(&owner_did, "protrepo");
state.db.create_repo(&rec).await.expect("seed repo");
state
.db
.protect_branch(&rec.id, "main", &owner_did)
.await
.expect("protect main");
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let delegation = Ucan::issue(
&owner,
agent.did(),
vec![Capability::new(
format!("gitlawb://repos/{owner_did}/protrepo"),
caps::GIT_PUSH,
)],
Some(hour),
)
.expect("issue delegation");
let invocation = Ucan::delegate(
&agent,
state.node_did.clone(),
delegation.payload.att.clone(),
Some(hour),
&delegation,
)
.expect("wrap")
.encode()
.expect("encode");
let router = Router::new()
.route(
"/{owner}/{repo}/git-receive-pack",
axum::routing::post(crate::api::repos::git_receive_pack),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
crate::auth::require_ucan_chain,
))
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::require_signature,
))
.with_state(state.clone());
let path = format!("/{short}/protrepo.git/git-receive-pack");
let line = format!("{ZERO} {new_sha} refs/heads/main");
let body = format!("{:04x}{}0000", line.len() + 4, line).into_bytes();
let signed = sign_request(&agent, "POST", &path, &body);
let req = Request::builder()
.method(Method::POST)
.uri(&path)
.header("content-type", "application/x-git-receive-pack-request")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.header("x-ucan", invocation)
.body(Body::from(body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"a delegation must not override branch protection"
);
let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("protected"),
"the refusal must come from branch protection, not the owner gate; got {text}"
);
}
/// A delegated push whose branch-protection lookup FAILS must be refused,
/// not silently allowed: the lookup is fail-closed for non-owners, so a
/// database error on a protected branch cannot be pushed through.
#[sqlx::test]
async fn delegated_push_fails_closed_when_protection_lookup_errors(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::sync::Arc;
const ZERO: &str = "0000000000000000000000000000000000000000";
let new_sha = "1111111111111111111111111111111111111111";
let owner = Keypair::generate();
let agent = Keypair::generate();
let owner_did = owner.did().to_string();
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = true;
state.config = Arc::new(cfg);
let rec = seed_repo(&owner_did, "protrepo");
state.db.create_repo(&rec).await.expect("seed repo");
state
.db
.protect_branch(&rec.id, "main", &owner_did)
.await
.expect("protect main");
// Poison the lookup: is_branch_protected SELECTs this table, so every
// call after the drop returns Err.
sqlx::query("DROP TABLE protected_branches")
.execute(state.db.pool())
.await
.expect("drop protected_branches");
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let delegation = Ucan::issue(
&owner,
agent.did(),
vec![Capability::new(
format!("gitlawb://repos/{owner_did}/protrepo"),
caps::GIT_PUSH,
)],
Some(hour),
)
.expect("issue delegation");
let invocation = Ucan::delegate(
&agent,
state.node_did.clone(),
delegation.payload.att.clone(),
Some(hour),
&delegation,
)
.expect("wrap")
.encode()
.expect("encode");
let router = Router::new()
.route(
"/{owner}/{repo}/git-receive-pack",
axum::routing::post(crate::api::repos::git_receive_pack),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
crate::auth::require_ucan_chain,
))
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::require_signature,
))
.with_state(state.clone());
let path = format!("/{short}/protrepo.git/git-receive-pack");
let line = format!("{ZERO} {new_sha} refs/heads/main");
let body = format!("{:04x}{}0000", line.len() + 4, line).into_bytes();
let signed = sign_request(&agent, "POST", &path, &body);
let req = Request::builder()
.method(Method::POST)
.uri(&path)
.header("content-type", "application/x-git-receive-pack-request")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.header("x-ucan", invocation)
.body(Body::from(body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert!(
!resp.status().is_success(),
"a failed protection lookup must refuse the push, got {}",
resp.status()
);
}
/// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after
/// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack
@@ -19283,366 +18723,6 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// Public-but-quarantined repos must not expose encrypted blob indexes
/// (discovery or replicate). Previously these handlers used
/// `visibility_check` alone and skipped the quarantine short-circuit in
/// `authorize_repo_read`.
#[sqlx::test]
async fn encrypted_blobs_quarantined_repo_opaque_404(pool: PgPool) {
let state = test_state(pool).await;
let owner = "did:key:zENCQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let short = owner.split(':').next_back().unwrap();
let mut repo = seed_private_repo(owner, "enc-quar");
repo.is_public = true;
let repo_id = repo.id.clone();
state.db.create_repo(&repo).await.unwrap();
state
.db
.record_encrypted_blob(&repo_id, "deadbeef", "bafybeiquarantinedcid", "")
.await
.unwrap();
state.db.set_repo_quarantine(&repo_id, true).await.unwrap();
let router = crate::server::build_router(state.clone());
for suffix in [
"encrypted-blobs",
"encrypted-blobs/replicate",
"encrypted-blob/deadbeef",
] {
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"quarantined public repo must 404 on {path}"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
!text.contains("bafybeiquarantinedcid") && !text.contains("deadbeef"),
"blob index must not leak on quarantine 404 for {path}: {text}"
);
}
// Owner (full did:key and bare key) must also 404 — quarantine is not a
// visibility deny that the owner short-circuit can bypass.
for caller in [owner, short] {
for suffix in [
"encrypted-blobs",
"encrypted-blobs/replicate",
"encrypted-blob/deadbeef",
] {
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
let resp = router
.clone()
.oneshot(signed_request_as(caller, Method::GET, &path, Body::empty()))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"owner form {caller} must not read quarantined {path}"
);
}
}
// Control: clear quarantine → all three discovery surfaces admit again.
// `encrypted-blob/{oid}` may 500 without a live IPFS node after the gate
// opens; assert it is not the quarantine 404.
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] {
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("bafybeiquarantinedcid"),
"released repo must expose blob index on {path}: {text}"
);
}
let get_path = format!("/api/v1/repos/{short}/enc-quar/encrypted-blob/deadbeef");
let resp = router.oneshot(anon_get(&get_path)).await.unwrap();
assert!(
resp.status() == StatusCode::OK || resp.status().is_server_error(),
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
resp.status()
);
}
/// Mirror-admission path (`upsert_mirror_repo` + slash-form id) must also
/// opaque-404 encrypted discovery while quarantined.
#[sqlx::test]
async fn encrypted_blobs_quarantined_mirror_admission_opaque_404(pool: PgPool) {
let state = test_state(pool).await;
let short = "z6MkEncMirrorAdmitAAAAAAAAAAAAAAAAAAAA";
state
.db
.upsert_mirror_repo(short, "enc-mirror", "/tmp/enc-mirror", None, true)
.await
.unwrap();
let repo_id = format!("{short}/enc-mirror");
state
.db
.record_encrypted_blob(&repo_id, "aabbccdd", "bafybeimirrorcid", "")
.await
.unwrap();
let router = crate::server::build_router(state.clone());
for suffix in [
"encrypted-blobs",
"encrypted-blobs/replicate",
"encrypted-blob/aabbccdd",
] {
let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}");
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"anon must 404 quarantined mirror {path}"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
!text.contains("bafybeimirrorcid") && !text.contains("aabbccdd"),
"blob index must not leak on quarantine 404 for {path}: {text}"
);
let resp = router
.clone()
.oneshot(signed_request_as(short, Method::GET, &path, Body::empty()))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"bare-key mirror owner must not read quarantined {path}"
);
}
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] {
let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}");
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}");
}
let get_path = format!("/api/v1/repos/{short}/enc-mirror/encrypted-blob/aabbccdd");
let resp = router.oneshot(anon_get(&get_path)).await.unwrap();
assert!(
resp.status() == StatusCode::OK || resp.status().is_server_error(),
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
resp.status()
);
}
/// `withheld-paths` and `list_visibility` must share the quarantine gate.
#[sqlx::test]
async fn withheld_paths_and_list_visibility_quarantine_opaque(pool: PgPool) {
use crate::db::VisibilityMode;
let state = test_state(pool).await;
let owner = "did:key:zVISQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let short = owner.split(':').next_back().unwrap();
let mut repo = seed_private_repo(owner, "vis-quar");
repo.is_public = true;
let repo_id = repo.id.clone();
state.db.create_repo(&repo).await.unwrap();
state
.db
.set_visibility_rule(&repo_id, "/secret/**", VisibilityMode::B, &[], owner)
.await
.unwrap();
state.db.set_repo_quarantine(&repo_id, true).await.unwrap();
let router = crate::server::build_router(state.clone());
let withheld = format!("/api/v1/repos/{short}/vis-quar/withheld-paths");
let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"anon withheld-paths must 404 while quarantined"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
!text.contains("/secret"),
"withheld layout must not leak: {text}"
);
for caller in [owner, short] {
let resp = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/withheld-paths",
axum::routing::get(crate::api::visibility::withheld_paths),
)
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::optional_signature,
))
.with_state(state.clone())
.oneshot(signed_request_as(
caller,
Method::GET,
&withheld,
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"owner form {caller} withheld-paths must 404 while quarantined"
);
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
let resp = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/visibility",
axum::routing::get(crate::api::visibility::list_visibility),
)
.with_state(state.clone())
.oneshot(signed_request_as(caller, Method::GET, &vis, Body::empty()))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"owner form {caller} list_visibility must 404 while quarantined"
);
// PUT/DELETE must also opaque-404 while quarantined (no rule mutation).
let put_body = Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#);
let resp = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/visibility",
axum::routing::put(crate::api::visibility::set_visibility),
)
.with_state(state.clone())
.oneshot(signed_request_as(caller, Method::PUT, &vis, put_body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"owner form {caller} set_visibility must 404 while quarantined"
);
let del_body = Body::from(r#"{"path_glob":"/secret/**"}"#);
let resp = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/visibility",
axum::routing::delete(crate::api::visibility::remove_visibility),
)
.with_state(state.clone())
.oneshot(signed_request_as(caller, Method::DELETE, &vis, del_body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"owner form {caller} remove_visibility must 404 while quarantined"
);
}
// A signed non-owner must get 404 too, never 403: this leg is what pins
// the quarantine-before-owner ordering. If require_owner were hoisted
// above authorize_repo_read the same caller would read Forbidden, an
// existence oracle on a hidden repo.
let stranger = "did:key:zSTRANGERQUARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
for (method, path, body) in [
(Method::GET, withheld.as_str(), Body::empty()),
(Method::GET, vis.as_str(), Body::empty()),
(
Method::PUT,
vis.as_str(),
Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#),
),
(
Method::DELETE,
vis.as_str(),
Body::from(r#"{"path_glob":"/secret/**"}"#),
),
] {
let handler: axum::routing::MethodRouter<crate::state::AppState> = match method.clone()
{
Method::GET => axum::routing::get(crate::api::visibility::list_visibility),
Method::PUT => axum::routing::put(crate::api::visibility::set_visibility),
_ => axum::routing::delete(crate::api::visibility::remove_visibility),
};
let router = if path == withheld.as_str() {
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/withheld-paths",
axum::routing::get(crate::api::visibility::withheld_paths),
)
.layer(axum::middleware::from_fn_with_state(
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
crate::auth::optional_signature,
))
.with_state(state.clone())
} else {
Router::new()
.route("/api/v1/repos/{owner}/{repo}/visibility", handler)
.with_state(state.clone())
};
let resp = router
.oneshot(signed_request_as(stranger, method.clone(), path, body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"non-owner {method} {path} must 404 (not 403) while quarantined"
);
}
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("/secret"),
"released withheld-paths must return globs: {text}"
);
// Same release control for list_visibility (owner-gated after gate opens).
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
let resp = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/visibility",
axum::routing::get(crate::api::visibility::list_visibility),
)
.with_state(state.clone())
.oneshot(signed_request_as(owner, Method::GET, &vis, Body::empty()))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"released list_visibility must admit owner"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("/secret"),
"released list_visibility must return rules: {text}"
);
}
#[sqlx::test]
async fn repo_gate_public_repo_anon_read_admitted(pool: PgPool) {
struct DirGuard(std::path::PathBuf);
-968
View File
@@ -1,968 +0,0 @@
//! 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());
}
}
+12 -163
View File
@@ -38,151 +38,26 @@ pub fn fire_event(
event: &str,
payload: serde_json::Value,
) {
fire_event_occurrence(db, http_client, repo_id, event, payload, None, None);
let repo_id = repo_id.to_string();
let event = event.to_string();
tokio::spawn(async move {
fire_event_async(db, http_client, &repo_id, &event, payload).await;
});
}
/// 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.
pub fn fire_event_occurrence(
async fn fire_event_async(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
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_occurrence(
db,
http_client,
&repo_id,
&event,
payload,
request_id.as_deref(),
ref_name.as_deref(),
None,
)
.await;
});
}
/// 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>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
request_id: Option<&str>,
ref_name: Option<&str>,
claimed_hooks: Vec<crate::db::Webhook>,
) {
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,
&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);
}
}
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;
}
}
Some(claimed_hooks)
}
#[allow(clippy::too_many_arguments)]
async fn fire_event_async_occurrence(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
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() {
@@ -201,29 +76,8 @@ async fn fire_event_async_occurrence(
let client = Arc::clone(&http_client);
let event_name = event.to_string();
let bytes = payload_bytes.clone();
// 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 delivery_id = uuid::Uuid::new_v4().to_string();
let db_for_mark = db.clone();
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
@@ -238,7 +92,6 @@ async fn fire_event_async_occurrence(
return;
}
let delivery_for_mark = delivery_id.clone();
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
let mut req = client
@@ -260,10 +113,6 @@ async fn fire_event_async_occurrence(
"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,
+27 -577
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_raw")
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack_raw");
.find("smart_http::receive_pack(")
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack");
assert!(
lease_acquire < with_lease && with_lease < receive,
"F3 gate bypassed: the write lease must be acquired, then carried by the \
@@ -509,8 +509,8 @@ 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 disposition.spawn_tail`, and `release` must consume the same
/// disposition (`disposition.release_ok`) so the two cannot drift apart.
/// must sit inside `if push_succeeded`, and `release` must consume the same flag 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
@@ -519,9 +519,7 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
///
/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below
/// `guard.release(` and the ordering assertion fails; take it out of the
/// `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.
/// `if push_succeeded` block and the failed-push assertion fails.
#[test]
fn inv22_replication_tail_spawns_at_the_durability_boundary() {
let repos = src("api/repos.rs");
@@ -536,589 +534,41 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() {
.expect("split always yields a first chunk");
let success_flag = production
.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`.
let spawn_count = production
.matches("tokio::spawn(post_receive_replication_tail(")
.count();
assert!(
spawn_count >= 1,
"U5 gate missing: the replication tail must be spawned by git_receive_pack"
);
for (i, (pos, _)) in production
.match_indices("tokio::spawn(post_receive_replication_tail(")
.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`",
.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()",
);
// 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];
assert!(
call.contains("landed_refs"),
"U5 gate bypassed: tail #{i} must carry the proven-landed ref set, not unfiltered ref_updates"
);
}
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(")
.expect("U5 gate missing: the replication tail must be spawned by git_receive_pack");
let release = production
.find(".release(disposition.release_ok)")
.expect("U5 gate stale: release must consume the same disposition as the tail gate");
.find(".release(push_succeeded)")
.expect("U5 gate stale: release must consume the same success flag as the tail gate");
let touch = production
.find("state.db.touch_repo(")
.expect("U5 gate stale: git_receive_pack no longer calls touch_repo");
// #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");
let webhook = production
.find("webhooks::fire_event(")
.expect("U5 gate stale: git_receive_pack no longer fires push webhooks");
assert!(
success_flag < spawn,
"U5 gate bypassed: the disposition must be bound before any tail spawn"
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"
);
assert!(
spawn < release && spawn < touch && spawn < effects_executor,
spawn < release && spawn < touch && spawn < webhook,
"U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \
and the effect executor, so a disconnect in any of those windows cannot drop \
and the webhook fan-out, 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 `main.rs` and the contract is enforced by the `idx_receive_pack_requests_completed_at`
/// partial index from v41.
///
/// Assertions:
/// 1. `main.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 main_src = src("main.rs");
assert!(
main_src.contains("spawn_queue_lifecycle_sweep"),
"main.rs must spawn the periodic queue-lifecycle purge"
);
assert!(
main_src.contains("purge_request_queue"),
"main.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"
);
}
-1
View File
@@ -29,7 +29,6 @@ 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",
+2 -31
View File
@@ -267,31 +267,7 @@ 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) => {
// 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) if matches!(r.status().as_u16(), 404 | 501) => return Ok((Vec::new(), Vec::new())),
Ok(r) => bail!("withheld-paths lookup failed: {}", r.status()),
Err(err) => return Err(err).context("fetching withheld paths"),
};
@@ -351,12 +327,7 @@ async fn recover_encrypted_blobs(
.await
{
Ok(r) if r.status().is_success() => r,
// 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"),
_ => return Ok(vec![]),
};
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
if body.blobs.is_empty() {
+37 -98
View File
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -69,63 +69,22 @@ 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!();
// 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;
let dir = args.dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let mut checks = Vec::new();
// ── 1. Identity ───────────────────────────────────────────────────────
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)
let pem_path = dir.join("identity.pem");
if pem_path.exists() {
match std::fs::read_to_string(&pem_path)
.ok()
.and_then(|pem| gitlawb_core::identity::Keypair::from_pem(&pem).ok())
{
@@ -142,7 +101,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
));
}
}
} else if let Some(pem_path) = &pem_path {
} else {
checks.push(Check::fail(
"identity",
format!("not found at {}", pem_path.display()),
@@ -151,35 +110,33 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
// ── 2. Registration ───────────────────────────────────────────────────
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",
));
}
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 ───────────────────────────────────────────
@@ -648,24 +605,6 @@ 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"));
+2 -2
View File
@@ -324,8 +324,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 {
gitlawb_core::sanitize::strip_terminal_controls(s)
.chars()
s.chars()
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
.take(200)
.collect()
}
+58 -292
View File
@@ -9,8 +9,7 @@ use std::path::{Path, PathBuf};
pub enum IdentityCmd {
/// Generate a new Ed25519 keypair and DID
New {
/// Output directory for key files
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Output directory for key files (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Overwrite existing keys if present
@@ -64,51 +63,16 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
}
}
/// 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> {
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
if let Some(d) = override_dir {
return Ok(d);
}
// `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}"))
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".gitlawb"))
}
/// 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 key_path(dir: &Path) -> PathBuf {
dir.join("identity.pem")
}
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
@@ -117,14 +81,15 @@ 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 path = key_path_for(dir)?;
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 pem = fs::read_to_string(&path).with_context(|| {
format!(
"no identity found at {}\nRun `gl identity new` to create one",
@@ -140,20 +105,11 @@ pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Key
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)?;
let base = match dirs::home_dir() {
Some(h) => h.join(".gitlawb"),
None => return Ok(None),
};
let path = key_path(&base);
match fs::read_to_string(&path) {
Ok(pem) => Keypair::from_pem(&pem)
.context("failed to load keypair from PEM")
@@ -173,11 +129,8 @@ async fn cmd_new_with_reader(
force: bool,
reader: &mut impl std::io::BufRead,
) -> Result<()> {
let path = key_path_for(dir.as_deref())?;
let dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let dir = gitlawb_dir(dir)?;
let path = key_path(&dir);
if path.exists() {
if force {
@@ -199,13 +152,23 @@ async fn cmd_new_with_reader(
}
}
crate::secret_file::create_dir(&dir)
fs::create_dir_all(&dir)
.with_context(|| format!("failed to create directory {}", dir.display()))?;
let keypair = Keypair::generate();
let pem = keypair.to_pem()?;
crate::secret_file::write(&path, pem.as_bytes())?;
// 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())?;
}
let did = keypair.did();
println!("✓ Generated new identity");
@@ -241,7 +204,8 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
}
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
let src = key_path_for(dir.as_deref())?;
let base = gitlawb_dir(dir)?;
let src = key_path(&base);
let pem = fs::read_to_string(&src).with_context(|| {
format!(
@@ -259,7 +223,16 @@ async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
.join("identity.pem.bak")
});
crate::secret_file::write(&dest, pem.as_bytes())?;
#[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())?;
}
println!("✓ Identity backed up");
println!(" DID: {}", keypair.did());
@@ -287,11 +260,8 @@ 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 dest = key_path_for(dir.as_deref())?;
let base = dest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let base = gitlawb_dir(dir)?;
let dest = key_path(&base);
if dest.exists() {
if force {
@@ -313,10 +283,19 @@ async fn cmd_restore_with_reader(
}
}
crate::secret_file::create_dir(&base)
fs::create_dir_all(&base)
.with_context(|| format!("failed to create directory {}", base.display()))?;
crate::secret_file::write(&dest, pem.as_bytes())?;
#[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())?;
}
println!("✓ Identity restored");
println!(" DID: {}", keypair.did());
@@ -330,36 +309,6 @@ 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();
@@ -581,186 +530,3 @@ 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")
);
}
}
+28 -22
View File
@@ -26,7 +26,7 @@ pub struct InitArgs {
)]
pub node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -105,17 +105,20 @@ 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 = crate::identity::gitlawb_dir(args.dir.clone())?;
crate::secret_file::create_dir(&ucan_dir)?;
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 record = json!({
"ucan": ucan,
"node": args.node,
"did": did.to_string(),
"saved_at": chrono::Utc::now().to_rfc3339(),
});
crate::secret_file::write(
&ucan_dir.join("ucan.json"),
serde_json::to_string_pretty(&record)?.as_bytes(),
std::fs::write(
ucan_dir.join("ucan.json"),
serde_json::to_string_pretty(&record)?,
)?;
}
}
@@ -222,26 +225,29 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
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 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 keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
let path = base.join("identity.pem");
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)
}
})?;
#[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())?;
}
Ok(keypair)
}
+2 -2
View File
@@ -28,7 +28,7 @@ pub enum IpfsCmd {
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
@@ -79,7 +79,7 @@ pub enum IpfsCmd {
env = "GITLAWB_NODE"
)]
node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Resume token from a scan that stopped at a bound, as printed by a
-1
View File
@@ -26,7 +26,6 @@ mod protect;
mod quickstart;
mod register;
mod repo;
mod secret_file;
mod star;
mod status;
mod sync;
+57 -567
View File
@@ -286,7 +286,7 @@ fn tool_definitions() -> Value {
},
{
"name": "ucan_show",
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
"description": "Show the saved bootstrap UCAN token for this agent.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -564,7 +564,7 @@ fn tool_definitions() -> Value {
// ── UCAN delegation tools ───────────────────────────────────────────
{
"name": "ucan_delegate",
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
"inputSchema": {
"type": "object",
"required": ["to", "resource", "action"],
@@ -572,13 +572,13 @@ fn tool_definitions() -> Value {
"to": { "type": "string", "description": "Audience DID — who receives this capability (e.g. did:key:z6Mk...)" },
"resource": { "type": "string", "description": "Resource URI (e.g. gitlawb://repos/owner/repo)" },
"action": { "type": "string", "description": "Action to grant (e.g. git/push, pr/open, repo/admin)" },
"expiry_hours": { "type": "integer", "minimum": 1, "description": "Expiry in hours (default: 720, the same as `gl ucan delegate`). Every link of a push chain must expire, so there is no unbounded option." }
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
}
}
},
{
"name": "ucan_verify",
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
"inputSchema": {
"type": "object",
"required": ["token"],
@@ -587,17 +587,6 @@ fn tool_definitions() -> Value {
}
}
},
{
"name": "ucan_import",
"description": "Store a delegation received from a repository owner where `git push` (git-remote-gitlawb) will present it. Same checks as `gl ucan import`: addressed to this identity, owner-rooted, every link bounded and naming one repository. Requires agent identity.",
"inputSchema": {
"type": "object",
"required": ["token"],
"properties": {
"token": { "type": "string", "description": "UCAN token JSON, as returned by ucan_delegate" }
}
}
},
// ── Issue tools ─────────────────────────────────────────────────────
{
@@ -797,45 +786,15 @@ async fn call_tool(
]))?),
"ucan_show" => {
// The directory the server was started with, like every sibling tool.
// Reading the default here while the rest honour `--dir` splits one MCP
// session across two identity directories.
let ucan_path = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?
.join("ucan.json");
if !ucan_path.exists() {
return Ok(
"No UCAN saved. Run `gl register` or use the agent_register tool.".to_string(),
);
let ucan_path = dirs::home_dir()
.context("no home dir")?
.join(".gitlawb/ucan.json");
if ucan_path.exists() {
let content = std::fs::read_to_string(ucan_path)?;
Ok(content)
} else {
Ok("No UCAN saved. Run `gl register` or use the agent_register tool.".to_string())
}
let content = std::fs::read_to_string(&ucan_path)?;
// Decoded fields, matching what `gl ucan show` reports — the two are the
// same question asked through different surfaces and should not answer
// it in different shapes.
//
// Returning the file verbatim also handed the caller the bootstrap token
// itself. That is the credential the agent presents, and an MCP response
// travels further than a terminal: into a model's context, transcripts,
// and logs. The fields below are what a caller actually needs to know
// whether it is registered and until when.
let ucan = crate::ucan_cmd::decode_saved_ucan(&content).with_context(|| {
format!("could not read the saved UCAN at {}", ucan_path.display())
})?;
Ok(serde_json::to_string_pretty(&json!({
"issuer": crate::ucan_cmd::shown(&ucan.payload.iss.to_string()),
"audience": crate::ucan_cmd::shown(&ucan.payload.aud.to_string()),
"version": crate::ucan_cmd::shown(&ucan.payload.ucan),
"capabilities": ucan.payload.att.iter()
.map(|c| json!({ "with": crate::ucan_cmd::shown(&c.with), "can": crate::ucan_cmd::shown(&c.can) }))
.collect::<Vec<_>>(),
"expires": ucan.payload.exp.map(|e| {
chrono::DateTime::from_timestamp(e, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| e.to_string())
}),
"expired": ucan.is_expired(),
"signature_valid": ucan.verify_signature().is_ok(),
"path": ucan_path.display().to_string(),
}))?)
}
"did_resolve" => {
@@ -1004,7 +963,7 @@ async fn call_tool(
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_else(|| vec!["*"]);
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
let owner = resolve_owner(&args, &client).await?;
let body = serde_json::to_vec(&json!({
"url": url,
"secret": args["secret"],
@@ -1012,7 +971,7 @@ async fn call_tool(
}))?;
let resp = crate::http::read_json(
client
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &body)
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body)
.await?,
"create webhook",
)
@@ -1022,12 +981,19 @@ async fn call_tool(
"webhook_list" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
// Owner-gated route: `owner/repo` splits first, an explicit `owner`
// arg wins next, otherwise the signing keypair's short DID (NOT the
// node DID that resolve_owner returns). The request is signed by
// this keypair, so the path owner must match it or the node
// returns 403/404.
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
// Owner-gated route: an explicit `owner` arg wins, otherwise default to
// the signing keypair's short DID (NOT the node DID that resolve_owner
// returns). The request is signed by this keypair, so the path owner
// must match it or the node returns 403/404.
let owner = if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
o.to_string()
} else {
let kp = keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
};
// Owner-gated AND auth-required route (list_webhooks 401s a headerless
// caller, then 403s a non-owner). get_maybe_signed signs when an
// identity is present (so the owner is authorized) and otherwise sends
@@ -1038,7 +1004,7 @@ async fn call_tool(
// failing client-side before the node is contacted.
let resp = crate::http::read_json(
client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?,
"webhook_list",
)
@@ -1049,11 +1015,11 @@ async fn call_tool(
"webhook_delete" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
let id = args["id"].as_str().context("missing 'id'")?;
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
let owner = resolve_owner(&args, &client).await?;
let body = serde_json::to_vec(&json!({}))?;
let resp = crate::http::read_json(
client
.delete(&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"), &body)
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body)
.await?,
"delete webhook",
)
@@ -1241,47 +1207,14 @@ async fn call_tool(
let resource = args["resource"].as_str().context("missing 'resource'")?;
let action = args["action"].as_str().context("missing 'action'")?;
// The same canonicalization `gl ucan delegate` applies, so a token
// minted here names the repository the way import, the helper, and
// the node all resolve it.
let resource = crate::ucan_cmd::canonical_resource(resource);
let resource = resource.as_str();
// The same refusal `gl ucan delegate` applies. A push-class wildcard cannot
// be honoured anywhere downstream — import refuses it, the helper refuses
// it, the node refuses it — so issuing one only manufactures a token that
// fails later with less context than this.
if gitlawb_core::ucan::push::is_push_wildcard(resource, action) {
anyhow::bail!(
"a wildcard resource cannot carry a push capability: a delegation is \
scoped to the repositories it names when issued, and `*` cannot say \
which those were. Use resource gitlawb://repos/<owner>/<repo>."
);
}
let audience: gitlawb_core::did::Did = to_str
.parse()
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
// The CLI defaults to `DEFAULT_DELEGATION_EXPIRY_HOURS` and has no way
// to issue an unbounded token. An omitted field here used to mean "no
// expiry", which for a push capability is dead on arrival: import
// refuses it and so does the node (`chain_lifetime_is_bounded`). Same
// default, same rule.
let hours = match args.get("expiry_hours") {
None | Some(Value::Null) => crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64,
Some(v) => v
.as_i64()
.context("invalid expiry_hours: expected an integer")?,
};
if hours < 1 {
anyhow::bail!("expiry_hours must be at least 1: a token that has already expired grants nothing");
}
anyhow::ensure!(
hours <= crate::ucan_cmd::MAX_EXPIRY_HOURS as i64,
"expiry_hours is out of range ({hours}): a DateTime cannot represent it"
);
let exp = Some(chrono::Utc::now() + chrono::Duration::hours(hours));
let exp = args
.get("expiry_hours")
.and_then(|v| v.as_i64())
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
let ucan = gitlawb_core::ucan::Ucan::issue(
&kp,
@@ -1304,26 +1237,23 @@ async fn call_tool(
let token = args["token"].as_str().context("missing 'token'")?;
let ucan =
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
// The same report `gl ucan verify` prints, so the two cannot disagree
// about what "valid" means. In particular the proof chain is walked:
// a leaf with a good signature on a broken chain used to report valid
// here and then fail at import and at the node.
let report = crate::ucan_cmd::VerifyReport::of(&ucan);
Ok(serde_json::to_string_pretty(&report.to_json())?)
}
let sig_valid = ucan.verify_signature().is_ok();
let expired = ucan.is_expired();
let caps: Vec<Value> = ucan
.payload
.att
.iter()
.map(|c| json!({ "with": c.with, "can": c.can }))
.collect();
"ucan_import" => {
let token = args["token"].as_str().context("missing 'token'")?;
// `gl ucan import` with a JSON answer: one set of checks, one store, so
// an MCP-only agent can complete the delegated-push workflow without
// a shell and without a second copy of the rules.
let imported = crate::ucan_cmd::import_delegation(token, dir).await?;
Ok(serde_json::to_string_pretty(&json!({
"owner": imported.owner,
"repo": imported.repo,
"root_issuer": imported.root,
"expires": imported.expires,
"path": imported.path.display().to_string(),
"valid": sig_valid && !expired,
"signature_valid": sig_valid,
"expired": expired,
"issuer": ucan.payload.iss.to_string(),
"audience": ucan.payload.aud.to_string(),
"capabilities": caps,
"expires": ucan.payload.exp,
}))?)
}
@@ -1415,38 +1345,6 @@ async fn resolve_owner(args: &Value, client: &NodeClient) -> Result<String> {
Ok(did.split(':').next_back().unwrap_or(did).to_string())
}
/// Resolve (owner, repo) for the webhook tools, matching the CLI's
/// `resolve_owner_repo_pair` contract: an `owner/repo` string splits first, an
/// explicit `owner` arg wins next, then the signing keypair's short DID. The
/// last resort is the node root DID via `resolve_owner`, reachable only by
/// callers that have no keypair — the unsigned `webhook_list` arm, and an
/// unsigned `webhook_delete` the same way `resolve_owner` always served it —
/// for a bare
/// repo name under a signed caller the path owner must be the caller's DID or
/// the node 403/404s the request under the wrong namespace.
async fn resolve_owner_repo(
args: &Value,
repo: &str,
keypair: &Option<gitlawb_core::identity::Keypair>,
client: &NodeClient,
) -> Result<(String, String)> {
if let Some((o, r)) = repo.split_once('/') {
return Ok((o.to_string(), r.to_string()));
}
if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
return Ok((o.to_string(), repo.to_string()));
}
if let Some(kp) = keypair {
let did = kp.did().to_string();
return Ok((
did.split(':').next_back().unwrap_or(&did).to_string(),
repo.to_string(),
));
}
let owner = resolve_owner(args, client).await?;
Ok((owner, repo.to_string()))
}
/// Parse git pkt-line info/refs response into a list of {ref, sha} objects.
fn parse_info_refs(bytes: &[u8]) -> Value {
let mut refs = Vec::new();
@@ -1824,7 +1722,6 @@ mod tests {
.collect();
assert!(names.contains(&"ucan_delegate"));
assert!(names.contains(&"ucan_verify"));
assert!(names.contains(&"ucan_import"));
}
#[test]
@@ -1907,10 +1804,6 @@ mod tests {
/// follows cursors and, when a guard stops it, says so in the payload.
#[tokio::test]
async fn test_task_list_via_mcp_follows_cursors() {
// The env-mutating suites serialize on test_env's lock; a resolver
// reader that skips it can observe a half-set GITLAWB_KEY mid-race.
// Taking it here (with the key absent) keeps this read deterministic.
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
let mut server = mockito::Server::new_async().await;
let first = server
.mock(
@@ -1948,8 +1841,6 @@ mod tests {
/// cursor, so the model cannot read it as the whole answer.
#[tokio::test]
async fn test_task_list_via_mcp_flags_incomplete_results() {
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Any)
@@ -1980,7 +1871,6 @@ mod tests {
/// A legacy response without pagination metadata is reported as incomplete via MCP.
#[tokio::test]
async fn test_task_list_via_mcp_legacy_response_is_incomplete() {
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Any)
@@ -2009,7 +1899,6 @@ mod tests {
/// A legacy response with limit > 200 stops after 1 page and does not claim completeness via MCP.
#[tokio::test]
async fn test_task_list_via_mcp_legacy_limit_above_page_cap() {
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
let mut server = mockito::Server::new_async().await;
let ids: Vec<String> = (0..200).map(|i| format!("t{i}")).collect();
let tasks_json: Vec<Value> = ids.iter().map(|id| json!({ "id": id })).collect();
@@ -2121,7 +2010,6 @@ mod tests {
/// helper rejects it, so the model sees an invalid argument instead.
#[tokio::test]
async fn test_task_list_via_mcp_rejects_non_positive_limit() {
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
let mut server = mockito::Server::new_async().await;
let m = server
.mock(
@@ -2269,38 +2157,6 @@ mod tests {
assert_eq!(parsed["capability"]["can"], "git/push");
}
/// The same refusal the CLI applies. `test_ucan_delegate_via_mcp` uses a named
/// resource, so it could never catch the MCP arm issuing a wildcard the CLI
/// refuses.
#[tokio::test]
async fn test_ucan_delegate_via_mcp_refuses_a_push_class_wildcard() {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let audience = gitlawb_core::identity::Keypair::generate();
for action in ["git/push", "*", "repo/admin"] {
let err = call_tool(
"ucan_delegate",
json!({
"to": audience.did().to_string(),
"resource": "*",
"action": action,
"expiry_hours": 24,
}),
"http://localhost",
Some(dir.path()),
)
.await
.expect_err("MCP must refuse a push-class wildcard like the CLI does");
assert!(err.to_string().contains("wildcard"), "{action}: {err}");
}
}
#[tokio::test]
async fn test_ucan_verify_via_mcp() {
let kp = gitlawb_core::identity::Keypair::generate();
@@ -2329,204 +2185,6 @@ mod tests {
assert_eq!(parsed["valid"], true);
assert_eq!(parsed["signature_valid"], true);
assert_eq!(parsed["expired"], false);
assert_eq!(parsed["chain_valid"], true);
assert_eq!(parsed["root_issuer"], kp.did().to_string());
}
/// The round-ten P2: `ucan_verify` answered from the leaf alone. A leaf with a
/// good signature sitting on a proof that never named its issuer reported
/// `valid: true` here, and was then refused by import and by the node.
#[tokio::test]
async fn test_ucan_verify_via_mcp_walks_the_proof_chain() {
let alice = gitlawb_core::identity::Keypair::generate();
let bob = gitlawb_core::identity::Keypair::generate();
let eve = gitlawb_core::identity::Keypair::generate();
let node = gitlawb_core::identity::Keypair::generate();
let cap = || {
vec![gitlawb_core::ucan::Capability::new(
"gitlawb://repos/alice/r",
"git/push",
)]
};
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
// Alice grants Bob; Eve presents Alice's grant as if it were hers. Eve's
// own signature is fine — the break is in the chain.
let root = gitlawb_core::ucan::Ucan::issue(&alice, bob.did(), cap(), Some(hour)).unwrap();
let forged =
gitlawb_core::ucan::Ucan::delegate(&eve, node.did(), cap(), Some(hour), &root).unwrap();
assert!(
forged.verify_signature().is_ok(),
"fixture: the leaf signature is valid"
);
let result = call_tool(
"ucan_verify",
json!({"token": forged.encode().unwrap()}),
"http://localhost",
None,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["signature_valid"], true);
assert_eq!(parsed["expired"], false);
assert_eq!(parsed["chain_valid"], false);
assert_eq!(
parsed["valid"], false,
"a broken proof chain must not report valid: {parsed}"
);
assert!(
parsed["chain_error"]
.as_str()
.unwrap_or_default()
.contains("proof chain broken"),
"the reason must be reported: {parsed}"
);
assert!(parsed["root_issuer"].is_null());
}
/// The workflow an MCP-only agent could not finish: it could be issued a
/// delegation and verify it, but not store it where `git-remote-gitlawb`
/// looks. `ucan_import` is `gl ucan import` behind a tool — same checks, same
/// file — so a token the CLI would store lands in the same place, and one the
/// CLI would refuse is refused with the same reason.
#[tokio::test]
async fn test_ucan_import_via_mcp_stores_where_the_helper_looks() {
let dir = tempfile::TempDir::new().unwrap();
let me = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
me.to_pem().unwrap().as_bytes(),
)
.unwrap();
let owner = gitlawb_core::identity::Keypair::generate();
let owner_did = owner.did().to_string();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let token = gitlawb_core::ucan::Ucan::issue(
&owner,
me.did(),
vec![gitlawb_core::ucan::Capability::new(
format!("gitlawb://repos/{owner_did}/ci-repo"),
"git/push",
)],
Some(hour),
)
.unwrap()
.encode()
.unwrap();
let result = call_tool(
"ucan_import",
json!({"token": token}),
"http://localhost",
Some(dir.path()),
)
.await
.expect("a delegation addressed to this identity must import");
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["owner"], owner_did);
assert_eq!(parsed["repo"], "ci-repo");
assert_eq!(parsed["root_issuer"], owner_did);
assert_eq!(parsed["expires"], hour.timestamp());
let stored = crate::ucan_cmd::delegation_path(dir.path(), &owner_did, "ci-repo");
assert_eq!(parsed["path"], stored.display().to_string());
assert_eq!(
std::fs::read_to_string(&stored).unwrap(),
token,
"the token must be stored byte-for-byte where the helper reads it"
);
// Same refusals as the CLI: a token addressed to someone else is unusable
// here however well-formed it is.
let stranger = gitlawb_core::identity::Keypair::generate();
let misaddressed = gitlawb_core::ucan::Ucan::issue(
&owner,
stranger.did(),
vec![gitlawb_core::ucan::Capability::new(
format!("gitlawb://repos/{owner_did}/ci-repo"),
"git/push",
)],
Some(hour),
)
.unwrap()
.encode()
.unwrap();
let err = call_tool(
"ucan_import",
json!({"token": misaddressed}),
"http://localhost",
Some(dir.path()),
)
.await
.expect_err("a token addressed to another identity must be refused");
assert!(err.to_string().contains("addressed to"), "{err}");
}
/// The round-ten P2: an omitted `expiry_hours` issued an unbounded token, which
/// import and the node both refuse for push. The default is now the CLI's.
#[tokio::test]
async fn test_ucan_delegate_via_mcp_defaults_to_the_cli_expiry() {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let audience = gitlawb_core::identity::Keypair::generate();
let before = chrono::Utc::now();
let result = call_tool(
"ucan_delegate",
json!({
"to": audience.did().to_string(),
"resource": "gitlawb://repos/test/repo",
"action": "git/push",
}),
"http://localhost",
Some(dir.path()),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let expires = parsed["expires"]
.as_i64()
.expect("an omitted expiry_hours must still produce an expiring token");
let expected = (before
+ chrono::Duration::hours(crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64))
.timestamp();
assert!(
(expires - expected).abs() <= 60,
"expiry must default to the CLI's {} hours, got {expires} vs {expected}",
crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS
);
let token = gitlawb_core::ucan::Ucan::decode(parsed["token"].as_str().unwrap()).unwrap();
assert!(
token.chain_lifetime_is_bounded(),
"the token must satisfy the rule import and the node apply"
);
// A token that has already expired grants nothing, so the request is a
// mistake to report, not a token to mint.
for bad in [json!(0), json!(-5)] {
let err = call_tool(
"ucan_delegate",
json!({
"to": audience.did().to_string(),
"resource": "gitlawb://repos/test/repo",
"action": "git/push",
"expiry_hours": bad,
}),
"http://localhost",
Some(dir.path()),
)
.await
.expect_err("a non-positive expiry must be refused");
assert!(err.to_string().contains("expiry_hours"), "{bad}: {err}");
}
}
#[tokio::test]
@@ -2824,27 +2482,11 @@ mod tests {
}
#[tokio::test]
async fn test_webhook_list_without_keypair_surfaces_node_denial() {
// No identity loaded and no explicit owner → the tool falls back to the
// node DID for the path and still issues the GET unsigned, so the node
// itself answers the denial (list_webhooks 401s a headerless caller)
// rather than the client inventing a failure before contact.
let mut server = mockito::Server::new_async().await;
async fn test_webhook_list_without_keypair_errors() {
// No identity loaded and no explicit owner → the tool must error rather
// than fall back to the node DID and issue an unsigned, mis-scoped GET.
let server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty: no identity.pem
let _root = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"did":"did:key:z6MkNodeOperator"}"#)
.create_async()
.await;
let _hooks = server
.mock("GET", "/api/v1/repos/z6MkNodeOperator/myrepo/hooks")
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"signature required"}"#)
.create_async()
.await;
let err = call_tool(
"webhook_list",
@@ -2853,13 +2495,8 @@ mod tests {
Some(dir.path()),
)
.await
.expect_err("unsigned list must surface the node denial");
let msg = err.to_string();
assert!(
msg.contains("401") || msg.contains("signature required"),
"got: {msg}"
);
_hooks.assert_async().await;
.expect_err("must error without an identity");
assert!(err.to_string().contains("no identity found"), "got: {err}");
}
#[tokio::test]
@@ -2943,96 +2580,10 @@ mod tests {
}
#[test]
fn test_tool_count_matches_the_shipped_list() {
fn test_tool_count_is_42() {
let tools = tool_definitions();
let count = tools.as_array().unwrap().len();
// 41 with `ucan_import`. Bump this when a tool is added or removed; the
// point is that no tool is added without someone noticing.
assert_eq!(count, 41, "expected 41 tools, got {count}");
}
}
#[cfg(test)]
mod ucan_show_tests {
use super::*;
/// The MCP tool and `gl ucan show` answer the same question and must answer it
/// in the same shape — decoded fields, not the file. Returning the file verbatim
/// also handed back the bootstrap token, which an MCP response carries much
/// further than a terminal does.
#[tokio::test]
async fn ucan_show_returns_decoded_fields_and_withholds_the_token() {
let dir = tempfile::TempDir::new().unwrap();
let issuer = gitlawb_core::identity::Keypair::generate();
let audience = gitlawb_core::identity::Keypair::generate();
let token = gitlawb_core::ucan::Ucan::issue(
&issuer,
audience.did(),
vec![gitlawb_core::ucan::Capability::new(
"gitlawb://repos/z6MkAbc/myrepo",
gitlawb_core::ucan::caps::GIT_PUSH,
)],
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
)
.unwrap()
.encode()
.unwrap();
// The envelope shape every writer produces.
std::fs::write(
dir.path().join("ucan.json"),
serde_json::to_string_pretty(&json!({
"ucan": token,
"node": "https://node.gitlawb.com",
"did": issuer.did().to_string(),
"saved_at": "2026-08-18T00:00:00Z",
}))
.unwrap(),
)
.unwrap();
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
.await
.expect("ucan_show must read the envelope every writer produces");
let v: serde_json::Value = serde_json::from_str(&out).expect("must be JSON");
assert_eq!(v["issuer"], issuer.did().to_string());
assert_eq!(v["audience"], audience.did().to_string());
assert_eq!(v["capabilities"][0]["can"], "git/push");
assert_eq!(v["expired"], false);
assert_eq!(v["signature_valid"], true);
// NOT `!out.contains(&token)`: the token is itself JSON, so embedding it in
// a pretty-printed response escapes every quote and that assertion passes
// whether or not the token leaked. The signature is a bare base64 string that
// survives escaping unchanged, so it is the substring that actually proves
// absence.
let sig = serde_json::from_str::<serde_json::Value>(&token).unwrap()["s"]
.as_str()
.expect("a UCAN carries its signature in `s`")
.to_string();
assert!(!sig.is_empty());
assert!(
!out.contains(&sig),
"the bootstrap token must not be returned to an MCP caller"
);
assert!(
serde_json::from_str::<serde_json::Value>(&out)
.unwrap()
.get("ucan")
.is_none(),
"no field may carry the token itself"
);
}
/// An unregistered agent gets a usable message, not a decode error.
#[tokio::test]
async fn ucan_show_reports_no_saved_ucan() {
let dir = tempfile::TempDir::new().unwrap();
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
.await
.unwrap();
assert!(out.contains("No UCAN saved"), "got: {out}");
assert_eq!(count, 40, "expected 40 tools, got {count}");
}
// ── Gated read arms surface node denials, not fabricated results (#123) ──
@@ -3610,65 +3161,4 @@ mod ucan_show_tests {
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
#[tokio::test]
async fn resolve_owner_repo_splits_slash_form() {
let server = mockito::Server::new_async().await;
let (owner, name) = resolve_owner_repo(
&json!({}),
"alice/proj",
&None,
&NodeClient::new(server.url(), None),
)
.await
.unwrap();
assert_eq!((owner.as_str(), name.as_str()), ("alice", "proj"));
}
#[tokio::test]
async fn resolve_owner_repo_owner_arg_beats_keypair() {
let server = mockito::Server::new_async().await;
let kp = gitlawb_core::identity::Keypair::generate();
let (owner, name) = resolve_owner_repo(
&json!({"owner": "bob"}),
"proj",
&Some(kp.clone()),
&NodeClient::new(server.url(), None),
)
.await
.unwrap();
assert_eq!((owner.as_str(), name.as_str()), ("bob", "proj"));
}
#[tokio::test]
async fn resolve_owner_repo_bare_repo_uses_keypair_did() {
let mut server = mockito::Server::new_async().await;
// A root mock answering with a different DID must never be consulted:
// under a signed caller the path owner is the keypair's short DID.
let _m = server
.mock("GET", "/")
.with_status(200)
.with_body(r#"{"did":"did:key:z6MkNodeOperator"}"#)
.expect(0)
.create_async()
.await;
let kp = gitlawb_core::identity::Keypair::generate();
let short = kp
.did()
.to_string()
.split(':')
.next_back()
.unwrap()
.to_string();
let (owner, name) = resolve_owner_repo(
&json!({}),
"proj",
&Some(kp),
&NodeClient::new(server.url(), None),
)
.await
.unwrap();
assert_eq!((owner.as_str(), name.as_str()), (short.as_str(), "proj"));
_m.assert_async().await;
}
}
+10 -2
View File
@@ -168,8 +168,16 @@ 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 = crate::identity::key_path_for(dir.as_deref())?;
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
@@ -182,7 +190,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
}
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
let path = crate::identity::key_path_for(dir.as_deref())?;
let path = identity_dir(dir).join("identity.pem");
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: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
+6 -4
View File
@@ -328,10 +328,12 @@ pub async fn cmd_unstake(
// ── Helpers ─────────────────────────────────────────────────────────────────
fn load_did(dir: Option<PathBuf>) -> Result<String> {
// 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 base = dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let path = base.join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+35 -75
View File
@@ -12,7 +12,7 @@ use serde_json::{json, Value};
use std::io::{self, Write};
use std::path::PathBuf;
use crate::http::{sanitize_node_msg, NodeClient};
use crate::http::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: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -39,25 +39,19 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!("and create your first repository.");
println!();
// 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())?;
let dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
// ── Step 1: Identity ──────────────────────────────────────────────────
println!("── Step 1: Identity ─────────────────────────────────────────────────");
println!();
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
let pem_path = dir.join("identity.pem");
let keypair = if pem_path.exists() {
// `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()) {
match load_keypair_from_dir(Some(&dir)) {
Ok(kp) => {
let did = kp.did();
println!(" ✓ Identity already exists");
@@ -65,17 +59,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!();
kp
}
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()
);
Err(_) => {
println!(" Identity file exists but is unreadable. Regenerating...");
generate_identity(&dir)?
}
}
} else {
println!(" No identity found. Generating a new Ed25519 keypair...");
generate_identity(&dir, &pem_path)?
generate_identity(&dir)?
};
let did = keypair.did().to_string();
@@ -108,51 +99,27 @@ 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("");
// 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)?;
if !ucan.is_empty() {
std::fs::create_dir_all(&dir)?;
let record = json!({
"ucan": ucan,
"node": args.node,
"did": did,
"saved_at": chrono::Utc::now().to_rfc3339(),
});
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 {
println!(
" \u{00d7} Registration returned no UCAN (unexpected response body)"
);
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
}
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());
println!();
}
Ok(resp) => {
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}): {}",
sanitize_node_msg(msg)
);
println!(" ✗ Registration failed ({status}): {msg}");
println!(" You can retry with: gl register --node {}", args.node);
println!();
// Non-fatal — continue to repo creation
@@ -238,10 +205,7 @@ 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}): {}",
sanitize_node_msg(msg)
);
println!(" ✗ Repo creation failed ({status}): {msg}");
println!(
" Try manually: gl repo create {repo_name} --node {}",
args.node
@@ -262,27 +226,23 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────
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()))?;
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
std::fs::create_dir_all(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");
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)
}
})?;
#[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())?;
}
let did = keypair.did();
println!(" ✓ Generated new identity");
+20 -80
View File
@@ -1,15 +1,14 @@
//! `gl register` — register this agent identity with a Twigpine node.
//!
//! Sends a signed POST /api/register request and saves the returned bootstrap
//! UCAN token as `ucan.json` beside the identity key, where the other commands
//! look for it.
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
use anyhow::{Context, Result};
use clap::Args;
use serde_json::json;
use std::path::PathBuf;
use crate::http::{sanitize_node_msg, NodeClient};
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
@@ -34,7 +33,7 @@ pub struct RegisterArgs {
#[arg(long)]
pub model: Option<String>,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -65,20 +64,17 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
// Save bootstrap UCAN
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
let saved_to = if ucan.is_empty() {
None
} else {
let path = ucan_path(args.dir.as_deref())?;
if !ucan.is_empty() {
let ucan_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(),
});
crate::secret_file::write(&path, serde_json::to_string_pretty(&record)?.as_bytes())?;
tracing::debug!("saved UCAN to {}", path.display());
Some(path)
};
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
tracing::debug!("saved UCAN to {}", ucan_path.display());
}
let trust = payload
.get("trust_score")
@@ -94,39 +90,25 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
.unwrap_or("");
println!();
println!(" {}", sanitize_node_msg(message));
println!(" {message}");
println!(" Trust score: {trust:.2}");
println!(" UCAN expires: {}", sanitize_node_msg(expires));
println!(" UCAN expires: {expires}");
println!();
// 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.");
}
}
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
println!(" You are now a verified agent on the Twigpine network.");
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 = 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()))?;
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)?;
Ok(base.join("ucan.json"))
}
@@ -173,48 +155,6 @@ mod tests {
assert_eq!(content["node"].as_str().unwrap(), server.url());
}
/// `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,
})
.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();
-212
View File
@@ -1,212 +0,0 @@
//! 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
);
}
}
+41 -1885
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: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Node URL to query for registration info
+11 -110
View File
@@ -145,11 +145,9 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
## Owner-only push
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
The node requires the authenticated pusher to be the repo owner on **every**
branch. A push whose authenticated DID is not the repo owner is rejected with
HTTP 403 before any ref update is applied. The owner is matched in both the full
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
This is on by default, and the default is the point. The node authenticates every
@@ -159,25 +157,6 @@ 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
@@ -191,94 +170,16 @@ 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.
- **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.
### Caution: delegated and CI keys are non-owners
### Delegating push to a CI agent
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.
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.
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.
---
-221
View File
@@ -1,221 +0,0 @@
#!/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
@@ -1,121 +0,0 @@
#!/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"