Compare commits

..
Author SHA1 Message Date
Kevin CodexandOpenClaude f23e35e188 feat(infra): Fly config for node3 on the public OSS build
Deploys node3.gitlawb.com (Fly app gitlawb-node-3, nrt) from this repo's
Dockerfile instead of its old private source. Canary for migrating the
bootstrap nodes; node + node2 follow.

- region nrt, reuses volume gitlawb_data (identity + repos) and the
  attached Postgres (DATABASE_URL secret)
- corrects PUBLIC_URL to https://node3.gitlawb.com, bootstrap peers to
  node + node2 (not self), drops vestigial GITLAWB_DB (v0.3.9 is
  Postgres-only)
- 1024MB (was 256MB — too small for v0.3.9)

Migration note: the existing volume was created by the old root-based
image, so /data was root-owned; the public image runs as uid 1000 and
needed a one-time 'chown -R 1000:1000 /data'. node + node2 will need the
same until the image entrypoint self-heals ownership.

Co-Authored-By: OpenClaude <[email protected]>
2026-06-19 00:54:18 +08:00
164 changed files with 2499 additions and 97054 deletions

No files matched your search

-38
View File
@@ -1,38 +0,0 @@
# cargo-audit configuration.
#
# cargo-audit reads its config from .cargo/audit.toml (relative to the working
# directory) or ~/.cargo/audit.toml. It does NOT read a repo-root audit.toml,
# and there is no CLI flag to point at a config file (`-f` is the lockfile path).
# This file lives at .cargo/audit.toml for that reason.
#
# The audit job stays hard-fail: only the advisories explicitly listed below are
# suppressed, so any NEW advisory still breaks CI. Each entry below has no
# available upstream fix; everything fixable is fixed in Cargo.lock instead.
#
# Tracking: the hickory ignores are reachable accepted risk. A scheduled re-check
# (.github/workflows/audit-schedule.yml) runs without these ignores and fails if
# the lockfile moves off the pinned vulnerable versions while the ignores remain.
[advisories]
ignore = [
# hickory-proto 0.25.2 (pulled by libp2p-dns 0.44 -> hickory-resolver 0.25).
# REACHABLE: the node wraps its QUIC transport in the system DNS resolver
# (crates/gitlawb-node/src/p2p/mod.rs:238), so a malicious DNS response can
# trigger these DoS paths. No fix is available: the latest libp2p-dns on
# crates.io is 0.44 and pins hickory 0.25; the fix requires hickory >=0.26.1.
# REMOVE both once libp2p-dns ships a release using hickory 0.26 (verified
# 2026-06-21: cargo update -p hickory-proto locks 0 packages; still 0.25.2).
# Tracking: https://github.com/Gitlawb/node/issues/76
"RUSTSEC-2026-0118", # hickory NSEC3 closest-encloser unbounded loop
"RUSTSEC-2026-0119", # hickory O(n^2) name-compression CPU exhaustion
# rsa 0.9.10 (Marvin timing sidechannel). Present in Cargo.lock because sqlx
# resolves sqlx-mysql unconditionally, and sqlx-mysql depends on rsa. cargo
# audit scans the lockfile, so it flags rsa even though it is NOT linked into
# our binary: sqlx is built postgres-only (crates/gitlawb-node/Cargo.toml),
# so the MySQL RSA auth path is never compiled and there is no RSA oracle to
# attack. No upstream fix exists regardless. REMOVE this ignore if sqlx is
# ever built with the `mysql` feature, or any other consumer of rsa enters
# the build, at which point it becomes a real reachable advisory.
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
]
-253
View File
@@ -21,26 +21,6 @@ GITLAWB_REPOS_DIR=/data/repos
# When using the bundled docker-compose, this is wired automatically.
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
# ── Database pool & startup resilience ────────────────────────────────────
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
# connections open lazily. Size against the DB server's max_connections,
# remembering admin tooling opens its own pool. Each concurrent write pins one
# connection for its whole duration (the connection-affine advisory lock), so the
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
# headroom — keep this comfortably above that (default 48 for pushes 32).
GITLAWB_DB_MAX_CONNECTIONS=48
# Seconds a request waits for a pool connection before failing with 503.
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
# generous enough for a peer instance to finish migrating (the migration
# advisory lock is held across a deploy); on expiry the attempt is retried.
GITLAWB_DB_CONNECT_TIMEOUT_SECS=60
# Startup retry backoff while the database is unavailable: first delay and
# ceiling, in seconds. The node stays up serving 503s (with a truthful
# /ready) instead of crash-looping.
GITLAWB_DB_RETRY_INITIAL_SECS=5
GITLAWB_DB_RETRY_MAX_SECS=60
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
# Get a JWT at https://app.pinata.cloud/developers/api-keys
GITLAWB_PINATA_JWT=
@@ -92,24 +72,6 @@ GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
# rolling upgrades so existing live nodes can still communicate.
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# Require the authenticated pusher to be the repo owner on git-receive-pack.
# A valid did:key signature is authentication, not authorization: anyone can
# mint a key, derive its DID and sign, so with this off every signed caller may
# push to every repository, private ones included. On by default.
#
# Set to false only for a rolling upgrade whose pushers are not yet the repo
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
# verified but not yet honored for authorization, so they cannot push while this
# is on. See docs/RUN-A-NODE.md.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Require a `nonce` parameter on signatures that authorize a write. Without one
# the replay ledger keys on the signing-string hash, which rejects a repeated
# byte-identical mutation inside the same second. Keep false until every client
# signs a nonce; with this and GITLAWB_REQUIRE_SIGNED_PEER_WRITES both true, a
# peer still on a pre-nonce binary can no longer write to this node.
GITLAWB_REQUIRE_SIGNATURE_NONCE=false
# Comma-separated libp2p multiaddrs.
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
GITLAWB_P2P_BOOTSTRAP=
@@ -122,221 +84,6 @@ GITLAWB_PUBLIC_READ=true
# Maximum git smart-HTTP pack request size, in bytes.
GITLAWB_MAX_PACK_BYTES=2147483648
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
# worker and, on push, the repo write lock. Also bounds both info/refs
# advertisements, the withheld-blob pack build, and the push-side candidate
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
# On the path-scoped upload-pack path the withheld-blob classification walk and
# the pack serve share ONE deadline, so this value bounds their COMBINED duration
# rather than giving each stage a full budget: a walk that consumes it leaves the
# serve nothing and the clone gets a 504. Serving large path-scoped repos may
# need a higher value here than when each stage was budgeted separately.
# Must be 1..=3153600000 (100 years): the node derives deadlines from this value,
# and a larger one cannot be represented. Default 600.
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
# Max seconds the storage-ACQUISITION phase of a served git op may run before the
# request is shed with a 503, separate from the git-run timeout above. A
# concurrency permit is taken before this phase and GITLAWB_GIT_SERVICE_TIMEOUT_SECS
# only starts once git spawns, so without this a stalled backend (a hung Tigris
# HEAD/GET, or a hung pg advisory-lock iteration on push) pins the permit and drains
# the pool until every later request 503s. On expiry the permit is released
# (fail-closed). Kept separate because acquisition and git execution are distinct
# cost centers. Must be positive; set very large to effectively disable. Default 30.
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS=30
# Max concurrent git READ ops (upload-pack + the upload-pack info/refs
# advertisement) served at once, a global pool separate from the push pool below.
# The anon receive-pack info/refs advertisement has its OWN pool (see below), not
# this one. Over-cap sheds a clean 503 + Retry-After. Anonymous reads draw from
# here, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER (below) so one
# caller cannot monopolize the pool. Default 128.
GITLAWB_MAX_CONCURRENT_GIT_OPS=128
# Max concurrent git-receive-pack (push) POST operations, in a pool separate from
# the read pool (GITLAWB_MAX_CONCURRENT_GIT_OPS) so anonymous reads cannot shed an
# authenticated push at admission. The anon receive-pack info/refs advertisement
# runs in a SEPARATE pool of the same size (disjoint from this one), so an
# advertisement flood cannot shed a push either. Over-cap sheds a 503 +
# Retry-After. Default 32.
GITLAWB_MAX_CONCURRENT_GIT_PUSHES=32
# Max pushes PARKED at once waiting for the per-repo write lease of the SAME repo.
# A parked push holds its already-buffered pack body in memory, so this bounds that
# memory for a hot repo. Past the cap the newest push sheds a clean 503 +
# Retry-After ("repo is busy with another push") instead of joining the queue; a
# push to a DIFFERENT repo is never affected. The lease holder is not counted.
# Raising GITLAWB_MAX_CONCURRENT_GIT_PUSHES does not raise this; set it explicitly.
# Default 8.
GITLAWB_REPO_LEASE_MAX_WAITERS=8
# Max concurrent post-push pin loops (IPFS + Pinata pin_new_objects) across all
# repos. The per-repo encrypt-task set caps the pin-task COUNT; this caps how many
# of those loops RUN CONCURRENTLY, and so how much MB-scale object-id list memory is
# held WHILE BEING PINNED. It does not bound retained memory in general: on the local
# IPFS path the caller builds its list before this pool is entered, so a loop parked
# waiting for a permit still holds its full list, and how many park is capped only
# per repo. Do not size memory from this knob alone. A loop DEFERS (waits) when the
# pool is full, never drops a pin. Default 8.
GITLAWB_MAX_CONCURRENT_PIN_TASKS=8
# Max concurrent read ops (upload-pack + the upload-pack info/refs advertisement)
# a single caller may hold, so one caller cannot monopolize the read pool. Keyed
# on the resolved SOURCE IP, never the DID: a signature does not move a caller off
# this cap. The source-IP key is only as granular as GITLAWB_TRUSTED_PROXY below:
# left unset, a node behind an edge/NAT keys all callers on the edge IP and this
# collapses to one global cap. Set GITLAWB_TRUSTED_PROXY for per-client keying; a
# high-fanout caller (CI behind one NAT) then needs the operator to raise this.
# Default 16.
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
# Two further per-source concurrency caps exist on the PUSH side but have NO
# environment variable of their own: both are DERIVED as
# GITLAWB_MAX_CONCURRENT_GIT_PUSHES / 8, with a floor of 1. They are not settable
# independently, so raising the push pool raises both proportionally, and the
# default push pool of 32 gives each a cap of 4.
# * The anon receive-pack info/refs advertisement cap: one source IP may hold at
# most this many slots in the dedicated advert pool, so saturating that pool
# takes ~8 distinct source IPs.
# * The authenticated receive-pack POST cap: one source IP may hold at most this
# many WRITE-pool slots. This one is load-bearing for push availability, since
# it is acquired before the global write permit: without it, one host minting
# disposable did:key identities could open enough slow pushes to monopolize the
# write pool and 503 every other source (the push rate limiter caps arrival
# rate, not in-flight concurrency, and owner enforcement can be turned off for
# a rolling upgrade).
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
# Max receive-pack requests (info/refs advertisement + push POST) per client
# IP per hour. 0 disables. Default 600.
GITLAWB_PUSH_RATE_LIMIT=600
# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
# so it is bounded to keep a permissionless caller from fanning out unbounded
# concurrent walks and exhausting blocking-pool threads + PIDs.
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
# the served-git pools). Over-cap sheds a 503. Default 32.
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
# Default 4.
GITLAWB_IPFS_WALK_PER_SOURCE=4
# Max legacy (NULL-provenance) repos probed per single /ipfs request, bounding the
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
# truncated scan sheds a retryable 503, never a false 404. Default 256.
GITLAWB_IPFS_MAX_LEGACY_PROBES=256
# Max repo ROWS one /ipfs request's legacy scan may read from the database. The probe
# ceiling above only counts once a probe runs, and quarantined or private repos are
# denied before that, so without this an all-denying inventory paged the whole repo
# table for one anonymous request. A truncated scan sheds a retryable 503 carrying a
# sealed `continuation` token; echo it as ?scan= to resume, so a holder buried past the
# ceiling is reachable in ceil(repos / ceiling) + 1 requests. Raising this also raises
# the per-caller /ipfs work allowance, since each page is charged to it. Lowering it
# sharpens a coarse oracle: laddering to the end reveals the node's total repo count to
# within one ceiling. Default 2048.
GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS=2048
# Max EXPENSIVE path-scope visibility walks per single /ipfs request (only a
# blob in a path-scoped repo costs a full-history walk). Over-cap repos are
# skipped without a verdict and the scan continues; if the object is then found
# nowhere the request sheds a retryable 503 instead of a false 404. The effective
# cap is the tighter of this value and the node's internal per-request ceiling of
# 17, so values above 17 have no effect; lower values do tighten it. Default 64.
GITLAWB_IPFS_MAX_REPOS_WALKED=64
# Ceiling on repos one /ipfs request may VISIT past the visibility gate. Each
# visit costs a repo acquire — on a Tigris cache miss a full archive download,
# so this is also the worst-case object-store fetch count per request — plus a
# cat-file probe. On exhaustion the scan stops and sheds a retryable 503.
# Default 1024.
GITLAWB_IPFS_MAX_REPO_VISITS=1024
# Absolute wall-clock budget for one admitted /ipfs request's acquire+walk
# lifetime (all stages of the whole scan). Per-stage clamps bound the acquire
# and walk stages to the remaining budget, and no stage starts once it is
# exhausted (the scan then sheds a retryable 503). The object-type probe and
# content-read cat-file subprocesses are budget-checked before starting AND each
# run under their own deadline (the lesser of GITLAWB_GIT_SERVICE_TIMEOUT_SECS and
# the remaining budget), reaped via process-group teardown, so a hung cat-file
# cannot hold the request's walk slot past it. One hang path is still unbounded:
# the probe's object-store readability check is a plain filesystem sweep with
# nothing to reap, so a wedged filesystem can hold the slot past the deadline.
# Must be 1..=3153600000 (100 years): the node derives an Instant deadline from
# this value, and a larger one cannot be represented. Default 600.
GITLAWB_IPFS_REQUEST_BUDGET_SECS=600
# Shorter budget (seconds) for the pre-walk CID resolve: the lookup that maps a
# requested CID to its git oid(s), which runs while the scarce walk admission is
# already held. A well-formed CID with no pin row does no probe and no walk work,
# so without this a stalled lookup could hold a walk slot for the whole request
# budget while nothing walked. The effective deadline is the lesser of this and
# the remaining request budget; walk and probe work stay on the request budget,
# so a slow but progressing scan is never shed by it.
# Must be 1..=3153600000 (100 years). Default 10.
GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
# from the concurrency caps above). 0 disables. Default 600.
GITLAWB_IPFS_RATE_LIMIT=600
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
# limit alone is bypassed by a did:key farm (one throwaway identity per repo),
# which is how spam-repo floods slip past it and the iCaptcha gate; the per-IP
# 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
# ── Signed-write rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY) ────
# Max requests per client IP per hour on the write routes that require an HTTP
# Signature (tasks, PR merge/close/review/comment, webhooks, branch protection,
# stars, replicas, labels, visibility, agent deregistration, bounties, profile,
# issue close/comment). Every signed attempt spends a consumed_signatures row
# before the handler checks ownership or existence, and any freshly minted
# did:key can sign, so this brake is what bounds the durable writes an
# unregistered caller can force. Separate bucket from the creation, push and
# peer-sync limits. 0 disables. Default 600.
GITLAWB_SIGNED_WRITE_RATE_LIMIT=600
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
# known peers and run at higher frequency, so a generous bucket. Separate from
# the trigger bucket so an unsigned notify flood can't drain trigger's quota.
# 0 disables. Default 600.
GITLAWB_PEER_WRITE_RATE_LIMIT=600
# /api/v1/sync/trigger requires a signature and fans out to every peer per call,
# so it gets a tight bucket. 0 disables. Default 60.
GITLAWB_SYNC_TRIGGER_RATE_LIMIT=60
# Which forwarded header the edge is trusted to set, used to resolve the real
# client IP for the push limiter. One of:
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
# fly — behind Fly's edge (trust Fly-Client-IP).
# x-forwarded-for — behind a single reverse proxy like Caddy/NGINX (trust the
# rightmost X-Forwarded-For hop).
# Only set this when a proxy you control actually fronts the node; trusting a
# forwarded header on a directly-exposed node lets clients spoof the key.
GITLAWB_TRUSTED_PROXY=
# ── Sync ─────────────────────────────────────────────────────────────────
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false
# ── iCaptcha proof-of-intelligence gate ───────────────────────────────────
# Optional gate on create_repo + register: require callers to present an
# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com.
# Default off = fully inert (no behavior change).
# off - gate disabled (default)
# shadow - verify + log would-be rejections, but always allow
# enforce - reject requests without a valid, strong-enough proof
ICAPTCHA_MODE=off
# Optional base64url Ed25519 public key override; if unset it is fetched from
# ICAPTCHA_URL/v1/pubkey at startup. Recommended in production: pinning the key
# removes the startup dependency on the iCaptcha service being reachable and
# avoids silently running inert if that one-time fetch fails.
ICAPTCHA_PUBKEY=
# Minimum proof difficulty level required to pass.
ICAPTCHA_REQUIRED_LEVEL=3
# Base URL of the iCaptcha service.
ICAPTCHA_URL=https://icaptcha.gitlawb.com
-11
View File
@@ -1,11 +0,0 @@
# Ownership for CI, release, and security-sensitive infrastructure.
# A PR that edits these paths requires review from a code owner once
# branch protection's "Require review from Code Owners" is enabled.
/.github/ @beardthelion
/Dockerfile @beardthelion
/Dockerfile.bins @beardthelion
/docker-compose.yml @beardthelion
/release-please-config.json @beardthelion
/.release-please-manifest.json @beardthelion
/SECURITY.md @beardthelion
-66
View File
@@ -1,66 +0,0 @@
name: Bug report
description: Report a defect in a Gitlawb crate or node behavior
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for the report. Please fill in the fields below so we can reproduce the issue.
For security vulnerabilities, do NOT open an issue — follow SECURITY.md instead.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: dropdown
id: crate
attributes:
label: Affected crate
options:
- gitlawb-node
- gl
- git-remote-gitlawb
- gitlawb-core
- not sure / other
validations:
required: true
- type: input
id: version
attributes:
label: Version or commit
description: Output of the binary's `--version`, or the git commit you built.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: OS, Rust version (`rustc --version`), and anything else relevant.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Paste logs or errors. This will be rendered as code, no need for backticks.
render: shell
validations:
required: false
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/Gitlawb/node/blob/main/SECURITY.md
about: Please report security issues privately per our security policy, not as a public issue.
- name: Questions & discussion
url: https://github.com/Gitlawb/node/discussions
about: For usage questions and open-ended discussion.
@@ -1,34 +0,0 @@
name: Feature request
description: Suggest an improvement or new capability
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem / use case
description: What are you trying to do that's hard or impossible today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
validations:
required: false
- type: dropdown
id: protocol
attributes:
label: Does this touch the protocol?
description: Identity, signatures, UCAN, ref certs, or P2P wire formats. Protocol changes need an issue discussion before any PR.
options:
- "No"
- "Yes"
- "Not sure"
validations:
required: true
-63
View File
@@ -1,63 +0,0 @@
<!--
Thanks for contributing to Gitlawb. Keep PRs focused on one change.
Protocol-level changes (identity, signatures, wire format) should start
as an issue before code — see CONTRIBUTING.md.
-->
## Summary
<!-- One or two sentences: what changes and why it matters. -->
## Motivation & context
<!-- What problem does this solve? Link prior discussion if any. -->
Closes #
## Kind of change
- [ ] Bug fix
- [ ] Feature
- [ ] Security fix
- [ ] Docs
- [ ] Tests / CI
- [ ] Refactor (no behavior change)
- [ ] Breaking or protocol change (issue required first)
## What changed
<!-- Bullet the concrete changes. Note the crate(s) touched:
gitlawb-core / gitlawb-node / gl / git-remote-gitlawb -->
-
## How a reviewer can verify
<!-- Commands to run, or repro steps for a bug plus proof it's fixed. -->
```sh
```
## Before you request review
- [ ] Scope is one logical change; no unrelated churn
- [ ] `cargo test --workspace` passes locally
- [ ] New behavior is covered by tests (required for fixes)
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` are clean
- [ ] Commit titles use Conventional Commits (`feat(...)`, `fix(...)`, `docs(...)`)
- [ ] Docs / `.env.example` updated if behavior or config changed (or N/A)
- [ ] Checked existing PRs so this isn't a duplicate
## Protocol & signing impact
<!-- Delete this block if your change doesn't touch the protocol. -->
- [ ] Touches DID / `did:key`, Ed25519 / RFC 9421 signatures, UCAN, ref certs, or P2P wire formats
- [ ] Discussed in an issue before implementation
- [ ] Backward-compatible with existing nodes and previously signed history
## Notes for reviewers
<!-- Anything out of scope, follow-ups, or known limitations. Optional. -->
-88
View File
@@ -1,88 +0,0 @@
name: Scheduled Audit
# Self-expiring suppression check (companion to the PR-blocking audit gate in
# pr-checks.yml). Runs weekly. It re-runs cargo audit WITHOUT the .cargo/audit.toml
# ignore list to surface the full advisory set for visibility, and it hard-fails
# if the lockfile has moved off the pinned vulnerable versions while the ignores
# are still present, i.e. the upstream fix landed but the ignore was not dropped.
on:
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
scheduled-audit:
name: scheduled audit (no ignores)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: audit-schedule
- name: Install cargo-audit (floating; drift canary)
# Deliberately UNPINNED, unlike the blocking gate in pr-checks.yml. This
# job is cron-only (weekly); it is never pull_request- or push-triggered,
# so it NEVER gates a merge no matter what it does. Floating the scanner
# here is the drift canary: if a newer cargo-audit regresses, at worst
# this weekly run reds (the install step below is unguarded) or its
# visibility report changes, and that is exactly the signal that latest
# has moved and the pinned gate should not be bumped yet. The scan result
# itself never fails the run (the "Full audit report" step is `|| true`
# and the only other hard-fail is a Cargo.lock grep). The point is that
# drift surfaces here without ever blocking a PR. Do NOT pin to match
# pr-checks.yml.
run: cargo install --locked cargo-audit
# Full advisory report with NO suppressions. Run from a scratch dir that
# has no .cargo/audit.toml so the ignore list does not apply; never fail
# the build on this step, it is visibility only.
- name: Full audit report (ignores not applied)
run: |
mkdir -p "$RUNNER_TEMP/full-audit"
cp Cargo.lock "$RUNNER_TEMP/full-audit/Cargo.lock"
cd "$RUNNER_TEMP/full-audit"
{
echo '### Full cargo audit (no ignores applied)'
echo '```'
cargo audit -f Cargo.lock || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Drift guard: the hickory ignores are only valid while hickory-proto is
# pinned at 0.25.x. If the lockfile has moved past that, the upstream fix
# is in and the ignores in .cargo/audit.toml must be removed. Fail loudly.
- name: Fail if hickory moved past 0.25.x while ignores remain
run: |
set -euo pipefail
version="$(grep -A1 'name = "hickory-proto"' Cargo.lock | grep '^version' | head -1 | cut -d'"' -f2)"
echo "hickory-proto in Cargo.lock: ${version:-not present}"
ignores_present=false
if grep -q 'RUSTSEC-2026-011' .cargo/audit.toml; then
ignores_present=true
fi
# Drift = the lockfile no longer matches what the ignores assume:
# hickory moved off 0.25.x (fix shipped) OR was removed entirely
# (dead ignore entries left behind). Both must fail.
if [ "$ignores_present" = true ] && { [ -z "${version}" ] || [[ "${version}" != 0.25.* ]]; }; then
echo "::error::hickory-proto is ${version:-absent from Cargo.lock} but the RUSTSEC-2026-0118/0119 ignores are still in .cargo/audit.toml. The upstream fix appears to be available or the dependency is gone; remove the ignores."
exit 1
fi
echo "No drift: ignore list is consistent with the pinned hickory-proto version."
-121
View File
@@ -1,121 +0,0 @@
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
# issue when a push-to-main run of PR Checks ends red, and closes it when a
# subsequent run on main's tip goes green.
#
# Why: two individually-green PRs can break main in combination when the
# second merges on checks that ran against a stale base (semantic merge
# race — #113 + #145 broke test compilation, found only via #229). A red main
# is otherwise silent, and release-please PRs inherit the breakage.
#
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
# commit is no longer main's tip are ignored (a re-run of an old red run
# after a fix has landed must not cry wolf), and a green run on the tip
# closes any open alert.
name: Main red alert
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
branches: [main]
permissions:
issues: write
contents: read # getBranch tip check
# Serialize runs: concurrent completions would race the label/issue lookups
# into duplicates (or 422 on createLabel). queue: max keeps every pending
# completion instead of letting a later (possibly green-skipped) arrival
# displace a pending red alert under burst merges; it is only incompatible
# with cancel-in-progress: true, which we don't use.
concurrency:
group: main-red-alert
cancel-in-progress: false
queue: max
jobs:
track:
name: Track main CI state
# timed_out (the test job runs 45 min against Postgres) and
# startup_failure (malformed workflow) are red too — only cancelled is
# excluded as a deliberate human abort. success runs are needed to close
# the alert once main recovers.
if: >-
github.event.workflow_run.event == 'push' &&
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reconcile the broken-main issue with main's current state
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const label = 'ci-main-red';
// Freshness gate: only the run for main's current tip may open or
// close anything. Re-runs of superseded commits are events about
// the past, not statements about main's state.
const { data: branch } = await github.rest.repos.getBranch({
...context.repo, branch: 'main',
});
if (branch.commit.sha !== run.head_sha) {
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
return;
}
// listForRepo returns PRs carrying the label too — those must
// never be mistaken for the tracking issue.
const open = (await github.rest.issues.listForRepo({
...context.repo, state: 'open', labels: label, per_page: 100,
})).data.filter(item => !item.pull_request);
if (run.conclusion === 'success') {
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
for (const issue of open) {
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number, body,
});
await github.rest.issues.update({
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
});
}
return;
}
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
const body = [
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
'',
`> ${headline}`,
'',
'Likely a semantic merge race: each merged PR was green on a stale base.',
'Fix main before merging anything else — release-please PRs inherit this breakage.',
'',
"This issue closes automatically when a run on main's tip goes green.",
].join('\n');
try {
await github.rest.issues.getLabel({ ...context.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
...context.repo,
name: label,
color: 'b60205',
description: 'CI is failing on main',
});
}
if (open.length > 0) {
await github.rest.issues.createComment({
...context.repo, issue_number: open[0].number, body,
});
} else {
await github.rest.issues.create({
...context.repo,
title: 'CI is red on main',
body,
labels: [label],
});
}
+5 -294
View File
@@ -2,17 +2,6 @@ name: PR Checks
on:
pull_request:
# Run in the merge queue so a PR is re-validated against the true merged
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
# and the lock-sync job), and GitHub's recursion guard means those pushes
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
# the release branch to attach check runs to its head. Read-only workflow
# (contents: read), so the dispatch surface adds no privilege.
workflow_dispatch:
push:
branches:
- main
@@ -20,25 +9,18 @@ on:
permissions:
contents: read
# Cancel superseded runs on the same PR/branch; never cancel runs on main.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
fmt-clippy:
name: fmt + clippy
fmt-clippy-test:
name: fmt + clippy + test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
@@ -53,278 +35,7 @@ jobs:
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings
release-tag-validation:
name: release tag validation
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Test release tag resolver
run: scripts/test-resolve-release-tag.sh
test:
name: test (${{ matrix.toolchain }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
toolchain: [stable, beta]
# Beta is informational: an upstream beta regression should warn, not block merges.
continue-on-error: ${{ matrix.toolchain == 'beta' }}
# Postgres for the DB-backed integration tests (#[sqlx::test] provisions an
# isolated database per test off this connection). The health-check gate keeps
# `cargo test` from racing a not-yet-ready server.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gitlawb_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: ${{ matrix.toolchain }}
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: ${{ matrix.toolchain }}
run: cargo clippy --workspace --all-targets -- -D warnings
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
build-release:
name: build --release
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: release
- name: cargo build --release
run: cargo build --locked --release --workspace
audit:
name: cargo audit
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: audit-0.22.2
- name: Install cargo-audit (pinned)
# Pin the scanner on this BLOCKING gate: an unpinned `cargo install`
# floats to the latest release, so a regressed cargo-audit could red CI
# repo-wide with no code change. Advisory-DB freshness is fetched at scan
# time, independent of the binary, so this does not weaken what the gate
# catches. The weekly Scheduled Audit (audit-schedule.yml) deliberately
# floats to latest as the drift canary; when it shows a newer cargo-audit
# behaving differently, bump this version (and the cache key above) as a
# deliberate maintainer action. If 0.22.2 is ever yanked from crates.io
# this step fails loud until the pin is bumped.
run: |
set -euo pipefail
cargo install --locked --version 0.22.2 cargo-audit
# Assert the pin took effect so an accidental future unpin (or a drift
# off 0.22.2) fails loudly here instead of silently running a different
# scanner. `cargo audit --version` prints "cargo-audit-audit 0.22.2".
installed="$(cargo audit --version)"
echo "cargo-audit installed: $installed"
echo "$installed" | grep -qE '(^| )0\.22\.2($| )' || {
echo "::error::cargo-audit is not the pinned 0.22.2 (got: $installed)"
exit 1
}
# Hard-fail gate. Suppressions live in .cargo/audit.toml (read automatically
# from the repo root), each with no available upstream fix. A green check
# here means "no NEW advisories", not "advisory-clean": surface the active
# ignore list so a reviewer sees exactly what is being accepted.
- name: Show active audit ignores
run: |
{
echo '### cargo audit: accepted advisories (.cargo/audit.toml)'
echo '```toml'
sed -n '/^\[advisories\]/,$p' .cargo/audit.toml
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: cargo audit
run: cargo audit
msrv:
name: MSRV (Rust 1.91)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.91
with:
toolchain: "1.91"
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: msrv
- name: cargo check on MSRV
run: cargo check --locked --workspace --all-targets
docker-build:
name: Docker build smoke test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Build image
run: docker build -t gitlawb-node:ci-test .
- name: Smoke test --version
run: docker run --rm gitlawb-node:ci-test --version
test-windows:
name: test (windows, non-blocking)
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
# test coverage, so run their suites here to make Windows-only regressions
# VISIBLE on the PR without gating merge (#228). These two are the client
# CLIs and depend on no database, so no Postgres service is needed (service
# containers do not run on windows-latest regardless). `git` is preinstalled
# on windows-latest, which the real_git_fetch harness relies on.
continue-on-error: true
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test --locked -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
# dependency reds CI whether or not anyone thought to ban it, which a denylist
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: core-deps-purity
- name: Check gitlawb-core dependency allowlist
run: bash scripts/check-gitlawb-core-deps.sh
# release-please only rewrites a crate's version when the line carries the
# `# x-release-please-version` annotation AND the manifest is listed under
# extra-files in release-please-config.json. Miss either half and the crate
# is skipped silently at release time, freezing at its creation version:
# icaptcha-client was added as a workspace member after the config was last
# written and sat at 0.4.0 through four releases before anyone noticed.
# Exhaustive-by-construction, like the allowlist gate above: the required set
# is derived from `cargo metadata`, so a newly added crate reds CI whether or
# not its author knew this gate existed.
release-versioning:
name: release versioning coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: release-versioning
- name: Check every workspace crate is release-managed
run: bash scripts/check-release-versioning.sh
run: cargo test --workspace
-130
View File
@@ -1,130 +0,0 @@
name: PR Triage
# Runs on pull_request_target, not pull_request: fork PRs get a read-only
# GITHUB_TOKEN under pull_request regardless of the permissions below, so the
# label/comment writes 403. This job never checks out or runs PR head code (it
# only reads context.payload and the files API), so pull_request_target is safe.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: pr-triage-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
triage:
name: Quality-signal triage
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Label and guide
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const prNumber = pr.number;
// Trusted authors bypass triage entirely.
const trusted = ["OWNER", "MEMBER", "COLLABORATOR"];
if (trusted.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is trusted; skipping triage.`);
return;
}
// Labels this workflow owns. Anything here may be added or removed each run.
const MANAGED = ["needs-issue", "needs-description", "needs-tests", "workflow-change"];
const want = new Set();
const body = pr.body || "";
// Linked issue: look for "closes/fixes/resolves #N" or any bare #N reference.
const hasLinkedIssue =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\b[^\n]*#\d+/i.test(body) ||
/#\d+/.test(body);
if (!hasLinkedIssue) want.add("needs-issue");
// Description substance: strip HTML comments and markdown headings, then measure.
const stripped = body
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/^#+.*$/gm, "")
.trim();
if (stripped.length < 50) want.add("needs-description");
// File-based signals.
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNumber, per_page: 100,
});
const names = files.map(f => f.filename);
const isFork = pr.head.repo && pr.head.repo.full_name !== pr.base.repo.full_name;
const touchesWorkflows = names.some(n => n.startsWith(".github/workflows/"));
if (isFork && touchesWorkflows) want.add("workflow-change");
const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs"));
// A test can live in a tests/ dir, a *_test.rs file, or (the common Rust
// pattern) an inline #[test] / #[cfg(test)] block added inside the source file.
// Async tests use a namespaced attribute (#[tokio::test], #[async_std::test],
// possibly with args like #[tokio::test(flavor = ...)]) rather than the bare
// #[test]. Match only on added lines, with the attribute at the start of the
// line after horizontal whitespace, covering bare, cfg(test), namespaced,
// optional leading ::, and raw-identifier (r#test) forms. No head-file fetch
// or lexer: needs-tests is advisory, not a merge gate.
const addsInlineTest = files.some(f =>
f.filename.endsWith(".rs") && f.patch &&
/^\+[ \t]*#\[[ \t]*(?:cfg[ \t]*\([ \t]*test[ \t]*\)|(?:::[ \t]*)?(?:[\w-]+[ \t]*::[ \t]*)*(?:r#)?test\b)/m.test(f.patch));
const touchedTests =
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
if (changedRust && !touchedTests) want.add("needs-tests");
// Reconcile labels: add wanted managed labels, remove managed labels no longer wanted.
const current = (await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber,
})).data.map(l => l.name);
const toAdd = [...want].filter(l => !current.includes(l));
const toRemove = MANAGED.filter(l => current.includes(l) && !want.has(l));
if (toAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd });
}
for (const name of toRemove) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name })
.catch(() => {}); // ignore if already gone
}
// Guidance comment: only for advisory signals, and only once.
const advisory = ["needs-issue", "needs-description", "needs-tests"].filter(l => want.has(l));
if (!advisory.length) return;
const MARKER = "<!-- gitlawb-triage -->";
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
if (comments.some(c => c.body && c.body.includes(MARKER))) {
core.info("Guidance comment already present; not reposting.");
return;
}
const reasons = {
"needs-issue": "Link the issue this addresses (`Closes #123`). For protocol changes, open an issue first.",
"needs-description": "Add a short summary of what changes and why: the template's Summary and Motivation sections.",
"needs-tests": "This changes Rust source but no tests changed. Tests are required for fixes and strongly encouraged for features.",
};
const lines = advisory.map(l => `- ${reasons[l]}`).join("\n");
const comment = [
MARKER,
"Thanks for the contribution. A couple of things will help us review this faster:",
"",
lines,
"",
"See [CONTRIBUTING.md](https://github.com/Gitlawb/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
].join("\n");
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: comment });
+50 -747
View File
@@ -4,34 +4,14 @@ on:
push:
branches:
- main
# Manual backfill: republish an already-cut release to a channel that
# missed it. Fill in only the channel(s) you want — an empty input leaves
# that channel's jobs skipped. npm reuses the existing release's binary
# assets; docker rebuilds the image from the tag and re-tags the manifest.
workflow_dispatch:
inputs:
npm_backfill_tag:
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
required: false
type: string
docker_backfill_tag:
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
required: false
type: string
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Least privilege by default; every job opts in to exactly what it needs.
permissions: {}
jobs:
release-please:
# Skipped on manual dispatch: a backfill must never create or advance a
# release PR as a side effect (npm-publish tolerates the skipped need via
# !cancelled(), and every other job gates on release_created == 'true').
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
if: ${{ github.repository == 'Gitlawb/node' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
@@ -41,204 +21,29 @@ jobs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
pr: ${{ steps.release.outputs.pr }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.3.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
# The release PR bumps the workspace crate versions in the Cargo.tomls but
# release-please cannot update Cargo.lock, so without this job every release
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
# the release branch so the tagged tree is internally consistent.
#
# Runs on every push-triggered release run, not only when release-please
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
# the release PR, so gating on it would leave an already-open release PR
# (opened before this job existed, or untouched by a non-releasable push)
# permanently without the sync and without check runs. The resolve step
# discovers any open release-please PR itself and no-ops when there is none.
sync-release-lock:
name: Sync Cargo.lock on the release PR
needs: release-please
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
# actions: write lets the final step dispatch pr-checks on the release
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
# scope it to one workflow); acceptable here because reaching this job's
# steps already requires write to main. Needed because every push to the
# release branch (release-please's and the lock sync below) is made with
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
# dispatch the release PR's head carries no check runs and the --locked
# gate never validates it.
actions: write
steps:
# Prefer the fresh `pr` output when this run just created/updated the
# release PR; otherwise fall back to querying for an open release-please
# branch (prefix match: the branch carries `--components--` suffixes).
- name: Resolve the open release PR branch
id: resolve
env:
GH_TOKEN: ${{ github.token }}
OWNER: ${{ github.repository_owner }}
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
run: |
branch="$BRANCH_FROM_OUTPUT"
if [ -z "$branch" ]; then
# The branch name alone is not a trust signal. `gh pr list` includes
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
# prefix match by itself lets any fork nominate the branch this job
# checks out, commits to, and dispatches checks on. Require the head
# repo to be THIS repository and the author to be the release-please
# app, so neither a fork nor a same-repo human branch named
# `release-please--*` can be selected. Then take the highest PR
# number: `[0]` depended on gh's default ordering, which is not a
# documented guarantee, and picking a stable one keeps a second
# (component) release PR from making the choice flap between runs.
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
--json headRefName,headRepositoryOwner,number,author \
--jq '[ .[]
| select(.headRepositoryOwner.login == env.OWNER)
| select(.author.login == "app/github-actions")
| select(.headRefName | startswith("release-please--"))
] | max_by(.number).headRefName // empty')
fi
if [ -z "$branch" ]; then
echo "no open release PR; nothing to sync"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- name: Checkout release branch
if: ${{ steps.resolve.outputs.branch != '' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.resolve.outputs.branch }}
# Resolve on a declared toolchain instead of whatever Rust the runner
# image happens to ship, so the lock this job writes comes off the same
# stable channel the other cargo jobs use. Not a claim of identical
# resolution: `stable` still moves between runs, and the MSRV gate
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
# runner image as an undeclared input.
- name: Install Rust toolchain
if: ${{ steps.resolve.outputs.branch != '' }}
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Sync workspace crate versions into Cargo.lock
if: ${{ steps.resolve.outputs.branch != '' }}
run: cargo update --workspace
- name: Commit and push when the lock changed
if: ${{ steps.resolve.outputs.branch != '' }}
run: |
if git diff --quiet Cargo.lock; then
echo "Cargo.lock already in sync"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.lock
git commit -m "chore: sync Cargo.lock with the release version bump"
git push
# Every push to the release branch is made with GITHUB_TOKEN (release-
# please's own pushes and the lock sync above), and events created by
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
# branch to attach check runs to its current head. Unconditional (also on
# the already-in-sync path) because the branch's ORIGINAL head has the
# same no-runs problem. Loud on failure by design: a silent fallback
# would recreate the checkless-head problem, and a job rerun is safe
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
# is the sync commit. Merge-queue note: this validates the branch tip;
# the enforced merge queue's merge_group run still re-validates the true
# merged result before landing.
- name: Dispatch PR checks on the release branch
if: ${{ steps.resolve.outputs.branch != '' }}
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.resolve.outputs.branch }}
run: |
# Every dispatch failure fails the job, including the 422 a release
# branch cut before pr-checks gained its workflow_dispatch trigger
# returns ("does not have a workflow_dispatch trigger", reported
# against the dispatched ref's copy of the file). An earlier revision
# tolerated exactly that 422 on the theory that release-please would
# rewrite the branch and heal it, but a rewrite only happens when a
# later releasable commit lands: an open release PR nobody touches is
# never rewritten. Tolerating it therefore left the lock-sync commit
# this job had just pushed sitting on the release head with no
# --locked validation, which is the precise state this workflow exists
# to prevent. The tolerance is also no longer needed: once this lands,
# every release branch is cut from a main that already carries the
# trigger, so the only branch that could 422 is one open at merge time
# (there is none), and a job rerun after a manual rebase is safe (the
# sync no-ops, the dispatch retries).
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
-f "ref=$BRANCH"
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
# workflow concurrency group, blocked every queued release run behind it.
docker:
name: Build & Push Docker Image (${{ matrix.arch }})
name: Build & Push Docker Image
needs: release-please
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-please is skipped on dispatch, hence !cancelled()).
if: >-
${{ !cancelled() && (
needs.release-please.outputs.release_created == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
) }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
os: ubuntu-latest
platform: linux/amd64
- arch: arm64
os: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.os }}
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
# ghcr requires a lowercase repository path, and unlike metadata-action,
# buildx's `--output name=` does no lowercasing — a mixed-case owner
# makes the digest push fail with "invalid reference format".
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
ref: ${{ needs.release-please.outputs.tag_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@@ -250,130 +55,45 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally (smoke)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: ${{ matrix.platform }}
load: true
tags: gitlawb-node:smoke
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-from: type=gha
# Native runner on both arches, so the built image can always execute.
# Assert the released version so a stale artifact fails here, not in prod.
- name: Smoke test
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
out="$(docker run --rm gitlawb-node:smoke --version)"
echo "$out"
grep -qF "$VERSION" <<<"$out" || {
echo "::error::image --version did not report expected version $VERSION (got: $out)"
exit 1
}
run: docker run --rm gitlawb-node:smoke --version
# Push by digest only — tags are applied once by docker-manifest so a
# half-finished matrix can never publish a partially-tagged image.
# provenance:false keeps each push a plain single-arch manifest, which is
# what imagetools create expects to merge.
- name: Build and push by digest
id: push
- name: Build and push (multi-arch)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
- name: Export digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: docker-digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-manifest:
name: Publish Docker manifest
needs: [release-please, docker]
if: ${{ !cancelled() && needs.docker.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: docker-digest-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
# ghcr requires a lowercase repository path.
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
MAJOR_MINOR="${VERSION%.*}"
digests=""
for f in /tmp/digests/*; do
digests="$digests $IMAGE@sha256:$(basename "$f")"
done
# shellcheck disable=SC2086
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:$MAJOR_MINOR" \
-t "$IMAGE:latest" \
$digests
docker buildx imagetools inspect "$IMAGE:$VERSION"
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Release summary
env:
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
{
echo "## Released $TAG"
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY"
release-binaries:
@@ -381,9 +101,6 @@ jobs:
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ${{ matrix.os }}
# Windows is best-effort: a failure there must not fail the job or block the
# downstream npm/Homebrew jobs that only consume the unix artifacts.
continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }}
permissions:
contents: write
strategy:
@@ -392,24 +109,17 @@ jobs:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
# Native arm64 runner: cross-compiling this target with the glibc
# toolchain broke aws-lc-sys, and native lets the smoke test run.
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
# macos-13 (the last plain Intel image) is retired and queues forever;
# macos-15-intel is GitHub's supported Intel label.
os: ubuntu-latest
- target: x86_64-apple-darwin
os: macos-15-intel
os: macos-13
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-pc-windows-msvc
os: windows-latest
steps:
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
@@ -417,453 +127,46 @@ jobs:
toolchain: stable
targets: ${{ matrix.target }}
# Both musl targets build natively on a matching-arch runner, so the stock
# musl-gcc wrapper is all that's needed (no cross toolchain).
- name: Install musl tools (linux)
if: contains(matrix.target, 'linux-musl')
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
echo "CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: ${{ matrix.target }}
# The gitlawb-node daemon is a Linux/macOS service; on Windows we ship only
# the two CLI binaries. A Windows-only failure must not block the release.
- name: Determine binaries
shell: bash
run: |
BINS="gl git-remote-gitlawb gitlawb-node"
case "${{ matrix.target }}" in *windows*) BINS="gl git-remote-gitlawb" ;; esac
echo "BINS=$BINS" >> "$GITHUB_ENV"
- name: Build
shell: bash
run: |
set -euo pipefail
args=""
for b in $BINS; do args="$args -p $b"; done
cargo build --release --locked --target ${{ matrix.target }} $args
# Run each packaged binary's --version so a broken release artifact fails the
# build instead of shipping. Every target builds on a matching-arch runner,
# so all of them can execute here.
- name: Smoke test binaries
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.release-please.outputs.version }}"
BIN_DIR="target/${{ matrix.target }}/release"
EXE=""
case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac
for bin in $BINS; do
echo "== $bin --version =="
out="$("$BIN_DIR/$bin$EXE" --version)"
echo "$out"
# Each binary prints "<name> <version>"; assert the released version is present.
grep -qF "$VERSION" <<<"$out" || {
echo "::error::$bin --version did not report expected version $VERSION (got: $out)"
exit 1
}
done
run: cargo build --release --target ${{ matrix.target }} -p gl -p git-remote-gitlawb -p gitlawb-node
- name: Package
id: pkg
shell: bash
run: |
set -euo pipefail
TARGET="${{ matrix.target }}"
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}"
BIN_DIR="target/${TARGET}/release"
EXE=""
case "$TARGET" in *windows*) EXE=".exe" ;; esac
# Portable sha256 → "<hash> <file>" so install scripts and brew can parse it.
sha256_file() {
local f="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$f"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$f"
else
local h
h=$(certutil -hashfile "$f" SHA256 | sed -n 2p | tr -d ' \r')
printf '%s %s\n' "$h" "$f"
fi
}
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${{ matrix.target }}"
mkdir -p "dist/$NAME"
for bin in $BINS; do
cp "$BIN_DIR/$bin$EXE" "dist/$NAME/"
done
cp "target/${{ matrix.target }}/release/gl" "dist/$NAME/"
cp "target/${{ matrix.target }}/release/git-remote-gitlawb" "dist/$NAME/"
cp "target/${{ matrix.target }}/release/gitlawb-node" "dist/$NAME/"
cp README.md LICENSE-MIT LICENSE-APACHE "dist/$NAME/"
cd dist
case "$TARGET" in
*windows*)
# 7z is preinstalled on windows-latest runners.
7z a -tzip "$NAME.zip" "$NAME" >/dev/null
sha256_file "$NAME.zip" > "$NAME.zip.sha256"
;;
*)
tar czf "$NAME.tar.gz" "$NAME"
sha256_file "$NAME.tar.gz" > "$NAME.tar.gz.sha256"
;;
esac
tar czf "$NAME.tar.gz" "$NAME"
shasum -a 256 "$NAME.tar.gz" > "$NAME.tar.gz.sha256"
echo "asset=dist/$NAME.tar.gz" >> "$GITHUB_OUTPUT"
echo "checksum=dist/$NAME.tar.gz.sha256" >> "$GITHUB_OUTPUT"
- name: Attach to release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
files: |
dist/*.tar.gz
dist/*.zip
dist/*.sha256
npm-publish:
name: Publish to npm
needs: [release-please, release-binaries]
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
# @gitlawb package must have this repo + workflow (release.yml) configured
# as its trusted publisher on npmjs.com, or publish fails loudly here —
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
# never reached npm.
if: >-
${{ !cancelled() && (
(needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '')
) }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm trusted publishing (OIDC) + provenance
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
# package has a trusted publisher configured; older npm silently falls
# back to (absent) token auth and fails confusingly.
- name: Set up Node 24 + OIDC-capable npm
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "24"
- name: Ensure npm supports trusted publishing
run: |
set -euo pipefail
npm install -g npm@^11.5.1
npm --version
- name: Lay in release binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
set -euo pipefail
# npm platform package -> Rust target triple (unix only; Windows is not
# published to npm).
MAP="
gl-darwin-arm64:aarch64-apple-darwin
gl-darwin-x64:x86_64-apple-darwin
gl-linux-arm64:aarch64-unknown-linux-musl
gl-linux-x64:x86_64-unknown-linux-musl
"
mkdir -p _dl
for entry in $MAP; do
pkg="${entry%%:*}"
target="${entry#*:}"
archive="gitlawb-node-${VERSION}-${target}.tar.gz"
echo "==> $pkg <- $archive"
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$archive" --dir _dl --clobber
tar -xzf "_dl/$archive" -C _dl
src="_dl/gitlawb-node-${VERSION}-${target}"
cp "$src/gl" "npm/packages/$pkg/gl"
cp "$src/git-remote-gitlawb" "npm/packages/$pkg/git-remote-gitlawb"
chmod +x "npm/packages/$pkg/gl" "npm/packages/$pkg/git-remote-gitlawb"
done
- name: Set versions
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
node -e '
const fs = require("fs");
const v = process.env.VERSION;
for (const p of fs.readdirSync("npm/packages")) {
const f = `npm/packages/${p}/package.json`;
const pkg = JSON.parse(fs.readFileSync(f, "utf8"));
pkg.version = v;
if (pkg.optionalDependencies) {
for (const k of Object.keys(pkg.optionalDependencies)) {
pkg.optionalDependencies[k] = v;
}
}
fs.writeFileSync(f, JSON.stringify(pkg, null, 2) + "\n");
}
'
- name: Publish
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
# No token: npm exchanges this job's GitHub OIDC identity with the
# registry (trusted publishing); provenance is attested automatically.
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
# Skip versions already on the registry so a rerun after a partial publish
# is idempotent instead of erroring on the first existing package.
for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do
name="@gitlawb/$pkg"
if npm view "$name@$VERSION" version >/dev/null 2>&1; then
echo "==> $name@$VERSION already published, skipping"
continue
fi
echo "==> npm publish $name@$VERSION"
npm publish "npm/packages/$pkg" --provenance --access public
done
homebrew-bump:
name: Bump Homebrew tap
needs: [release-please, release-binaries]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT
steps:
- name: Guard on secret
id: guard
env:
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
run: |
if [ -z "${HOMEBREW_TAP_PAT:-}" ]; then
echo "::warning::HOMEBREW_TAP_PAT is not set — skipping Homebrew bump."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout tap repo
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: Gitlawb/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_PAT }}
path: tap
persist-credentials: false
- name: Regenerate formula
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
mkdir -p _sums
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern '*.tar.gz.sha256' --dir _sums --clobber
sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; }
SHA_MAC_ARM="$(sha aarch64-apple-darwin)"
SHA_MAC_X64="$(sha x86_64-apple-darwin)"
SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)"
SHA_LNX_X64="$(sha x86_64-unknown-linux-musl)"
mkdir -p tap/Formula
cat > tap/Formula/gl.rb <<EOF
class Gl < Formula
desc "Gitlawb CLI — decentralized git for AI agents and developers"
homepage "https://gitlawb.com"
version "${VERSION}"
license "MIT OR Apache-2.0"
on_macos do
on_arm do
url "${base}/gitlawb-node-${VERSION}-aarch64-apple-darwin.tar.gz"
sha256 "${SHA_MAC_ARM}"
end
on_intel do
url "${base}/gitlawb-node-${VERSION}-x86_64-apple-darwin.tar.gz"
sha256 "${SHA_MAC_X64}"
end
end
on_linux do
on_arm do
url "${base}/gitlawb-node-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
sha256 "${SHA_LNX_ARM}"
end
on_intel do
url "${base}/gitlawb-node-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
sha256 "${SHA_LNX_X64}"
end
end
def install
bin.install "gl"
bin.install "git-remote-gitlawb"
end
def caveats
<<~CAVEATS
oh-my-zsh's git plugin aliases gl='git pull', which shadows this
binary in interactive shells. If \`gl\` prints "fatal: not a git
repository", run:
echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc
CAVEATS
end
test do
assert_match version.to_s, shell_output("#{bin}/gl --version")
end
end
EOF
- name: Commit and push
if: ${{ steps.guard.outputs.enabled == 'true' }}
working-directory: tap
env:
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
VERSION: ${{ needs.release-please.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Stage first so a brand-new (untracked) formula is detected by the no-op check.
git add Formula/gl.rb
if git diff --cached --quiet; then
echo "Formula already up to date."
exit 0
fi
git commit -m "gl ${VERSION}"
# Credentials are not persisted in .git/config; supply the token only for the push.
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Gitlawb/homebrew-tap.git" HEAD:main
web-sync:
name: Sync web (install scripts + version)
needs: [release-please, release-binaries]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT
steps:
- name: Guard on secret
id: guard
env:
WEB_SYNC_PAT: ${{ secrets.WEB_SYNC_PAT }}
run: |
if [ -z "${WEB_SYNC_PAT:-}" ]; then
echo "::warning::WEB_SYNC_PAT is not set — skipping web sync."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout node (release tag)
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
path: node
persist-credentials: false
- name: Checkout web
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: Gitlawb/web
token: ${{ secrets.WEB_SYNC_PAT }}
path: web
persist-credentials: false
# web-sync publishes install.ps1 + version.json, so the Windows ZIP must exist.
# Windows is best-effort for npm/Homebrew (continue-on-error), so verify the
# asset is present before advertising a release the site can't actually serve.
- name: Verify website release assets
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
--jq '.assets[].name' > /tmp/release-assets
for asset in \
"gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip" \
"gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip.sha256"; do
grep -Fxq "$asset" /tmp/release-assets || {
echo "::error::release asset missing: $asset (Windows build likely failed); not syncing web"
exit 1
}
done
- name: Sync and open PR
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
# Canonical install scripts (source of truth lives in the node repo).
cp node/install.sh web/public/install.sh
cp node/install.ps1 web/public/install.ps1
# Single version source the site can read.
printf '{\n "version": "%s",\n "tag": "%s"\n}\n' "$VERSION" "$TAG" > web/public/version.json
# One-time, idempotent drift fixes: drop orphaned binaries, fix stale link.
rm -rf web/public/bin
if [ -f web/public/skill.md ]; then
sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/node/releases#g' web/public/skill.md
fi
cd web
# Stage first so brand-new (untracked) files are caught by the no-op check.
git add -A
if git diff --cached --quiet; then
echo "web already up to date."
exit 0
fi
branch="release-sync/${TAG}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git commit -m "chore: sync gitlawb ${TAG} (install scripts + version)"
# Credentials are not persisted in .git/config; supply the token only for the push.
git push --force "https://x-access-token:${GH_TOKEN}@github.com/Gitlawb/web.git" "HEAD:$branch"
gh pr create --repo Gitlawb/web --head "$branch" \
--title "Sync gitlawb ${TAG}" \
--body "Automated sync from Gitlawb/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \
|| {
# Only swallow the "PR already exists" case; surface auth/API failures
# (a failed gh pr list must not be misread as "PR exists").
pr_count="$(gh pr list --repo Gitlawb/web --head "$branch" --state open --json number --jq 'length')" || exit 1
if [ "$pr_count" != "0" ]; then
echo "PR for $branch already exists; branch was updated."
else
exit 1
fi
}
${{ steps.pkg.outputs.asset }}
${{ steps.pkg.outputs.checksum }}
-40
View File
@@ -1,40 +0,0 @@
name: Stale PRs
on:
schedule:
- cron: "30 1 * * *" # daily at 01:30 UTC
workflow_dispatch:
permissions:
contents: read
pull-requests: write
issues: write
jobs:
stale:
name: Flag and close ignored PRs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
days-before-stale: 14
days-before-close: 7
# Act on PRs only; never on issues.
days-before-issue-stale: -1
days-before-issue-close: -1
# Only PRs the triage step flagged as missing a real description.
# needs-issue is advisory-only (linking an issue is a SHOULD, and many
# legitimate small PRs have none) so it does not drive auto-close.
only-pr-labels: "needs-description"
stale-pr-label: "stale"
exempt-pr-labels: "workflow-change,security,pinned"
remove-stale-when-updated: true
stale-pr-message: >
This PR has been inactive for 14 days and is still missing a description.
It will be closed in 7 days if there's no update. Push a change or leave a
comment to keep it open. No hard feelings, you can reopen anytime.
close-pr-message: >
Closing due to inactivity. Reopen whenever you're ready to update it. See
CONTRIBUTING.md for what helps us review quickly.
ascending: true
+2 -2
View File
@@ -24,5 +24,5 @@ keys/
*.log
.openclaude-profile.json
# Per-operator Fly config (node1 prod config is pulled from Fly, not tracked)
infra/fly/node1.fly.toml
# Local planning / scratch docs (never commit)
docs/superpowers/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.7.1"
".": "0.3.8"
}
-44
View File
@@ -1,44 +0,0 @@
# Contributor guide for coding agents
This file carries the rules a patch author cannot recover from the code alone, for humans and coding agents alike. For build, test, and PR basics, read `CONTRIBUTING.md` first.
## Authorization invariants (the rules PRs most often break)
Every repo-scoped endpoint must bind the caller to an authorization decision before serving or mutating anything. The `known_ungated` allowlist in `authz_guard::every_repo_scoped_handler_is_gated` is empty on main: `list_webhooks`, `list_replicas`, and `list_protected_branches` are gated (`list_webhooks` with read-visibility then owner; the other two with `authorize_repo_read`). Do not re-open those surfaces as exceptions.
- Owner-only mutations (visibility, webhooks, protected branches, merges) are gated against the repo owner. Three gate forms are in use and all are recognized: webhooks and merges call `require_repo_owner` (`crates/gitlawb-node/src/api/mod.rs`), visibility uses its module-local `require_owner`, and protected branches compare inline with `did_matches`. Use the form the surrounding module already uses.
- Not every write is owner-only, and that is by design: `star_repo`/`unstar_repo` bind to the signer (they still require repo read access), `register_replica`/`unregister_replica` bind to the replica's own DID, the bounty actions (claim, submit, approve, cancel, dispute) have their own multi-party rules, and closing a PR or issue is owner-or-author. Do not add owner gates to these.
- Content-serving reads (blobs, trees, raw files) gate through `authorize_repo_read` with the specific path being served, so a withheld subtree is denied even on an otherwise-public repo; repo-level reads (listings) pass `"/"` by the helper's own contract. The source scans below cannot check this argument's value, so review will.
- A route that reaches repo data through a global id, with no repo in its path, or through an extractor shape the scans do not recognize still needs the same gate; the scans cannot see those.
- Read-surface denials must not reveal existence: a caller who may not read a repo (or a withheld subtree) gets the same 404 as a missing repo, never a 403. (Owner-gated mutations currently return 403 after the repo lookup; do not widen that shape to read paths.)
- Two tests in module `authz_guard` (`crates/gitlawb-node/src/api/mod.rs`) scan handler sources and fail on a repo-scoped handler without a recognized gate: `every_in_scope_mutation_has_its_gate` and `every_repo_scoped_handler_is_gated`. If they fail on your handler, add the gate. The second test's `known_ungated` allowlist is empty; never add an entry to it without an open issue and review.
## Adding or changing routes
- A new gated handler needs tests proving its route-specific gate fires: exercise an unauthorized authenticated caller and an anonymous caller where applicable, assert the exact denial status each way, and assert the body leaks nothing. The non-owner tests in `crates/gitlawb-node/src/test_support.rs` show the shape for owner-gated routes.
- A new mutation handler also gets a row in `every_in_scope_mutation_has_its_gate` naming its gate type; the per-handler row is the point of that test.
- A table-driven deny suite covering every deny-bearing route is in review (#194, #195). If it is present in your checkout, add a probe row there for any new deny-bearing route.
## Verifying signatures and certificates
- Derive the verifying key from something outside the artifact you are checking: the node's own DID, a pinned or configured value, a peers lookup. A key read from a field of the object being verified (`Did::from_str(&cert.node_did)`) only proves the artifact is self-consistent, and identities here are permissionless, so anyone can mint a keypair, name it in the artifact, self-sign, and be told it is valid. A mismatch against that anchor has to fail the result, not log a warning.
- A handler whose correct answer is "valid" needs a positive case driven by a trusted, independently anchored artifact, and a rejection case for a well-formed, fully populated forged artifact. Invalid-only suites miss an implementation that rejects everything; a forged artifact that still returns valid is the regression that matters.
- The fields a signature covers are a versioned format. Adding one invalidates every artifact already signed, so give the payload a version, keep a path that verifies the older form, and add a test that verifies an artifact signed before your change.
## Removing a serving path
When a fix removes a path that used to serve content, the same PR must add a test asserting the removed path now denies. The deletion is what turns that test green; without it the path can quietly come back.
## Database migrations
Schema changes are code-defined in `MIGRATIONS` (`crates/gitlawb-node/src/db/mod.rs`). Always append a new versioned entry; never edit an entry that has merged (operators deploy from main). Test runs build the schema from scratch, so editing an applied version stays green in CI while breaking every existing deployment.
## Client behavior (`gl`, `git-remote-gitlawb`)
When a node denies a request, surface the denial to the user. Never render a denial as an empty list or a silent success; that turns an authorization boundary into invisible data loss.
## Toolchain and CI reality
- MSRV is Rust 1.91 (`rust-version` in the workspace `Cargo.toml`). The MSRV CI job runs `cargo check --workspace --all-targets` on exactly 1.91, so check that an API you rely on is stable there; the full test suite runs on stable.
- The PR workflow runs these jobs on every PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
- 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.
-146
View File
@@ -1,146 +0,0 @@
# Changelog
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
### Bug Fixes
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
### Features
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
### Bug Fixes
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
### Features
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
### Bug Fixes
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
### Bug Fixes
* **bounties:** add tests for claim_bounty repo-read gate ([#160](https://github.com/Gitlawb/node/issues/160)) ([#169](https://github.com/Gitlawb/node/issues/169)) ([6bafaa6](https://github.com/Gitlawb/node/commit/6bafaa6dc5f05c0dcd61c708397afddbcf8c2e3f))
* **deps:** bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#162](https://github.com/Gitlawb/node/issues/162)) ([#163](https://github.com/Gitlawb/node/issues/163)) ([67ad2b8](https://github.com/Gitlawb/node/commit/67ad2b876c8d9a336219d1016968de7a88fc4e75))
* **node,git:** bound a hung served git with a total-duration timeout ([#62](https://github.com/Gitlawb/node/issues/62)) ([#165](https://github.com/Gitlawb/node/issues/165)) ([cd67718](https://github.com/Gitlawb/node/commit/cd67718f49ec38726a40f6bcf36f539ccdb42969))
* **node:** bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth ([#147](https://github.com/Gitlawb/node/issues/147)) ([#149](https://github.com/Gitlawb/node/issues/149)) ([6b5e5bc](https://github.com/Gitlawb/node/commit/6b5e5bc7aee00a2d03295d3620df1ee4d8c024a2))
* **node:** gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes ([#82](https://github.com/Gitlawb/node/issues/82)) ([#161](https://github.com/Gitlawb/node/issues/161)) ([d00d89a](https://github.com/Gitlawb/node/commit/d00d89ae5be992d1f63e95b714ae1bd3735e8457))
* **node:** rate-limit repo/agent creation per client IP to stop DID-farm spam floods ([#180](https://github.com/Gitlawb/node/issues/180)) ([dfcaa22](https://github.com/Gitlawb/node/commit/dfcaa22b23ec91be4c75926956aa994ca89de8d5))
* **release:** build aarch64-musl natively on arm64 runners, replace retired macos-13 ([#155](https://github.com/Gitlawb/node/issues/155)) ([6cff528](https://github.com/Gitlawb/node/commit/6cff5286b436fdee44ad881999e2a5f4bdba18f9))
* **visibility:** gate repo-scoped read surfaces on visibility ([#120](https://github.com/Gitlawb/node/issues/120)) ([#157](https://github.com/Gitlawb/node/issues/157)) ([26bc3f6](https://github.com/Gitlawb/node/commit/26bc3f69870aa77e43c0d92115a5aa59555b7d88))
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
### Features
* **gl:** sanctioned iCaptcha client flow + secure git lifecycle ([#138](https://github.com/Gitlawb/node/issues/138)) ([06388ec](https://github.com/Gitlawb/node/commit/06388ec26aa29d356ae311276fdb91be054e9ecc))
### Bug Fixes
* **gl:** sign the CLI's /ipfs/pins reads under the [#134](https://github.com/Gitlawb/node/issues/134) auth gate ([#146](https://github.com/Gitlawb/node/issues/146)) ([20d6848](https://github.com/Gitlawb/node/commit/20d6848846b3a988d604208833167a528b7d8820))
* **node,git-remote:** gate receive-pack advertisement, sign client fetch/push ([#119](https://github.com/Gitlawb/node/issues/119)) ([6f36fc0](https://github.com/Gitlawb/node/commit/6f36fc07b8e10a650c5948b269feac1cb25cae2a))
* **node,gossip:** route gossip HTTP through the no-redirect client ([#93](https://github.com/Gitlawb/node/issues/93)) ([#140](https://github.com/Gitlawb/node/issues/140)) ([563c456](https://github.com/Gitlawb/node/commit/563c456803bf3e958d63869db424b3940472bc3d))
* **node:** close two spam-vector root causes (trust upsert + ungated push) ([#152](https://github.com/Gitlawb/node/issues/152)) ([2df6ff9](https://github.com/Gitlawb/node/commit/2df6ff9d30de62f754fa41473e85db316021718e))
* **node:** gate GET /ipfs/{cid} on reachable allowed-set, not deny-set ([#126](https://github.com/Gitlawb/node/issues/126)) ([#133](https://github.com/Gitlawb/node/issues/133)) ([466a550](https://github.com/Gitlawb/node/commit/466a550915edd711856ef32035f9f474e2577c4f))
* **node:** gate the ref-updates feeds on read visibility ([#112](https://github.com/Gitlawb/node/issues/112), [#114](https://github.com/Gitlawb/node/issues/114)) ([#143](https://github.com/Gitlawb/node/issues/143)) ([4891db3](https://github.com/Gitlawb/node/commit/4891db38892663326ee0c1417a2db931988be4b5))
* **node:** prefer canonical repo row over mirror row in get_repo ([#124](https://github.com/Gitlawb/node/issues/124)) ([#141](https://github.com/Gitlawb/node/issues/141)) ([6c95592](https://github.com/Gitlawb/node/commit/6c95592d188222ac3446dc23ef8d9befbf82ad6f))
* **remote:** include HTTP error response body ([#137](https://github.com/Gitlawb/node/issues/137)) ([09a0cb2](https://github.com/Gitlawb/node/commit/09a0cb23b9f284ccbd69aca6958b70671f3bfb46))
* **repos:** log the cause when repo create fails ([#103](https://github.com/Gitlawb/node/issues/103)) ([2620e97](https://github.com/Gitlawb/node/commit/2620e973e3cd4835ed42ddd4adcd8183b5b3080e))
## [0.4.0](https://github.com/Gitlawb/node/compare/v0.3.9...v0.4.0) (2026-06-30)
### Features
* agent profiles (display name, bio, avatar, social links) ([#23](https://github.com/Gitlawb/node/issues/23)) ([09a3397](https://github.com/Gitlawb/node/commit/09a339745eca40a2567d911e947e3fc7426fc621))
* **db:** versioned schema migrations with idempotent backfill ([#21](https://github.com/Gitlawb/node/issues/21)) ([927e4d0](https://github.com/Gitlawb/node/commit/927e4d0cfb4ea11dca6930780939afaa067797ba))
* encrypted replication for private subtrees (B1/B2/B3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#36](https://github.com/Gitlawb/node/issues/36)) ([5ff7af8](https://github.com/Gitlawb/node/commit/5ff7af84fa21fab53a12dfa04a0e7fb7e7d672e6))
* **git-remote-gitlawb:** add --version and --help flags ([#30](https://github.com/Gitlawb/node/issues/30)) ([3a401eb](https://github.com/Gitlawb/node/commit/3a401eb0792f9c5f5a10de6c16861655ab3836e0))
* **gitlawb-attest:** External Attestation v1 for ref-update certs ([#20](https://github.com/Gitlawb/node/issues/20)) ([924bccd](https://github.com/Gitlawb/node/commit/924bccd8e53e9be2dc9d6d4e4f1376952d6462bb))
* **node:** blind recipient identities at rest and gate B1 by repo readability ([#40](https://github.com/Gitlawb/node/issues/40)) ([abdc775](https://github.com/Gitlawb/node/commit/abdc7757708d2bbb2bfda99bb65d50756142a42e))
* **node:** enforce per-route authorization across the REST and GraphQL surface ([#87](https://github.com/Gitlawb/node/issues/87)) ([2202b00](https://github.com/Gitlawb/node/commit/2202b0097fab6976ab366a2cdc385f1146a72f86))
* **node:** graceful shutdown + Prometheus metrics endpoint ([#22](https://github.com/Gitlawb/node/issues/22)) ([2ce4da9](https://github.com/Gitlawb/node/commit/2ce4da9cc5a4791e8ae5a1cd90270b67f60d9ec3))
* **node:** iCaptcha proof-of-intelligence gate on create_repo + register ([#108](https://github.com/Gitlawb/node/issues/108)) ([adc20f9](https://github.com/Gitlawb/node/commit/adc20f9effad7b42fab55002875d209c4ed79518))
* **node:** iCaptcha-aware repo propagation gate with quarantine ([#125](https://github.com/Gitlawb/node/issues/125)) ([8b9ceec](https://github.com/Gitlawb/node/commit/8b9ceec25ef338965d1db72d39a7f2adb5300cc9))
* **node:** owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH ([#31](https://github.com/Gitlawb/node/issues/31)) ([#68](https://github.com/Gitlawb/node/issues/68)) ([0a15e76](https://github.com/Gitlawb/node/commit/0a15e763d2ab46737a3831715facbe51045b33ba))
* **node:** peer partial-mirrors for repos with private subtrees ([#35](https://github.com/Gitlawb/node/issues/35)) ([e365a57](https://github.com/Gitlawb/node/commit/e365a57b51c167abf06e87bfeb0565c80ed1b849))
* **node:** pin the per-push object delta instead of re-enumerating the whole repo ([#90](https://github.com/Gitlawb/node/issues/90)) ([1af4fdf](https://github.com/Gitlawb/node/commit/1af4fdf485c7084cd160551b757b1ad1eed65cc6))
* **node:** replication enforcement (Phase 2) for [#18](https://github.com/Gitlawb/node/issues/18) ([#34](https://github.com/Gitlawb/node/issues/34)) ([8680d0f](https://github.com/Gitlawb/node/commit/8680d0f9d6600bba1a52d15624f8a2802a169511))
* **node:** signature-gated agent self-deregister ([#29](https://github.com/Gitlawb/node/issues/29)) ([#63](https://github.com/Gitlawb/node/issues/63)) ([ff492b4](https://github.com/Gitlawb/node/commit/ff492b452126f5568dac4286a7249f0cadb8b380))
* **node:** subtree content withholding (Phase 3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#28](https://github.com/Gitlawb/node/issues/28)) ([61b3830](https://github.com/Gitlawb/node/commit/61b383019fd895a1a6adfad934ad6c626e0f095e))
* path-scoped repository visibility (Phase 1) for [#18](https://github.com/Gitlawb/node/issues/18) ([#25](https://github.com/Gitlawb/node/issues/25)) ([6abaf1d](https://github.com/Gitlawb/node/commit/6abaf1d7ed8fc55c6547568ae7247131311bde98))
* per-DID rate limiting on creation endpoints (10/hour) ([#13](https://github.com/Gitlawb/node/issues/13)) ([b12c6bc](https://github.com/Gitlawb/node/commit/b12c6bc3283c2647224a62fb520a3cb7acf4a747))
* **sync:** auto-register as replica with origin after successful mirror ([#56](https://github.com/Gitlawb/node/issues/56)) ([c03c9af](https://github.com/Gitlawb/node/commit/c03c9af8fadafe262a2bf3cf25e19edf7160d376))
### Bug Fixes
* **api:** blob endpoint returns 400/404 instead of 500 on bad paths ([#37](https://github.com/Gitlawb/node/issues/37)) ([b61a1bd](https://github.com/Gitlawb/node/commit/b61a1bd46ffe78b39c0967e97b0ad349eba0b046))
* **core:** route seed access through the zeroizing wrapper ([#41](https://github.com/Gitlawb/node/issues/41)) ([#64](https://github.com/Gitlawb/node/issues/64)) ([c9f43b0](https://github.com/Gitlawb/node/commit/c9f43b010576edb3c92a3be3d935cad232250344))
* **core:** zeroize the derived X25519 secret ([#65](https://github.com/Gitlawb/node/issues/65)) ([#91](https://github.com/Gitlawb/node/issues/91)) ([2f6611a](https://github.com/Gitlawb/node/commit/2f6611a30fbb66cef9991cf4c6e507548acc5038))
* **gl:** paginate gl-clone Arweave recovery and make /encrypted-blobs parsing schema-strict ([#49](https://github.com/Gitlawb/node/issues/49)) ([#70](https://github.com/Gitlawb/node/issues/70)) ([2153b0b](https://github.com/Gitlawb/node/commit/2153b0b7a67a48c29a33cd70b80cfbb69760805d))
* **infra:** drop fly idle_timeout 600 -&gt; 120 ([#38](https://github.com/Gitlawb/node/issues/38)) ([a2217bf](https://github.com/Gitlawb/node/commit/a2217bff27ff3d6cae02bd8a12f8a0af7ce2b0a1))
* **node:** anchor the real old_sha and issue a per-ref certificate ([#72](https://github.com/Gitlawb/node/issues/72)) ([6809201](https://github.com/Gitlawb/node/commit/6809201daa223d6f833be6e95876a7b4e1f2b0b5))
* **node:** close under-withholding via full ref scope and full-history classification ([#42](https://github.com/Gitlawb/node/issues/42)) ([#84](https://github.com/Gitlawb/node/issues/84)) ([3e1e904](https://github.com/Gitlawb/node/commit/3e1e9045e4e3aa5ea0aee69767ceb01637920d2a))
* **node:** dedupe mirror and canonical repo rows on list surfaces ([#6](https://github.com/Gitlawb/node/issues/6)) ([#73](https://github.com/Gitlawb/node/issues/73)) ([3e8e333](https://github.com/Gitlawb/node/commit/3e8e333aa03d7a2fe455d5d83f23089d58feb8c9))
* **node:** enforce path-scoped visibility on the REST read API ([#52](https://github.com/Gitlawb/node/issues/52)) ([e37ea7f](https://github.com/Gitlawb/node/commit/e37ea7fec6d5a3171b526c84f884670bbbd258fb))
* **node:** fail closed when a recipient DID can't be resolved ([#47](https://github.com/Gitlawb/node/issues/47)) ([#67](https://github.com/Gitlawb/node/issues/67)) ([abc9ad0](https://github.com/Gitlawb/node/commit/abc9ad03acb48e608234650a25049622673fa53a))
* **node:** gate fork_repo on per-caller path-scoped visibility ([#98](https://github.com/Gitlawb/node/issues/98)) ([#109](https://github.com/Gitlawb/node/issues/109)) ([6ae316c](https://github.com/Gitlawb/node/commit/6ae316cc88521747cbacb2a612b9433897d2e490))
* **node:** gate GET /ipfs/{cid} on per-caller path-scoped visibility ([#110](https://github.com/Gitlawb/node/issues/110)) ([#128](https://github.com/Gitlawb/node/issues/128)) ([174f25a](https://github.com/Gitlawb/node/commit/174f25a206380b26796b8782e1bd860b0a409fc9))
* **node:** gate repo-listing and stats surfaces on visibility ([#97](https://github.com/Gitlawb/node/issues/97), [#99](https://github.com/Gitlawb/node/issues/99), [#101](https://github.com/Gitlawb/node/issues/101), [#104](https://github.com/Gitlawb/node/issues/104)) ([#111](https://github.com/Gitlawb/node/issues/111)) ([828dd27](https://github.com/Gitlawb/node/commit/828dd279a286f58bbb3c73627b2a1e23778b25cf))
* **node:** make Tigris repo hydration resilient to corrupt archives & failed writes ([#54](https://github.com/Gitlawb/node/issues/54)) ([7a99d0f](https://github.com/Gitlawb/node/commit/7a99d0f27cfa869e9f8a803f16cff30191745a51))
* **node:** preserve promisor mirror mode on unknown withheld-paths lookup ([#48](https://github.com/Gitlawb/node/issues/48)) ([#69](https://github.com/Gitlawb/node/issues/69)) ([96fcfdb](https://github.com/Gitlawb/node/commit/96fcfdb1ca6d4cd27226670068702c9f177e283a))
* **node:** reap leaked git child processes ([#53](https://github.com/Gitlawb/node/issues/53)) ([#61](https://github.com/Gitlawb/node/issues/61)) ([803d83e](https://github.com/Gitlawb/node/commit/803d83efdbcfa9d98affac1c5aaf8aacc511ae64))
* **node:** reject malformed path globs with non-trailing or empty-segment wildcards ([#74](https://github.com/Gitlawb/node/issues/74)) ([#75](https://github.com/Gitlawb/node/issues/75)) ([3d880cd](https://github.com/Gitlawb/node/commit/3d880cd56fed10e4c5ae787b52ce411384950f5e))
* **node:** skip withheld-walk when no path-scoped rule can withhold ([#60](https://github.com/Gitlawb/node/issues/60)) ([338ff83](https://github.com/Gitlawb/node/commit/338ff83584f2ab960be7e42ecec64191f5aaeb95))
* **release:** give crates explicit versions for release-please ([#129](https://github.com/Gitlawb/node/issues/129)) ([788a868](https://github.com/Gitlawb/node/commit/788a8686c1deafe03573703d72eb927cde81f54d))
* **release:** use simple release-type + generic Cargo.toml updaters ([#130](https://github.com/Gitlawb/node/issues/130)) ([12bfb1b](https://github.com/Gitlawb/node/commit/12bfb1b3c86b38f0b22d488cd6e85dfd96e0c37f))
* **security:** gate webhook creation through the public-host validator ([#81](https://github.com/Gitlawb/node/issues/81)) ([#92](https://github.com/Gitlawb/node/issues/92)) ([f28fa02](https://github.com/Gitlawb/node/commit/f28fa02236bd65c6a4fb690131ab14640cc53a12))
* **security:** reject non-public peer URLs + prune poisoned peers ([#78](https://github.com/Gitlawb/node/issues/78)) ([a8cc33a](https://github.com/Gitlawb/node/commit/a8cc33a185f2649d3fe100ec271ee5739a55eba7))
+2 -24
View File
@@ -31,9 +31,7 @@ crates/
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN, ref certs)
├── gitlawb-node/ axum HTTP server, git smart HTTP, P2P, GraphQL
├── gl/ CLI — identity, repos, MCP server, Base L2 names
├── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
├── gitlawb-attest/ provenance attestations for ref-update certificates
└── icaptcha-client/ client for the iCaptcha proof-of-intelligence service
└── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
docs/ Operator guides
scripts/ Build helpers
```
@@ -56,26 +54,6 @@ Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://
3. **No breaking changes without discussion.** Open an issue first for protocol-level changes.
4. **Conventional commits.** Use `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Releases are automated by [release-please](https://github.com/googleapis/release-please) — your commit prefixes drive the next version bump.
5. **Format and lint.** Run `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` before submitting. CI will reject anything that fails these.
6. **Security-surface rules.** [`AGENTS.md`](AGENTS.md) carries the authorization invariants PRs most often break; it is written for coding agents and humans alike, so read it before touching endpoints, visibility, or migrations.
## What gets merged, what gets closed
We welcome contributions from humans and agents alike. To keep review sustainable, PRs are
expected to clear a basic quality bar:
- **Link an issue.** Bug fixes and features should reference an issue (`Closes #123`). For
protocol-level changes (identity, signatures, UCAN, ref certs, wire formats), open the
issue *before* writing code.
- **One change per PR.** Unrelated churn slows review and gets sent back.
- **Tests and a green pipeline.** New behavior needs tests; `cargo fmt`, `cargo clippy`,
and the full CI suite must pass.
- **A real description.** Say what changes and why. "Update code" is not a description.
A triage bot labels PRs that are missing these and leaves a short note. Nothing is closed
automatically while you're engaging. A flagged PR still missing a description after 14 days of
inactivity gets a stale warning, and is closed 7 days later if still untouched. A missing issue
link alone never triggers closure; that label stays advisory. Closed PRs can be reopened at any
time once updated.
## Development environment
@@ -108,7 +86,7 @@ cargo test -p gitlawb-node
- **TypeScript SDK** (`@gitlawb/sdk`) — client library for the HTTP API
- **Python SDK** (`gitlawb`) — for ML/agent pipeline integration
- **UCAN authorization** — add trusted issuer anchoring, capability checks, and authorization-aware revocation
- **UCAN chain validation** — complete the auth middleware
- **Filecoin storage tier** — wire up cold storage deals
- **Documentation** — guides, tutorials, API examples
- **Node operators** — run a public node and report issues
Generated
+36 -263
View File
@@ -8,16 +8,6 @@ version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -329,7 +319,7 @@ dependencies = [
"keccak-asm",
"paste",
"proptest",
"rand 0.9.3",
"rand 0.9.2",
"rapidhash",
"ruint",
"rustc-hash",
@@ -770,23 +760,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ark-ff"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
dependencies = [
"ark-ff-asm 0.6.0",
"ark-ff-macros 0.6.0",
"ark-serialize 0.6.0",
"ark-std 0.6.0",
"digest 0.10.7",
"educe",
"num-bigint",
"num-traits",
"zeroize",
]
[[package]]
name = "ark-ff-asm"
version = "0.3.0"
@@ -817,16 +790,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-asm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.3.0"
@@ -865,19 +828,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
dependencies = [
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-serialize"
version = "0.3.0"
@@ -911,30 +861,6 @@ dependencies = [
"num-bigint",
]
[[package]]
name = "ark-serialize"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
dependencies = [
"ark-serialize-derive",
"ark-std 0.6.0",
"digest 0.10.7",
"num-bigint",
"serde_with",
]
[[package]]
name = "ark-serialize-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-std"
version = "0.3.0"
@@ -965,16 +891,6 @@ dependencies = [
"rand 0.8.6",
]
[[package]]
name = "ark-std"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
dependencies = [
"num-traits",
"rand 0.8.6",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2059,30 +1975,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -2109,17 +2001,6 @@ dependencies = [
"unsigned-varint 0.8.0",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "clap"
version = "4.5.60"
@@ -2181,7 +2062,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2336,7 +2217,7 @@ checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3"
dependencies = [
"crc",
"digest 0.10.7",
"rand 0.9.3",
"rand 0.9.2",
"regex",
"rustversion",
]
@@ -2367,9 +2248,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
@@ -2426,41 +2307,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"chacha20",
"crypto_secretbox",
"curve25519-dalek",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"chacha20",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -2630,7 +2479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@@ -3002,7 +2851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3374,22 +3223,18 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.7.1"
version = "0.3.9"
dependencies = [
"anyhow",
"gitlawb-core",
"libc",
"mockito",
"reqwest",
"tempfile",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "gitlawb-attest"
version = "0.7.1"
version = "0.3.9"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3406,15 +3251,12 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.7.1"
version = "0.3.9"
dependencies = [
"anyhow",
"base64",
"chacha20poly1305",
"chrono",
"cid",
"crypto_box",
"curve25519-dalek",
"ed25519-dalek",
"hex",
"multibase",
@@ -3427,14 +3269,13 @@ dependencies = [
"sha2",
"thiserror 2.0.18",
"tokio",
"url",
"uuid",
"zeroize",
]
[[package]]
name = "gitlawb-node"
version = "0.7.1"
version = "0.3.9"
dependencies = [
"alloy",
"anyhow",
@@ -3444,19 +3285,16 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"axum",
"base64",
"bytes",
"chrono",
"cid",
"clap",
"dirs-next",
"ed25519-dalek",
"futures",
"gitlawb-core",
"hex",
"hmac",
"http-body-util",
"libc",
"libp2p-core",
"libp2p-dns",
"libp2p-gossipsub",
@@ -3467,7 +3305,6 @@ dependencies = [
"libp2p-swarm",
"mockito",
"multiaddr",
"multibase",
"prometheus",
"reqwest",
"serde",
@@ -3484,23 +3321,20 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"unicode-normalization",
"uuid",
"zstd",
]
[[package]]
name = "gl"
version = "0.7.1"
version = "0.3.9"
dependencies = [
"alloy",
"anyhow",
"base64",
"chrono",
"clap",
"dirs",
"gitlawb-core",
"icaptcha-client",
"mockito",
"reqwest",
"serde",
@@ -3544,9 +3378,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.18"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
@@ -3683,7 +3517,7 @@ dependencies = [
"idna",
"ipnet",
"once_cell",
"rand 0.9.3",
"rand 0.9.2",
"ring",
"thiserror 2.0.18",
"tinyvec",
@@ -3705,7 +3539,7 @@ dependencies = [
"moka",
"once_cell",
"parking_lot",
"rand 0.9.3",
"rand 0.9.2",
"resolv-conf",
"smallvec",
"thiserror 2.0.18",
@@ -3865,7 +3699,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -3895,21 +3729,6 @@ dependencies = [
"cc",
]
[[package]]
name = "icaptcha-client"
version = "0.4.0"
dependencies = [
"anyhow",
"gitlawb-core",
"mockito",
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -4100,15 +3919,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
@@ -4635,7 +4445,7 @@ dependencies = [
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.3",
"rand 0.9.2",
"regex",
"serde_json",
"serde_urlencoded",
@@ -5001,12 +4811,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -5256,17 +5060,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -5382,7 +5175,7 @@ dependencies = [
"bit-vec",
"bitflags 2.11.0",
"num-traits",
"rand 0.9.3",
"rand 0.9.2",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
@@ -5433,7 +5226,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5442,14 +5235,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.15"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.3",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -5470,7 +5263,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
@@ -5516,9 +5309,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.3"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
@@ -5820,15 +5613,14 @@ dependencies = [
[[package]]
name = "ruint"
version = "1.20.0"
version = "1.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
dependencies = [
"alloy-rlp",
"ark-ff 0.3.0",
"ark-ff 0.4.2",
"ark-ff 0.5.0",
"ark-ff 0.6.0",
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
@@ -5839,7 +5631,7 @@ dependencies = [
"primitive-types",
"proptest",
"rand 0.8.6",
"rand 0.9.3",
"rand 0.9.2",
"rlp",
"ruint-macro",
"serde_core",
@@ -5902,7 +5694,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5944,9 +5736,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
@@ -5995,15 +5787,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -6777,9 +6560,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -6796,7 +6579,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7159,7 +6942,7 @@ dependencies = [
"http 1.4.0",
"httparse",
"log",
"rand 0.9.3",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
@@ -7252,16 +7035,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsigned-varint"
version = "0.7.2"
+1 -16
View File
@@ -6,19 +6,11 @@ members = [
"crates/gl",
"crates/git-remote-gitlawb",
"crates/gitlawb-attest",
"crates/icaptcha-client",
]
[workspace.package]
# Per-crate versions live in each crate's [package] so release-please can manage
# them (it cannot rewrite an inherited `version.workspace = true`). They are kept
# in lockstep by release-please's generic updater: each crate's `version` line
# carries an `# x-release-please-version` annotation and is listed under
# `extra-files` in release-please-config.json. The release type is `simple`
# (not `rust`) because the rust strategy unconditionally tries to rewrite this
# virtual workspace root as a package manifest and errors out.
version = "0.3.9"
edition = "2021"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
authors = ["gitlawb contributors"]
repository = "https://github.com/gitlawb/node"
@@ -53,12 +45,5 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
# http client
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
# take a parsed URL without pulling reqwest into gitlawb-core)
url = "2"
# HMAC
hmac = "0.12"
[profile.release]
lto = "thin"
strip = "symbols"
+6 -27
View File
@@ -8,33 +8,20 @@ FROM rust:1.91-bookworm AS builder
WORKDIR /build
# Cache dependencies first for faster rebuilds.
# EVERY workspace member's manifest must be copied here, not just the ones this
# image ships: cargo loads the whole workspace before it resolves anything, so a
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
# layer before a single crate is fetched.
# Cache dependencies first for faster rebuilds
COPY Cargo.toml Cargo.lock ./
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
COPY crates/gl/Cargo.toml crates/gl/
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
# Now copy real sources and build for real.
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
@@ -47,7 +34,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
target/release/.fingerprint/gl-* \
target/release/.fingerprint/git-remote-gitlawb-* && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
# ── Runtime stage ───────────────────────────────────────────────────────────
@@ -57,7 +44,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
curl \
tini \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for runtime
@@ -86,11 +72,4 @@ VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://127.0.0.1:${GITLAWB_PORT}/health || exit 1
# Run under tini so the node is never PID 1. As PID 1 a process must reap
# reparented orphans itself; the node does not, so git's reparented grandchildren
# (e.g. pack-objects orphaned when a served upload-pack dies on a client
# disconnect) would accumulate as zombies until fork() fails with EAGAIN (#53).
# tini reaps them and forwards SIGTERM/SIGINT to the node, which runs its own
# graceful shutdown. (No `-g`: the node manages its own children, so group-wide
# signalling would only disturb in-flight git helpers during shutdown.)
ENTRYPOINT ["tini", "--", "gitlawb-node"]
ENTRYPOINT ["gitlawb-node"]
+2 -5
View File
@@ -3,10 +3,7 @@
# Used by scripts/build-bins.sh — not used for node deployment.
ARG TARGET=x86_64-unknown-linux-musl
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
# base image here fails only when someone runs scripts/build-bins.sh.
FROM rust:1.91-bookworm AS builder
FROM rust:1.85-bookworm AS builder
ARG TARGET
WORKDIR /build
@@ -26,7 +23,7 @@ COPY crates/ crates/
# Configure cross-linker for aarch64
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
FROM debian:bookworm-slim
ARG TARGET
+20 -150
View File
@@ -4,7 +4,7 @@
Gitlawb Node is the open-source node software behind the Gitlawb network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
Gitlawb is not trying to be only "another git host." The long-term direction is:
Gitlawb is not trying to be only “another git host.” The long-term direction is:
```txt
Decentralized GitHub
@@ -19,7 +19,7 @@ The mission is simple: once code is pushed to the network, it should not disappe
## What is in this repository?
This is a Rust workspace with six crates:
This is a Rust workspace with four crates:
| Crate | Purpose |
|---|---|
@@ -27,8 +27,6 @@ This is a Rust workspace with six crates:
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
| `gitlawb-attest` | Pluggable external provenance attestations for ref-update certificates. |
| `icaptcha-client` | Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
---
@@ -36,12 +34,12 @@ This is a Rust workspace with six crates:
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
- **Own your identity**: every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
- **Signed writes by default**: write requests use RFC 9421 HTTP Signatures instead of passwords.
- **Git-native transport**: repositories are still real git repositories served over smart HTTP.
- **Agent-native workflows**: the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
- **Peer-aware delivery**: nodes can announce, discover, gossip, and sync with each other.
- **App CDN direction**: the network can evolve from decentralized code storage into code + asset + app delivery.
- **Own your identity** — every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
- **Signed writes by default** — write requests use RFC 9421 HTTP Signatures instead of passwords.
- **Git-native transport** — repositories are still real git repositories served over smart HTTP.
- **Agent-native workflows** — the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
- **Peer-aware delivery** — nodes can announce, discover, gossip, and sync with each other.
- **App CDN direction** — the network can evolve from decentralized code storage into code + asset + app delivery.
---
@@ -56,7 +54,6 @@ Good today:
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
- Repository and path-scoped visibility enforcement for repository and Git content reads, with 404-shaped repository denials.
- DID identities.
- `gl` CLI workflows.
- libp2p peer discovery/gossip foundation.
@@ -66,13 +63,11 @@ Good today:
Known limitations:
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
- 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.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Private repository read enforcement is not wired yet. Treat public nodes as public infrastructure unless you restrict access at your proxy/firewall.
- UCAN chain validation and revocation are not complete.
- Repository write authorization is not capability-complete yet; HTTP signatures prove identity, not full authorization policy.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
See:
@@ -124,18 +119,10 @@ docker compose down
## Install the CLI
Use the installer once releases are available for your platform:
```bash
# npm (macOS / Linux)
npm install -g @gitlawb/gl
# Homebrew (macOS / Linux)
brew install gitlawb/tap/gl
# curl (macOS / Linux)
curl -fsSL https://gitlawb.com/install.sh | sh
# PowerShell (Windows)
irm https://gitlawb.com/install.ps1 | iex
```
Or build from source:
@@ -190,92 +177,6 @@ git clone gitlawb://did:key:z6Mk.../my-repo
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
### Full lifecycle against an iCaptcha-enforcing node
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
1. **RFC 9421 HTTP Signatures**: every write is signed by your identity key. `gl`
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
locally (arithmetic / algebra / sequence), and **retries the same signed request**
with the `x-icaptcha-proof` header. No manual steps, no env vars.
```bash
gl identity new # create did:key identity
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
gl doctor # preflight: identity, node, version, iCaptcha
```
Notes:
- **`requesterId` is always your DID.** The proof's `sub` claim must equal the
authenticated signer; `gl`/helper set this automatically and the node enforces
`sub == authenticated DID` (so a proof minted for another identity is rejected).
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
solving and use, the client transparently solves a fresh one and retries.
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
`git push` is **signed-only** (owner signature is the gate, no per-push challenge);
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
clear `404`, never a placeholder.
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
`https` origin whose host is allowlisted (that URL or the public default), and
sends the bearer token **only** to your configured origin, never to a URL a
node advertises, so a hostile node can't capture the key or redirect the solve.
### Fetching an object by CID
```bash
gl ipfs list # CIDs this node has pinned
gl ipfs get bafkrei... > object.bin # object bytes on stdout
```
Objects that were pinned before the node started recording which repo they came
from are found by scanning its repo inventory, and that scan stops at the
per-request ceilings in the [Configuration](#configuration) table. A stopped scan
answers 503 with a resume token instead of a false "not found", and `gl ipfs get`
follows the token automatically: up to 8 resumes after the first request, so at
most 9 calls to the node, waiting between attempts for as long as the node's
`Retry-After` asks and never longer than 5 seconds.
The whole ladder runs under a 60 second wall-clock deadline. The deadline bounds
the search, not the download: each attempt gets the time left on it to produce
response headers, and once an object is found its bytes stream outside that
deadline. They are not unbounded, though. The client's own 30 second HTTP timeout
is a total request timeout, running from the start of a request until its body
has finished, so a transfer still going 30 seconds after its request began is cut
off. Waits between attempts never run past the deadline either, so a single run
spends at most around 90 seconds on the network: the deadline plus the 30 second
timeout covering the last attempt. Writing the object out sits outside both
bounds, so piping into a reader that stops reading can hold the command open
longer than that.
Two node-side brakes end a ladder early and are reported rather than retried
around. A 429 is terminal, because the node's rate-limit window is an hour and
that wait cannot be honored inside one invocation; a transient overload (a 503
carrying no incomplete-scan code) is retried on the token already held, under the
same cap, clamp and deadline. The per-IP fanout brake can also stop a ladder well
short of the 9 calls, so automatic resumption is not a guarantee of reaching the
object.
When a bound stops the ladder with a usable token in hand, the command prints the
token and the invocation that continues from it before exiting nonzero:
```txt
resume from where this stopped: gl ipfs get bafkrei... --scan <token>
```
Run that to carry on from where the scan stopped. Re-running without `--scan`
restarts at the first row, reproduces the same truncation and spends the node's
per-IP budget again, so the token is the only thing that makes progress. Tokens
are valid for an hour.
---
## Architecture
@@ -354,29 +255,20 @@ POST /api/v1/bounties/{id}/...
POST /{owner}/{repo}/git-receive-pack
```
These peer write routes support staged rollout:
Peer write routes support staged rollout:
```txt
POST /api/v1/peers/announce
POST /api/v1/sync/notify
POST /api/v1/sync/trigger
```
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Staged rollout relaxes who may announce, not who owns a peer row. An unsigned announce may register a previously unseen peer, and it may refresh an existing row whose `http_url` is unchanged, but changing an existing peer's `http_url` requires an RFC 9421 signature from that peer's own DID and is refused with 403 otherwise. An unsigned announce can only register a `did:key`, since that is the only method whose verifying key can be resolved and therefore the only kind of row anyone could ever correct through the signed path; any other DID is refused with 400. Once all live peers upgrade, operators can set:
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted, but signed requests are verified when signature headers are present. Once all live peers upgrade, operators can set:
```bash
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
```
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
A verified signature is spent once on the write routes that require one: repo, issue, PR, task, bounty, profile, webhook, label, star, replica, visibility and agent mutations, plus git push and `POST /api/v1/sync/trigger`. A captured request to any of those cannot be replayed. The two peer-write routes are the exception. `POST /api/v1/peers/announce` and `POST /api/v1/sync/notify` reach the ledger only when `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true`; under the default (`false`) they verify a signature when the headers are present but never spend it, so a captured signed peer write is still replayable there until the fleet upgrades and the flag is turned on. The ledger keys on the RFC 9421 `nonce` parameter when the client signs one and falls back to the signing-string hash when it does not. The fallback is correct but coarser: a client that repeats a byte-identical mutation within the same second is rejected as a replay and has to re-sign. `GITLAWB_REQUIRE_SIGNATURE_NONCE` closes that fallback once every client emits a nonce:
```bash
GITLAWB_REQUIRE_SIGNATURE_NONCE=true
```
With it on, a nonce-less signature on a write route is rejected with 400 and `X-Gitlawb-Error: signature_nonce_required`. Signed reads are never affected. Flipping it on has a federation precondition: three of the node's own outbound signed calls (peer announce, replica registration, sync-notify) are peer traffic, so a node running with both this and `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` set to true will reject peer writes from any node still on a pre-nonce binary. Upgrade the fleet before enabling it.
---
## Configuration
@@ -400,37 +292,15 @@ Important node settings:
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `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_REQUIRE_SIGNATURE_NONCE` | Require a signed `nonce` on write routes. Default false; see the staged rollout note above. |
| `GITLAWB_SIGNED_WRITE_RATE_LIMIT` | Per-client-IP requests per hour on the signed write routes (tasks, PR merge/close/review/comment, webhooks, branch protection, stars, replicas, labels, visibility, agent deregistration, bounties, profile, issue close/comment). Each signed attempt spends a ledger row before the handler checks anything, so the brake bounds what an unregistered caller can write. Own bucket; `0` disables. Default 600. |
<!-- verbatim -->
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
| `GITLAWB_MAX_CONCURRENT_GIT_OPS` | Max concurrent served git READ ops (upload-pack and its `info/refs` advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER`. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
| `GITLAWB_MAX_CONCURRENT_GIT_PUSHES` | Max concurrent `git-receive-pack` POST operations, in a pool separate from the read pool. The anon receive-pack `info/refs` advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (`/8`, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
| `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER` | Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as `GITLAWB_TRUSTED_PROXY`: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Default 8. |
| `GITLAWB_REPO_LEASE_MAX_WAITERS` | Max pushes parked at once waiting for the same repo's write lease. Each waiter pins its buffered pack body, so this bounds that memory for a hot repo; past the cap the newest push sheds a 503 + Retry-After instead of queueing. Pushes to other repos are unaffected, and the lease holder is not counted. Default 8. |
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
| `GITLAWB_IPFS_MAX_LEGACY_PROBES` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
| `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` | Max repo rows one `/ipfs/{cid}` request's legacy scan may read from the database. The probe ceiling above only starts counting once a probe runs, and quarantined or private repos are denied before that, so this is what bounds a scan over an inventory that denies the caller everywhere. A truncated scan sheds a retryable 503 carrying an opaque `continuation` token; echoing it as `?scan=` resumes the scan where it stopped. Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one, so each request advances the ladder by at least the rows it read and a holder buried past a ceiling is reached in a bounded number of requests, `ceil(repos / ceiling) + 1` when this row ceiling is the one that binds. No ceiling ever produces a 404. Every page is charged to the caller's `/ipfs` work allowance, so raising this raises that allowance too. Lowering it sharpens an oracle: because a truncation emits a token and a completed wrap does not, laddering to the end reveals the node's total repo count (private and quarantined included) to within one ceiling. Default 2048. |
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks a `/ipfs/{cid}` request may run per phase; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. It is charged per phase: the provenance lookup and the legacy-scan fallback get separate equal budgets, so one request can run up to twice the cap in total. Default 64. |
| `GITLAWB_IPFS_MAX_REPO_VISITS` | Ceiling on repos one `/ipfs/{cid}` request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
Production note: change the default Postgres password before exposing a node publicly.
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
---
## Optional node staking
@@ -535,8 +405,8 @@ Short-term priorities:
2. Add Docker and installer smoke tests.
3. Improve operator docs and `gl doctor` checks.
4. Harden peer writes and publish the signed-peer rollout plan.
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
5. Implement repo write authorization: owner checks, protected branches, and UCAN capability checks.
6. Implement private-read enforcement or remove private repo affordances until it exists.
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
Product direction:
+22 -32
View File
@@ -28,11 +28,10 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
- Every git object is content-addressed via CIDv1 (SHA-256)
- Tamper-evident by construction — a modified object changes its CID
**UCAN token validation**
- Bootstrap UCAN tokens are issued at registration.
- A supplied token's signature, audience, expiry, and proof-chain attenuation are validated.
- Tokens use a signed JSON wire format with expiry.
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
**UCAN capability tokens**
- Bootstrap UCAN tokens issued at registration
- Capability-scoped: `git:push`, `git:fetch`, `issue:create`, `pr:open`
- JWT-format tokens with expiry
**Smart contracts (Base Sepolia testnet)**
- `GitlawbDIDRegistry` — on-chain DID → document registry
@@ -49,20 +48,21 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
---
## Known Limitations
## Known Limitations (Planned for v0.2)
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades.
### Repository write authorization defaults
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
### UCAN chain validation
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization.
- **Fix target:** v0.2
### UCAN delegation and revocation
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
- Agent lifecycle revocation is not checked by HTTP Signature authorization. Removing or revoking an agent record does not itself block a compromised DID from authenticating.
- **Impact:** Do not use UCANs for collaborator permissions or rely on agent-record revocation as a key-compromise response.
- **Mitigation:** Keep sensitive deployments behind operational network controls and enable owner-only push enforcement where it fits the deployment until trusted delegation and authorization-aware revocation are implemented.
### UCAN revocation
- Issued UCAN tokens cannot be revoked before expiry.
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Until revocation/blocklisting is implemented, operators should remove compromised DIDs directly from their local database.
- **Fix target:** v0.2
### git-receive-pack authentication
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
@@ -71,20 +71,10 @@ These are documented limitations of the current live release. They should be pri
- **Fix target:** v0.2
### Private repository reads
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
### Pull-request review enforcement
- Pull-request review comments are not yet threaded or line-anchored, and merges do not enforce required approvals.
- **Impact:** Teams must use their own review policy or external controls for merge approval requirements.
### GraphQL mutation coverage
- Existing GraphQL mutations require an authenticated signer, but a mutation-specific source-level guardrail has not yet been added for future mutations.
- **Impact:** A new mutation could accidentally omit its signer check without an explicit test fence.
- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release.
- **Impact:** Do not store private repositories or secrets on public nodes.
- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer.
- **Fix target:** v0.2
### Peer route hardening rollout
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
@@ -111,7 +101,7 @@ These are documented limitations of the current live release. They should be pri
| Key storage | PKCS#8 PEM, 0600 permissions |
| Content hashing | SHA-256 via CIDv1 |
| HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) |
| UCAN tokens | Signed JSON object (Ed25519 signature) |
| UCAN tokens | JWT (Ed25519 signatures) |
| On-chain | ECDSA secp256k1 (Base L2 / Ethereum) |
---
@@ -119,7 +109,7 @@ These are documented limitations of the current live release. They should be pri
## Threat Model
gitlawb is designed to be secure against:
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Unauthorized writes** — HTTP Signature auth on all write endpoints
- **Tampered git objects** — CIDv1 content addressing detects modification
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
+2 -26
View File
@@ -1,40 +1,16 @@
{
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-06-30",
"updated": "2026-05-16",
"peers": [
{
"name": "gitlawb",
"operator": "Gitlawb",
"operator": "Gitlawb (Kevin)",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-04-29"
},
{
"name": "gitlawb-node2",
"operator": "Gitlawb",
"did": null,
"http_url": "https://node2.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-06-30"
},
{
"name": "gitlawb-node3",
"operator": "Gitlawb",
"did": null,
"http_url": "https://node3.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-06-30"
},
{
"name": "gitlawb-manila",
"operator": "Gitlawb",
"did": null,
"http_url": "https://manila.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-06-30"
},
{
"name": "rapybus-gitlawb",
"operator": "RapyBus S.A.C.",
-112
View File
@@ -1,112 +0,0 @@
# Dependency allowlist for the `gitlawb-core` crate.
#
# gitlawb-core is the shared-primitives crate every consumer embeds (the `gl`
# CLI, git-remote-gitlawb, the node daemon). It must stay lean so those
# consumers do not inherit daemon-weight dependencies. This file is the source
# of truth for the crates gitlawb-core's NORMAL (non-dev, non-build) transitive
# dependency closure is allowed to contain. CI (`scripts/check-gitlawb-core-deps.sh`,
# wired into the `core-deps-purity` job in .github/workflows/pr-checks.yml)
# fails if gitlawb-core's tree contains any crate not listed here.
#
# The allowlist is exhaustive-by-construction: a NEW dependency reds CI whether
# or not anyone thought to ban it, which a denylist cannot do.
#
# Measured in-workspace, so feature unification across the workspace can, in
# rare cases, surface a crate here that gitlawb-core would not pull standalone.
# An unexpected new entry is a prompt to check what pulled it in, not noise to
# rubber-stamp.
#
# Regenerate (from repo root, after an INTENTIONAL dependency change):
# cargo tree -p gitlawb-core --edges normal --prefix none \
# | sed -E 's/ v[0-9].*$//' \
# | grep -v '^gitlawb-core$' \
# | sort -u
# then paste the result below this header block. (The checker snapshots and
# restores Cargo.lock; a manual regen may refresh it — `git checkout Cargo.lock`
# afterward if you did not intend that.)
#
# Lines starting with `#` and blank lines are ignored by the checker.
aead
anyhow
base-x
base256emoji
base64
base64ct
block-buffer
cfg-if
chacha20
chacha20poly1305
chrono
cid
cipher
const-oid
const-str
core2
cpufeatures
crypto-common
crypto_box
crypto_secretbox
curve25519-dalek
curve25519-dalek-derive
data-encoding
data-encoding-macro
data-encoding-macro-internal
der
digest
ed25519
ed25519-dalek
equivalent
generic-array
getrandom
hashbrown
hex
iana-time-zone
indexmap
inout
itoa
libc
match-lookup
memchr
multibase
multihash
multihash-codetable
multihash-derive
multihash-derive-impl
num-traits
opaque-debug
pem-rfc7468
pkcs8
poly1305
ppv-lite86
proc-macro-crate
proc-macro2
quote
rand
rand_chacha
rand_core
salsa20
serde
serde_core
serde_derive
serde_json
sha2
signature
spki
subtle
syn
synstructure
thiserror
thiserror-impl
toml_datetime
toml_edit
toml_parser
typenum
unicode-ident
universal-hash
unsigned-varint
uuid
winnow
zerocopy
zeroize
zeroize_derive
zmij
+2 -23
View File
@@ -1,9 +1,8 @@
[package]
name = "git-remote-gitlawb"
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
version = "0.7.1" # x-release-please-version
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
@@ -11,28 +10,8 @@ name = "git-remote-gitlawb"
path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
gitlawb-core = { path = "../gitlawb-core" }
anyhow = { workspace = true }
reqwest = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
mockito = "1"
tempfile = "3"
[target.'cfg(unix)'.dev-dependencies]
libc = "0.2"
[target.'cfg(windows)'.dev-dependencies]
# Job Object bindings for the real_git_fetch harness: the Windows analog of the
# unix process-group teardown, so the whole `git fetch` tree can be killed
# together on both the timeout and clean-exit paths (INV-22). Win32_Security is
# required by CreateJobObjectW, and Win32_System_Threading by
# JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -1,9 +1,8 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version = "0.7.1" # x-release-please-version
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
+3 -38
View File
@@ -16,7 +16,7 @@
//! by exact match.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::error::{Error, Result};
@@ -111,7 +111,7 @@ impl Attestation {
.try_into()
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
let sig = Signature::from_bytes(&sig_bytes);
vk.verify_strict(&bytes, &sig)
vk.verify(&bytes, &sig)
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
Ok(vk)
@@ -127,7 +127,7 @@ impl Attestation {
self.type_
)));
}
Ok(P::deserialize(&self.payload)?)
Ok(serde_json::from_value(self.payload.clone())?)
}
}
@@ -554,39 +554,4 @@ mod tests {
let err = tampered.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
/// Regression guard for strict verification: a signature forged under a
/// weak (small-order) public key satisfies the verification equation
/// but must be rejected. The identity point is such a key: with R the
/// identity and S = 0, [S]B - [k]A is the identity for any message.
#[test]
fn verify_rejects_weak_key_signature() {
let mut weak_key_bytes = [0u8; 32];
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
assert!(weak_vk.is_weak(), "identity point must be a weak key");
let cert_hash = sample_cert_hash();
let mut forged = [0u8; 64];
forged[0] = 1; // R = identity point, S = 0
let forged_sig = B64U.encode(forged);
// Build an attestation with the weak key as signer and the forged
// signature. The weak-key check happens at verify time.
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&weak_key_bytes);
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
att.sig = forged_sig;
let err = att.verify_signature(cert_hash).unwrap_err();
assert!(
matches!(err, Error::Signature(_)),
"signature under a weak (small-order) public key must be rejected"
);
}
}
+1 -12
View File
@@ -1,9 +1,8 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.7.1" # x-release-please-version
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
@@ -22,18 +21,8 @@ multihash-codetable = { workspace = true }
cid = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
url = { workspace = true, optional = true }
zeroize = { version = "1", features = ["derive"] }
pkcs8 = { version = "0.10", features = ["pem", "std"] }
curve25519-dalek = "4"
crypto_box = { version = "0.9", features = ["std", "chacha20"] }
chacha20poly1305 = "0.10"
[dev-dependencies]
tokio = { workspace = true }
# Not optional here: the redirect matrix must run under a bare
# `cargo test -p gitlawb-core`, with no feature selected.
url = { workspace = true }
[features]
redirect = ["dep:url"]
+2 -35
View File
@@ -6,8 +6,6 @@
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
//! Nodes that receive a certificate with an unknown version MUST reject it.
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -137,14 +135,10 @@ impl RefUpdateCert {
/// Check if this certificate satisfies a threshold of valid signatures
/// from the provided set of authorized maintainer DIDs.
///
/// Counts distinct signer DIDs, not signature entries: a repeated
/// signature from the same maintainer counts once.
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
let valid = self.verify_all()?;
let distinct_signers: HashSet<&Did> =
valid.iter().filter(|d| maintainers.contains(d)).collect();
Ok(distinct_signers.len() >= threshold)
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
Ok(count >= threshold)
}
/// Validate the certificate structure (not signatures).
@@ -350,33 +344,6 @@ mod tests {
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
}
#[test]
fn satisfies_threshold_rejects_duplicated_signature() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let kp3 = Keypair::generate();
let repo_did = kp1.did();
let mut cert = RefUpdateCert::new(
repo_did,
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
// Copy-paste the only real signature onto the certificate a second
// time. Same signer, same valid signature, still one real signer.
let dup = cert.signatures[0].clone();
cert.signatures.push(dup);
let maintainers = vec![kp1.did(), kp2.did(), kp3.did()];
// Two signature entries but a single distinct signer must not
// satisfy a 2-of-3 threshold.
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
}
#[test]
fn threshold_zero_is_always_satisfied() {
let kp = Keypair::generate();
-45
View File
@@ -64,19 +64,6 @@ impl Cid {
}
}
/// True when `s` parses as a CIDv1 with the raw codec — the exact shape
/// [`Cid::from_git_object_bytes`] produces and the `/ipfs` resolver looks up.
/// A legacy provider CID (Kubo dag-pb, Pinata CIDv0) parses to a different
/// version or codec and returns `false`, marking it an opportunistic-repair
/// candidate. Decidable from the string alone (no object bytes), so the pin path
/// can gate the byte-read/recompute cost on it and leave non-legacy rows at the
/// existing DB-only skip cost. An unparseable string is non-canonical (`false`).
pub fn is_raw_cidv1(s: &str) -> bool {
s.parse::<CidGeneric<64>>()
.map(|c| c.version() == cid::Version::V1 && c.codec() == RAW)
.unwrap_or(false)
}
impl fmt::Display for Cid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
@@ -190,38 +177,6 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn is_raw_cidv1_classifies_codec_from_string() {
// The canonical resolver key: CIDv1 + raw codec → not a repair candidate.
let raw = Cid::from_git_object_bytes(b"blob 5\0hello");
assert!(
is_raw_cidv1(raw.as_str()),
"from_git_object_bytes output is CIDv1/raw"
);
// A CIDv0 (Pinata dag-pb legacy shape) → repair candidate.
assert!(
!is_raw_cidv1("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"),
"a CIDv0 dag-pb value is a legacy-repair candidate"
);
// A CIDv1 with the dag-pb codec (the Kubo above-block-size root) over the
// same multihash → still a repair candidate (codec, not just version).
let parsed = raw.as_str().parse::<CidGeneric<64>>().unwrap();
const DAG_PB: u64 = 0x70;
let dagpb = CidGeneric::<64>::new_v1(DAG_PB, *parsed.hash()).to_string();
assert!(
!is_raw_cidv1(&dagpb),
"a CIDv1 dag-pb value is a legacy-repair candidate"
);
// Garbage is non-canonical.
assert!(
!is_raw_cidv1("not-a-cid"),
"an unparseable string is non-canonical"
);
}
#[test]
fn sha256_hex_of_empty_input_is_well_known() {
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
-61
View File
@@ -80,19 +80,6 @@ impl Did {
)));
}
// Refuse an oversized id before decoding. base58 decoding is quadratic
// in its input, and both callers of this function take the string from
// an untrusted request: the unauthenticated peer-announce gate, and the
// HTTP-signature keyid. An ed25519 did:key method-id is a fixed 48
// characters, so this bound is slack rather than a behavior change, and
// it has to sit ahead of the decode to be worth anything.
const MAX_METHOD_ID_LEN: usize = 64;
if self.method_id().len() > MAX_METHOD_ID_LEN {
return Err(Error::InvalidDid(
"did:key method-specific id too long".to_string(),
));
}
let (_, bytes) =
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
@@ -321,52 +308,4 @@ mod tests {
assert_eq!(Did::web("example.com").method_id(), "example.com");
assert_eq!(Did::gitlawb("z6MkSomeKey").method_id(), "z6MkSomeKey");
}
/// An oversized method-specific id is refused on its length, before the
/// base58 decode runs.
///
/// base58 decoding is quadratic in the input, and `to_verifying_key` is
/// reached from two places that take an attacker-supplied string: the
/// unauthenticated peer-announce gate, and the HTTP-signature keyid. A
/// 64k-character DID measured 8s through the router without this guard
/// against 4.6ms with it, on a tokio worker with no spawn_blocking, so a
/// single caller could buy CPU-seconds by the thousand.
///
/// Asserting the MESSAGE is what makes this load-bearing: an oversized id
/// errors either way, so a test that only checked `is_err()` would pass
/// with the guard removed, having merely paid the cost it exists to avoid.
#[test]
fn to_verifying_key_rejects_an_oversized_method_id_before_decoding() {
let oversized = format!("did:key:z{}", "z".repeat(64_000));
let did: Did = oversized.parse().expect("method is still `key`");
let start = std::time::Instant::now();
let err = did.to_verifying_key().expect_err("must be refused");
let elapsed = start.elapsed();
assert!(
err.to_string().contains("too long"),
"must be refused on length, not after the decode: {err}"
);
assert!(
elapsed < std::time::Duration::from_secs(1),
"refusal must not pay the quadratic decode: took {elapsed:?}"
);
}
/// The bound must not reject a real key. Every `did:key` this project
/// mints is a fixed 48 characters, so the limit is slack, not a behavior
/// change.
#[test]
fn to_verifying_key_still_accepts_a_real_did_key() {
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(&[7u8; 32]);
let did = Did::from_verifying_key(&signing.verifying_key());
assert_eq!(
did.method_id().len(),
48,
"did:key method-id is fixed-width"
);
did.to_verifying_key().expect("a real did:key must resolve");
}
}
-296
View File
@@ -1,296 +0,0 @@
//! Envelope encryption for withheld blobs (Option B). A random content key
//! encrypts the blob (XChaCha20-Poly1305); the content key is wrapped to each
//! recipient via an X25519 box keyed from their Ed25519 `did:key`. The node
//! seals with public keys only; readers open with their own private key.
use crate::identity::Keypair;
use anyhow::{Context, Result};
use ed25519_dalek::VerifyingKey;
use zeroize::Zeroizing;
/// X25519 public key (Montgomery u) for an Ed25519 verifying key.
fn x25519_public(vk: &VerifyingKey) -> Result<[u8; 32]> {
use curve25519_dalek::edwards::CompressedEdwardsY;
let edwards = CompressedEdwardsY::from_slice(vk.as_bytes())
.ok()
.and_then(|c| c.decompress())
.context("verifying key is not a valid edwards point")?;
Ok(edwards.to_montgomery().to_bytes())
}
/// X25519 secret scalar for an Ed25519 seed (SHA-512 of seed, lower 32, clamped).
/// Returns the scalar wrapped in `Zeroizing`, and scrubs the intermediate
/// SHA-512 digest, so no copy of this secret material lingers in freed memory.
fn x25519_secret_from_seed(seed: &[u8; 32]) -> Zeroizing<[u8; 32]> {
use sha2::{Digest, Sha512};
use zeroize::Zeroize;
let mut h = Sha512::digest(seed);
let mut s = Zeroizing::new([0u8; 32]);
s.copy_from_slice(&h[..32]);
s[0] &= 248;
s[31] &= 127;
s[31] |= 64;
h.as_mut_slice().zeroize();
s
}
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use crypto_box::{
aead::{AeadCore, OsRng},
ChaChaBox, PublicKey as XPublic, SecretKey as XSecret,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
const MAGIC: &[u8] = b"GLENC";
const VERSION: u8 = 2;
#[derive(Serialize, Deserialize)]
struct Recipient {
eph: String, // base64 ephemeral x25519 pubkey (32B)
nonce: String, // base64 box nonce (24B)
wrap: String, // base64 wrapped content key
}
#[derive(Serialize, Deserialize)]
struct Header {
alg: String,
nonce: String, // base64 body nonce (24B)
recipients: Vec<Recipient>,
}
/// Encrypt `plaintext` so any of `recipients` (Ed25519 keys) can decrypt.
pub fn seal_blob(plaintext: &[u8], recipients: &[VerifyingKey]) -> Result<Vec<u8>> {
if recipients.is_empty() {
return Err(anyhow::anyhow!("seal_blob: no recipients"));
}
let mut content_key = [0u8; 32];
OsRng.fill_bytes(&mut content_key);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
.map_err(|e| anyhow::anyhow!("content key: {e}"))?;
let mut body_nonce = [0u8; 24];
OsRng.fill_bytes(&mut body_nonce);
let body = body_cipher
.encrypt(XNonce::from_slice(&body_nonce), plaintext)
.map_err(|e| anyhow::anyhow!("body encrypt: {e}"))?;
let mut wrapped = Vec::with_capacity(recipients.len());
for vk in recipients {
let recip_x = XPublic::from(x25519_public(vk)?);
let eph = XSecret::generate(&mut OsRng);
let abox = ChaChaBox::new(&recip_x, &eph);
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let ct = abox
.encrypt(&nonce, &content_key[..])
.map_err(|e| anyhow::anyhow!("wrap: {e}"))?;
wrapped.push(Recipient {
eph: B64.encode(eph.public_key().as_bytes()),
nonce: B64.encode(nonce),
wrap: B64.encode(ct),
});
}
let header = Header {
alg: "xchacha20poly1305".into(),
nonce: B64.encode(body_nonce),
recipients: wrapped,
};
let header_json = serde_json::to_vec(&header).context("encode header")?;
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
out.extend_from_slice(&header_json);
out.extend_from_slice(&body);
Ok(out)
}
/// Decrypt an envelope with `keypair`. Errors if not a recipient or on auth fail.
pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
let mut p = 0;
if envelope.len() < MAGIC.len() + 1 + 4 || &envelope[..MAGIC.len()] != MAGIC {
return Err(anyhow::anyhow!("bad envelope magic"));
}
p += MAGIC.len();
if envelope[p] != VERSION {
return Err(anyhow::anyhow!("unsupported envelope version"));
}
p += 1;
let hlen = u32::from_le_bytes(envelope[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header: Header =
serde_json::from_slice(envelope.get(p..p + hlen).context("truncated header")?)
.context("decode header")?;
let body = &envelope[p + hlen..];
let my_x = XSecret::from(*x25519_secret_from_seed(&keypair.to_seed()));
// Identities are blinded: no entry says which recipient it belongs to, so
// try each one. The ChaChaBox AEAD tag authenticates, so exactly the
// reader's own entry unwraps; every other entry fails cleanly.
let mut content_key: Option<Vec<u8>> = None;
for entry in &header.recipients {
let eph = match B64
.decode(&entry.eph)
.ok()
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
{
Some(b) => XPublic::from(b),
None => continue,
};
// from_slice panics on a wrong length, and the envelope is attacker
// controlled, so validate the 24-byte box nonce before using it.
let nonce = match B64
.decode(&entry.nonce)
.ok()
.and_then(|n| <[u8; 24]>::try_from(n.as_slice()).ok())
{
Some(n) => n,
None => continue,
};
let wrap = match B64.decode(&entry.wrap) {
Ok(w) => w,
Err(_) => continue,
};
let abox = ChaChaBox::new(&eph, &my_x);
if let Ok(ck) = abox.decrypt(
crypto_box::aead::generic_array::GenericArray::from_slice(&nonce),
wrap.as_slice(),
) {
content_key = Some(ck);
break;
}
}
let content_key = content_key.context("not a recipient of this envelope")?;
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
.map_err(|e| anyhow::anyhow!("content key: {e}"))?;
let body_nonce = B64
.decode(&header.nonce)
.ok()
.and_then(|n| <[u8; 24]>::try_from(n.as_slice()).ok())
.context("invalid body nonce")?;
body_cipher
.decrypt(XNonce::from_slice(&body_nonce), body)
.map_err(|_| anyhow::anyhow!("body decrypt failed"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Keypair;
#[test]
fn ed25519_to_x25519_keypair_agrees() {
// The X25519 public derived from the Ed25519 public must equal the
// X25519 public of the X25519 secret derived from the same seed.
let kp = Keypair::generate();
let seed = kp.to_seed();
let xpub_from_public = x25519_public(&kp.verifying_key()).unwrap();
let xsec = x25519_secret_from_seed(&seed);
let xpub_from_secret = crypto_box::SecretKey::from(*xsec).public_key().to_bytes();
assert_eq!(xpub_from_public, xpub_from_secret);
}
#[test]
fn seal_open_round_trip_for_recipients() {
let owner = Keypair::generate();
let reader_a = Keypair::generate();
let reader_b = Keypair::generate();
let msg = b"private blob contents";
let env = seal_blob(msg, &[owner.verifying_key(), reader_a.verifying_key()]).unwrap();
assert_eq!(open_blob(&env, &owner).unwrap(), msg);
assert_eq!(open_blob(&env, &reader_a).unwrap(), msg);
assert!(
open_blob(&env, &reader_b).is_err(),
"non-recipient must fail"
);
}
#[test]
fn tampered_envelope_fails() {
let owner = Keypair::generate();
let mut env = seal_blob(b"hi", &[owner.verifying_key()]).unwrap();
let last = env.len() - 1;
env[last] ^= 0x01;
assert!(open_blob(&env, &owner).is_err());
}
#[test]
fn v2_header_contains_no_recipient_pubkey() {
// The blinded envelope header must not carry any recipient's public key.
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
// Slice out the header bytes using the envelope framing:
// MAGIC | version(1B) | header_len(4B LE) | header_json | body
let mut p = MAGIC.len() + 1; // skip MAGIC + version byte
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header = &env[p..p + hlen];
let header_str = String::from_utf8_lossy(header);
let pubkey_b64 = B64.encode(reader.verifying_key().as_bytes());
assert!(
!header_str.contains(&pubkey_b64),
"recipient public key must not appear in the blinded header"
);
}
#[test]
fn v1_envelope_is_rejected() {
let reader = Keypair::generate();
let mut env = seal_blob(b"hi", &[reader.verifying_key()]).unwrap();
// Flip the version byte (immediately after MAGIC) from 2 to 1.
env[MAGIC.len()] = 1;
let err = open_blob(&env, &reader).unwrap_err();
assert!(
err.to_string().contains("unsupported envelope version"),
"expected version-rejection error, got: {err}"
);
}
#[test]
fn malformed_nonce_returns_err_not_panic() {
// from_slice panics on wrong-length input; a crafted envelope on the
// public recovery path must surface an error, never panic.
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
// Split the envelope framing into header JSON and body.
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header_bytes = &env[p..p + hlen];
let body = &env[p + hlen..];
let reframe = |header: &serde_json::Value| -> Vec<u8> {
let hj = serde_json::to_vec(header).unwrap();
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.extend_from_slice(&(hj.len() as u32).to_le_bytes());
out.extend_from_slice(&hj);
out.extend_from_slice(body);
out
};
let bad_nonce = serde_json::Value::String(B64.encode([0u8; 12]));
// Corrupted per-recipient nonce: entry is skipped, no match.
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
header["recipients"][0]["nonce"] = bad_nonce.clone();
assert!(open_blob(&reframe(&header), &reader).is_err());
// Corrupted body nonce: unwrap succeeds, body nonce is rejected.
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
header["nonce"] = bad_nonce;
assert!(open_blob(&reframe(&header), &reader).is_err());
}
}
+14 -371
View File
@@ -8,16 +8,11 @@
//!
//! RFC 9421 headers produced by `sign_request`:
//! Content-Digest: sha-256=:base64hash:
//! Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:z6Mk...";alg="ed25519";created=<unix>;nonce="<32 hex chars>"
//! Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:z6Mk...";alg="ed25519";created=<unix>
//! Signature: sig1=:base64signature:
//!
//! The `nonce` is a per-request 128-bit value. It sits in the parameter tail,
//! which is what `@signature-params` is built from, so it is covered by the
//! signature without appearing in the covered-component list.
use base64::{engine::general_purpose::STANDARD, Engine};
use chrono::Utc;
use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -28,28 +23,15 @@ use crate::{Error, Result};
/// The component identifiers covered by every gitlawb signature.
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
/// How old a signature may be and still verify.
///
/// Do not shrink this without measuring large-pack pushes: the verifier
/// buffers the entire request body before checking `created`, so a multi-GB
/// pack spends its upload time inside this budget.
pub const MAX_SIGNATURE_AGE_SECS: i64 = 300;
/// How far into the future a signature's `created` may sit. Absorbs ordinary
/// clock skew without letting a signer pre-date its way to a wider window.
pub const MAX_FUTURE_SKEW_SECS: i64 = 30;
/// The three headers produced by RFC 9421 signing.
#[derive(Debug, Clone)]
pub struct SignedHeaders {
/// `Content-Digest: sha-256=:base64:`
pub content_digest: String,
/// `Signature-Input: sig1=(...);keyid="...";alg="ed25519";created=<unix>;nonce="..."`
/// `Signature-Input: sig1=(...);keyid="...";alg="ed25519";created=<unix>`
pub signature_input: String,
/// `Signature: sig1=:base64:`
pub signature: String,
/// The per-request nonce carried in `Signature-Input`.
pub nonce: String,
}
/// A parsed RFC 9421 signature (from Signature-Input + Signature headers).
@@ -60,8 +42,6 @@ pub struct HttpSignature {
pub created: i64,
pub components: Vec<String>,
pub signature_bytes: Vec<u8>,
/// The `nonce` param, absent on signatures from a pre-nonce signer.
pub nonce: Option<String>,
}
impl HttpSignature {
@@ -84,15 +64,6 @@ impl HttpSignature {
.find(')')
.ok_or_else(|| Error::HttpSignature("missing ')' in Signature-Input".into()))?;
// Both parentheses are located independently, so a header carrying them
// out of order (`sig1=)(`) would build a reversed range and panic. This
// runs on unauthenticated header text, so it has to be a rejection.
if close < open {
return Err(Error::HttpSignature(
"'(' must precede ')' in Signature-Input".into(),
));
}
let components_str = &rest[open + 1..close];
let params_str = &rest[close + 1..]; // starts with ';'
@@ -122,9 +93,6 @@ impl HttpSignature {
.parse()
.map_err(|_| Error::HttpSignature("invalid created timestamp".into()))?;
// Optional: absent on signatures from a signer that predates the nonce.
let nonce = params.get("nonce").map(|v| v.trim_matches('"').to_string());
// Signature: sig1=:base64bytes:
let sig_b64 = sig_header
.trim()
@@ -142,39 +110,18 @@ impl HttpSignature {
created,
components,
signature_bytes,
nonce,
})
}
/// Reject a `created` timestamp outside the acceptance window.
///
/// The window is deliberately asymmetric. A signature may be up to
/// [`MAX_SIGNATURE_AGE_SECS`] old, because the verifier buffers the whole
/// request body before this check runs and the git routes accept packs up
/// to gigabytes, so upload time is charged against that budget. It may be
/// only [`MAX_FUTURE_SKEW_SECS`] in the future, which is enough to absorb
/// ordinary clock skew.
///
/// This used to compare `(now - created).abs()` against a single bound,
/// which let a signer pre-date `created` and hold a signature valid for
/// twice the intended span (#253).
/// Reject if the `created` timestamp is more than 5 minutes from now.
pub fn check_created(&self) -> Result<()> {
let now = Utc::now().timestamp();
if self.created > now + MAX_FUTURE_SKEW_SECS {
let skew = (now - self.created).abs();
if skew > 300 {
return Err(Error::HttpSignature(format!(
"signature is dated {}s in the future (max {MAX_FUTURE_SKEW_SECS}s)",
self.created - now
"clock skew too large: {skew}s (max 300s)"
)));
}
let age = now - self.created;
if age > MAX_SIGNATURE_AGE_SECS {
return Err(Error::HttpSignature(format!(
"signature is {age}s old (max {MAX_SIGNATURE_AGE_SECS}s)"
)));
}
Ok(())
}
@@ -182,7 +129,7 @@ impl HttpSignature {
pub fn missing_components(&self) -> Vec<&str> {
COVERED_COMPONENTS
.iter()
.filter(|c| !self.components.iter().any(|s| s.as_str() == **c))
.filter(|c| !self.components.contains(&c.to_string()))
.copied()
.collect()
}
@@ -211,36 +158,6 @@ pub fn build_signing_string(
Ok(lines.join("\n"))
}
/// Render `COVERED_COMPONENTS` as the parenthesised component list that goes
/// inside `Signature-Input`, e.g. `"@method" "@path" "content-digest"`.
///
/// Both the wire header and [`build_signing_string`]'s input are built from
/// this one source. Writing the list out by hand in the header while passing
/// the const to the signing-string builder lets a client sign over one set
/// while advertising another, which no verifier can reconcile.
fn covered_components_list() -> String {
COVERED_COMPONENTS
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(" ")
}
/// The request-derived value for every component this signer knows how to
/// cover. Kept alongside [`covered_components_list`] so the guard test can
/// check the two agree without signing anything.
fn request_values_for(
method: &str,
path_and_query: &str,
content_digest: &str,
) -> HashMap<String, String> {
let mut values = HashMap::new();
values.insert("@method".to_string(), method.to_uppercase());
values.insert("@path".to_string(), path_and_query.to_string());
values.insert("content-digest".to_string(), content_digest.to_string());
values
}
/// Sign an HTTP request per RFC 9421 and return the three headers to inject.
pub fn sign_request(
keypair: &Keypair,
@@ -252,32 +169,22 @@ pub fn sign_request(
let content_digest = compute_content_digest(body);
let did = keypair.did();
// Full Signature-Input header value. The advertised component list is
// derived from COVERED_COMPONENTS rather than written out, so the wire
// header and the list we actually sign over cannot drift apart.
let advertised = covered_components_list();
// 128 bits from the OS CSPRNG, appended to the parameter tail. The tail is
// sliced into `sig_params_value` below and so lands in the
// `@signature-params` line, which puts the nonce under the signature
// (RFC 9421 §2.3) without touching COVERED_COMPONENTS. Verifiers that
// predate the nonce rebuild `@signature-params` from the received header
// text, so they keep verifying an unknown parameter unchanged.
let nonce = generate_nonce();
// Full Signature-Input header value
let signature_input = format!(
r#"sig1=({advertised});keyid="{did}";alg="ed25519";created={created};nonce="{nonce}""#
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
// The @signature-params component value is the part after "sig1="
let sig_params_value = &signature_input["sig1=".len()..];
let request_values = request_values_for(method, path_and_query, &content_digest);
let mut request_values = HashMap::new();
request_values.insert("@method".to_string(), method.to_uppercase());
request_values.insert("@path".to_string(), path_and_query.to_string());
request_values.insert("content-digest".to_string(), content_digest.clone());
// Guarded by `sign_request_supplies_every_covered_component`: that test
// fails cleanly if COVERED_COMPONENTS gains an entry `request_values_for`
// cannot supply, which is the only way this could fail.
let signing_string =
build_signing_string(COVERED_COMPONENTS, sig_params_value, &request_values)
.expect("request_values_for covers COVERED_COMPONENTS (see guard test)");
.expect("required components always present when building");
let sig_bytes = keypair.sign(signing_string.as_bytes());
let sig_b64 = STANDARD.encode(sig_bytes.to_bytes());
@@ -286,17 +193,9 @@ pub fn sign_request(
content_digest,
signature_input,
signature: format!("sig1=:{sig_b64}:"),
nonce,
}
}
/// Draw a fresh 128-bit nonce from the OS CSPRNG, hex-encoded.
fn generate_nonce() -> String {
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// Compute RFC 9421 Content-Digest value: `sha-256=:base64(sha256(body)):`
pub fn compute_content_digest(body: &[u8]) -> String {
let mut hasher = Sha256::new();
@@ -424,95 +323,6 @@ mod tests {
assert!(d.len() > 12);
}
/// U1 guard: the component list on the wire must be exactly what we sign
/// over. Load-bearing by mutation, not by red-then-green: adding a fourth
/// entry to COVERED_COMPONENTS turns this red.
#[test]
fn signature_input_advertises_exactly_covered_components() {
let kp = Keypair::generate();
let headers = sign_request(&kp, "POST", "/api/test", b"body");
let parsed = HttpSignature::parse(&headers.signature_input, &headers.signature)
.expect("emitted Signature-Input must parse");
assert_eq!(
parsed.components, COVERED_COMPONENTS,
"the advertised component list drifted from COVERED_COMPONENTS"
);
}
/// U1 guard: every covered component must have a request-derived value.
/// This is the check that fails *cleanly* when COVERED_COMPONENTS grows,
/// instead of letting sign_request panic inside build_signing_string.
#[test]
fn sign_request_supplies_every_covered_component() {
let values = request_values_for("POST", "/api/test", "sha-256=:abc:");
let missing: Vec<&str> = COVERED_COMPONENTS
.iter()
.copied()
.filter(|c| !values.contains_key(*c))
.collect();
assert!(
missing.is_empty(),
"COVERED_COMPONENTS entries with no value in request_values_for: {missing:?}. \
sign_request would panic on every call; add the value or drop the component."
);
}
/// U3 boundary: the forward bound is real and tight.
#[test]
fn future_dated_created_outside_skew_is_rejected() {
let kp = Keypair::generate();
let mut headers = sign_request(&kp, "GET", "/api/v1/agents", b"");
let future = Utc::now().timestamp() + MAX_FUTURE_SKEW_SECS + 1;
headers.signature_input = headers
.signature_input
.split(";created=")
.next()
.map(|head| format!("{head};created={future}"))
.expect("signature_input carries created");
let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap();
assert!(
sig.check_created().is_err(),
"created beyond the forward skew allowance must be rejected"
);
}
/// U3 boundary: just inside the forward allowance still verifies, so the
/// bound absorbs real clock skew rather than being effectively zero.
#[test]
fn future_dated_created_within_skew_is_accepted() {
let kp = Keypair::generate();
let mut headers = sign_request(&kp, "GET", "/api/v1/agents", b"");
let future = Utc::now().timestamp() + MAX_FUTURE_SKEW_SECS - 1;
headers.signature_input = headers
.signature_input
.split(";created=")
.next()
.map(|head| format!("{head};created={future}"))
.expect("signature_input carries created");
let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap();
assert!(sig.check_created().is_ok(), "within skew must be accepted");
}
/// U3 regression: the backward budget must not be narrowed. A large pack
/// spends this long uploading before check_created ever runs.
#[test]
fn backward_budget_still_accepts_a_nearly_stale_signature() {
let kp = Keypair::generate();
let mut headers = sign_request(&kp, "POST", "/api/test", b"body");
let old = Utc::now().timestamp() - (MAX_SIGNATURE_AGE_SECS - 20);
headers.signature_input = headers
.signature_input
.split(";created=")
.next()
.map(|head| format!("{head};created={old}"))
.expect("signature_input carries created");
let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap();
assert!(
sig.check_created().is_ok(),
"a signature well inside the age budget must still verify"
);
}
#[test]
fn clock_skew_rejection() {
let kp = Keypair::generate();
@@ -574,173 +384,6 @@ mod tests {
assert_ne!(d1, d2);
}
/// U2: the nonce is per-call, so two signatures over an identical request
/// differ and so do the `Signature-Input` values they were computed over.
#[test]
fn two_signatures_over_an_identical_request_differ() {
let kp = Keypair::generate();
let a = sign_request(&kp, "POST", "/api/register", b"same body");
let b = sign_request(&kp, "POST", "/api/register", b"same body");
assert_ne!(
a.nonce, b.nonce,
"each sign_request call must draw a fresh nonce"
);
assert_ne!(
a.signature_input, b.signature_input,
"the nonce must reach the wire header, not just the struct"
);
assert_ne!(
a.signature, b.signature,
"a covered nonce must change the signature bytes"
);
}
/// U2: `parse` round-trips the nonce, and a nonce-less header still parses.
#[test]
fn parse_exposes_nonce_and_tolerates_its_absence() {
let kp = Keypair::generate();
let headers = sign_request(&kp, "POST", "/api/register", b"body");
let parsed = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap();
assert_eq!(
parsed.nonce.as_deref(),
Some(headers.nonce.as_str()),
"the parsed nonce must be the one that was signed, unquoted"
);
let did = kp.did();
let nonceless = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
);
let parsed = HttpSignature::parse(&nonceless, &headers.signature).unwrap();
assert_eq!(
parsed.nonce, None,
"a pre-U2 signer's header must parse with no nonce"
);
}
/// U2: 128 bits of entropy, hex-encoded.
#[test]
fn nonce_is_128_bits() {
let kp = Keypair::generate();
let headers = sign_request(&kp, "GET", "/api/v1/agents", b"");
let raw = hex::decode(&headers.nonce).expect("nonce must be hex");
assert_eq!(raw.len(), 16, "nonce must carry exactly 128 bits");
}
/// U2 backward compatibility, the load-bearing one: a nonce-bearing
/// signature still verifies through the same reconstruct-and-verify flow
/// the node runs, with no verifier change.
#[test]
fn nonce_bearing_signature_verifies_through_the_unchanged_flow() {
use crate::identity::verify;
let kp = Keypair::generate();
let body = b"{\"did\":\"did:key:z6Mk\"}";
let headers = sign_request(&kp, "POST", "/api/register", body);
assert!(
headers.signature_input.contains(";nonce=\""),
"this test is only meaningful once the header carries a nonce"
);
let sig = HttpSignature::parse(&headers.signature_input, &headers.signature).unwrap();
assert_eq!(
sig.components, COVERED_COMPONENTS,
"the nonce must not disturb the covered-component list"
);
// Exactly what the node does: rebuild @signature-params from the
// received header text and verify over it.
let sig_params_value = headers.signature_input.strip_prefix("sig1=").unwrap();
let mut request_values = HashMap::new();
request_values.insert("@method".to_string(), "POST".to_string());
request_values.insert("@path".to_string(), "/api/register".to_string());
request_values.insert("content-digest".to_string(), headers.content_digest.clone());
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string =
build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let vk = sig.key_id.to_verifying_key().unwrap();
let sig_bytes: [u8; 64] = sig.signature_bytes.clone().try_into().unwrap();
assert!(
verify(&vk, signing_string.as_bytes(), &sig_bytes).is_ok(),
"a nonce-bearing signature must verify unchanged"
);
// And the nonce is genuinely covered: strip it and verification fails.
let without_nonce = sig_params_value
.split(";nonce=")
.next()
.unwrap()
.to_string();
let tampered =
build_signing_string(&components_ref, &without_nonce, &request_values).unwrap();
assert!(
verify(&vk, tampered.as_bytes(), &sig_bytes).is_err(),
"the nonce must be inside @signature-params, not decoration"
);
}
/// A `)` before the `(` must be rejected, not sliced backwards. The two
/// parentheses are found independently, so an out-of-order pair used to
/// build a reversed range and panic inside `parse` — reachable with two
/// headers and no credentials at all.
#[test]
fn reversed_parentheses_are_rejected_not_panicked() {
let err = HttpSignature::parse("sig1=)(", "sig1=:AAAA:")
.expect_err("reversed parentheses must return an error");
let msg = err.to_string();
assert!(
msg.contains("'(' must precede ')'"),
"expected the paren-ordering error, got: {msg}"
);
}
/// `parse` runs on attacker-controlled header text before any
/// authentication, so no input may reach a panic. Every entry here must
/// come back as `Err`; the test failing by panic is the case it exists for.
#[test]
fn malformed_signature_input_always_errors_never_panics() {
let did = Keypair::generate().did();
let long = format!("sig1=({}", "(".repeat(4096));
let cases: Vec<(&str, &str)> = vec![
("", "sig1=:AAAA:"),
("sig1=", "sig1=:AAAA:"),
("sig1=()", "sig1=:AAAA:"),
("sig1=)(", "sig1=:AAAA:"),
("sig1=(", "sig1=:AAAA:"),
("sig1=)", "sig1=:AAAA:"),
("sig1=))((", "sig1=:AAAA:"),
("sig1=;", "sig1=:AAAA:"),
(";", "sig1=:AAAA:"),
("sig1=)(", ""),
("sig1=)(", "sig1=:not base64!:"),
("sig1=(\"@method\");created=notanumber", "sig1=:AAAA:"),
("sig1=(\"@method\");created=", "sig1=:AAAA:"),
("sig1=(é)é;created=1000", "sig1=:AAAA:"),
("sig1=)é(", "sig1=:AAAA:"),
("sig1=é)(é", "sig1=:AAAA:"),
(&long, "sig1=:AAAA:"),
];
for (input, header) in cases {
let out = HttpSignature::parse(input, header);
assert!(
out.is_err(),
"malformed Signature-Input {input:?} parsed successfully"
);
}
// An unbalanced quote inside an otherwise well-formed header is
// tolerated: `trim_matches('"')` strips whatever quotes are there.
// The invariant that matters is that it returns rather than panics, so
// assert it parses and leaves the quote-stripped component behind.
let unbalanced = format!(r#"sig1=("@method);keyid="{did}";alg="ed25519";created=1000"#);
let parsed = HttpSignature::parse(&unbalanced, "sig1=:AAAA:")
.expect("an unbalanced quote is tolerated, not a parse failure");
assert_eq!(parsed.components, vec!["@method".to_string()]);
}
#[test]
fn method_uppercased_in_signing_string() {
let kp = Keypair::generate();
+3 -40
View File
@@ -52,9 +52,7 @@ impl Keypair {
URL_SAFE_NO_PAD.encode(sig.to_bytes())
}
/// The raw 32-byte Ed25519 seed, wrapped in `Zeroizing` so the copy is
/// scrubbed on drop. This is the only seed accessor; it is used to derive
/// the X25519 secret for envelope decryption (see `crate::encrypt`).
/// Export the signing key as raw 32-byte seed (wrapped in Zeroizing).
pub fn to_seed(&self) -> Zeroizing<[u8; 32]> {
Zeroizing::new(self.signing_key.to_bytes())
}
@@ -78,9 +76,10 @@ impl Keypair {
/// Verify an Ed25519 signature.
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
use ed25519_dalek::Verifier;
let sig = Signature::from_bytes(sig_bytes);
verifying_key
.verify_strict(msg, &sig)
.verify(msg, &sig)
.map_err(|_| Error::SignatureInvalid)
}
@@ -167,20 +166,6 @@ mod tests {
assert_eq!(kp.verifying_key(), kp2.verifying_key());
}
// `to_seed()` is the only seed accessor on `Keypair` (the raw, unzeroized
// `seed_bytes()` was removed in #41). Pin its return type so a bare-array
// accessor can't slip back in, and confirm it is deterministic. The
// from_seed -> verifying_key round-trip is already covered by
// `from_seed_round_trip`.
#[test]
fn to_seed_returns_zeroizing_and_is_deterministic() {
let kp = Keypair::generate();
// The type annotation is the regression guard: the seed must be handed
// out wrapped in `Zeroizing`, never as a bare `[u8; 32]`.
let seed: Zeroizing<[u8; 32]> = kp.to_seed();
assert_eq!(*kp.to_seed(), *seed); // stable across calls
}
#[test]
fn sign_b64_decodes_to_valid_signature() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
@@ -208,28 +193,6 @@ mod tests {
);
}
#[test]
fn verify_rejects_weak_key_signature() {
// Regression guard for strict verification: a signature forged under a
// weak (small-order) public key satisfies the verification equation
// but must be rejected. The identity point is such a key: with R the
// identity and S = 0, [S]B - [k]A is the identity for any message.
let mut weak_key_bytes = [0u8; 32];
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
assert!(weak_vk.is_weak(), "identity point must be a weak key");
let msg = b"forged message";
let mut forged = [0u8; 64];
forged[0] = 1; // R = identity point, S = 0
let result = verify(&weak_vk, msg, &forged);
assert!(
result.is_err(),
"signature under a weak (small-order) public key must be rejected"
);
}
#[test]
fn signed_payload_round_trip() {
let kp = Keypair::generate();
-12
View File
@@ -1,21 +1,9 @@
pub mod cert;
pub mod cid;
pub mod did;
pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod identity;
pub mod node_denial;
// `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
// pays. `test` is in the cfg so `cargo test -p gitlawb-core` still compiles and
// runs the matrix below with no feature selected; without it the tests would
// silently not run, which is the failure this module exists to prevent.
#[cfg(any(feature = "redirect", test))]
pub mod redirect;
pub mod sanitize;
pub mod scan_token;
pub mod ucan;
pub use error::Error;
-229
View File
@@ -1,229 +0,0 @@
//! The denial codes the node puts in `X-Gitlawb-Error`, in one place.
//!
//! When the node refuses a write it answers with an `X-Gitlawb-Error` header
//! and a matching `error` field in the JSON body. The `gl` client keys off that
//! header to turn a denial into a hard error instead of handing the caller a
//! response that pretty-prints like a success.
//!
//! # Why this is not named for the signature ledger
//!
//! It was, and only the ledger's `consume_signature` middleware built these
//! responses. Then the per-client flood brake in `rate_limit` started answering
//! 429 on the same five route groups the ledger's `signature_ledger_full` 429
//! already covered, and the only thing telling the two apart was the *absence*
//! of a header — the same signal we already accept a proxy may strip. Two
//! unrelated refusals were indistinguishable on the wire.
//!
//! The fix is to give the brake a code, which means this type's subject is the
//! wire vocabulary rather than one middleware. Putting [`RateLimited`] here
//! instead of in a sibling enum is deliberate: the exhaustive match in `gl` is
//! the whole reason the type exists, and a parallel type would need its own
//! parallel match, which is exactly the duplication that let
//! `signature_nonce_too_short` drift in the first place. One enum, one match,
//! one place to add a code.
//!
//! Both halves used to carry their own list of string literals, agreeing only
//! by having been typed the same way twice in two crates. They drifted exactly
//! as you would expect: `signature_nonce_too_short` was added to the node after
//! the client's list was written, so for a while the client returned `Ok(400)`
//! for that denial and the write looked like it might have happened.
//!
//! This enum is the single source of truth. The node builds its responses from
//! it, so the wire strings and statuses come from one place, and `gl` matches
//! it with no wildcard arm, so adding a variant here is a compile error in the
//! client until the client handles it.
//!
//! # Deliberately not `#[non_exhaustive]`
//!
//! Marking this `#[non_exhaustive]` would force every downstream crate to add a
//! wildcard arm, which is precisely the silent fallthrough this type exists to
//! prevent. The compile-time guarantee is worth more here than the freedom to
//! add a variant without touching the client: the client and the node ship from
//! this same repo and are versioned together.
//!
//! The guarantee is over the codes *this repo's node emits*, not over arbitrary
//! input. A client talks to nodes it does not control, so [`from_code`] returns
//! `Option` and an unrecognised string stays unrecognised at runtime.
//!
//! [`from_code`]: NodeDenial::from_code
//! [`RateLimited`]: NodeDenial::RateLimited
/// A refusal the node names in `X-Gitlawb-Error`.
///
/// The `as_str` value is the wire contract: it appears verbatim in the
/// `X-Gitlawb-Error` response header and in the body's `error` field, and
/// scripts match on it. Never change one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum NodeDenial {
/// 400 — the node requires a `nonce` in `Signature-Input` and the request
/// carried none. A pre-nonce client; the caller must upgrade.
NonceRequired,
/// 400 — the request carried a `nonce`, but too short to be unique. The
/// client already emits the parameter, it just does not fill it properly.
NonceTooShort,
/// 409 — the node already admitted a request bearing this signature.
Replayed,
/// 429 — too many unexpired signatures for this identity. A rate condition,
/// not a permanent refusal.
LedgerFull,
/// 500 — the ledger was reached without a verified identity, meaning the
/// node's own auth layer order is wrong. The one code that is not the
/// caller's fault.
IdentityMissing,
/// 503 — the ledger backend is down and the node is failing closed.
LedgerUnavailable,
/// 429 — the node's per-client flood brake refused the request before any
/// handler ran. Not a ledger outcome: it is keyed on the caller's resolved
/// network address, not on an identity or a signature, so it clears when
/// the address's window ages out and no amount of re-signing helps. Shares
/// its status with [`LedgerFull`](Self::LedgerFull), which is precisely why
/// it needs a code of its own.
RateLimited,
}
impl NodeDenial {
/// Every variant. Kept in the same order as the declaration so a reader can
/// check it by eye; the `all_is_exhaustive` test below asserts it
/// is complete.
pub const ALL: [Self; 7] = [
Self::NonceRequired,
Self::NonceTooShort,
Self::Replayed,
Self::LedgerFull,
Self::IdentityMissing,
Self::LedgerUnavailable,
Self::RateLimited,
];
/// The wire code, as it appears in `X-Gitlawb-Error` and in the body.
pub const fn as_str(self) -> &'static str {
match self {
Self::NonceRequired => "signature_nonce_required",
Self::NonceTooShort => "signature_nonce_too_short",
Self::Replayed => "signature_replayed",
Self::LedgerFull => "signature_ledger_full",
Self::IdentityMissing => "signature_identity_missing",
Self::LedgerUnavailable => "signature_ledger_unavailable",
Self::RateLimited => "rate_limited",
}
}
/// The HTTP status the node pairs with this code.
///
/// A `u16` rather than an `http::StatusCode` because this crate carries no
/// HTTP dependency; the node converts once, and its own test proves every
/// value here is a valid status.
pub const fn status(self) -> u16 {
match self {
Self::NonceRequired | Self::NonceTooShort => 400,
Self::Replayed => 409,
Self::LedgerFull | Self::RateLimited => 429,
Self::IdentityMissing => 500,
Self::LedgerUnavailable => 503,
}
}
/// Parse a code off the wire.
///
/// Returns `None` for anything this build does not know, because the string
/// comes from a node the caller does not control. An unknown code is not an
/// error to report; it is a denial this client cannot describe, and the
/// caller decides what to do with the raw response.
pub fn from_code(code: &str) -> Option<Self> {
Self::ALL.into_iter().find(|d| d.as_str() == code)
}
}
impl std::fmt::Display for NodeDenial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Guards [`NodeDenial::ALL`] against a variant added above it and
/// forgotten here: the match is exhaustive, so a new variant stops this
/// compiling, and the assertion catches the case where someone adds the arm
/// but not the `ALL` entry.
#[test]
fn all_is_exhaustive() {
fn tag(d: NodeDenial) -> u8 {
match d {
NodeDenial::NonceRequired => 0,
NodeDenial::NonceTooShort => 1,
NodeDenial::Replayed => 2,
NodeDenial::LedgerFull => 3,
NodeDenial::IdentityMissing => 4,
NodeDenial::LedgerUnavailable => 5,
NodeDenial::RateLimited => 6,
}
}
let mut tags: Vec<u8> = NodeDenial::ALL.iter().copied().map(tag).collect();
tags.sort_unstable();
assert_eq!(
tags,
(0..NodeDenial::ALL.len() as u8).collect::<Vec<_>>(),
"NodeDenial::ALL must list every variant exactly once",
);
}
/// The wire contract, pinned literally. These strings and statuses are what
/// deployed nodes emit and what deployed clients and scripts match on, so a
/// change here is a protocol break, not a rename.
#[test]
fn wire_codes_and_statuses_are_pinned() {
let pinned: [(NodeDenial, &str, u16); 7] = [
(NodeDenial::NonceRequired, "signature_nonce_required", 400),
(NodeDenial::NonceTooShort, "signature_nonce_too_short", 400),
(NodeDenial::Replayed, "signature_replayed", 409),
(NodeDenial::LedgerFull, "signature_ledger_full", 429),
(
NodeDenial::IdentityMissing,
"signature_identity_missing",
500,
),
(
NodeDenial::LedgerUnavailable,
"signature_ledger_unavailable",
503,
),
(NodeDenial::RateLimited, "rate_limited", 429),
];
assert_eq!(pinned.len(), NodeDenial::ALL.len());
for (denial, code, status) in pinned {
assert_eq!(denial.as_str(), code);
assert_eq!(denial.status(), status);
}
}
#[test]
fn from_code_round_trips_every_variant() {
for denial in NodeDenial::ALL {
assert_eq!(NodeDenial::from_code(denial.as_str()), Some(denial));
}
}
#[test]
fn from_code_rejects_what_this_build_does_not_know() {
for unknown in [
"",
"signature_",
"signature_nonce",
"signature_replayed_",
" signature_replayed",
"SIGNATURE_REPLAYED",
"repo_exists",
"human_detected",
"signature_seventh_code_from_a_newer_node",
] {
assert_eq!(
NodeDenial::from_code(unknown),
None,
"must not recognise {unknown:?}",
);
}
}
}
-241
View File
@@ -1,241 +0,0 @@
//! Redirect policy shared by every gitlawb HTTP client that signs its requests.
//!
//! Both `gl` (async) and `git-remote-gitlawb` (blocking) attach RFC 9421
//! `Signature` and `Signature-Input` headers, and reqwest strips only
//! `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` when a
//! redirect crosses hosts. A signature would survive that hop, and it binds
//! `@method`, `@path` and `content-digest` with no authority component, so a node
//! answering 302 could hand a working credential to a host of its choosing and read
//! as the caller anywhere until the clock-skew window closes. On a 307/308 the
//! request body goes along with it, which for the remote helper is the pack.
//!
//! That same missing authority component is why the predicate also pins the
//! request-target: `@path` is signed as the client sent it and verified as the node
//! received it, so a same-origin hop that rewrites the path or the query (a
//! trailing-slash or query normalization) makes the signature cover a target the
//! node never saw and the read 401s. Only a hop that re-issues the identical target,
//! an http-to-https upgrade being the one that matters in practice, is followed.
//!
//! The decision lives here rather than in either client because the two used to
//! disagree: `gl` was scoped to the origin while the remote helper, the binary that
//! actually runs `git clone gitlawb://`, still ran reqwest's default and followed
//! anywhere. One predicate is what keeps a future third client from repeating that.
//!
//! The type is `url::Url`, which is what `reqwest::Url` re-exports, so both clients
//! pass their attempt URLs straight in.
/// Longest redirect chain followed. `reqwest::redirect::Policy::custom` replaces
/// reqwest's built-in limit, so the bound has to be restated by every client that
/// installs a custom policy; the value is reqwest's own default.
///
/// This counts FOLLOWS, matching `Policy::limited`: reqwest pushes the redirecting
/// URL onto `previous` before consulting the policy, and `Limit(max)` refuses once
/// `previous.len() > max`, so a caller comparing against this constant must use `>`
/// too or it permits one hop fewer than it says.
pub const MAX_REDIRECTS: usize = 10;
/// Follow a redirect only when it stays on the origin that issued it AND re-issues
/// the identical request-target.
///
/// `Policy::none()` would have been the simpler answer, but one same-origin redirect
/// shape is legitimate here: a node fronted by a proxy that upgrades http to https,
/// or otherwise re-issues the same path and query, so the policy is scoped rather
/// than switched off.
///
/// Path and query must match exactly, and that is the request-target clause rather
/// than an origin one. `@path` is signed as the client sent it and verified as the
/// node received it, so a hop that rewrites either half leaves a signature covering
/// a target nobody asked for and the node answers 401. Refusing the hop turns a
/// confusing 401 into the 3xx that names what actually happened. One policy covers
/// signed and unsigned callers alike, for the same reason the predicate is shared:
/// two rules would drift.
///
/// The clause pins `@path`, and only `@path`. A gitlawb signature also covers
/// `@method` and `content-digest`, and both of those are still open on a followed
/// hop: on a 301, 302 or 303, reqwest 0.12.28 delegates to tower-http's
/// `FollowRedirect`, which rewrites a POST to a GET and empties the body
/// (tower-http-0.6.8 `src/follow_redirect/mod.rs:273-285`), while its
/// `drop_payload_headers` removes only `Content-Type`, `Content-Length`,
/// `Content-Encoding` and `Transfer-Encoding`. So `Signature`, `Signature-Input` and
/// `Content-Digest` ride along on a request that no longer has the method or the body
/// they were computed over. Only 307 and 308 preserve both. This predicate returning
/// true therefore makes a GET-shaped hop safe to replay against the node's verifier
/// and says nothing about a bodied one: a signed write must not rely on it alone.
///
/// `Url::query` is `None` for `/a` and `Some("")` for `/a?`, and those are two
/// different request-targets on the node side too, so the comparison is strict and
/// needs no special case.
///
/// Host and port must match exactly. Port is compared as `Url::port`, which is
/// `None` for a scheme's default port, so http -> https on the same host compares
/// equal while http -> http on a different port does not. A downgrade from https to
/// http is refused as well: the target is the same host, but the signature would go
/// out in cleartext, which is the same credential leak by a slower route.
///
/// Host comparison rides on `url`'s parse-time normalization (lowercasing and IDN
/// -> punycode), so the spellings an attacker reaches for do not open a gap. That
/// is a property of the parsed `Url`, not of this function, which is why the test
/// matrix pins it: a move to raw string comparison would silently lose it.
pub fn may_follow(previous: &url::Url, next: &url::Url) -> bool {
let same_origin = next.host_str() == previous.host_str() && next.port() == previous.port();
let same_target = next.path() == previous.path() && next.query() == previous.query();
let downgraded = previous.scheme() == "https" && next.scheme() != "https";
same_origin && same_target && !downgraded
}
#[cfg(test)]
mod tests {
use super::*;
/// Every branch of the decision, both ways, plus the spellings that would slip
/// past a comparison less careful than `Url`'s own normalization.
#[test]
fn may_follow_covers_each_origin_branch() {
let url = |s: &str| url::Url::parse(s).unwrap();
let cases: &[(&str, &str, bool, &str)] = &[
(
"http://node.example/a",
"http://node.example/b",
false,
"same origin but the path changed: @path is signed as sent and verified as received",
),
(
"http://node.example/a",
"https://node.example/a",
true,
"http to https on one host with an identical request-target: both ports are \
the scheme default, so the proxy upgrade is still followed",
),
(
"https://node.example/a",
"https://node.example/a/",
false,
"trailing-slash normalization changes the request-target, so it is refused",
),
(
"https://node.example:8443/a",
"https://node.example:8443/a",
true,
"same explicit port",
),
(
"http://node.example/a",
"http://attacker.example/a",
false,
"different host",
),
(
"http://node.example/a",
"http://node.example:8080/a",
false,
"same host, different port",
),
(
"https://node.example/a",
"http://node.example/a",
false,
"https downgraded to cleartext on the same host",
),
(
"https://node.example/a",
"http://node.example:443/a",
false,
"a downgrade dressed up as the https port",
),
// The rows below pass today because `Url::parse` normalizes the host, not
// because anything here compares case-insensitively or decodes IDN. They
// are the variants an attacker reaches for, so they are pinned: swapping
// this predicate for a raw string comparison must break the suite.
// Each of these pairs an IDENTICAL path on both sides, deliberately. The
// request-target clause below would make every one of them false on the
// path alone, and a row that is false for two reasons has stopped pinning
// either. With the paths equal, the host or port comparison is the only
// thing left that can decide them.
(
"https://node.example/a",
"https://NODE.EXAMPLE/a",
true,
"same host in a different case: parse lowercases it",
),
(
"https://node.example/a",
"https://node.example./a",
false,
"a trailing dot is a different host to url, so the redirect is refused",
),
(
"https://exämple.test/a",
"https://xn--exmple-cua.test/a",
true,
"unicode host and its punycode spelling are one host after parse",
),
(
"https://node.example/a",
"https://user:[email protected]/a",
true,
"userinfo is not part of the origin: same host, still followed",
),
(
"https://node.example/a",
"https://[email protected]/a",
false,
"the node's name smuggled into userinfo: the host is the attacker's",
),
(
"https://node.example/a",
"https://attacker.example/a#node.example",
false,
"the node's name pushed into the fragment: the host is the attacker's",
),
// The request-target clause, both directions. `@path` is the only thing a
// gitlawb signature binds the request to, so a hop that rewrites it hands
// the node a signature over a target it never received.
(
"https://node.example/a?x=1",
"https://node.example/a?x=1",
true,
"identical request-target: the same-origin hop that is still followed",
),
(
"https://node.example/a?x=1",
"https://node.example/a?x=2",
false,
"same path but a different query: the request-target covers the query too",
),
(
"https://node.example/a",
"https://node.example/a?x=1",
false,
"a query added where there was none",
),
(
"http://node.example/a",
"http://node.example/a?",
false,
"an empty query added where there was none: a missing query and an empty \
one are different request-targets",
),
(
"https://node.example/a?x=1",
"https://node.example/a",
false,
"the query dropped where there was one: the comparison is symmetric, and \
nothing else in the matrix pins that direction",
),
(
"https://node.example/a#x",
"https://node.example/a#y",
true,
"a fragment-only difference is still followed: a fragment never reaches \
the wire, so it is no part of the request-target the node verifies",
),
];
for (previous, next, expected, why) in cases {
assert_eq!(
may_follow(&url(previous), &url(next)),
*expected,
"{previous} -> {next} ({why})"
);
}
}
}
-69
View File
@@ -1,69 +0,0 @@
//! Text sanitization predicates shared across the workspace.
//!
//! Untrusted peer/remote bytes that reach an operator's terminal (node error
//! bodies, node-advertised strings) must be stripped of BOTH `Cc` control bytes
//! (`char::is_control()`) AND `Cf` bidi/format controls before display: a
//! right-to-left override or directional isolate can visually reorder the line
//! and spoof the text (Trojan Source, CWE-451). This is INV-6.
//!
//! `is_bidi_format` is the single workspace definition of the second half of
//! that obligation. It was split out of `gl`'s local copy so the two terminal
//! sanitizers that shipped with only the `Cc` half (#183) — and any future
//! sanitizer — reuse one audited predicate instead of re-deriving the set.
/// True for the Unicode bidirectional/directional-isolate format characters
/// (the reordering subset of category `Cf`) that can visually reorder a terminal
/// line. These are NOT matched by [`char::is_control()`], which covers `Cc` only.
///
/// This is deliberately the reordering subset, not the whole `Cf` category:
/// legitimate `Cf` characters such as ZWJ (`U+200D`, emoji sequences) and the
/// soft hyphen (`U+00AD`) are NOT matched, so honest international text passes
/// through unchanged. Widening this set is a behavior change for every consumer.
///
/// Pair it with `!c.is_control()` in a terminal-bound sanitizer (INV-6); see the
/// wrappers in `gl`, `git-remote-gitlawb`, and `icaptcha-client`.
pub fn is_bidi_format(c: char) -> bool {
matches!(c,
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_every_reordering_code_point() {
// Asserted individually so a partial predicate (e.g. one that covers the
// marks but not the isolates) fails loudly rather than passing.
for c in [
'\u{200E}', '\u{200F}', '\u{061C}', // LRM, RLM, ALM
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', // LRE, RLE, PDF, LRO, RLO
'\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // LRI, RLI, FSI, PDI
] {
assert!(
is_bidi_format(c),
"U+{:04X} must be a bidi-format char",
c as u32
);
}
}
#[test]
fn passes_legitimate_and_control_chars() {
// Must NOT over-strip: plain text, a genuine RTL SCRIPT letter (Lo, not a
// format char), ZWJ (legitimate Cf), and soft hyphen (legitimate Cf) are
// not bidi-format. ESC is a Cc control char — owned by is_control(), not
// this predicate, which is bidi-only.
for c in ['a', '\u{0627}', '\u{200D}', '\u{00AD}', '\u{1b}'] {
assert!(
!is_bidi_format(c),
"U+{:04X} must NOT be a bidi-format char",
c as u32
);
}
}
}
-499
View File
@@ -1,499 +0,0 @@
//! Sealed continuation token for the node's bounded legacy CID scan (INV-13).
//!
//! The `/ipfs/{cid}` resolver's legacy scan stops at a row ceiling and sheds a
//! retryable 503. To let a holder buried past that ceiling still be reached, the
//! shed carries the scan position so the caller can echo it back and resume. The
//! whole point of the design is that the node keeps NO server-side scan state: the
//! position rides in the caller's token.
//!
//! That makes the token an EMITTED continuation derived from a FETCHED row, and on
//! a scan that served nothing every fetched row is by construction a private or
//! quarantined repo the caller may not read. The row's `created_at` leaks its
//! creation time and its `id` carries the owner's DID, so both halves are withheld
//! fields and the token must be CONFIDENTIAL, not merely tamper-evident:
//!
//! * AEAD-sealed (XChaCha20-Poly1305), never base64-of-plaintext and never
//! signed plaintext. Integrity is not confidentiality.
//! * A fresh `OsRng` nonce on EVERY seal. Under a stream cipher a repeated nonce
//! means repeated keystream, and an attacker who can force the node to seal a
//! position whose plaintext they know XORs two tokens and recovers a withheld
//! row's fields in full, strictly worse than emitting plaintext.
//! * FIXED-WIDTH plaintext. AEAD ciphertext is plaintext-length plus the tag, and
//! every field of a scan position varies in length, so a variable encoding would
//! make token LENGTH a side channel for the sealed row (a short name under a
//! short owner vs a long one) and for the candidate oid's width (40 hex on a
//! sha1 repo, 64 on a sha256 one). Every token this module mints is byte-identical
//! in length. Each field is padded to its own separate width, which is a per-field
//! constant and so still leaks nothing about a given row or candidate.
//! * The canonical CID as associated data, so a token minted while scanning for
//! one CID does not authenticate when replayed against another.
//!
//! Every failure to open (wrong key, tampered bytes, wrong CID, expired, malformed)
//! returns the same `None`. The caller treats that as "no token" and starts at the
//! front, so no failure class is distinguishable and the token is no oracle.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng, Payload},
XChaCha20Poly1305, XNonce,
};
use rand::RngCore;
/// Keyset position of the last row a truncated scan fetched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanPosition {
/// The row's raw stored `created_at` text, the first half of the keyset cursor.
pub created_at_key: String,
/// The row's `id`, the tiebreaking half of the keyset cursor.
pub id: String,
/// The oid hex of the CANDIDATE this position resumes. One CID can map to several
/// git oids, so a bare row cursor names a row without naming whose walk it belongs
/// to. The candidate is named by IDENTITY rather than by position in the candidate
/// list: that list is ordered by hex and mutates between rungs, so an index would
/// name a different candidate the moment anything is pinned or unpinned.
pub sha256_hex: String,
}
/// Plaintext version byte, so a future layout change is a clean open-failure
/// (treated as absent) rather than a misparse.
///
/// Bumped to 2 when the two halves stopped sharing one width (see [`ID_WIDTH`]), and
/// to 3 when the position gained the candidate oid it resumes (see [`OID_WIDTH`]).
/// A token minted under an earlier layout is a different length and a different
/// framing, so it opens to `None` and the caller restarts at the front, which is the
/// safe direction: a misparse would resume at a fabricated row and skip coverage.
const VERSION: u8 = 3;
/// Byte width the `created_at` half is padded to. Every value stored here is a
/// serialized timestamp, about 30 bytes, so 64 is roomy for the field's whole domain.
/// It is deliberately NOT widened to match [`ID_WIDTH`]: padding both halves to the id
/// width would nearly double every token for a field that can never use the space.
const CREATED_WIDTH: usize = 64;
/// Byte width the `id` half is padded to.
///
/// The bound is set by the WRITERS, not by what a typical id happens to look like.
/// `upsert_mirror_repo` builds `repos.id` as `{owner}/{name}`, and the slug validators
/// in the node's `repo_store` admit an owner of up to 255 bytes and a name of up to
/// 100, so 356 bytes is reachable through the ordinary write path and repo names are
/// peer-controllable. 384 clears that with margin.
///
/// Under-sizing this is not a cosmetic bug. A row at a truncation boundary whose id
/// exceeds the width fails the seal, the handler sheds a 503 with no continuation, and
/// a tokenless shed is byte-identical to the wrapped-scan response whose contract is
/// "the absence of a token means the ladder is over". The boundary row is deterministic
/// for a stable inventory, so every retry reproduces it and every row past it becomes
/// permanently unreachable. Anything past the width still fails loudly rather than
/// silently truncating a cursor into one that resumes at the wrong row.
const ID_WIDTH: usize = 384;
/// Byte width the candidate oid half is padded to.
///
/// Git mints exactly two oid widths and this field carries BOTH. A production repo is
/// created by `store::init_bare` with `git init --bare --object-format=sha1`, so its
/// oids are 40 hex; only the sha256 test fixtures mint 64. The field is therefore
/// length-prefixed like the two row halves rather than a bare fixed 64: a 64-only
/// field would fail every seal on a real deployment, and a failed seal sheds a
/// tokenless 503 that is byte-identical to "your ladder is over".
///
/// The padding to 64 is what keeps the WIDTH off the wire. Without it a 40-hex token
/// is 24 bytes shorter than a 64-hex one, and token length would say which object
/// format the holder's repo uses.
const OID_WIDTH: usize = 64;
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
const PLAINTEXT_LEN: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 2 + OID_WIDTH + 8;
/// Nonce width for XChaCha20-Poly1305.
const NONCE_LEN: usize = 24;
/// A fresh random 32-byte sealing key from the OS CSPRNG.
pub fn new_key() -> [u8; 32] {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
// The two halves below are deliberately separate and adjacent. The framing pair owns
// "every token is the same length"; the AEAD pair owns "the contents are confidential
// and CID-bound". Keeping them apart is what lets each property be exercised (and
// broken) without disturbing the other.
/// Encode a position into the FIXED-WIDTH plaintext:
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
///
/// The padding is the point. AEAD ciphertext is plaintext-length plus the tag, and every
/// field of a scan position varies in length, so a length-prefixed encoding with no
/// padding would make token LENGTH a side channel for the sealed row and for the
/// candidate's object format. Each field is padded to its OWN fixed width, which keeps
/// every minted token the same length while letting the id half carry the range the
/// write path actually admits.
fn encode_position(pos: &ScanPosition, expires_at_unix: i64) -> anyhow::Result<Vec<u8>> {
let mut out = vec![0u8; PLAINTEXT_LEN];
out[0] = VERSION;
let mut at = 1;
for (field, width) in [
(pos.created_at_key.as_bytes(), CREATED_WIDTH),
(pos.id.as_bytes(), ID_WIDTH),
(pos.sha256_hex.as_bytes(), OID_WIDTH),
] {
if field.len() > width {
// Loud rather than truncating: a clipped cursor resumes at the wrong row and
// silently skips coverage, which is the availability half of the bug this
// token exists to fix.
anyhow::bail!(
"scan token field is {} bytes, over the {width}-byte fixed width",
field.len()
);
}
out[at..at + 2].copy_from_slice(&(field.len() as u16).to_le_bytes());
at += 2;
out[at..at + field.len()].copy_from_slice(field);
at += width;
}
out[at..at + 8].copy_from_slice(&expires_at_unix.to_le_bytes());
Ok(out)
}
/// Decode what [`encode_position`] wrote. `None` on any structural mismatch.
fn decode_position(bytes: &[u8]) -> Option<(ScanPosition, i64)> {
if bytes.len() != PLAINTEXT_LEN || bytes[0] != VERSION {
return None;
}
let mut at = 1;
let mut fields = [const { String::new() }; 3];
for (slot, width) in fields.iter_mut().zip([CREATED_WIDTH, ID_WIDTH, OID_WIDTH]) {
let len = u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize;
at += 2;
if len > width {
return None;
}
*slot = String::from_utf8(bytes[at..at + len].to_vec()).ok()?;
at += width;
}
let expires_at = i64::from_le_bytes(bytes[at..at + 8].try_into().ok()?);
let [created_at_key, id, sha256_hex] = fields;
// A zero-length candidate is a third state the encoder never mints. The front-of-table
// sentinel is empty ROW halves with a real oid, so an empty oid would hand the resume
// path a candidate that names nothing; refuse it like every other malformed frame.
if sha256_hex.is_empty() {
return None;
}
Some((
ScanPosition {
created_at_key,
id,
sha256_hex,
},
expires_at,
))
}
/// AEAD-seal `plaintext` under `key`, bound to `cid`, framed as `nonce || ciphertext`.
fn seal_bytes(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("scan token key: {e}"))?;
// A FRESH nonce per seal, from the OS CSPRNG. Under a stream cipher a repeated nonce
// repeats the keystream, and two tokens sealed under one nonce XOR to the difference
// of their plaintexts, which recovers a withheld row in full when the attacker can
// force one of the two positions. This draw is the property the whole confidentiality
// claim rests on.
let mut nonce = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce);
let sealed = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: plaintext,
// The canonical CID as associated data: a token minted while scanning for
// one CID does not authenticate against another, so it cannot be replayed
// to seed a different scan.
aad: cid.as_bytes(),
},
)
.map_err(|e| anyhow::anyhow!("scan token seal: {e}"))?;
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
/// Open what [`seal_bytes`] framed. `None` on any failure, including a wrong `cid`.
fn open_bytes(key: &[u8; 32], cid: &str, raw: &[u8]) -> Option<Vec<u8>> {
if raw.len() <= NONCE_LEN + 16 {
return None;
}
let (nonce, sealed) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new_from_slice(key).ok()?;
cipher
.decrypt(
XNonce::from_slice(nonce),
Payload {
msg: sealed,
aad: cid.as_bytes(),
},
)
.ok()
}
/// Seal `pos` under `key`, bound to `cid`, expiring at `expires_at_unix`.
///
/// Returns the base64url (no pad) token. Errors only when a field exceeds
/// its own fixed width ([`CREATED_WIDTH`], [`ID_WIDTH`], [`OID_WIDTH`]) or the AEAD
/// itself fails, never silently truncates.
pub fn seal_scan_token(
key: &[u8; 32],
cid: &str,
pos: &ScanPosition,
expires_at_unix: i64,
) -> anyhow::Result<String> {
let plaintext = encode_position(pos, expires_at_unix)?;
Ok(B64URL.encode(seal_bytes(key, cid, &plaintext)?))
}
/// Open a token minted by [`seal_scan_token`] under the same key and CID.
///
/// `None` for every failure class alike (wrong key, tampered, foreign CID, expired,
/// malformed, wrong version), so the caller can treat all of them as "absent" without
/// leaking which one occurred.
pub fn open_scan_token(
key: &[u8; 32],
cid: &str,
token: &str,
now_unix: i64,
) -> Option<ScanPosition> {
let raw = B64URL.decode(token).ok()?;
let plaintext = open_bytes(key, cid, &raw)?;
let (pos, expires_at) = decode_position(&plaintext)?;
if now_unix >= expires_at {
return None;
}
Some(pos)
}
#[cfg(test)]
mod tests {
use super::*;
/// A production-shaped candidate: `git init --bare --object-format=sha1`, so 40 hex.
const OID_40: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
/// A test-fixture-shaped candidate: the sha256 repos the suite creates mint 64 hex.
const OID_64: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn pos(created: &str, id: &str) -> ScanPosition {
pos_for(created, id, OID_40)
}
fn pos_for(created: &str, id: &str, sha256_hex: &str) -> ScanPosition {
ScanPosition {
created_at_key: created.to_string(),
id: id.to_string(),
sha256_hex: sha256_hex.to_string(),
}
}
/// Seal a hand-built plaintext through the AEAD half, so a test can frame bytes the
/// encoder would never mint (an old version byte, a zero-length oid) and still exercise
/// the real open path.
fn seal_raw(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> String {
B64URL.encode(seal_bytes(key, cid, plaintext).unwrap())
}
/// Byte offset of the oid length prefix inside the plaintext, derived from the widths
/// rather than hardcoded so a width change moves it with the layout.
const OID_LEN_AT: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH;
/// Scenario 1: both of git's oid widths round trip. The 40-hex case is the PRODUCTION
/// shape (`git init --bare --object-format=sha1`), and an all-64 fixture suite would
/// never exercise it, which is exactly how a fixed-64 field would ship broken.
#[test]
fn round_trips_at_both_oid_widths() {
let key = new_key();
for hex in [OID_40, OID_64] {
let p = pos_for("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo", hex);
let t =
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("both oid widths must seal");
assert_eq!(
open_scan_token(&key, "bafkcid", &t, 0),
Some(p),
"a {}-hex candidate must open to the identical position",
hex.len()
);
}
}
/// Scenario 2: a token framed under the OLD version opens to `None`, never a misparse.
/// Both legs matter: the version-2 plaintext was a different LENGTH, and a future
/// same-length layout would only be caught by the version byte itself.
#[test]
fn a_prior_layout_version_opens_to_none() {
let key = new_key();
// The version-2 layout verbatim: no oid field, so 461 bytes.
let mut old = vec![0u8; 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 8];
old[0] = 2;
assert_eq!(old.len(), 461, "the version-2 plaintext was 461 bytes");
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &old), 0),
None,
"a version-2 token must open to None so the caller restarts at the front"
);
// Same length, stale version byte: only the version check can refuse this one.
let mut stamped = encode_position(
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
1 << 40,
)
.unwrap();
stamped[0] = VERSION - 1;
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &stamped), 0),
None,
"a stale version byte must open to None even at the current width"
);
}
/// Scenario 3: token length is invariant across candidate VALUE and candidate WIDTH.
/// The padding is what keeps the oid width off the wire: without it a 40-hex token is
/// 24 bytes shorter than a 64-hex one and the length says which repo format the
/// holder uses.
#[test]
fn token_length_is_invariant_across_oid_widths() {
let key = new_key();
let created = "2020-01-01T00:00:00+00:00";
let id = "z6MkOwner/private-repo";
let short =
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_40), 1 << 40).unwrap();
let long =
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_64), 1 << 40).unwrap();
assert_eq!(
short.len(),
long.len(),
"a 40-hex and a 64-hex candidate must mint tokens of identical length, or the \
oid width is a side channel"
);
// The absolute width, pinned by execution rather than by arithmetic on paper. The
// gl client's mock fixtures hardcode this number (`TOKEN_LEN` in
// crates/gl/src/ipfs_cmd.rs), and nothing in that crate seals a real token, so this
// assertion is the only executable check that the two agree.
assert_eq!(
short.len(),
756,
"24 nonce + {PLAINTEXT_LEN} plaintext + 16 tag, base64url no pad"
);
}
/// Scenario 4: the front-of-table sentinel. Empty row halves with a REAL candidate
/// round trip, which is what lets a seal say "this candidate, no row cursor yet".
#[test]
fn empty_row_fields_round_trip_with_a_real_candidate() {
let key = new_key();
let p = pos_for("", "", OID_40);
let t =
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("the front sentinel must seal");
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
/// Scenario 6, third leg: a zero-length oid is a distinguishable third state that the
/// encoder never mints, and accepting it would hand the sentinel machinery a candidate
/// naming nothing. The decode path refuses it.
#[test]
fn a_zero_length_candidate_opens_to_none() {
let key = new_key();
let mut plaintext = encode_position(
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
1 << 40,
)
.unwrap();
plaintext[OID_LEN_AT..OID_LEN_AT + 2].copy_from_slice(&0u16.to_le_bytes());
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &plaintext), 0),
None,
"a zero-length candidate must open to None, not to a position naming nothing"
);
}
/// Scenario 6, second leg: an oid past the 64-byte width fails the seal loudly rather
/// than being clipped into a hex that names a different candidate.
#[test]
fn an_oid_over_the_fixed_width_fails_loudly() {
let key = new_key();
let p = pos_for(
"2020-01-01T00:00:03+00:00",
"z6MkOwner/private-repo",
&"a".repeat(OID_WIDTH + 1),
);
assert!(
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
"an over-wide candidate must fail the seal, never be truncated into a hex that \
resumes the wrong candidate"
);
}
#[test]
fn round_trips_under_the_same_key_and_cid() {
let key = new_key();
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
#[test]
fn every_failure_class_opens_to_none() {
let key = new_key();
let other = new_key();
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
assert_eq!(open_scan_token(&other, "bafkcid", &t, 0), None, "wrong key");
assert_eq!(
open_scan_token(&key, "bafkOTHER", &t, 0),
None,
"foreign CID"
);
assert_eq!(
open_scan_token(&key, "bafkcid", &t, 1 << 41),
None,
"expired"
);
assert_eq!(open_scan_token(&key, "bafkcid", "!!not b64", 0), None);
assert_eq!(open_scan_token(&key, "bafkcid", "", 0), None);
let mut flipped: Vec<u8> = t.bytes().collect();
let last = flipped.len() - 1;
flipped[last] = if flipped[last] == b'A' { b'B' } else { b'A' };
assert_eq!(
open_scan_token(&key, "bafkcid", &String::from_utf8(flipped).unwrap(), 0),
None,
"tampered"
);
}
/// The id half must clear the LARGEST repo id the node's own write path admits,
/// not merely a typical one. `upsert_mirror_repo` builds `repos.id` as
/// `{owner}/{name}`, and the slug validators in `repo_store` admit 255 bytes of
/// owner and 100 of name, so 356 is reachable. A width under that turns the
/// boundary row into a seal failure, which sheds a tokenless 503 that is
/// byte-identical to "your ladder is over" and strands every row past it forever.
#[test]
fn round_trips_a_repo_id_at_the_write_paths_maximum() {
let key = new_key();
let id = format!("{}/{}", "o".repeat(255), "n".repeat(100));
assert_eq!(id.len(), 356, "255 owner + '/' + 100 name");
let p = pos("2020-01-01T00:00:03+00:00", &id);
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40)
.expect("a repo id the write path admits must seal, never fail the width");
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
#[test]
fn a_field_over_the_fixed_width_fails_loudly() {
let key = new_key();
let p = pos("2020-01-01T00:00:03+00:00", &"x".repeat(ID_WIDTH + 1));
assert!(
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
"an over-wide field must fail the seal, never be truncated into a cursor \
that resumes at the wrong row"
);
}
}
+4 -67
View File
@@ -187,17 +187,11 @@ impl Ucan {
}
/// Check if this UCAN grants a specific capability on a resource.
///
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
/// requested resource/action, since a valid delegation chain can produce
/// exactly that capability (see `is_attenuated_by`).
pub fn can(&self, resource: &str, action: &str) -> bool {
self.payload.att.iter().any(|cap| {
let resource_ok = cap.with == resource || cap.with == "*";
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
resource_ok && action_ok
})
self.payload
.att
.iter()
.any(|cap| cap.with == resource && cap.can == action)
}
/// Encode to a compact JSON string (the wire format).
@@ -318,63 +312,6 @@ mod tests {
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_resource_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("*", caps::GIT_PUSH)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_repo_admin_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new(
"gitlawb://repos/test/repo",
caps::REPO_ADMIN,
)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn can_honors_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("gitlawb://repos/test/repo", "*")],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn bootstrap_ucan() {
let issuer = Keypair::generate();
+2 -13
View File
@@ -1,9 +1,8 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version = "0.7.1" # x-release-please-version
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
@@ -12,8 +11,6 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
ed25519-dalek = { workspace = true }
base64 = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -29,10 +26,9 @@ async-graphql-axum = "7"
tokio-stream = { version = "0.1", features = ["sync"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "limit"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "macros", "migrate"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] }
clap = { version = "4", features = ["derive", "env"] }
bytes = "1"
libc = "0.2"
cid = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
@@ -40,9 +36,6 @@ hmac = { workspace = true }
http-body-util = "0.1"
tokio-util = { version = "0.7", features = ["io"] }
dirs-next = "2"
# NFC-normalize visibility globs/paths at the matcher seam so a deny rule
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
unicode-normalization = "0.1"
reqwest = { workspace = true }
libp2p-core = "0.43"
libp2p-gossipsub = "0.49.4"
@@ -75,9 +68,5 @@ alloy = { version = "1", default-features = false, features = [
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
[dev-dependencies]
multibase = { workspace = true }
mockito = "1"
tempfile = "3"
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
# on which timer fired instead of on wall clock.
tokio = { workspace = true, features = ["test-util"] }
+2 -103
View File
@@ -2,10 +2,9 @@
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::{Extension, Json};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
@@ -21,14 +20,6 @@ fn agent_key_segment(did: &str) -> &str {
did.split(':').next_back().unwrap_or(did)
}
/// Whether `caller` (the verified `AuthenticatedDid`) is the same identity as
/// `target` (the DID being acted on), tolerant of full `did:key:...` vs short
/// key-only forms on either side. Used to gate self-deregistration so a DID
/// can only retire itself.
fn caller_matches_did(caller: &str, target: &str) -> bool {
agent_key_segment(caller) == agent_key_segment(target)
}
async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
let normalized_did = normalize_agent_did(did);
if state.db.get_agent(&normalized_did).await?.is_some() {
@@ -80,8 +71,6 @@ pub struct AgentResponse {
pub capabilities: Vec<String>,
pub registered_at: String,
pub last_seen: Option<String>,
/// Lifecycle status: `active` or `revoked`.
pub status: String,
}
/// GET /api/v1/agents
@@ -98,7 +87,6 @@ pub async fn list_agents(
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
status: a.status,
})
.collect();
Ok(Json(serde_json::json!({ "agents": list })))
@@ -123,7 +111,6 @@ pub async fn show_agent(
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
status: agent.status,
}),
))
}
@@ -146,97 +133,9 @@ pub async fn get_trust(
}))
}
/// DELETE /api/v1/agents/{did}
///
/// Self-deregistration: the holder of a DID's key marks their own agent
/// `revoked`, removing it from discovery (issue #29). Authenticated by the
/// rfc9421 signature middleware; a caller may only retire its own DID.
pub async fn deregister_agent(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(did): Path<String>,
) -> Result<StatusCode> {
// Authorize on the verified identity, and act on it too. The path DID is
// only an intent check (it must name the caller); the row we revoke is the
// authenticated DID itself, never a value derived from the untrusted path.
// Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix
// resolver act on a different identity than the one just authorized.
if !caller_matches_did(&auth.0, &did) {
return Err(AppError::BadRequest(
"an agent can only deregister itself".into(),
));
}
if state.db.revoke_agent(&auth.0).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound(format!("agent {} not found", auth.0)))
}
}
#[cfg(test)]
mod tests {
use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse};
#[test]
fn caller_matches_own_did_full_form() {
assert!(caller_matches_did(
"did:key:z6MkExample",
"did:key:z6MkExample"
));
}
#[test]
fn caller_matches_own_did_short_form() {
// Authenticated full DID vs short path form, and vice versa.
assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample"));
assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample"));
}
#[test]
fn caller_cannot_revoke_a_different_did() {
// The core authorization property for issue #29.
assert!(!caller_matches_did(
"did:key:z6MkAttacker",
"did:key:z6MkVictim"
));
assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim"));
}
#[test]
fn agent_response_surfaces_status() {
// A revoked DID's response must carry its status so callers can see it
// is retired (issue #29).
let resp = AgentResponse {
did: "did:key:orphan".to_string(),
trust_score: 0.1,
capabilities: vec!["reputation:score".to_string()],
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: "revoked".to_string(),
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "revoked");
assert!(json.get("replaced_by").is_none());
}
#[test]
fn agent_response_active_status() {
let resp = AgentResponse {
did: "did:key:active".to_string(),
trust_score: 0.5,
capabilities: vec![],
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: "active".to_string(),
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "active");
}
use super::{agent_key_segment, normalize_agent_did};
#[test]
fn normalize_agent_did_preserves_full_did() {
+2 -49
View File
@@ -26,61 +26,14 @@ pub async fn list_anchors(
Query(q): Query<ListAnchorsQuery>,
) -> Result<Json<serde_json::Value>> {
let limit = q.limit.min(200);
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let anchors = state
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await?;
.await
.map_err(crate::error::AppError::Internal)?;
Ok(Json(serde_json::json!({
"anchors": anchors,
"count": anchors.len(),
})))
}
#[cfg(test)]
mod closed_pool_tests {
use super::*;
use axum::http::{Request, StatusCode};
use axum::Router;
use serde_json::Value;
use sqlx::PgPool;
use tower::ServiceExt;
/// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable.
#[sqlx::test]
async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
pool.close().await;
let resp = Router::new()
.route("/api/v1/arweave/anchors", axum::routing::get(list_anchors))
.with_state(state)
.oneshot(
Request::builder()
.uri("/api/v1/arweave/anchors")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"closed-pool outage must be retryable 503, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
serde_json::json!({
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})
);
}
}
+28 -129
View File
@@ -79,9 +79,12 @@ pub async fn create_bounty(
return Err(AppError::BadRequest("amount must be positive".into()));
}
// Read-gate: anyone who can read the repo may fund a bounty on it (this closes
// the private-repo bounty leak); ownership is not required.
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
// Verify repo exists
let _ = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let now = Utc::now().to_rfc3339();
let bounty = BountyRecord {
@@ -110,28 +113,19 @@ pub async fn create_bounty(
Ok((StatusCode::CREATED, Json(bounty)))
}
const MAX_BOUNTY_LIMIT: i64 = 200;
/// GET /api/v1/repos/{owner}/{repo}/bounties
pub async fn list_repo_bounties(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let limit = q.limit.unwrap_or(50).clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state
.db
.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
limit,
None,
None,
q.limit.unwrap_or(50),
)
.await?;
@@ -142,92 +136,25 @@ pub async fn list_repo_bounties(
pub async fn list_all_bounties(
State(state): State<AppState>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let raw_limit = q.limit.unwrap_or(50);
let limit = raw_limit.clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state
.db
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
.await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let mut memo = std::collections::HashMap::new();
let mut allowed: Vec<BountyRecord> = Vec::new();
let page_size = (limit * 5).clamp(1, 200);
let max_scanned = 10_000i64;
let mut total_scanned: i64 = 0;
let mut cursor: Option<(String, String)> = None;
while (allowed.len() as i64) < limit {
let after = cursor.as_ref().map(|(ts, id)| (ts.as_str(), id.as_str()));
let bounties = state
.db
.list_bounties(
None,
None,
q.status.as_deref(),
page_size,
after.map(|(ts, _)| ts),
after.map(|(_, id)| id),
)
.await?;
if bounties.is_empty() {
break;
}
total_scanned += bounties.len() as i64;
if total_scanned > max_scanned {
return Err(AppError::Incomplete(
"too many rows scanned; refine your filters".into(),
));
}
let last = bounties.last().unwrap();
for b in &bounties {
if (allowed.len() as i64) >= limit {
break;
}
let key = format!("{}/{}", b.repo_owner, b.repo_name);
let admitted = if let Some(&result) = memo.get(&key) {
result
} else {
let result = crate::api::authorize_repo_read(
&state,
&b.repo_owner,
&b.repo_name,
caller,
"/",
)
.await
.is_ok();
memo.insert(key, result);
result
};
if admitted {
allowed.push(b.clone());
}
}
cursor = Some((last.created_at.clone(), last.id.clone()));
}
Ok(Json(serde_json::json!({ "bounties": allowed })))
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
/// GET /api/v1/bounties/{id}
pub async fn get_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if crate::api::authorize_repo_read(&state, &bounty.repo_owner, &bounty.repo_name, caller, "/")
.await
.is_err()
{
return Err(not_found());
}
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(bounty))
}
@@ -238,22 +165,11 @@ pub async fn claim_bounty(
Path(id): Path<String>,
Json(req): Json<ClaimBountyRequest>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
@@ -297,12 +213,8 @@ pub async fn submit_bounty(
bounty.status
)));
}
let is_claimant = bounty
.claimant_did
.as_deref()
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
if !is_claimant {
return Err(AppError::Forbidden("only the claimant can submit".into()));
if bounty.claimant_did.as_deref() != Some(&auth.0) {
return Err(AppError::BadRequest("only the claimant can submit".into()));
}
let now = Utc::now().to_rfc3339();
@@ -337,8 +249,8 @@ pub async fn approve_bounty(
bounty.status
)));
}
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
return Err(AppError::Forbidden(
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest(
"only the bounty creator can approve".into(),
));
}
@@ -384,8 +296,8 @@ pub async fn cancel_bounty(
bounty.status
)));
}
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
return Err(AppError::Forbidden(
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest(
"only the bounty creator can cancel".into(),
));
}
@@ -420,19 +332,6 @@ pub async fn dispute_bounty(
)));
}
// Only the bounty creator or the current claimant may dispute (N19 — this
// endpoint had no identity check at all).
let is_creator = crate::api::did_matches(&auth.0, &bounty.creator_did);
let is_claimant = bounty
.claimant_did
.as_deref()
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
if !is_creator && !is_claimant {
return Err(AppError::Forbidden(
"only the bounty creator or claimant can dispute this bounty".into(),
));
}
// Check if deadline exceeded
if let Some(ref claimed_at) = bounty.claimed_at {
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
+15 -41
View File
@@ -1,44 +1,23 @@
//! API handlers for ref certificates.
use std::collections::HashMap;
use axum::extract::{Extension, Path, Query, State};
use axum::extract::{Path, State};
use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50
/// GET /api/v1/repos/{owner}/{repo}/certs
pub async fn list_certs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let limit = params
.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.map(|v| v.max(1))
.unwrap_or(50)
.min(200);
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
// When a prefix is given (short-ID resolution from the CLI) use a
// generous limit and delegate to the prefix-matched query so the
// caller can resolve IDs regardless of how many certs exist.
let prefix = params.get("prefix").filter(|p| !p.is_empty());
let certs = if let Some(prefix) = prefix {
state
.db
.list_ref_certificates_by_prefix(&record.id, prefix, 200)
.await?
} else {
state.db.list_ref_certificates(&record.id, limit).await?
};
let certs = state.db.list_ref_certificates(&record.id).await?;
let certs_json: Vec<serde_json::Value> = certs
.iter()
.map(|c| {
@@ -56,21 +35,20 @@ pub async fn list_certs(
})
.collect();
let count = certs_json.len();
Ok(Json(
serde_json::json!({ "certificates": certs_json, "count": count }),
))
Ok(Json(serde_json::json!({ "certificates": certs_json })))
}
/// GET /api/v1/repos/{owner}/{repo}/certs/{id}
pub async fn get_cert(
State(state): State<AppState>,
Path((owner, name, id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
// Verify the repo exists
let _record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let cert = state
.db
@@ -78,10 +56,6 @@ pub async fn get_cert(
.await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
if cert.repo_id != record.id {
return Err(AppError::NotFound(format!("certificate {id}")));
}
Ok(Json(serde_json::json!({
"id": cert.id,
"repo_id": cert.repo_id,
+6 -6
View File
@@ -1,10 +1,9 @@
//! Changelog endpoint — unified timeline of commits, merged PRs, and closed issues.
use axum::extract::{Extension, Path, Query, State};
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::git::store;
use crate::state::AppState;
@@ -28,11 +27,12 @@ pub async fn get_changelog(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(query): Query<ChangelogQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
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 record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let limit = query.limit.min(100);
-123
View File
@@ -1,123 +0,0 @@
//! Authenticated discovery + fetch for encrypted withheld blobs (Option B1).
use axum::extract::{Extension, Path, State};
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).
pub async fn list_encrypted_blobs(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
.map(|(oid, cid)| serde_json::json!({ "oid": oid, "cid": cid }))
.collect();
Ok(Json(serde_json::json!({ "blobs": blobs })))
}
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blob/{oid}
/// Returns raw envelope bytes to callers who can read the repo; the envelope
/// crypto still ensures only true recipients can decrypt.
pub async fn get_encrypted_blob(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo, oid)): Path<(String, String, String)>,
) -> Result<Vec<u8>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
}
let cid = state
.db
.encrypted_blob_cid(&record.id, &oid)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")))?;
let bytes = crate::ipfs_pin::cat(&state.config.ipfs_api, &cid)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
Ok(bytes)
}
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate
/// Returns [{oid, cid}] for every encrypted blob in the repo, for peer-mirror
/// replication (Option B2). Gated by repo readability, like discovery, so a
/// non-readable repo does not expose its blob index; for the intended case (a
/// public repo with withheld subtrees) the public root keeps this open to peers.
/// Recipient identities are deliberately withheld: the v2 envelopes no longer
/// carry recipient public keys, so peers must not learn the reader set either. A
/// mirror detects a re-seal by the CID changing (the OID is stable across
/// re-seals). Ciphertext metadata only, never plaintext.
pub async fn replicate_encrypted_blobs(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
.map(|(oid, cid)| replicate_blob_json(oid, cid))
.collect();
Ok(Json(serde_json::json!({ "blobs": blobs })))
}
/// Serialize one blob for the replication wire. Recipient identities are
/// intentionally absent so a mirror never learns the reader set.
fn replicate_blob_json(oid: String, cid: String) -> serde_json::Value {
serde_json::json!({ "oid": oid, "cid": cid })
}
#[cfg(test)]
mod tests {
use super::replicate_blob_json;
#[test]
fn replicate_blob_json_omits_recipients() {
let v = replicate_blob_json("oid1".into(), "cidA".into());
assert_eq!(v["oid"], "oid1");
assert_eq!(v["cid"], "cidA");
assert!(
v.get("recipients").is_none(),
"replication wire must not carry recipient identities"
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+29 -239
View File
@@ -39,11 +39,11 @@ pub async fn create_issue(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<CreateIssueRequest>,
) -> Result<(StatusCode, Json<IssueRecord>)> {
// Authorize the caller as a reader before accepting an issue: a non-reader
// must not be able to file an issue against a private repo they cannot read.
// Mirrors create_issue_comment / create_review / create_bounty.
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 issue_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
@@ -61,14 +61,11 @@ pub async fn create_issue(
let json_str = serde_json::to_string(&issue)
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
// git 500 (#173 F1). This path holds no admission permit, so it reaches the pool
// unthrottled; reuse the push handler's mapping so the two cannot drift.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
@@ -93,11 +90,12 @@ pub async fn create_issue(
pub async fn list_issues(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
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 record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
@@ -122,11 +120,12 @@ pub async fn list_issues(
pub async fn get_issue(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
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 record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
@@ -162,9 +161,11 @@ pub async fn create_issue_comment(
));
}
// Read-gate: a commenter must be able to read the repo, but need not own it.
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 disk_path = state
.repo_store
@@ -192,38 +193,21 @@ pub async fn create_issue_comment(
pub async fn list_issue_comments(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
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 _record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
// Resolve the full issue ID (accepts 8-char prefix) so the DB fetch
// below uses the same canonical id as the git ref.
let full_id = match git_issues::resolve_issue_id(&disk_path, &issue_id)
.map_err(|e| AppError::Git(e.to_string()))?
{
Some(id) => id,
None => {
return Err(AppError::RepoNotFound(format!(
"issue {issue_id} not found"
)))
}
};
let comments = state.db.list_issue_comments(&full_id).await?;
let comments = state.db.list_issue_comments(&issue_id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
/// POST /api/v1/repos/{owner}/{repo}/issues/{id}/close
pub async fn close_issue(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
@@ -232,42 +216,13 @@ pub async fn close_issue(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Same capacity shed as create_issue above (#173 F1): an exhausted write-lock
// pool is a 503 + Retry-After, not a 500 git error.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
// Owner OR issue author may close. The author lives in the issue's git-JSON
// blob (not a DB column); a None author (legacy issues) falls back to
// owner-only. Read it under the write guard, before mutating.
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
.ok()
.and_then(|i| i.author),
Ok(None) => {
guard.release(false).await;
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
}
Err(e) => {
guard.release(false).await;
return Err(AppError::Git(e.to_string()));
}
};
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
let is_author = author_did
.as_deref()
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
if !is_owner && !is_author {
guard.release(false).await;
return Err(AppError::Forbidden(
"only the repo owner or the issue author can close this issue".into(),
));
}
let close_result = git_issues::close_issue(&disk_path, &issue_id);
// Always release the advisory lock — even on error; upload to Tigris only on success.
@@ -284,168 +239,3 @@ pub async fn close_issue(
Ok(Json(issue))
}
/// #173 F1 follow-up: the two issue write paths reach `acquire_write` holding NO
/// admission permit (unlike the push handler, which is capped by the git-push
/// semaphore), so they are the callers most likely to meet an exhausted write-lock
/// POOL under load. An exhausted pool is a capacity signal, so both must shed
/// 503 + Retry-After (`AppError::Overloaded`) the way the push handler does, not
/// report the generic 500 git error that says nothing about retrying.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = Utc::now();
crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// State whose repo store draws write locks from a ONE-connection pool with a
/// short checkout timeout, so a single held guard exhausts it promptly rather
/// than at the pool default.
async fn one_connection_lock_pool_state(pool: &PgPool) -> AppState {
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store = crate::git::repo_store::RepoStore::new(
std::path::PathBuf::from("/tmp/gitlawb-issues-lockpool"),
None,
crate::git::repo_store::build_lock_pool(pool, 1, std::time::Duration::from_secs(1)),
);
state
}
/// The shed must be a real 503 carrying Retry-After, not just an internal enum
/// variant: assert on the rendered response so a remapping of `Overloaded` is
/// caught here too.
fn assert_sheds_503_with_retry_after(err: AppError, what: &str) {
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"{what}: an exhausted write-lock pool must shed 503, not a 500 git error"
);
assert_eq!(
resp.headers()
.get("retry-after")
.map(|v| v.to_str().unwrap()),
Some("1"),
"{what}: a capacity shed must tell the client when to retry"
);
}
/// RED-before/GREEN-after for `create_issue`. Both directions: the shed while the
/// only lock-pool connection is held by a guard on a DIFFERENT repo (so this is
/// pool capacity, not advisory-lock contention on this repo), and the must-not
/// case once that connection is back.
#[sqlx::test]
async fn create_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zISSUECREATELOCKPOOLAAAAAAAAAAAAAAAAAAAA";
let state = one_connection_lock_pool_state(&pool).await;
state
.db
.create_repo(&seed_repo(owner, "lp-create"))
.await
.expect("seed repo");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-create".to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
assert_sheds_503_with_retry_after(err, "create_issue");
// MUST-NOT: with the pool free again the call is not shed as capacity (it
// fails later on the nonexistent on-disk repo, which is a git 500).
held.release(false).await;
let admitted = create_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-create".to_string())),
Json(CreateIssueRequest {
title: "t".to_string(),
body: None,
signed_payload: None,
}),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, create_issue must not be shed as capacity; got {:?}",
admitted.err()
);
}
/// RED-before/GREEN-after for `close_issue`, same two directions.
#[sqlx::test]
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
let state = one_connection_lock_pool_state(&pool).await;
state
.db
.create_repo(&seed_repo(owner, "lp-close"))
.await
.expect("seed repo");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((
owner.to_string(),
"lp-close".to_string(),
"deadbeef".to_string(),
)),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
assert_sheds_503_with_retry_after(err, "close_issue");
held.release(false).await;
let admitted = close_issue(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((
owner.to_string(),
"lp-close".to_string(),
"deadbeef".to_string(),
)),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, close_issue must not be shed as capacity; got {:?}",
admitted.err()
);
}
}
+7 -12
View File
@@ -17,7 +17,7 @@ pub struct LabelRequest {
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
@@ -39,7 +39,6 @@ pub async fn add_label(
.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?;
let status = if added {
@@ -56,7 +55,7 @@ pub async fn add_label(
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
pub async fn remove_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
@@ -64,25 +63,21 @@ pub async fn remove_label(
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
state.db.remove_label(&record.id, &label).await?;
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
}
/// GET /api/v1/repos/:owner/:repo/labels
///
/// Read-visibility-gated (INV-2 root listing): a public repo's labels stay
/// anonymously listable; a private repo's label names are hidden (404) from
/// anyone who cannot read it at the root.
pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let labels = state.db.list_labels(&record.id).await?;
Ok(Json(serde_json::json!({ "labels": labels })))
-682
View File
@@ -1,14 +1,8 @@
use crate::db::{RepoRecord, VisibilityRule};
use crate::error::{AppError, Result};
use crate::state::AppState;
use crate::visibility::{visibility_check, Decision};
pub mod agents;
pub mod arweave;
pub mod bounties;
pub mod certs;
pub mod changelog;
pub mod encrypted;
pub mod events;
pub mod ipfs;
pub mod issues;
@@ -25,679 +19,3 @@ pub mod stars;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
/// Resolve a repo for a read request and enforce path-scoped visibility.
///
/// Returns 404 (`RepoNotFound`) if the repo does not exist or the caller may not
/// read `path`, using the same opaque response the git serve path returns so
/// existence is not confirmed. Returns the record and its visibility rules so a
/// content handler can apply an extra per-path check without a second DB query.
///
/// Callers pass `"/"` for repo-level reads (listings); content endpoints pass the
/// specific path so a withheld subtree is denied even on an otherwise-public repo.
pub(crate) async fn authorize_repo_read(
state: &AppState,
owner: &str,
name: &str,
caller: Option<&str>,
path: &str,
) -> Result<(RepoRecord, Vec<VisibilityRule>)> {
let record = state
.db
.get_repo(owner, name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// A quarantined mirror (admitted by the iCaptcha propagation gate but not
// validated) is hidden from every reader — serve/clone and fork alike — as if
// it did not exist, until an operator releases it. Checked before the
// visibility gate so its existence is never disclosed.
if state.db.is_repo_quarantined(&record.id).await? {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, path) == Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}
Ok((record, rules))
}
/// Match a presented DID against a stored DID that may be the full `did:key:<id>`
/// form or the bare `<id>` short form (mirror rows store the bare key). Collapse
/// representation only within `did:key`; never let a bare id match across methods —
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
/// and the author check (close policy) share one normalization.
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
if did_matches(caller, &record.owner_did) {
Ok(())
} else {
Err(AppError::Forbidden(
"only the repo owner can perform this action".into(),
))
}
}
#[cfg(test)]
mod did_tests {
use super::did_matches;
#[test]
fn full_matches_bare_same_key() {
assert!(did_matches("did:key:zABC", "zABC"));
assert!(did_matches("zABC", "did:key:zABC"));
}
#[test]
fn rejects_cross_method_collision() {
assert!(!did_matches("did:key:zABC", "did:gitlawb:zABC"));
assert!(!did_matches("did:key:zABC", "did:web:zABC"));
}
#[test]
fn exact_match_and_distinct_keys() {
assert!(did_matches("did:key:zABC", "did:key:zABC"));
assert!(!did_matches("did:key:zABC", "did:key:zXYZ"));
assert!(!did_matches("zABC", "zXYZ"));
}
#[test]
fn empty_did_matches() {
assert!(did_matches("", ""));
assert!(did_matches("", "did:key:"));
}
}
/// Drift guard (plan 002 §Gate-type table, Step 5). Every in-scope mutation
/// handler must contain its expected gate marker in its own body; removing a
/// gate fails this test. Source-level (no DB), so it runs everywhere. When a new
/// route is added to an in-scope group, add its row here with a deliberate gate
/// type — that forced decision is the point.
///
/// Markers are gate-SHAPED — a call (`require_repo_owner(`, `did_matches(`) or a
/// binding/comparison expression (`caller != &record.owner_did`,
/// `let owner_did = auth.0`) — never a bare identifier that could also appear in
/// a log line. Full-line comments are stripped before matching, so a marker that
/// survives only as a comment above a deleted gate does NOT satisfy a row.
#[cfg(test)]
mod authz_guard {
/// The body of `func` with full-line comments removed. Bounds the slice at the
/// next top-level fn item so a marker in a later handler can't leak in,
/// tolerating `pub async`, `pub(crate) async`, `async`, `pub`, and bare `fn`
/// declarations (the old single-`pub async fn` delimiter over-ran on any other
/// form).
fn fn_body(src: &str, func: &str) -> String {
let needle = format!("fn {func}(");
let start = src
.find(&needle)
.unwrap_or_else(|| panic!("handler `{func}` not found (renamed or removed?)"));
let rest = &src[start..];
let end = [
"\npub async fn ",
"\npub(crate) async fn ",
"\nasync fn ",
"\npub fn ",
"\nfn ",
]
.iter()
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
.min()
.unwrap_or(rest.len());
rest[..end]
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn every_in_scope_mutation_has_its_gate() {
let pulls = include_str!("pulls.rs");
let webhooks = include_str!("webhooks.rs");
let labels = include_str!("labels.rs");
let issues = include_str!("issues.rs");
let bounties = include_str!("bounties.rs");
let replicas = include_str!("replicas.rs");
let events = include_str!("events.rs");
let tasks = include_str!("tasks.rs");
let stars = include_str!("stars.rs");
let protect = include_str!("protect.rs");
let visibility = include_str!("visibility.rs");
let profiles = include_str!("profiles.rs");
let repos = include_str!("repos.rs");
let register = include_str!("register.rs");
let ipfs = include_str!("ipfs.rs");
// (source, handler, expected gate marker)
let rows: &[(&str, &str, &str)] = &[
// Bucket A — owner-gate (require_repo_owner -> 403)
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "require_repo_owner("),
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
// authorize_repo_read 404s a non-reader of a private repo, then
// require_repo_owner 403s a non-owner of a public one. Both halves
// are pinned: the authorize_repo_read marker guards the read half
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
(pulls, "close_pr", "did_matches("),
(issues, "close_issue", "did_matches("),
// Bucket B — read-gate (authorize_repo_read)
(pulls, "create_review", "authorize_repo_read("),
(pulls, "create_comment", "authorize_repo_read("),
(pulls, "create_pr", "authorize_repo_read("),
(issues, "create_issue_comment", "authorize_repo_read("),
(issues, "create_issue", "authorize_repo_read("),
(bounties, "create_bounty", "authorize_repo_read("),
(repos, "fork_repo", "authorize_repo_read("),
// get_by_cid resolves each candidate (provenance path + legacy scan) through
// the shared `gate_and_serve`; the gate markers themselves are asserted
// below. This row proves the delegation is real, so the gate is actually
// reached rather than dead code. The delegated gate still calls
// `visibility_check` directly and never `authorize_repo_read`, so it keeps
// the property the pre-merge marker enforced: no fuzzy re-resolve.
(ipfs, "get_by_cid", "gate_and_serve("),
// Sibling read surfaces: gate private-repo metadata on read visibility
// (public repos stay anonymous; private repos 404).
(replicas, "list_replicas", "authorize_repo_read("),
(protect, "list_protected_branches", "authorize_repo_read("),
(labels, "list_labels", "authorize_repo_read("),
(events, "list_repo_events", "authorize_repo_read("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
(tasks, "complete_task", "did_matches("),
(tasks, "fail_task", "did_matches("),
(repos, "create_repo", "let owner_did = auth.0"),
(profiles, "set_profile", "let did = auth.0"),
(register, "register", "did_matches("),
(stars, "star_repo", "caller = &auth.0"),
(stars, "unstar_repo", "caller = &auth.0"),
// Bucket D — non-owner-by-design, positive per-route marker
(bounties, "claim_bounty", "claim_bounty(&id, &auth.0"),
(bounties, "submit_bounty", "did_matches("),
(bounties, "approve_bounty", "did_matches("),
(bounties, "cancel_bounty", "did_matches("),
(bounties, "dispute_bounty", "did_matches("),
(replicas, "register_replica", "did_matches("),
(replicas, "unregister_replica", "replica_did = &auth.0"),
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
(protect, "protect_branch", "did_matches("),
(protect, "unprotect_branch", "did_matches("),
(visibility, "set_visibility", "require_owner("),
(visibility, "remove_visibility", "require_owner("),
(visibility, "list_visibility", "require_owner("),
];
// The visibility rows prove require_owner is CALLED; this proves the helper
// itself does DID-safe matching, not a raw/trailing-segment compare.
assert!(
fn_body(visibility, "require_owner").contains("did_matches("),
"visibility::require_owner must use did_matches for DID-safe owner matching"
);
// 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).
// The repo's own "/" visibility check (KTD2a — never authorize_repo_read's fuzzy
// re-resolve) and the quarantine hard-drop BEFORE visibility (INV-11) are both
// load-bearing: removing either re-opens a leak on the provenance path.
let gate_body = fn_body(ipfs, "gate_and_serve");
assert!(
gate_body.contains("visibility_check("),
"gate_and_serve must gate the CID read surface via visibility_check (KTD2a)"
);
assert!(
gate_body.contains("if quarantined"),
"gate_and_serve must hard-drop a quarantined repo before the visibility gate (INV-11)"
);
for (src, func, marker) in rows {
let body = fn_body(src, func);
assert!(
body.contains(marker),
"handler `{func}` is missing its gate marker `{marker}` — gate removed or route reclassified"
);
}
}
/// Proves the comment-stripping that GUARD-1 added: a marker that appears only
/// in a full-line comment (the real `replicas.rs` false-pass shape) must NOT
/// satisfy a row.
#[test]
fn comment_only_marker_does_not_satisfy_a_row() {
let src = "pub async fn demo() {\n // did_matches( handles the owner form\n do_thing();\n}\n";
assert!(
!fn_body(src, "demo").contains("did_matches("),
"a marker present only in a comment must not count as an enforced gate"
);
}
/// All `pub async fn` / `pub(crate) async fn` handler names declared in `src`.
/// Verb-agnostic on purpose: a repo-scoped read of any name is in scope, so a
/// handler named `fetch_*` / `replicate_*` / `info_refs` cannot escape the gate
/// check by not being called `list_*` / `get_*`.
fn handler_names(src: &str) -> Vec<String> {
["pub async fn ", "pub(crate) async fn "]
.iter()
.flat_map(|decl| {
src.match_indices(decl).map(move |(i, _)| {
src[i + decl.len()..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>()
})
})
.collect()
}
/// True when the handler's signature takes an owner+repo path
/// (`Path<(String, String...)>`), i.e. it is a repo-scoped read rather than a
/// node-wide aggregate (`/stats`, `/ipfs/pins`, the global feeds).
fn is_repo_scoped(body: &str) -> bool {
let header = &body[..body.find('{').unwrap_or(body.len())];
header.contains("Path<(String, String")
}
/// True when at least one gate marker runs for EVERY service — i.e. some
/// marker sits outside any `if service == ...` discriminator block. A gate
/// that appears ONLY inside such a block (as the info/refs advertisement gate
/// did before #119: `visibility_check` ran under `if service ==
/// "git-upload-pack"`, leaving `git-receive-pack` ungated) covers a subset of
/// services and must NOT count as a full gate. Other handlers carry no
/// `service ==` discriminator, so for them this matches the plain
/// substring check. NOTE: only `if service ==` is detected — a
/// `match service { .. }` discriminator is NOT tracked and a gate inside one
/// arm would pass as full; avoid that shape, or extend the span loop below.
fn gate_runs_unconditionally(body: &str, markers: &[&str]) -> bool {
// Brace-matched spans of each `if service == ...` block.
let mut cond_spans: Vec<(usize, usize)> = Vec::new();
let mut search = 0;
while let Some(rel) = body[search..].find("if service ==") {
let cond_start = search + rel;
let Some(brace_rel) = body[cond_start..].find('{') else {
break;
};
let open = cond_start + brace_rel;
let mut depth = 0i32;
let mut end = body.len();
for (i, c) in body[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i;
break;
}
}
_ => {}
}
}
cond_spans.push((open, end));
// On an unclosed block `end` stays at body.len() (the fail-safe
// direction: treat the rest as conditional rather than mask a gate);
// clamp so the next slice can't index past the end and panic.
search = (end + 1).min(body.len());
}
markers.iter().any(|m| {
body.match_indices(m)
.any(|(pos, _)| !cond_spans.iter().any(|(s, e)| pos >= *s && pos <= *e))
})
}
/// Collect `.rs` source files under `dir`. Recursive so the completeness scan
/// covers nested API modules (`api/<module>/mod.rs` and deeper), not only the
/// immediate `api/*.rs` children.
fn collect_rs_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).expect("read api dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
out.extend(collect_rs_files(&path));
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
out
}
/// The `.rs` files under `api_root` (as paths RELATIVE to it) that the
/// completeness scan must inspect: everything except the top-level `mod.rs` (the
/// guard file itself) and the top-level files already covered by the per-handler
/// `sources` loop. A nested `api/<module>/<name>.rs` is a distinct source file
/// even when its basename matches a listed top-level file, so it stays in scope.
fn unlisted_source_files(
api_root: &std::path::Path,
listed: &std::collections::HashSet<&str>,
) -> Vec<String> {
collect_rs_files(api_root)
.iter()
.filter_map(|path| {
let rel = path
.strip_prefix(api_root)
.ok()?
.to_string_lossy()
.replace('\\', "/");
if rel == "mod.rs" || listed.contains(rel.as_str()) {
None
} else {
Some(rel)
}
})
.collect()
}
#[test]
fn collect_rs_files_recurses_subdirs() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join("a.rs"), "").unwrap();
std::fs::write(root.join("note.txt"), "").unwrap();
std::fs::create_dir_all(root.join("sub/deep")).unwrap();
std::fs::write(root.join("sub/mod.rs"), "").unwrap();
std::fs::write(root.join("sub/deep/c.rs"), "").unwrap();
let names: std::collections::HashSet<String> = collect_rs_files(root)
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(names.contains("a.rs"));
assert!(
names.contains("sub/mod.rs"),
"nested module file must be collected"
);
assert!(
names.contains("sub/deep/c.rs"),
"deeply nested file must be collected"
);
assert!(
!names.iter().any(|n| n.ends_with(".txt")),
"non-rs files excluded"
);
assert_eq!(names.len(), 3);
}
// P3 (#119): the completeness scan must skip already-covered files by their path
// RELATIVE to api_root, not by basename. A nested api/<module>/repos.rs is a
// distinct source file from the covered top-level repos.rs and must still be
// scanned, or a new nested module could smuggle in an ungated repo-scoped handler
// behind a colliding filename.
#[test]
fn unlisted_source_files_scans_nested_file_with_colliding_basename() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join("repos.rs"), "").unwrap();
std::fs::write(root.join("mod.rs"), "").unwrap();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/repos.rs"), "").unwrap();
std::fs::write(root.join("sub/fresh.rs"), "").unwrap();
let listed: std::collections::HashSet<&str> = ["repos.rs"].into_iter().collect();
let unlisted = unlisted_source_files(root, &listed);
assert!(
unlisted.contains(&"sub/repos.rs".to_string()),
"a nested file whose basename matches a listed top-level file must still be scanned"
);
assert!(
unlisted.contains(&"sub/fresh.rs".to_string()),
"a nested file with a unique name must be scanned"
);
assert!(
!unlisted.contains(&"repos.rs".to_string()),
"the listed top-level file is covered by the per-handler loop"
);
assert!(
!unlisted.contains(&"mod.rs".to_string()),
"the top-level guard file is skipped"
);
}
/// Egress gate guard: every repo-scoped handler (`Path<(String, String..)>`)
/// must carry an authz marker — a read gate (`authorize_repo_read` /
/// `visibility_check`), or a write gate (`require_repo_owner` / `require_owner`
/// / `did_matches` / a `&auth.0` self-binding) — or be listed in KNOWN_UNGATED
/// (the tracked, ungated reads). A new ungated handler of ANY verb trips this,
/// and a completeness scan over `src/api/` trips it for a whole new module that
/// adds a repo-scoped handler without being wired into `sources`. Mutations are
/// also checked precisely, per handler, by the mutation guard above; here they
/// only need *some* binding so the net stays verb-agnostic.
///
/// Scope and limits (this is a source scrape; the runtime route guard covers
/// behaviour):
/// - It proves a gate is CALLED, not that it runs on the requested path: a
/// content handler gating `"/"` instead of the subtree still passes here.
/// - It sees handlers taking the owner+repo tuple `Path<(String, String..)>`; a
/// repo identified by a struct/custom extractor would be missed (the helper
/// unit tests pin these boundaries).
/// - KNOWN_UNGATED entries need a real open issue and review; the staleness
/// assert removes one only once its gate lands.
#[test]
fn every_repo_scoped_handler_is_gated() {
let sources: &[(&str, &str)] = &[
(include_str!("bounties.rs"), "bounties.rs"),
(include_str!("certs.rs"), "certs.rs"),
(include_str!("changelog.rs"), "changelog.rs"),
(include_str!("encrypted.rs"), "encrypted.rs"),
(include_str!("events.rs"), "events.rs"),
(include_str!("issues.rs"), "issues.rs"),
(include_str!("labels.rs"), "labels.rs"),
(include_str!("protect.rs"), "protect.rs"),
(include_str!("pulls.rs"), "pulls.rs"),
(include_str!("replicas.rs"), "replicas.rs"),
(include_str!("repos.rs"), "repos.rs"),
(include_str!("stars.rs"), "stars.rs"),
(include_str!("visibility.rs"), "visibility.rs"),
(include_str!("webhooks.rs"), "webhooks.rs"),
];
let listed: std::collections::HashSet<&str> = sources.iter().map(|(_, f)| *f).collect();
assert!(!listed.is_empty(), "read-guard `sources` is empty");
// Completeness: every api/*.rs NOT already in `sources` must declare no
// repo-scoped handler, so a brand-new module cannot add an ungated handler
// the scrape never looks at. Reads the directory at test time (so the list
// cannot silently drift from the filesystem) and only inspects unlisted
// files — listed files are covered by the per-handler loop below.
let api_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/api");
for (_, f) in sources {
assert!(
std::path::Path::new(api_dir).join(f).exists(),
"read-guard `sources` lists {f} but the file does not exist"
);
}
let api_root = std::path::Path::new(api_dir);
for rel in unlisted_source_files(api_root, &listed) {
let src = std::fs::read_to_string(api_root.join(&rel)).expect("read api file");
let has_repo_handler = handler_names(&src)
.iter()
.any(|n| is_repo_scoped(&fn_body(&src, n)));
assert!(
!has_repo_handler,
"api/{rel} declares a repo-scoped handler but is not in the egress \
guard `sources` list — add it so its handlers are gate-checked"
);
}
// Repo-scoped reads known to be ungated today, each tracked by an issue.
// Remove an entry the moment its gate lands (the staleness assert enforces it).
let known_ungated: &[(&str, &str)] = &[];
let is_known = |n: &str| known_ungated.iter().any(|(k, _)| *k == n);
// Any one of these = the handler binds the caller to an authz decision: the
// first two are read gates, the rest are the write/owner/self-binding forms.
// A repo-scoped mutation passes here on its write gate; the mutation guard
// above additionally verifies the exact gate type per handler. NOTE: a local
// rename of `caller`/`replica_did` must be paired with a marker update here.
let markers = [
"authorize_repo_read(",
"visibility_check(",
"require_repo_owner(",
"require_owner(",
"did_matches(",
"caller = &auth.0",
"replica_did = &auth.0",
];
// Every KNOWN_UNGATED name must be a real handler (catch typos / renames).
let all: std::collections::HashSet<String> =
sources.iter().flat_map(|(s, _)| handler_names(s)).collect();
for (n, _) in known_ungated {
assert!(
all.contains(*n),
"KNOWN_UNGATED lists `{n}`, which is not a real handler (renamed or removed?)"
);
}
let mut checked = 0usize;
for (src, file) in sources {
for name in handler_names(src) {
let body = fn_body(src, &name);
if !is_repo_scoped(&body) {
continue; // node-wide aggregate, not a repo-scoped surface
}
checked += 1;
let gated = gate_runs_unconditionally(&body, &markers);
assert!(
gated || is_known(&name),
"repo-scoped handler `{name}` ({file}) has no authz gate and is \
not in KNOWN_UNGATED — add the visibility/owner gate with the \
caller, or track it there"
);
assert!(
!(gated && is_known(&name)),
"handler `{name}` ({file}) is now gated — remove it from \
KNOWN_UNGATED so the allowlist stays an accurate gap list"
);
}
}
// Tripwire: if the scrape silently stopped finding handlers (e.g. a parser
// regression in handler_names/is_repo_scoped), this floor fails loudly
// instead of the guard passing vacuously. Current count is ~54 repo-scoped
// handlers; 20 is a deliberate floor that trips only on a gross regression.
assert!(
checked >= 20,
"egress guard only checked {checked} repo-scoped handlers — the scrape likely broke"
);
}
/// Pins the `handler_names` boundary: it collects every `pub`/`pub(crate)
/// async fn` regardless of verb (so a `fetch_*` read cannot escape), and skips
/// private `async fn` and sync `fn` helpers.
#[test]
fn handler_names_finds_all_pub_async_and_skips_others() {
let src = "pub async fn list_things() {}\n\
pub async fn fetch_thing() {}\n\
pub(crate) async fn get_crate_thing() {}\n\
async fn private_helper() {}\n\
fn sync_helper() {}\n";
let names = handler_names(src);
// Verb-agnostic: a non-list/get read verb is still seen.
assert!(names.contains(&"list_things".to_string()));
assert!(names.contains(&"fetch_thing".to_string()));
// pub(crate) routed handlers are in scope too.
assert!(names.contains(&"get_crate_thing".to_string()));
// Private/sync helpers are not routed handlers and are skipped.
assert!(!names
.iter()
.any(|n| n == "private_helper" || n == "sync_helper"));
}
/// Pins the `is_repo_scoped` boundary: an owner+repo tuple Path is repo-scoped;
/// a single-segment or absent Path is a node-wide aggregate.
#[test]
fn is_repo_scoped_requires_owner_repo_path() {
let repo =
"fn get_x(s: State, Path((owner, name)): Path<(String, String)>) {\n body();\n}";
let three = "fn get_y(Path((o, n, id)): Path<(String, String, String)>) {\n body();\n}";
let node_wide = "fn list_z(s: State<AppState>) {\n body();\n}";
let single = "fn get_w(Path(cid): Path<String>) {\n body();\n}";
assert!(is_repo_scoped(repo), "owner+repo tuple is repo-scoped");
assert!(is_repo_scoped(three), "owner+repo+id tuple is repo-scoped");
assert!(!is_repo_scoped(node_wide), "no Path is node-wide");
assert!(
!is_repo_scoped(single),
"single-segment Path is not repo-scoped"
);
}
/// Pins `gate_runs_unconditionally`: a gate nested only inside an
/// `if service == ...` block is conditional (does NOT count), while the same
/// gate at the top level — or an additional unconditional one — does.
#[test]
fn conditional_service_gate_is_not_a_full_gate() {
let markers = ["visibility_check("];
// Gate runs only for one service: not a full gate.
let conditional = "fn f() {\n \
if service == \"git-upload-pack\" {\n \
visibility_check(rules, caller);\n \
}\n \
if service == \"git-receive-pack\" { acquire_fresh(); }\n}";
assert!(
!gate_runs_unconditionally(conditional, &markers),
"a gate only inside `if service ==` covers a subset of services"
);
// Same gate at top level: full gate.
let unconditional = "fn f() {\n \
visibility_check(rules, caller);\n \
if service == \"git-receive-pack\" { acquire_fresh(); }\n}";
assert!(
gate_runs_unconditionally(unconditional, &markers),
"an unconditional gate runs for every service"
);
// A gate inside EACH of two service blocks, none outside: still a
// subset (no service clears it unconditionally), so not a full gate.
let both_conditional = "fn f() {\n \
if service == \"git-upload-pack\" { visibility_check(a); }\n \
if service == \"git-receive-pack\" { visibility_check(b); }\n}";
assert!(
!gate_runs_unconditionally(both_conditional, &markers),
"a gate inside every service block is still conditional"
);
// A marker inside one block AND again unconditionally: the
// unconditional occurrence makes it a full gate (exercises the
// match_indices scan past the in-block hit).
let inside_and_outside = "fn f() {\n \
if service == \"git-upload-pack\" { visibility_check(a); }\n \
visibility_check(b);\n}";
assert!(
gate_runs_unconditionally(inside_and_outside, &markers),
"an unconditional occurrence counts even when another is conditional"
);
// No marker at all: not gated.
assert!(!gate_runs_unconditionally(
"fn f() { do_thing(); }",
&markers
));
// An unclosed `if service ==` block (e.g. phantom brace from a string
// literal) must not panic on the slice advance; the span runs to EOF, so
// the in-block marker reads as conditional. Real Rust source is balanced,
// so this only guards the scraper against a future pathological body.
let unclosed = "fn f() { if service == \"x\" { visibility_check(a);";
assert!(
!gate_runs_unconditionally(unclosed, &markers),
"an unclosed service block must not panic and stays conditional"
);
}
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -98,7 +98,7 @@ pub async fn set_profile(
&state.http_client,
&state.config.pinata_upload_url,
&state.config.pinata_jwt,
&format!("profile-{}", did),
&format!("profile-{}", &did),
&profile_json,
)
.await
+20 -12
View File
@@ -23,10 +23,15 @@ pub async fn protect_branch(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Only the repo owner can protect branches (DID-safe match, shared idiom).
// Only the repo owner can protect branches
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
return Err(AppError::Forbidden(
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can protect branches".into(),
));
}
@@ -58,8 +63,13 @@ pub async fn unprotect_branch(
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
return Err(AppError::Forbidden(
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can unprotect branches".into(),
));
}
@@ -79,14 +89,12 @@ pub async fn unprotect_branch(
pub async fn list_protected_branches(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// Read-visibility-gated (INV-2 root listing): a public repo's protected
// branches stay anonymously listable; a private repo's branch names are
// hidden (404) from anyone who cannot read it at the root.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let branches = state.db.list_protected_branches(&record.id).await?;
+41 -182
View File
@@ -40,11 +40,11 @@ pub async fn create_pr(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreatePrRequest>,
) -> Result<(StatusCode, Json<PullRequest>)> {
// Authorize the caller as a reader before accepting a PR: a non-reader must
// not be able to open a PR (and fire its webhooks) against a private repo
// they cannot read. Mirrors create_review / create_comment / create_bounty.
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 author_did = auth.0;
let target_branch = req
@@ -101,11 +101,12 @@ pub async fn create_pr(
pub async fn list_prs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let prs = state.db.list_prs(&record.id).await?;
Ok(Json(
@@ -117,11 +118,12 @@ pub async fn list_prs(
pub async fn get_pr(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<PullRequest>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
@@ -136,11 +138,12 @@ pub async fn get_pr(
pub async fn get_pr_diff(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
@@ -153,25 +156,6 @@ pub async fn get_pr_diff(
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
// Withhold the entire diff if it touches a path the caller cannot read, so a
// PR diff cannot leak private-subtree content of an otherwise-public repo.
let touched = store::branch_diff_names(&disk_path, &pr.target_branch, &pr.source_branch)
.map_err(|e| AppError::Git(e.to_string()))?;
for p in &touched {
let gate = format!("/{}", p.trim_start_matches('/'));
if crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
caller,
&gate,
) == crate::visibility::Decision::Deny
{
return Err(AppError::NotFound(format!("PR #{number} not found")));
}
}
let diff = store::branch_diff(&disk_path, &pr.target_branch, &pr.source_branch)
.map_err(|e| AppError::Git(e.to_string()))?;
@@ -194,11 +178,6 @@ pub async fn merge_pr(
.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
// non-owner cannot merge to any branch, protected or not).
crate::api::require_repo_owner(&record, &auth.0)?;
let pr = state
.db
.get_pr(&record.id, number)
@@ -209,14 +188,11 @@ pub async fn merge_pr(
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
}
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
// unthrottled; reuse the push handler's mapping so the two cannot drift.
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
let merge_result = store::merge_branch(
@@ -260,7 +236,6 @@ pub async fn merge_pr(
/// POST /api/v1/repos/:owner/:repo/pulls/:number/close
pub async fn close_pr(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
@@ -275,15 +250,6 @@ pub async fn close_pr(
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
// Owner OR author may close (forge norm).
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
let is_author = crate::api::did_matches(&auth.0, &pr.author_did);
if !is_owner && !is_author {
return Err(AppError::Forbidden(
"only the repo owner or the PR author can close this PR".into(),
));
}
state.db.close_pr(&pr.id).await?;
webhooks::fire_event(
@@ -308,9 +274,11 @@ pub async fn create_review(
Path((owner, name, number)): Path<(String, String, i64)>,
Json(req): Json<CreateReviewRequest>,
) -> Result<(StatusCode, Json<PrReview>)> {
// Read-gate: a reviewer must be able to read the repo, but need not own it.
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
@@ -356,11 +324,12 @@ pub async fn create_review(
pub async fn list_reviews(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
@@ -385,9 +354,11 @@ pub async fn create_comment(
));
}
// Read-gate: a commenter must be able to read the repo, but need not own it.
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
@@ -412,11 +383,12 @@ pub async fn create_comment(
pub async fn list_comments(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
@@ -427,116 +399,3 @@ pub async fn list_comments(
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
/// 500 git error that says nothing about retrying.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = Utc::now();
crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
/// capacity, not advisory-lock contention on this repo), and the must-not case
/// once that connection is back.
#[sqlx::test]
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool.clone()).await;
// One lock-pool connection with a short checkout timeout, so a single held
// guard exhausts it promptly rather than at the pool default.
state.repo_store = crate::git::repo_store::RepoStore::new(
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
None,
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
);
let repo = seed_repo(owner, "lp-merge");
let repo_id = repo.id.clone();
state.db.create_repo(&repo).await.expect("seed repo");
let now = Utc::now().to_rfc3339();
state
.db
.create_pr(&PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.clone(),
number: 1,
title: "lp".to_string(),
body: None,
author_did: owner.to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
})
.await
.expect("seed open PR");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-merge".to_string(), 1)),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"merge_pr: an exhausted write-lock pool must shed 503, not a 500 git error"
);
assert_eq!(
resp.headers()
.get("retry-after")
.map(|v| v.to_str().unwrap()),
Some("1"),
"merge_pr: a capacity shed must tell the client when to retry"
);
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
// fails later on the nonexistent on-disk repo, which is a git 500).
held.release(false).await;
let admitted = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-merge".to_string(), 1)),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, merge_pr must not be shed as capacity; got {:?}",
admitted.err()
);
}
}
-18
View File
@@ -35,32 +35,14 @@ pub struct RegisterResponse {
/// Accepts only requests with a valid HTTP Signature (enforced by middleware).
pub async fn register(
State(state): State<AppState>,
axum::Extension(auth): axum::Extension<crate::auth::AuthenticatedDid>,
headers: axum::http::HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>)> {
// iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify up front; spend
// the proof only once the request is admissible, just before the write, so a
// rejected request never burns a valid proof.
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;
// Parse and validate the DID
let agent_did: Did = req
.did
.parse()
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
// Bind registration to the authenticated signer: an agent may only register
// itself, not create or refresh a trust row for a DID it does not control.
if !crate::api::did_matches(&auth.0, agent_did.as_str()) {
return Err(AppError::Forbidden(
"did must be the authenticated signer".into(),
));
}
// Request is admissible — spend the proof now, immediately before the write.
proof.consume(&state.db).await?;
// Store the agent in the local index
state
.db
+8 -10
View File
@@ -47,9 +47,8 @@ pub async fn register_replica(
let replica_did = &auth.0;
// Don't let an owner register themselves as a replica of their own repo
// (did_matches handles the full vs bare did:key owner form).
if crate::api::did_matches(replica_did, &record.owner_did) {
// Don't let an owner register themselves as a replica of their own repo.
if replica_did == &record.owner_did {
return Err(AppError::BadRequest(
"the repo owner is not a replica of their own repo".into(),
));
@@ -113,17 +112,16 @@ pub async fn unregister_replica(
}
/// GET /api/v1/repos/:owner/:repo/replicas
/// Read-visibility-gated: a PUBLIC repo's replica list stays anonymously
/// listable (mirror-discovery), but a PRIVATE repo's replica URLs are hidden
/// (404) from anyone who cannot read the repo at its root.
/// Public — returns the list of replicas (DID + URL + registration timestamp).
pub async fn list_replicas(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let 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 record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replicas = state.db.list_replicas(&record.id).await?;
File diff suppressed because it is too large. Load diff
+18 -11
View File
@@ -2,14 +2,14 @@
//!
//! Any authenticated agent can star or unstar a repo.
//! One agent = one star per repo (enforced by UNIQUE constraint in repo_stars).
//! Star count is gated on repo read access.
//! Star count is public — no auth required on GET.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::Result;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// PUT /api/v1/repos/:owner/:repo/star
@@ -19,8 +19,11 @@ pub async fn star_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(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}")))?;
let caller = &auth.0;
let inserted = state.db.star_repo(&record.id, caller).await?;
@@ -51,8 +54,11 @@ pub async fn unstar_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(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;
state.db.unstar_repo(&record.id, caller).await?;
@@ -68,15 +74,16 @@ pub async fn unstar_repo(
}
/// GET /api/v1/repos/:owner/:repo/star
/// Returns star count for callers who can read the repo.
/// Returns star count — unauthenticated.
pub async fn get_star_status(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
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 record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let count = state.db.count_stars(&record.id).await?;
+19 -85
View File
@@ -9,7 +9,7 @@
//! POST /api/v1/tasks/{id}/fail — fail task
use axum::{
extract::{Extension, Path, Query, State},
extract::{Path, Query, State},
http::StatusCode,
Json,
};
@@ -18,18 +18,9 @@ use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::AgentTask;
use crate::state::{AppState, TaskEventBroadcast};
/// 403 in this module's error shape (`(StatusCode, Json<Value>)`, not `AppError`).
fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
(
StatusCode::FORBIDDEN,
Json(json!({ "error": "forbidden", "message": msg })),
)
}
// ── Request / response types ──────────────────────────────────────────────────
#[derive(Deserialize)]
@@ -64,11 +55,13 @@ pub struct ClaimTaskBody {
#[derive(Deserialize)]
pub struct CompleteTaskBody {
pub result: Option<String>,
pub by_did: Option<String>,
}
#[derive(Deserialize)]
pub struct FailTaskBody {
pub reason: Option<String>,
pub by_did: Option<String>,
}
fn task_to_json(t: &AgentTask) -> Value {
@@ -94,20 +87,15 @@ fn task_to_json(t: &AgentTask) -> Value {
/// POST /api/v1/tasks
pub async fn create_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Json(body): Json<CreateTaskBody>,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
// Bind the delegator to the authenticated signer (N13).
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
return Err(forbidden("delegator_did must be the authenticated signer"));
}
let now = Utc::now().to_rfc3339();
let task = AgentTask {
id: Uuid::new_v4().to_string(),
repo_id: body.repo_id,
kind: body.kind,
status: "pending".to_string(),
delegator_did: auth.0,
delegator_did: body.delegator_did,
assignee_did: body.assignee_did,
capability: body.capability,
ucan_token: body.ucan_token,
@@ -166,25 +154,24 @@ pub async fn get_task(
/// POST /api/v1/tasks/{id}/claim
pub async fn claim_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
Json(body): Json<ClaimTaskBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Bind the assignee to the authenticated signer (N13).
if !crate::api::did_matches(&auth.0, &body.assignee_did) {
return Err(forbidden("assignee_did must be the authenticated signer"));
}
let task = state.db.claim_task(&id, &auth.0).await.map_err(|e| {
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let task = state
.db
.claim_task(&id, &body.assignee_did)
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let _ = state.task_event_tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did: auth.0,
by_did: body.assignee_did,
at: Utc::now().to_rfc3339(),
});
Ok(Json(task_to_json(&task)))
@@ -193,44 +180,17 @@ pub async fn claim_task(
/// POST /api/v1/tasks/{id}/complete
pub async fn complete_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
Json(body): Json<CompleteTaskBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Authorize the actor, not just bind their identity: the N13 signer-binding
// proved the caller was whoever they claimed, but never that they were the
// task's assignee. Load the task and require the caller to be its assignee;
// finish_task then transitions only a claimed task.
let existing = state
.db
.get_task(&id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "task not found" })),
)
})?;
if !crate::api::did_matches(
&auth.0,
existing.assignee_did.as_deref().unwrap_or_default(),
) {
return Err(forbidden("only the task assignee can complete it"));
}
let by_did = auth.0;
let by_did = body.by_did.unwrap_or_default();
let task = state
.db
.finish_task(&id, "completed", body.result.as_deref())
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": e.to_string() })),
)
})?;
@@ -247,36 +207,10 @@ pub async fn complete_task(
/// POST /api/v1/tasks/{id}/fail
pub async fn fail_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
Json(body): Json<FailTaskBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Authorize the actor, not just bind their identity (see complete_task): only
// the task's assignee may fail it, and finish_task transitions only a claimed
// task.
let existing = state
.db
.get_task(&id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "task not found" })),
)
})?;
if !crate::api::did_matches(
&auth.0,
existing.assignee_did.as_deref().unwrap_or_default(),
) {
return Err(forbidden("only the task assignee can fail it"));
}
let by_did = auth.0;
let by_did = body.by_did.unwrap_or_default();
let reason = body.reason.unwrap_or_default();
let task = state
.db
@@ -284,7 +218,7 @@ pub async fn fail_task(
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": e.to_string() })),
)
})?;
+14 -40
View File
@@ -26,16 +26,17 @@ pub struct RemoveVisibilityRequest {
}
fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
// DID-safe owner match (collapses did:key full vs bare on both sides, never
// across methods), shared with require_repo_owner — not a trailing-segment
// compare that only normalized the owner side.
if crate::api::did_matches(caller, &record.owner_did) {
Ok(())
} else {
Err(AppError::Forbidden(
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
if caller != record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can manage visibility".into(),
))
));
}
Ok(())
}
/// Reject malformed globs before they reach the store, where they would
@@ -56,23 +57,9 @@ fn validate_path_glob(path_glob: &str) -> Result<()> {
"path_glob must not end with '/'".into(),
));
}
// Reject empty path segments. `glob_prefix` collapses "//**" to "/" (whole
// repo), so without this an operator could bypass the "/**" guard above and
// grant repo-wide read with "//**", or set a dead rule like "//secret/**"
// whose prefix never matches a real single-slash path and silently leaves
// the subtree readable.
if path_glob.contains("//") {
if path_glob.contains('*') && !path_glob.ends_with("/**") {
return Err(AppError::BadRequest(
"path_glob must not contain empty path segments".into(),
));
}
// Strip the one permitted trailing "/**" before checking for stray
// wildcards, otherwise a "*" elsewhere in the prefix (e.g. "/a*b/**" or
// "/a/**/**") slips through because the string still ends with "/**".
let prefix = path_glob.strip_suffix("/**").unwrap_or(path_glob);
if prefix.contains('*') {
return Err(AppError::BadRequest(
"the only supported wildcard is a single trailing '/**'".into(),
"the only supported wildcard is a trailing '/**'".into(),
));
}
Ok(())
@@ -253,22 +240,9 @@ mod tests {
#[test]
fn rejects_malformed_globs() {
// empty, no leading slash, whole-repo via "/**", trailing slash,
// non-trailing wildcards, double wildcards, and empty path segments
// (which would collapse to whole-repo or a dead rule) are all rejected.
for g in [
"",
"secret/**",
"/**",
"/**/**",
"/secret/",
"/a*b",
"/*/x",
"/a*b/**",
"/a/**/**",
"//**",
"//secret/**",
] {
// empty, no leading slash, whole-repo via "/**", trailing slash, and
// non-trailing wildcards are all rejected.
for g in ["", "secret/**", "/**", "/secret/", "/a*b", "/*/x"] {
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
}
}
+11 -64
View File
@@ -1,8 +1,8 @@
//! Webhook CRUD API.
//!
//! POST /api/v1/repos/:owner/:repo/hooks — create (owner only)
//! GET /api/v1/repos/:owner/:repo/hooks — list (owner only; auth required)
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (owner only)
//! POST /api/v1/repos/:owner/:repo/hooks — create (auth required)
//! GET /api/v1/repos/:owner/:repo/hooks — list
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (auth required)
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
@@ -38,16 +38,11 @@ pub async fn create_webhook(
.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
// announce path uses, so an authenticated owner cannot register a webhook
// that makes the node POST to loopback/private/link-local/metadata
// endpoints (SSRF). Delivery runs on the shared no-redirect client
// (main.rs), which closes the 3xx-to-internal bounce.
if !crate::api::peers::is_public_http_url(&req.url) {
// Validate URL is http/https
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
return Err(AppError::BadRequest(
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
"webhook URL must be http:// or https://".into(),
));
}
@@ -73,26 +68,12 @@ pub async fn create_webhook(
pub async fn list_webhooks(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// This route sits on `optional_signature`, so the DID is optional. Webhook
// callback URLs are owner-secret config and there is no anonymous form, so a
// headerless caller is rejected before any lookup (401, which fires for an
// existing-private and an absent repo alike, so it leaks no existence).
let Some(Extension(AuthenticatedDid(caller))) = auth else {
return Err(AppError::Unauthorized(
"listing webhooks requires authentication".into(),
));
};
// Read-visibility first, then owner. authorize_repo_read returns 404 on a
// visibility deny, so a non-reader of a private repo cannot tell it exists
// (uniform with the sibling read surfaces); require_repo_owner then yields
// 403 only for a non-owner of a public/readable repo, where existence is not
// secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(&caller), "/").await?;
crate::api::require_repo_owner(&record, &caller)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let hooks = state.db.list_webhooks(&record.id).await?;
// Redact secrets in list response
@@ -114,7 +95,6 @@ pub async fn list_webhooks(
/// DELETE /api/v1/repos/:owner/:repo/hooks/:id
pub async fn delete_webhook(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
@@ -122,7 +102,6 @@ pub async fn delete_webhook(
.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
let hook = state
@@ -138,35 +117,3 @@ pub async fn delete_webhook(
state.db.delete_webhook(&id).await?;
Ok(Json(serde_json::json!({ "deleted": true, "id": id })))
}
#[cfg(test)]
mod tests {
use crate::api::peers::is_public_http_url;
// create_webhook gates req.url through is_public_http_url. Pin the exact
// SSRF targets from issue #81 so the webhook path can never regress to the
// old scheme-only check, independent of the validator's own peer tests.
#[test]
fn webhook_url_gate_rejects_ssrf_targets() {
for bad in [
"http://127.0.0.1:5432/",
"http://169.254.169.254/latest/meta-data/",
"http://localhost/",
"http://10.0.0.5/",
"http://[::1]/",
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
"http://[2002:7f00:1::]/",
"http://[64:ff9b::7f00:1]/",
"ftp://example.com/",
"not-a-url",
] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
#[test]
fn webhook_url_gate_allows_public_targets() {
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
assert!(is_public_http_url("http://203.0.113.10:7545/"));
}
}
+2 -215
View File
@@ -103,108 +103,6 @@ pub async fn anchor_ref_update(
Ok(tx_id)
}
/// A per-push manifest of the blobs encrypted this push (Option B3). The
/// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON
/// body so the discovery index survives total node loss. Recipient identities are
/// never part of the manifest.
pub struct EncryptedManifest<'a> {
pub repo: &'a str,
pub owner_did: &'a str,
pub node_did: &'a str,
pub timestamp: &'a str,
pub blobs: &'a [(String, String)],
}
/// Anchor a per-push encrypted-blob manifest to Arweave via Irys. The manifest
/// JSON body is the payload (not a CID pointer to IPFS), so the index is
/// permanent and self-contained. Recipient identities are deliberately omitted:
/// the anchor is permanent and public, and the v2 envelopes no longer expose
/// recipients, so the reader set must not be written to Arweave either.
///
/// Returns the Irys/Arweave transaction ID, or `Ok("")` when `irys_url` is empty
/// (anchoring disabled) or there are no blobs to anchor.
pub async fn anchor_encrypted_manifest(
client: &reqwest::Client,
irys_url: &str,
manifest: &EncryptedManifest<'_>,
) -> Result<String> {
if irys_url.is_empty() || manifest.blobs.is_empty() {
return Ok(String::new());
}
let blobs_json: Vec<serde_json::Value> = manifest
.blobs
.iter()
.map(|(oid, cid)| manifest_blob_json(oid, cid))
.collect();
let payload = json!({
"schema": "gitlawb/encrypted-manifest/v1",
"repo": manifest.repo,
"owner_did": manifest.owner_did,
"node_did": manifest.node_did,
"timestamp": manifest.timestamp,
"blobs": blobs_json,
});
let body = serde_json::to_vec(&payload)?;
let url = format!("{}/upload", irys_url.trim_end_matches('/'));
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.header("x-irys-tags", build_manifest_tags_header(manifest))
.body(body)
.send()
.await
.map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("Irys returned {status}: {body}"));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?;
let tx_id = json["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))?
.to_string();
tracing::info!(
repo = %manifest.repo,
tx_id = %tx_id,
blobs = manifest.blobs.len(),
"anchored encrypted manifest to Arweave"
);
Ok(tx_id)
}
/// Serialize one blob for the Arweave manifest. Recipient identities are
/// intentionally absent so the permanent public anchor never records who can
/// read a blob.
fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value {
json!({ "oid": oid, "cid": cid })
}
/// Build the Irys tag header for an encrypted-blob manifest. `Repo` and `Schema`
/// are the tags the `gl` recovery query filters on.
fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String {
[
"App-Name:gitlawb".to_string(),
"Schema:gitlawb/encrypted-manifest/v1".to_string(),
format!("Repo:{}", sanitize_tag(manifest.repo)),
format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)),
format!("Node-DID:{}", sanitize_tag(manifest.node_did)),
]
.join(",")
}
/// Arweave permanent URL for a given Irys transaction ID.
pub fn arweave_url(tx_id: &str) -> String {
format!("https://arweave.net/{tx_id}")
@@ -243,7 +141,7 @@ mod tests {
repo: "alice/myrepo".into(),
owner_did: "did:key:z6Mk...".into(),
ref_name: "refs/heads/main".into(),
old_sha: "0000000000000000000000000000000000000000".into(),
old_sha: "0000000000000000000000000000000000000000000000000000000000000000".into(),
new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
cid: Some("bafyreib5...".into()),
timestamp: "2026-03-14T00:00:00Z".into(),
@@ -270,7 +168,7 @@ mod tests {
repo: "alice/myrepo".into(),
owner_did: "did:key:z6Mk...".into(),
ref_name: "refs/heads/main".into(),
old_sha: "0".repeat(40),
old_sha: "0".repeat(64),
new_sha: "a1b2c3d4".repeat(8),
cid: None,
timestamp: "2026-03-14T00:00:00Z".into(),
@@ -286,44 +184,6 @@ mod tests {
_mock.assert_async().await;
}
#[tokio::test]
async fn test_anchor_body_carries_real_old_sha() {
// The anchored body must serialize the real old→new transition the
// node was handed, never a zero placeholder. Regression guard for the
// push handler that used to hardcode `old_sha` to 64 zeros (#26).
let mut server = mockito::Server::new_async().await;
let real_old = "1111111111111111111111111111111111111111";
let real_new = "2222222222222222222222222222222222222222";
let _mock = server
.mock("POST", "/upload")
.match_body(mockito::Matcher::AllOf(vec![
mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)),
mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"TX_REAL_OLD_SHA","timestamp":1710000000000,"version":"1.0.0"}"#)
.create_async()
.await;
let client = reqwest::Client::new();
let anchor = RefAnchor {
repo: "alice/myrepo".into(),
owner_did: "did:key:z6Mk...".into(),
ref_name: "refs/heads/main".into(),
old_sha: real_old.into(),
new_sha: real_new.into(),
cid: None,
timestamp: "2026-03-14T00:00:00Z".into(),
node_did: "did:key:z6Mknnd...".into(),
};
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA");
// The mock only matches when the posted JSON carries both real SHAs.
_mock.assert_async().await;
}
#[test]
fn test_arweave_url() {
let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
@@ -333,79 +193,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_manifest_anchor_noop_when_url_empty() {
let client = reqwest::Client::new();
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
let m = EncryptedManifest {
repo: "alice/r",
owner_did: "did:key:zO",
node_did: "did:key:zN",
timestamp: "2026-06-11T00:00:00Z",
blobs: &blobs,
};
assert_eq!(
anchor_encrypted_manifest(&client, "", &m).await.unwrap(),
""
);
}
#[tokio::test]
async fn test_manifest_anchor_noop_when_no_blobs() {
let client = reqwest::Client::new();
let blobs: Vec<(String, String)> = vec![];
let m = EncryptedManifest {
repo: "alice/r",
owner_did: "did:key:zO",
node_did: "did:key:zN",
timestamp: "2026-06-11T00:00:00Z",
blobs: &blobs,
};
// Non-empty URL, but no blobs: still a no-op.
assert_eq!(
anchor_encrypted_manifest(&client, "https://example.invalid", &m)
.await
.unwrap(),
""
);
}
#[tokio::test]
async fn test_manifest_anchor_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/upload")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"MANIFESTTX123","timestamp":1710000000000,"version":"1.0.0"}"#)
.create_async()
.await;
let client = reqwest::Client::new();
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
let m = EncryptedManifest {
repo: "alice/r",
owner_did: "did:key:zO",
node_did: "did:key:zN",
timestamp: "2026-06-11T00:00:00Z",
blobs: &blobs,
};
let r = anchor_encrypted_manifest(&client, &server.url(), &m).await;
assert_eq!(r.unwrap(), "MANIFESTTX123");
_mock.assert_async().await;
}
#[test]
fn manifest_blob_json_omits_recipients() {
let v = manifest_blob_json("oid1", "cidA");
assert_eq!(v["oid"], "oid1");
assert_eq!(v["cid"], "cidA");
assert!(
v.get("recipients").is_none(),
"Arweave manifest must not anchor recipient identities"
);
}
#[test]
fn test_sanitize_tag() {
assert_eq!(sanitize_tag("alice/myrepo"), "alice/myrepo");
+2 -584
View File
@@ -1,16 +1,14 @@
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{Method, StatusCode};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use http_body_util::BodyExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use gitlawb_core::did::Did;
use gitlawb_core::node_denial::NodeDenial;
use gitlawb_core::ucan::Ucan;
use crate::state::AppState;
@@ -19,54 +17,8 @@ use crate::state::AppState;
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
/// The canonical identity of a verified HTTP signature, published into request
/// extensions by [`require_signature`] so a spent-signature ledger can be keyed
/// without re-parsing headers.
///
/// `signing_string_hash` is a hex SHA-256 of the *reconstructed* signing string,
/// never of the raw `Signature` header. `HttpSignature::parse` trims, and the
/// header is not a covered component, so whitespace variants are distinct header
/// bytes that reconstruct to one signing string; hashing the reconstruction is
/// what collapses those variants to a single ledger key. The signing string also
/// embeds `keyid` and `created` through its `@signature-params` line, so the
/// hash cannot collide across DIDs.
#[derive(Clone, Debug)]
pub struct SignatureIdentity {
/// The signing DID, as it appeared in the `keyid` parameter. Human-readable
/// and good for logs; NOT an identity key — see [`Self::key_fingerprint`].
pub keyid: String,
/// Hex of the 32 resolved Ed25519 public key bytes: the canonical identity.
///
/// `Did` stores the wire string verbatim while `to_verifying_key` resolves
/// it through `multibase::decode`, which accepts any multibase prefix. One
/// keypair therefore has many valid `did:key` spellings (`z6Mk…` base58btc,
/// `f…` base16, `m…` base64url, and more) that are all distinct strings and
/// all resolve to the same key. Anything counting per identity by string
/// equality hands that keypair a fresh budget per spelling, so per-identity
/// accounting keys on this instead.
pub key_fingerprint: String,
/// The `nonce` parameter, absent on signatures from a pre-nonce signer.
/// Present but short is not the same as unique: see [`unique_nonce`].
pub nonce: Option<String>,
/// Hex SHA-256 of the reconstructed signing string: exactly 64 characters.
pub signing_string_hash: String,
}
/// Whether `caller` is authorized to push to `record`.
///
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
/// intentionally a distinct, intent-named gate rather than a bare owner check so
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
/// call sites.
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
crate::api::did_matches(caller, &record.owner_did)
}
use gitlawb_core::http_sig::{
build_signing_string, compute_content_digest, HttpSignature, COVERED_COMPONENTS,
MAX_FUTURE_SKEW_SECS, MAX_SIGNATURE_AGE_SECS,
};
use gitlawb_core::identity::verify;
@@ -74,7 +26,7 @@ use gitlawb_core::identity::verify;
///
/// Every write request must carry:
/// Content-Digest: sha-256=:base64hash:
/// Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:...";alg="ed25519";created=<unix>;nonce="..."
/// Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:...";alg="ed25519";created=<unix>
/// Signature: sig1=:base64signature:
///
/// The middleware:
@@ -278,15 +230,6 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
request
.extensions_mut()
.insert(AuthenticatedDid(sig.key_id.to_string()));
// Publish the canonical ledger key while the verified signing string is still
// in hand. Hashing `signing_string` (the reconstruction that was just verified
// against) rather than any raw header is deliberate: see `SignatureIdentity`.
request.extensions_mut().insert(SignatureIdentity {
keyid: sig.key_id.to_string(),
key_fingerprint: hex::encode(verifying_key.to_bytes()),
nonce: sig.nonce.clone(),
signing_string_hash: hex::encode(Sha256::digest(signing_string.as_bytes())),
});
next.run(request).await
}
@@ -411,251 +354,6 @@ pub async fn require_ucan_chain(
next.run(request).await
}
/// How long a spent signature stays in the ledger, in seconds.
///
/// Retention must cover the whole window in which the signature would still be
/// accepted, or a key is evicted while its signature still passes the time
/// check. `check_created` accepts a request when `created` falls in
/// `[now - 300, now + 30]`, so for one fixed signature the acceptable *arrival*
/// window is `[created - 30, created + 300]`: 330 seconds wide. Expiry is
/// computed from arrival rather than from `created`, which can only over-retain
/// (an early arrival is charged the full 330s from when it landed), never
/// under-retain.
///
/// 330 + [`CROSS_INSTANCE_SKEW_MARGIN_SECS`] = 390. The margin is not slack:
/// single-node a flush 330 is exactly right (at `now = created + 300` the sweep
/// predicate `expires_at < now` is false so the row survives, and at
/// `created + 301` the signature is already too old), but the sweep runs on
/// whichever instance's timer fires, using ITS clock. With instance B running
/// `d` seconds ahead of A, a flush TTL has B delete the row at true time
/// `created + 300 - d` while A still accepts the replay until `created + 300`,
/// a replay window of exactly `d`. 60s covers the disagreement an NTP-synced
/// fleet can actually reach.
///
/// Derived rather than written out so it cannot drift from the window it has to
/// cover: widening either skew bound in `gitlawb-core` carries the TTL with it.
const SIGNATURE_LEDGER_TTL_SECS: i64 =
MAX_SIGNATURE_AGE_SECS + MAX_FUTURE_SKEW_SECS + CROSS_INSTANCE_SKEW_MARGIN_SECS;
/// Clock disagreement between two instances that the ledger TTL must absorb.
const CROSS_INSTANCE_SKEW_MARGIN_SECS: i64 = 60;
/// The shortest nonce this node will treat as a unique ledger key.
///
/// `HttpSignature::parse` maps the raw parameter, so `nonce=""` arrives as
/// `Some("")` rather than `None`. Length is the only property a verifier can
/// check (entropy is not observable), so it is the floor: 16 characters is 64
/// bits even on the weakest plausible alphabet, hex at 4 bits per character. At
/// the enforced ceiling of `MAX_LIVE_SIGNATURES_PER_KEYID` (512) live rows per
/// identity, the birthday probability of an accidental collision within one
/// identity is about `512^2 / 2^65`, roughly 1e-14.
///
/// Set below the 32 hex characters (128 bits) our own `sign_request` emits so
/// the floor costs no upgrade churn, yet still admits a third-party client
/// signing a 22-character base64 UUID or a 16-character hex draw.
const MIN_NONCE_CHARS: usize = 16;
/// The nonce, but only when it is long enough to stand in as a unique key.
///
/// Without this filter an empty nonce is `Some("")`, which both satisfies the
/// staged `require a nonce` flag (whose stated purpose is that every client
/// signs one) and puts [`ledger_key`] on its `(key, nonce)` arm with a value
/// that is CONSTANT per identity: every mutation from that keypair would
/// collapse onto one ledger key, so the first one in a retention window would
/// succeed and every later one would be refused as a replay.
fn unique_nonce(identity: &SignatureIdentity) -> Option<&str> {
identity
.nonce
.as_deref()
.filter(|nonce| nonce.chars().count() >= MIN_NONCE_CHARS)
}
/// The ledger key for a verified signature: always a 64-character hex SHA-256,
/// which is what the `consumed_signatures` CHECK constraint requires.
///
/// Two disjoint schemes, kept apart by a domain tag so a nonce key can never
/// collide with a signing-string key:
/// * with a nonce of usable width, `(key_fingerprint, nonce)` — short,
/// fixed-width, and it lets two legitimately identical requests be told
/// apart;
/// * otherwise the signing-string hash, which is canonical by construction
/// and collapses whitespace variants of the `Signature` header onto one key.
///
/// A too-short nonce takes the second arm rather than the first. That is the
/// safe direction: the hash arm is unique by construction, so a client with a
/// weak nonce loses only the ability to repeat byte-identical requests inside
/// one second, whereas trusting the nonce would collapse its whole traffic onto
/// one key. The fingerprint rather than `keyid` keeps the nonce arm on the
/// resolved key, so two spellings of one DID cannot reuse a nonce.
fn ledger_key(identity: &SignatureIdentity) -> String {
let mut hasher = Sha256::new();
match unique_nonce(identity) {
Some(nonce) => {
hasher.update(b"gitlawb/sig-nonce\x00");
hasher.update(identity.key_fingerprint.as_bytes());
hasher.update(b"\x00");
hasher.update(nonce.as_bytes());
}
None => {
hasher.update(b"gitlawb/sig-string\x00");
hasher.update(identity.signing_string_hash.as_bytes());
}
}
hex::encode(hasher.finalize())
}
/// Refuse a signed write with one of the shared ledger denial codes.
///
/// The code and status both come from [`NodeDenial`], which `gl` matches
/// exhaustively, so the two halves cannot drift: adding a variant there is a
/// compile error in the client until it handles it.
fn ledger_rejection(denial: NodeDenial, message: &str) -> Response {
let code = denial.as_str();
(
// Infallible: every `NodeDenial::status` is a real HTTP status,
// which `denial_statuses_are_valid_http_codes` proves over every
// variant.
StatusCode::from_u16(denial.status()).expect("NodeDenial status is a valid HTTP code"),
[("X-Gitlawb-Error", code)],
Json(json!({ "error": code, "message": message })),
)
.into_response()
}
/// Axum middleware that spends a verified HTTP signature exactly once.
///
/// Layer it so it runs *after* [`require_signature`] (which publishes the
/// [`SignatureIdentity`] read here) and after [`require_ucan_chain`], but before
/// the handler. Consuming last means only a request that cleared every auth
/// check spends its signature, so a valid signature paired with a rejected UCAN
/// can be retried with the same bytes; consuming before the handler is what
/// closes the concurrent-replay race.
pub async fn consume_signature(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
// Skip reads. `write_routes` chains `PUT/DELETE/GET` on one path
// (`/api/v1/repos/{owner}/{repo}/visibility`, `server.rs`), so
// `list_visibility` is served by a router inside `add_auth_layers` and `gl`
// drives it through `get_signed`. Ledgering there would put a database write
// on the signed read path, which R7 forbids. A method check is the general
// form of that exclusion and needs no router split: replaying a read has no
// side effect to spend.
if matches!(*request.method(), Method::GET | Method::HEAD) {
crate::metrics::record_signature_ledger("skipped_read");
return next.run(request).await;
}
let identity = match request.extensions().get::<SignatureIdentity>() {
Some(identity) => identity.clone(),
None => {
crate::metrics::record_signature_ledger("identity_missing");
// Fail closed. If this passed the request through, a wrong layer
// order would silently delete the replay defense while every test
// that exercises the correct stack kept passing.
tracing::error!(
path = %request.uri().path(),
"signature ledger reached without a verified SignatureIdentity — check the layer order",
);
return ledger_rejection(
NodeDenial::IdentityMissing,
"the request reached the signature ledger without a verified identity",
);
}
};
// Staged rollout of R6: once every client signs a nonce, an operator closes
// the signing-string fallback here. This runs after the method skip above,
// so it never reaches a signed read, and before the ledger is charged, so a
// refused request spends nothing.
if state.config.require_signature_nonce && unique_nonce(&identity).is_none() {
// A present-but-short nonce gets its own code. It is a different
// client bug from a pre-nonce signer (the client already emits the
// parameter, it just does not fill it), and the two need different
// instructions.
return match identity.nonce {
None => {
crate::metrics::record_signature_ledger("nonce_required");
tracing::warn!(did = %identity.keyid, "rejected a nonce-less signature: a nonce is required");
ledger_rejection(
NodeDenial::NonceRequired,
"this node requires a `nonce` parameter in Signature-Input — upgrade your client",
)
}
Some(nonce) => {
crate::metrics::record_signature_ledger("nonce_too_short");
tracing::warn!(
did = %identity.keyid,
len = nonce.chars().count(),
"rejected a signature whose nonce is too short to be unique",
);
ledger_rejection(
NodeDenial::NonceTooShort,
&format!(
"the `nonce` parameter in Signature-Input must be at least \
{MIN_NONCE_CHARS} characters drawn from a CSPRNG — \
`gl` signs 32 hex characters",
),
)
}
};
}
let key = ledger_key(&identity);
let now = chrono::Utc::now().timestamp();
// Charge the cap against the RESOLVED key, never the wire DID: see
// `SignatureIdentity::key_fingerprint`. This does not weaken single-use,
// which never depended on the identity column — a replay has to reproduce
// the signed bytes, and `keyid` sits inside `@signature-params`, so
// re-spelling the DID changes the signing string and needs a fresh
// signature. It fixes the per-identity cap only.
match state
.db
.consume_signature(
&key,
&identity.key_fingerprint,
now,
now + SIGNATURE_LEDGER_TTL_SECS,
)
.await
{
Ok(crate::db::ConsumeSignature::Inserted) => {
crate::metrics::record_signature_ledger("admitted");
next.run(request).await
}
Ok(crate::db::ConsumeSignature::Replayed) => {
crate::metrics::record_signature_ledger("replayed");
tracing::warn!(did = %identity.keyid, "rejected a replayed HTTP signature");
ledger_rejection(
NodeDenial::Replayed,
"this signature has already been used — sign a fresh request",
)
}
Ok(crate::db::ConsumeSignature::IdentityLedgerFull) => {
// A rate condition, not a permanent rejection: the caller's live
// rows drain as they expire, so this is retryable.
crate::metrics::record_signature_ledger("identity_ledger_full");
tracing::warn!(did = %identity.keyid, "signature ledger full for this identity");
ledger_rejection(
NodeDenial::LedgerFull,
"too many unexpired signatures for this identity — retry shortly",
)
}
Err(e) => {
// Fail closed (KTD5). An outage is exactly when a holder of a
// captured signature would try, and the mutation handlers all need
// the same database anyway.
crate::metrics::record_signature_ledger("unavailable");
tracing::error!(did = %identity.keyid, err = %e, "signature ledger unavailable");
ledger_rejection(
NodeDenial::LedgerUnavailable,
"the signature ledger is unavailable — retry shortly",
)
}
}
}
fn human_detected(message: &str) -> impl IntoResponse {
(
StatusCode::UNAUTHORIZED,
@@ -699,39 +397,6 @@ mod tests {
.unwrap()
}
/// `ledger_rejection` converts `NodeDenial::status` with an `expect`.
/// This is what makes that infallible: every variant is a real HTTP status,
/// and it is the status the node has always paired with that code.
#[test]
fn denial_statuses_are_valid_http_codes() {
for denial in NodeDenial::ALL {
let status = StatusCode::from_u16(denial.status())
.unwrap_or_else(|e| panic!("{denial} has an invalid status: {e}"));
assert_eq!(status.as_u16(), denial.status());
}
}
/// The exact bytes `consume_signature` puts on the wire for each denial:
/// the `X-Gitlawb-Error` header, the body's `error` field, and the status
/// must all agree, because `gl` keys on the header and `gl init` keys on the
/// body field.
#[tokio::test]
async fn ledger_rejection_emits_the_code_in_both_the_header_and_the_body() {
for denial in NodeDenial::ALL {
let resp = ledger_rejection(denial, "why it was refused");
assert_eq!(resp.status().as_u16(), denial.status(), "{denial}");
assert_eq!(
resp.headers().get("X-Gitlawb-Error").unwrap(),
denial.as_str(),
"{denial}",
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], denial.as_str(), "{denial}");
assert_eq!(json["message"], "why it was refused", "{denial}");
}
}
#[test]
fn validate_ucan_chain_valid() {
let node = Keypair::generate();
@@ -813,7 +478,6 @@ mod tests {
use clap::Parser;
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let pool = sqlx::postgres::PgPoolOptions::new()
@@ -838,256 +502,10 @@ mod tests {
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
ipfs_max_legacy_scan_rows: crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
ipfs_max_legacy_scan_rule_bytes:
crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
signed_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
shutdown_tx: tokio::sync::watch::channel(false).0,
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
encrypt_inflight: crate::state::EncryptInflight::new(),
repo_write_leases: crate::state::RepoWriteLeases::new(8),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_ipfs_walk_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_bin: "git".to_string(),
}
}
/// The ledger TTL must keep a spent signature past the last instant any
/// instance would still accept it, INCLUDING one whose clock disagrees.
/// The sweep runs on whichever instance's timer fires, using its own clock,
/// so a TTL flush against the acceptance window lets an instance running
/// ahead delete a row that a lagging instance would still honour.
#[test]
fn ledger_ttl_keeps_a_margin_over_the_acceptance_window() {
// For one fixed signature the arrival window is
// `[created - MAX_FUTURE_SKEW_SECS, created + MAX_SIGNATURE_AGE_SECS]`.
let arrival_window = MAX_SIGNATURE_AGE_SECS + MAX_FUTURE_SKEW_SECS;
assert_eq!(arrival_window, 330, "the window this TTL is derived from");
// The floor is written out rather than read from
// `CROSS_INSTANCE_SKEW_MARGIN_SECS`, so zeroing that constant is caught
// here instead of quietly satisfying the comparison against itself.
let margin = SIGNATURE_LEDGER_TTL_SECS - arrival_window;
assert!(
margin >= 60,
"TTL {SIGNATURE_LEDGER_TTL_SECS}s leaves {margin}s over a {arrival_window}s \
arrival window, under the 60s skew budget: an instance running ahead \
would sweep a row still accepted elsewhere"
);
}
// ── `gitlawb_signature_ledger_total{outcome}` ────────────────────────────
//
// The ledger fails CLOSED, so a fault local to it (statement timeout, lock
// contention) turns every REST mutation into a 503 while the handlers' own
// queries stay healthy. These assert the counter is really charged on each
// terminal outcome, driven through the layer and read back through the same
// gather/encode path `/metrics` serves.
//
// Each assertion is a strict increase rather than an exact delta: the
// counter is process-wide, `metrics::init` is a `OnceLock`, and the ledger
// tests in `test_support` run in the same binary, so a concurrent test can
// add to any outcome. Pollution can only ever add, never subtract, so a
// strict increase never flakes; to attribute a delta exactly (e.g. when
// mutating out a `record_*` call to confirm RED), run these serially:
// `cargo test --bin gitlawb-node ledger_metrics -- --test-threads=1`.
fn ledger_metric(outcome: &str) -> u64 {
let needle = format!("gitlawb_signature_ledger_total{{outcome=\"{outcome}\"}} ");
crate::metrics::encode()
.expect("encode should succeed after init")
.lines()
.find_map(|line| line.strip_prefix(needle.as_str()))
.map(|value| value.trim().parse().expect("counter value is an integer"))
.unwrap_or(0)
}
/// One route, mounted behind `consume_signature` alone. The layer reads the
/// `SignatureIdentity` extension, so tests inject it directly rather than
/// producing real RFC-9421 signatures.
fn ledger_app(state: crate::state::AppState) -> Router {
Router::new()
.route(
"/probe",
axum::routing::post(|| async { StatusCode::OK }).get(|| async { StatusCode::OK }),
)
.layer(middleware::from_fn_with_state(state, consume_signature))
}
fn ledger_identity(seed: &str, nonce: Option<&str>) -> SignatureIdentity {
SignatureIdentity {
keyid: format!("did:key:zLedgerMetrics{seed}"),
key_fingerprint: hex::encode(Sha256::digest(format!("key/{seed}").as_bytes())),
nonce: nonce.map(str::to_owned),
signing_string_hash: hex::encode(Sha256::digest(format!("sig/{seed}").as_bytes())),
}
}
fn ledger_request(identity: Option<SignatureIdentity>) -> Request {
let builder = Request::builder().method(Method::POST).uri("/probe");
let builder = match identity {
Some(identity) => builder.extension(identity),
None => builder,
};
builder.body(Body::empty()).expect("request builder")
}
async fn ledger_status(state: crate::state::AppState, request: Request) -> StatusCode {
ledger_app(state)
.oneshot(request)
.await
.expect("router response")
.status()
}
#[tokio::test]
async fn ledger_metrics_count_a_skipped_read() {
crate::metrics::init("0.0.0-test", "did:key:test");
let before = ledger_metric("skipped_read");
let request = Request::builder()
.method(Method::GET)
.uri("/probe")
.body(Body::empty())
.expect("request builder");
let status = ledger_status(make_test_state(Keypair::generate().did()), request).await;
assert_eq!(status, StatusCode::OK, "a read must still pass through");
assert!(
ledger_metric("skipped_read") > before,
"the read skip must be counted, or the outcomes do not sum to the layer's traffic"
);
}
#[tokio::test]
async fn ledger_metrics_count_a_missing_identity() {
crate::metrics::init("0.0.0-test", "did:key:test");
let before = ledger_metric("identity_missing");
let status = ledger_status(
make_test_state(Keypair::generate().did()),
ledger_request(None),
)
.await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(ledger_metric("identity_missing") > before);
}
#[tokio::test]
async fn ledger_metrics_count_the_nonce_rejections() {
use clap::Parser;
crate::metrics::init("0.0.0-test", "did:key:test");
let mut state = make_test_state(Keypair::generate().did());
state.config = Arc::new(crate::config::Config::parse_from([
"gitlawb-node",
"--require-signature-nonce",
]));
let before_required = ledger_metric("nonce_required");
let status = ledger_status(
state.clone(),
ledger_request(Some(ledger_identity("no-nonce", None))),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(ledger_metric("nonce_required") > before_required);
let before_short = ledger_metric("nonce_too_short");
let status = ledger_status(
state,
ledger_request(Some(ledger_identity("short-nonce", Some("abc")))),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(ledger_metric("nonce_too_short") > before_short);
}
/// The fail-closed 503 is the outcome an operator most needs a numerator
/// for: it is returned by the ledger while the handlers are healthy. The
/// state here carries a lazy pool pointed at a database that does not
/// exist, so `consume_signature` returns `Err`.
#[tokio::test]
async fn ledger_metrics_count_a_ledger_error() {
crate::metrics::init("0.0.0-test", "did:key:test");
let before = ledger_metric("unavailable");
let status = ledger_status(
make_test_state(Keypair::generate().did()),
ledger_request(Some(ledger_identity("db-down", None))),
)
.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(ledger_metric("unavailable") > before);
}
#[sqlx::test]
async fn ledger_metrics_count_admitted_and_replayed(pool: sqlx::PgPool) {
crate::metrics::init("0.0.0-test", "did:key:test");
let state = crate::test_support::test_state(pool).await;
let identity = ledger_identity("spend-once", None);
let before_admitted = ledger_metric("admitted");
let status = ledger_status(state.clone(), ledger_request(Some(identity.clone()))).await;
assert_eq!(status, StatusCode::OK, "a fresh signature is admitted");
assert!(ledger_metric("admitted") > before_admitted);
let before_replayed = ledger_metric("replayed");
let status = ledger_status(state, ledger_request(Some(identity))).await;
assert_eq!(status, StatusCode::CONFLICT, "the same signature replays");
assert!(ledger_metric("replayed") > before_replayed);
}
#[sqlx::test]
async fn ledger_metrics_count_a_full_identity_ledger(pool: sqlx::PgPool) {
crate::metrics::init("0.0.0-test", "did:key:test");
let state = crate::test_support::test_state(pool.clone()).await;
let identity = ledger_identity("capped", None);
// Seeded through SQL rather than 512 requests. The cap is charged
// against the resolved key, which is the `keyid` column's value.
sqlx::query(
"INSERT INTO consumed_signatures (sig_hash, keyid, expires_at)
SELECT md5(i::text) || md5((i + 1)::text), $1, $2
FROM generate_series(1, $3) AS i",
)
.bind(&identity.key_fingerprint)
.bind(9_000_000_000i64)
.bind(crate::db::MAX_LIVE_SIGNATURES_PER_KEYID)
.execute(&pool)
.await
.expect("seed a full ledger for this identity");
let before = ledger_metric("identity_ledger_full");
let status = ledger_status(state, ledger_request(Some(identity))).await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert!(ledger_metric("identity_ledger_full") > before);
}
#[tokio::test]
async fn require_ucan_chain_no_header_passes_through() {
let state = make_test_state(Keypair::generate().did());
+2 -3
View File
@@ -52,7 +52,6 @@ pub async fn issue_ref_certificate(
issued_at,
};
// Persist and return the row as it exists in the database (on a
// conflict the existing row survives when it is newer).
state.db.insert_ref_certificate(&cert).await
state.db.insert_ref_certificate(&cert).await?;
Ok(cert)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-362
View File
@@ -1,362 +0,0 @@
//! Encrypt-then-pin for withheld blobs (Option B1). Each withheld blob is sealed
//! to its recipient DIDs and the envelope pinned to IPFS, recorded in
//! `encrypted_blobs`. Best-effort per blob: a failure is logged and skipped,
//! never pinned in plaintext.
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::str::FromStr;
use ed25519_dalek::VerifyingKey;
use gitlawb_core::did::Did;
use gitlawb_core::encrypt::seal_blob;
use crate::db::Db;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Opaque, node-keyed fingerprint of a blob's recipient set. Stored in place of
/// the cleartext DID list so a DB compromise cannot reveal the reader set; used
/// only to detect a recipient-set change so an unchanged blob is not re-sealed.
/// Order-insensitive (the input `BTreeSet` is already sorted).
pub fn recipients_tag(node_seed: &[u8; 32], dids: &BTreeSet<String>) -> String {
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
mac.update(b"gitlawb/recipients-tag/v1");
for did in dids {
mac.update(b"\n");
mac.update(did.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
/// Resolve a DID string to its Ed25519 verifying key, or None if it carries no
/// inline key (e.g. did:web / did:gitlawb).
fn did_to_key(did: &str) -> Option<VerifyingKey> {
Did::from_str(did).ok()?.to_verifying_key().ok()
}
/// Resolve every recipient DID to its verifying key, all-or-nothing.
///
/// Returns `Ok(keys)` only when every DID resolves. If any DID fails, returns
/// `Err(unresolved)` listing the unresolvable DID strings so the caller can fail
/// closed rather than seal to a partial recipient set (#47): sealing to a subset
/// while recording the full set as covered permanently locks out the dropped
/// readers. Resolution is local-only, so `did:web`/`did:gitlawb` recipients (and
/// any malformed `did:key`) land in the unresolved set until off-`did:key`
/// resolution exists.
fn resolve_all_recipients(dids: &BTreeSet<String>) -> Result<Vec<VerifyingKey>, Vec<String>> {
let mut keys = Vec::with_capacity(dids.len());
let mut unresolved = Vec::new();
for did in dids {
match did_to_key(did) {
Some(k) => keys.push(k),
None => unresolved.push(did.clone()),
}
}
if unresolved.is_empty() {
Ok(keys)
} else {
Err(unresolved)
}
}
/// What to do with a single withheld blob, decided without any DB or IO so the
/// fail-closed invariant (#47) is unit-testable in isolation.
#[derive(Debug)]
enum SealPlan {
/// An existing envelope already covers exactly this recipient set; nothing to do.
SkipUnchanged,
/// No recipient DID resolved to a key, so there is nothing to seal to.
SkipNoRecipients,
/// At least one recipient DID is unresolvable. Fail closed: never seal to a
/// partial set. Carries the unresolvable DIDs for logging.
SkipUnresolvable(Vec<String>),
/// Seal to `keys` and record coverage under `tag`.
Seal {
keys: Vec<VerifyingKey>,
tag: String,
},
}
/// Decide what to do with one blob given its desired recipient set and the tag
/// already stored for it (if any). Pure: no DB, no IO.
///
/// This is the #47 fail-closed gate in isolation: it returns `Seal` only when
/// EVERY recipient DID resolves, so no caller can seal to a partial set. A
/// changed recipient set (different tag) re-seals so a newly added reader can
/// recover the blob; reader removal is not retroactive (the old envelope is
/// already public). The comparison is on the opaque node-keyed tag, never the
/// DID list.
fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&str>) -> SealPlan {
let tag = recipients_tag(node_seed, dids);
if stored_tag == Some(tag.as_str()) {
return SealPlan::SkipUnchanged;
}
match resolve_all_recipients(dids) {
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
Ok(keys) => SealPlan::Seal { keys, tag },
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
}
}
/// Encrypt and pin every withheld blob. `recipients` maps blob oid -> DID set;
/// `node_seed` keys the opaque recipients tag. Returns `(oid, cid)` for each blob
/// actually sealed and recorded this call (the per-push delta), used by Option B3
/// to anchor a manifest. Recipient identities are never stored or returned.
pub async fn encrypt_and_pin(
ipfs_api: &str,
repo_path: &Path,
db: &Db,
repo_id: &str,
node_seed: &[u8; 32],
recipients: &HashMap<String, BTreeSet<String>>,
) -> Vec<(String, String)> {
let mut sealed = Vec::new();
let mut skipped_unresolvable = 0usize;
for (oid, dids) in recipients {
// A DB read failure is not a cache miss: re-sealing here would do an
// avoidable IPFS write during a partial outage. Skip and retry next push.
let stored_tag = match db.encrypted_blob_recipients_tag(repo_id, oid).await {
Ok(t) => t,
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "recipients_tag lookup failed; skipping reseal");
continue;
}
};
// Fail closed: plan_seal returns Seal only when every recipient DID
// resolves, so we never seal to a partial set and record the full set as
// covered (which would permanently lock out the dropped readers, #47).
let (keys, tag) = match plan_seal(node_seed, dids, stored_tag.as_deref()) {
SealPlan::SkipUnchanged => continue,
SealPlan::SkipNoRecipients => {
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
continue;
}
SealPlan::SkipUnresolvable(unresolved) => {
skipped_unresolvable += 1;
// DIDs are user-controlled (rule reader_dids); log a bounded
// sample, not an unbounded dump. Wording stays neutral about the
// cause: a malformed did:key is not DHT-pending and never will
// resolve, unlike a did:gitlawb awaiting anchoring.
let sample: Vec<&String> = unresolved.iter().take(3).collect();
tracing::warn!(
oid = %oid,
unresolved_count = unresolved.len(),
unresolved_sample = ?sample,
"unresolvable recipient DID(s); skipping encrypted pin to avoid sealing to a partial set"
);
continue;
}
SealPlan::Seal { keys, tag } => (keys, tag),
};
let data = match crate::git::store::read_object(repo_path, oid) {
Ok(Some((_t, bytes))) => bytes,
Ok(None) => {
tracing::warn!(oid = %oid, "git object not found; skipping encrypted pin");
continue;
}
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "read_object failed; skipping encrypted pin");
continue;
}
};
let envelope = match seal_blob(&data, &keys) {
Ok(e) => e,
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "seal_blob failed; skipping");
continue;
}
};
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
Ok(c) if !c.is_empty() => c,
Ok(_) => {
tracing::warn!(oid = %oid, "pin_git_object returned no cid; skipping");
continue;
}
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "pin_git_object failed; skipping");
continue;
}
};
if let Err(e) = db.record_encrypted_blob(repo_id, oid, &cid, &tag).await {
tracing::warn!(oid = %oid, err = %e, "record_encrypted_blob failed");
continue;
}
sealed.push((oid.clone(), cid.clone()));
}
// One aggregate signal so a coverage collapse is a single greppable line, not
// a per-oid scrape. In a fully-migrated did:gitlawb org every blob skips and
// recovery coverage silently drops to zero; this is the operator's cue that
// the gap is the deliberate fail-closed posture, not a malfunction.
if skipped_unresolvable > 0 {
tracing::warn!(
sealed = sealed.len(),
skipped = skipped_unresolvable,
"encrypted-pin coverage reduced: blobs skipped for unresolvable recipients"
);
}
sealed
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn did_key(seed: u8) -> String {
let vk = SigningKey::from_bytes(&[seed; 32]).verifying_key();
Did::from_verifying_key(&vk).to_string()
}
// Accepts both `&[String]` (resolve tests, built from `did_key`) and
// `&[&str]` (tag tests, built from literals).
fn set<S: AsRef<str>>(dids: &[S]) -> BTreeSet<String> {
dids.iter().map(|s| s.as_ref().to_string()).collect()
}
#[test]
fn all_did_key_recipients_resolve() {
let dids = set(&[did_key(1), did_key(2)]);
let keys = resolve_all_recipients(&dids).expect("all did:key resolve");
assert_eq!(keys.len(), 2);
}
#[test]
fn mixed_set_with_did_gitlawb_fails_closed() {
// Core #47 regression: a resolvable subset must never yield a sealable
// key set. Two did:key plus one did:gitlawb -> Err naming only the
// unresolvable DID.
let gitlawb = Did::gitlawb("zSomeDhtKey").to_string();
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
assert_eq!(unresolved, vec![gitlawb]);
}
#[test]
fn did_web_recipient_fails_closed() {
let web = Did::web("example.com").to_string();
let dids = set(&[did_key(1), web.clone()]);
let unresolved = resolve_all_recipients(&dids).expect_err("did:web cannot resolve locally");
assert_eq!(unresolved, vec![web]);
}
#[test]
fn malformed_did_key_fails_closed() {
// The third state: not a method-resolution gap but a permanently broken
// did:key (invalid multibase). Must also fail closed.
let broken = "did:key:z!!!invalid".to_string();
let dids = set(&[did_key(1), broken.clone()]);
let unresolved = resolve_all_recipients(&dids).expect_err("malformed did:key fails");
assert_eq!(unresolved, vec![broken]);
}
#[test]
fn empty_set_resolves_to_empty_keys() {
let dids = BTreeSet::new();
let keys = resolve_all_recipients(&dids).expect("empty set is not an error");
assert!(keys.is_empty());
}
#[test]
fn single_unresolvable_did_returns_that_did() {
let gitlawb = Did::gitlawb("zOnlyOne").to_string();
let dids: BTreeSet<String> = [gitlawb.clone()].into_iter().collect();
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
assert_eq!(unresolved, vec![gitlawb]);
}
#[test]
fn tag_is_order_insensitive() {
let seed = [7u8; 32];
let a = recipients_tag(&seed, &set(&["did:key:zA", "did:key:zB"]));
let b = recipients_tag(&seed, &set(&["did:key:zB", "did:key:zA"]));
assert_eq!(a, b);
}
#[test]
fn tag_differs_for_different_sets() {
let seed = [7u8; 32];
let a = recipients_tag(&seed, &set(&["did:key:zA"]));
let b = recipients_tag(&seed, &set(&["did:key:zA", "did:key:zB"]));
assert_ne!(a, b);
}
#[test]
fn tag_is_keyed_by_node_seed() {
let dids = set(&["did:key:zA", "did:key:zB"]);
let a = recipients_tag(&[1u8; 32], &dids);
let b = recipients_tag(&[2u8; 32], &dids);
assert_ne!(
a, b,
"tag must depend on the node seed, not be a plain hash"
);
}
// plan_seal is the seal/skip decision `encrypt_and_pin` acts on. Testing it
// directly pins the #47 fail-closed invariant at the function that owns it,
// which a unit test of `resolve_all_recipients` alone cannot do (it can't
// catch the caller falling through to a partial seal).
const SEED: [u8; 32] = [9u8; 32];
#[test]
fn plan_seal_seals_when_all_recipients_resolve() {
let dids = set(&[did_key(1), did_key(2)]);
match plan_seal(&SEED, &dids, None) {
SealPlan::Seal { keys, tag } => {
assert_eq!(keys.len(), 2, "must seal to the full recipient set");
assert_eq!(
tag,
recipients_tag(&SEED, &dids),
"records the full-set tag"
);
}
other => panic!("expected Seal, got {other:?}"),
}
}
#[test]
fn plan_seal_fails_closed_on_any_unresolvable_recipient() {
// The #47 invariant at the decision boundary: one unresolvable DID among
// resolvable ones must NOT yield a Seal (which would seal a partial set).
let gitlawb = Did::gitlawb("zPending").to_string();
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
match plan_seal(&SEED, &dids, None) {
SealPlan::SkipUnresolvable(unresolved) => assert_eq!(unresolved, vec![gitlawb]),
other => panic!("must fail closed, never seal a partial set; got {other:?}"),
}
}
#[test]
fn plan_seal_skips_empty_recipient_set() {
let dids = BTreeSet::new();
assert!(matches!(
plan_seal(&SEED, &dids, None),
SealPlan::SkipNoRecipients
));
}
#[test]
fn plan_seal_skips_when_tag_unchanged() {
let dids = set(&[did_key(1)]);
let stored = recipients_tag(&SEED, &dids);
assert!(matches!(
plan_seal(&SEED, &dids, Some(&stored)),
SealPlan::SkipUnchanged
));
}
#[test]
fn plan_seal_reseals_when_recipient_set_changed() {
// A stored tag for a DIFFERENT set is a miss: a newly added reader must
// trigger a re-seal, not be skipped as unchanged.
let dids = set(&[did_key(1), did_key(2)]);
let stale = recipients_tag(&SEED, &set(&[did_key(1)]));
match plan_seal(&SEED, &dids, Some(&stale)) {
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2),
other => panic!("changed recipient set must re-seal; got {other:?}"),
}
}
}
+9 -322
View File
@@ -23,128 +23,21 @@ pub enum AppError {
#[allow(dead_code)]
Forbidden(String),
#[error("icaptcha proof required: {message}")]
IcaptchaProofRequired {
message: String,
/// iCaptcha service base URL the client should solve against.
url: String,
/// Minimum proof level this node requires.
level: u32,
},
#[error("invalid request: {0}")]
BadRequest(String),
/// A DID was well-formed enough to carry to a resolver but no verifying key
/// could be derived from it. Its own code rather than plain `bad_request`
/// because the auth middleware already answers `unresolvable_did` for the
/// same failure on a request's keyid, and a client should not have to
/// substring-match a message to tell this apart from the other validation
/// failures on the same route.
#[error("unresolvable did: {0}")]
UnresolvableDid(String),
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("incomplete: {0}")]
Incomplete(String),
/// A bounded search that could not complete. `continuation`, when present, is the
/// sealed scan position the caller echoes as `?scan=` to resume where the search
/// stopped (#173 round 13, F2). It is AEAD-sealed at the mint site, never plaintext:
/// the row it names is by construction one the caller was denied (INV-13).
#[error("search incomplete: {message}")]
SearchIncomplete {
message: String,
continuation: Option<String>,
},
#[error("git error: {0}")]
Git(String),
#[error("git service timed out: {0}")]
Timeout(String),
#[error("server overloaded: {0}")]
Overloaded(String),
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(anyhow::Error),
}
/// Shared error code/message for "the database is unreachable", also used by
/// the degraded startup server (main.rs) and the readiness probe (server.rs)
/// so clients see one vocabulary for the condition.
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
pub const DB_UNAVAILABLE_MESSAGE: &str = "database is temporarily unavailable";
/// Generic client-facing message for `AppError::Internal`. The real error is
/// logged server-side; never put sqlx/anyhow detail in the HTTP body (#226).
pub const INTERNAL_ERROR_MESSAGE: &str = "an internal error occurred";
/// Generic client-facing message for non-unavailable `AppError::Db`. Query /
/// schema errors stay in logs; the HTTP body must not leak them (#226).
pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
/// Connection-level sqlx failures that mean the database is unreachable right
/// now (retryable, 503), as opposed to server-reported query errors.
fn db_unavailable(e: &sqlx::Error) -> bool {
matches!(
e,
sqlx::Error::PoolTimedOut
| sqlx::Error::PoolClosed
| sqlx::Error::Io(_)
| sqlx::Error::Tls(_)
)
}
/// The db layer returns `anyhow::Result`, so sqlx errors reach handlers inside
/// anyhow chains. Downcast them back out so the status mapping below can see
/// them — without this, every database outage surfaces as a 500 instead of a
/// 503. anyhow preserves downcastability through `.context()` layers.
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
match err.downcast::<sqlx::Error>() {
Ok(sql) => AppError::Db(sql),
Err(err) => AppError::Internal(err),
}
}
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// iCaptcha challenges carry structured discovery so clients don't have to
// scrape the message: the service URL and required level are returned as
// both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers
// (mirroring the header-bearing `human_detected` response in auth/mod.rs).
if let AppError::IcaptchaProofRequired {
message,
url,
level,
} = &self
{
use axum::http::HeaderValue;
let body = Json(json!({
"error": "icaptcha_proof_required",
"message": message,
"icaptcha_url": url,
"required_level": level,
}));
let mut resp = (StatusCode::FORBIDDEN, body).into_response();
let headers = resp.headers_mut();
if let Ok(v) = HeaderValue::from_str(url) {
headers.insert("x-icaptcha-url", v);
}
if let Ok(v) = HeaderValue::from_str(&level.to_string()) {
headers.insert("x-icaptcha-level", v);
}
return resp;
}
let (status, code, message) = match &self {
AppError::RepoNotFound(r) => (
StatusCode::NOT_FOUND,
@@ -159,229 +52,23 @@ impl IntoResponse for AppError {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::UnresolvableDid(msg) => {
(StatusCode::BAD_REQUEST, "unresolvable_did", msg.clone())
}
// Same wire code as the flood brake's `too_many_requests`, taken
// from the shared enum rather than retyped so the two cannot drift.
AppError::TooManyRequests(msg) => (
StatusCode::TOO_MANY_REQUESTS,
gitlawb_core::node_denial::NodeDenial::RateLimited.as_str(),
msg.clone(),
),
AppError::Incomplete(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
}
// A bounded search that could not complete (the CID resolver hit its
// legacy-probe or walk ceiling), distinct from the 404 that asserts a
// definitive not-found: absence was NOT proven, so the caller should
// retry rather than treat it as gone (#173, F2). 503, retryable.
AppError::SearchIncomplete { message, .. } => (
StatusCode::SERVICE_UNAVAILABLE,
"search_incomplete",
message.clone(),
),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
// 504, distinct from the 500 git_error and from the read-gate's 404 /
// the auth 401, so the client can tell a deadline from a failure.
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
AppError::Db(e) if db_unavailable(e) => (
StatusCode::SERVICE_UNAVAILABLE,
DB_UNAVAILABLE_CODE,
DB_UNAVAILABLE_MESSAGE.into(),
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
AppError::Internal(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
e.to_string(),
),
// 503 with a Retry-After (attached after this match — the shared tail
// can't carry per-variant headers). This is the single place Overloaded
// becomes a response, so it can never ship a 503 without the retry hint.
AppError::Overloaded(msg) => {
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", msg.clone())
}
// Opaque body + server log: bare `?` on sqlx paths becomes `AppError::Db`
// via `From`, so this arm (not `Internal`) is the common leak for open
// routes like GET /api/v1/repos and GET /api/v1/peers (#226).
AppError::Db(e) => {
tracing::error!(error = %e, "database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"db_error",
DB_ERROR_MESSAGE.into(),
)
}
// Opaque body: handlers that map with `.map_err(AppError::Internal)`
// (e.g. GET /ipfs/{cid}) land here; other DB failures usually hit `Db`.
// Log `{e:#}` so context-wrapped anyhow chains keep the leaf cause
// (Display alone is only the outermost layer; see api/repos.rs).
AppError::Internal(e) => {
tracing::error!(error = %format!("{e:#}"), "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
INTERNAL_ERROR_MESSAGE.into(),
)
}
};
let mut body = json!({
let body = Json(json!({
"error": code,
"message": message,
});
// A truncated CID search may carry the sealed position the caller echoes as
// `?scan=` to resume. Rendered as a third body field, present only when the
// shed actually left something to resume: a wrapped scan and a throttled
// request both omit it, and its ABSENCE is what tells a caller the ladder is
// over. It is opaque ciphertext; see `gitlawb_core::scan_token`.
if let AppError::SearchIncomplete {
continuation: Some(token),
..
} = &self
{
body["continuation"] = json!(token);
}
}));
let mut resp = (status, Json(body)).into_response();
if matches!(self, AppError::TooManyRequests(_)) {
use axum::http::HeaderValue;
let code = gitlawb_core::node_denial::NodeDenial::RateLimited.as_str();
if let Ok(v) = HeaderValue::from_str(code) {
resp.headers_mut().insert("X-Gitlawb-Error", v);
}
}
// Both retryable 503s advertise when to retry: Overloaded (capacity shed) and
// SearchIncomplete (a bounded CID search cut short by a cap — retry may complete
// it). They ride the shared tail above for body/status, so the header is attached
// here rather than in bespoke early returns, keeping each variant handled once.
if matches!(
self,
AppError::Overloaded(_) | AppError::SearchIncomplete { .. }
) {
resp.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static("1"),
);
}
resp
(status, body).into_response()
}
}
pub type Result<T> = std::result::Result<T, AppError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeout_maps_to_504_distinct_from_git_500() {
assert_eq!(
AppError::Timeout("x".into()).into_response().status(),
StatusCode::GATEWAY_TIMEOUT
);
// Guard against a swap with the generic git failure (500).
assert_eq!(
AppError::Git("x".into()).into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn too_many_requests_carries_rate_limited_header() {
use axum::http::HeaderValue;
let resp = AppError::TooManyRequests("slow down".into()).into_response();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
resp.headers().get("X-Gitlawb-Error"),
Some(&HeaderValue::from_static("rate_limited"))
);
}
#[test]
fn overloaded_maps_to_503_with_retry_after() {
let resp = AppError::Overloaded("x".into()).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
/// #226: raw sqlx/DB detail must never appear in the Internal 500 body.
#[tokio::test]
async fn internal_error_body_is_opaque() {
use serde_json::{json, Value};
let leak = "error returned from database: relation \"repos\" does not exist";
let resp = AppError::Internal(anyhow::anyhow!("{leak}")).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
// Exact object: a new `detail` field with different sensitive text must
// also fail, not only a repeat of the original error string.
assert_eq!(
v,
json!({
"error": "internal_error",
"message": INTERNAL_ERROR_MESSAGE,
})
);
}
/// #226: `AppError::Db` query errors (the common `?` path) must also be opaque.
#[tokio::test]
async fn db_error_body_is_opaque() {
use serde_json::{json, Value};
let resp = AppError::Db(sqlx::Error::Protocol(
"error returned from database: column \"is_public\" does not exist".into(),
))
.into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
json!({
"error": "db_error",
"message": DB_ERROR_MESSAGE,
})
);
}
/// Connection-level failures must stay 503 `db_unavailable`, not collapse
/// into the opaque 500 `db_error` arm if `db_unavailable` loses a variant.
#[tokio::test]
async fn db_pool_timeout_stays_503_unavailable() {
use serde_json::{json, Value};
let resp = AppError::Db(sqlx::Error::PoolTimedOut).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
json!({
"error": DB_UNAVAILABLE_CODE,
"message": DB_UNAVAILABLE_MESSAGE,
})
);
}
/// #251: bare `?` on anyhow-wrapped sqlx relies on this downcast so a
/// closed pool becomes 503 `db_unavailable`, not 500 `internal_error`.
#[test]
fn pool_closed_via_anyhow_from_is_503_db_unavailable() {
let err: AppError = anyhow::Error::from(sqlx::Error::PoolClosed).into();
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}
+6 -11
View File
@@ -23,18 +23,13 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
use std::io::Write;
let mut child = hash_output;
// Write the blob to stdin, but always reap the child afterward even if the
// write fails, so a stdin-write error can't drop the Child unwaited and leak
// a zombie (#53).
let write_result = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(json.as_bytes()),
None => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"git hash-object stdin unavailable",
)),
};
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin
.write_all(json.as_bytes())
.context("failed to write to git hash-object stdin")?;
}
let output = child.wait_with_output().context("git hash-object failed")?;
write_result.context("failed to write to git hash-object stdin")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
-1
View File
@@ -1,5 +1,4 @@
pub mod issues;
pub mod push_delta;
pub mod repo_store;
pub mod smart_http;
pub mod store;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -50
View File
@@ -3,9 +3,7 @@
//! Repos are stored as `repos/v1/{owner_slug}/{repo_name}.tar.zst` — a
//! zstd-compressed tar archive of the bare repo directory.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::path::Path;
use anyhow::{Context, Result};
use aws_sdk_s3::Client as S3Client;
@@ -31,25 +29,6 @@ impl TigrisClient {
})
}
/// Test-only constructor with an explicit S3 endpoint, region, and static
/// credentials — no env-var reads, so parallel tests cannot race each other's
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
/// the client at a non-routable endpoint to exercise acquire-stall paths.
#[cfg(test)]
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.endpoint_url(endpoint_url)
.region(aws_config::Region::new("auto"))
.credentials_provider(creds)
.load()
.await;
Self {
s3: S3Client::new(&config),
bucket: bucket.to_string(),
}
}
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
@@ -131,7 +110,8 @@ impl TigrisClient {
.collect()
.await
.context("reading tigris response body")?
.into_bytes();
.into_bytes()
.to_vec();
// Extract tar.zst to local path
tokio::task::spawn_blocking({
@@ -176,23 +156,6 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
Ok(compressed)
}
/// Per-repo-path lock serializing the publish (swap-into-place) step of
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
// entry accrues per distinct repo path for the process lifetime. Bounded by
// the number of repos a node hosts, so it's negligible for normal use, but
// high-volume/churning deployments may want LRU or weak-ref eviction here.
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = locks.lock().expect("publish lock map poisoned");
map.entry(local_path.to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
/// Decompress a tar.zst byte vector into a local directory.
///
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
@@ -208,12 +171,10 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
.file_name()
.context("repo path has no file name")?
.to_string_lossy();
// Unique per-extraction temp dir: a fixed name would let two concurrent
// extractions of the same repo share one dir and clobber each other's
// in-progress unpack. A fresh UUID also means it can't collide with a
// leftover dir from a previously-interrupted run.
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract.{}", uuid::Uuid::new_v4()));
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract"));
// Clear any leftover temp dir from a previously-interrupted extraction.
let _ = std::fs::remove_dir_all(&tmp_dir);
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
// Unpack into the temp dir; on any failure, clean up and bail without
@@ -231,11 +192,7 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
// Swap the freshly-extracted repo into place. rename within the same parent
// is effectively atomic, but most platforms refuse to rename onto a
// non-empty dir, so remove the old copy first. Serialize this per repo path:
// concurrent extractions unpack into isolated temp dirs, but their swaps
// must not interleave or they race to a nondeterministic overwrite/failure.
let lock = publish_lock(local_path);
let _publish = lock.lock().expect("publish lock poisoned");
// non-empty dir, so remove the old copy first.
if local_path.exists() {
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
}
File diff suppressed because it is too large. Load diff
-164
View File
@@ -14,68 +14,6 @@ use subscription::SubscriptionRoot;
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
/// Client-facing message for GraphQL resolver failures that wrap a real
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
/// detail in the GraphQL `errors` array (#250).
///
/// Kept as its own constant on this PR's base (main still renders
/// `AppError::Db` with `e.to_string()`). If/when #247's `DB_ERROR_MESSAGE`
/// lands, fold this into that shared constant.
pub const GRAPHQL_DB_ERROR_MESSAGE: &str = "a database error occurred";
fn anyhow_has_sqlx(e: &anyhow::Error) -> bool {
e.chain().any(|c| c.downcast_ref::<sqlx::Error>().is_some())
}
/// Map an `anyhow` failure from the db layer to a GraphQL error.
///
/// - Real DB faults (`sqlx::Error` anywhere in the chain) → opaque client
/// message + `error!` log with the full `{e:#}` cause chain.
/// - Application/business errors (e.g. claim race, not-in-claimed-state) →
/// keep the actionable message; log at `warn!` so they are not mistaken for
/// infrastructure failures (#250 review).
pub(crate) fn graphql_db_err(e: anyhow::Error) -> async_graphql::Error {
if anyhow_has_sqlx(&e) {
tracing::error!(error = %format!("{e:#}"), "graphql database error");
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
} else {
tracing::warn!(error = %format!("{e:#}"), "graphql application error");
async_graphql::Error::new(e.to_string())
}
}
/// Map an `AppError` from a shared collector (e.g. ref-update feed) to a
/// GraphQL error.
///
/// Fail closed: only explicitly curated variants surface their `Display`
/// text. Unnamed variants (including `Git`, which may embed on-disk paths)
/// render opaque so a future addition cannot leak by default (#255 review).
pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error {
match e {
crate::error::AppError::Db(sql) => graphql_db_err(sql.into()),
crate::error::AppError::Internal(err) => {
tracing::error!(error = %format!("{err:#}"), "graphql internal error");
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
}
// Curated client-safe variants — `Display` is intentional API text.
safe @ (crate::error::AppError::RepoNotFound(_)
| crate::error::AppError::RepoExists(_)
| crate::error::AppError::NotFound(_)
| crate::error::AppError::Unauthorized(_)
| crate::error::AppError::Forbidden(_)
| crate::error::AppError::BadRequest(_)
| crate::error::AppError::TooManyRequests(_)
| crate::error::AppError::Incomplete(_)) => {
tracing::warn!(error = %safe, "graphql application error");
async_graphql::Error::new(safe.to_string())
}
other => {
tracing::error!(error = %other, "graphql unclassified AppError (opaque)");
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
}
}
}
pub fn build_schema(
db: Arc<Db>,
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
@@ -87,105 +25,3 @@ pub fn build_schema(
.data(task_event_tx)
.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graphql_db_err_opaques_sqlx_chain() {
let leak = "error returned from database: column \"is_public\" does not exist";
// Context layer must not hide sqlx from the chain walk (db helpers
// wrap with `.context(...)` in several places).
let err = graphql_db_err(
anyhow::Error::from(sqlx::Error::Protocol(leak.into())).context("loading repos"),
);
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!err.message.contains("is_public"));
assert!(!err.message.contains(leak));
assert!(!err.message.contains("loading repos"));
}
#[test]
fn graphql_db_err_keeps_business_message() {
let msg = "task not claimable: not found or already claimed";
let err = graphql_db_err(anyhow::anyhow!("{msg}"));
assert_eq!(err.message, msg);
}
#[test]
fn graphql_app_err_opaques_db_and_internal() {
let leak = "column \"is_public\" does not exist";
let db_err = graphql_app_err(crate::error::AppError::Db(sqlx::Error::Protocol(
leak.into(),
)));
assert_eq!(db_err.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!db_err.message.contains("is_public"));
let internal = graphql_app_err(crate::error::AppError::Internal(anyhow::anyhow!(
"loading repo: {leak}"
)));
assert_eq!(internal.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!internal.message.contains("is_public"));
}
#[test]
fn graphql_app_err_keeps_safe_variant_messages() {
let err = graphql_app_err(crate::error::AppError::NotFound("widget".into()));
assert!(
err.message.contains("widget"),
"safe NotFound message must reach the client: {}",
err.message
);
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
let err = graphql_app_err(crate::error::AppError::BadRequest("bad cid".into()));
assert!(
err.message.contains("bad cid"),
"safe BadRequest message must reach the client: {}",
err.message
);
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
}
#[test]
fn graphql_app_err_opaques_unclassified_variants() {
// `Git` may embed on-disk paths from libgit2; fail closed.
let path = "/var/lib/gitlawb/repos/owner/secret.git";
let err = graphql_app_err(crate::error::AppError::Git(format!(
"failed to open '{path}'"
)));
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!err.message.contains(path));
assert!(!err.message.contains("failed to open"));
}
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
/// through the opaque helpers, or discard the error (`|_|`). Same source-
/// scrape pattern as `api::authz_guard` (#255 review).
#[test]
fn every_graphql_map_err_uses_opaque_helpers() {
for (file, src) in [
("query.rs", include_str!("query.rs")),
("mutation.rs", include_str!("mutation.rs")),
] {
for (lineno, line) in src.lines().enumerate() {
let code = line.split("//").next().unwrap_or(line);
let Some(idx) = code.find(".map_err(") else {
continue;
};
let after = code[idx + ".map_err(".len()..].trim_start();
let ok = after.starts_with("crate::graphql::graphql_db_err")
|| after.starts_with("crate::graphql::graphql_app_err")
|| after.starts_with("|_|")
|| after.starts_with("|_ ");
assert!(
ok,
"{file}:{}: `.map_err(` must use graphql_db_err / graphql_app_err \
or discard (`|_|`): {line}",
lineno + 1
);
}
}
}
}
+4 -227
View File
@@ -3,21 +3,11 @@ use chrono::Utc;
use std::sync::Arc;
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::{AgentTask, Db};
use crate::state::TaskEventBroadcast;
use super::types::{AgentTaskType, CreateTaskInput, FinishTaskInput};
/// The verified signer DID for this request, or an auth error (N2). The DID is
/// attached request-scoped by the `/graphql` `optional_signature` layer; its
/// absence means the request was unsigned, so every mutation rejects.
fn require_signer<'a>(ctx: &'a Context<'_>) -> Result<&'a str> {
ctx.data::<AuthenticatedDid>()
.map(|d| d.0.as_str())
.map_err(|_| async_graphql::Error::new("authentication required"))
}
pub struct MutationRoot;
#[Object]
@@ -28,13 +18,6 @@ impl MutationRoot {
delegator_did: String,
input: CreateTaskInput,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &delegator_did) {
return Err(async_graphql::Error::new(
"delegator_did must be the authenticated signer",
));
}
let delegator_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let now = Utc::now().to_rfc3339();
let task = AgentTask {
@@ -54,7 +37,7 @@ impl MutationRoot {
};
db.create_task(&task)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(AgentTaskType::from(task))
}
@@ -64,19 +47,12 @@ impl MutationRoot {
id: String,
assignee_did: String,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &assignee_did) {
return Err(async_graphql::Error::new(
"assignee_did must be the authenticated signer",
));
}
let assignee_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.claim_task(&id, &assignee_did)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
@@ -94,31 +70,12 @@ impl MutationRoot {
by_did: String,
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &by_did) {
return Err(async_graphql::Error::new(
"by_did must be the authenticated signer",
));
}
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: binding by_did to the signer is necessary but not
// sufficient — only the task's assignee may finish it.
let existing = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_db_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
return Err(async_graphql::Error::new(
"only the task assignee can complete it",
));
}
let task = db
.finish_task(&id, "completed", input.result.as_deref())
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
@@ -136,31 +93,13 @@ impl MutationRoot {
by_did: String,
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &by_did) {
return Err(async_graphql::Error::new(
"by_did must be the authenticated signer",
));
}
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: only the task's assignee may fail it.
let existing = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_db_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
return Err(async_graphql::Error::new(
"only the task assignee can fail it",
));
}
let reason = input.reason.unwrap_or_default();
let task = db
.finish_task(&id, "failed", Some(&reason))
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
@@ -171,165 +110,3 @@ impl MutationRoot {
Ok(AgentTaskType::from(task))
}
}
#[cfg(test)]
mod tests {
use crate::auth::AuthenticatedDid;
use async_graphql::{Request, Response};
use sqlx::PgPool;
fn errors(resp: &Response) -> String {
resp.errors
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("; ")
}
/// N2: GraphQL mutations require a verified signer and bind the acting DID to
/// it. Unsigned is rejected; a signer other than the claimed actor is
/// rejected; the matching signer passes the auth gate.
#[sqlx::test]
async fn mutation_requires_and_binds_signer(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let schema = state.graphql_schema.as_ref();
let assignee = "did:key:zASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let q = format!(
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{assignee}") {{ id }} }}"#
);
// 1. Unsigned → rejected before any DB work.
let resp = schema.execute(Request::new(&q)).await;
assert!(
errors(&resp).contains("authentication required"),
"unsigned mutation must be rejected: {}",
errors(&resp)
);
// 2. Signed as someone other than the claimed assignee → rejected.
let resp = schema
.execute(Request::new(&q).data(AuthenticatedDid(
"did:key:zOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
)))
.await;
assert!(
errors(&resp).contains("authenticated signer"),
"DID mismatch must be rejected: {}",
errors(&resp)
);
// 3. Signed as the claimed assignee → passes the auth gate. The missing
// task is a business error from claim_task, not a sqlx fault, so the
// actionable message must survive (not the opaque DB string) (#250).
let resp = schema
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
"matching signer must pass the auth gate: {errs}"
);
assert!(
errs.contains("task not claimable"),
"claim race / missing task must keep its business message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"business error must not be rewritten as opaque DB error: {errs}"
);
}
/// #250: mutation DB faults must be opaque; create_task hits agent_tasks.
#[sqlx::test]
async fn create_task_db_error_message_is_opaque(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
.execute(&pool)
.await
.unwrap();
let delegator = "did:key:zGQLDELEGATORAAAAAAAAAAAAAAAAAAAAAAAAAA";
let q = format!(
r#"mutation {{
createTask(
delegatorDid: "{delegator}",
input: {{ kind: "build", capability: "repo:write" }}
) {{ id }}
}}"#
);
let resp = state
.graphql_schema
.execute(Request::new(&q).data(AuthenticatedDid(delegator.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"sqlx fault must be opaque: {errs}"
);
assert!(
!errs.contains("column") && !errs.contains("status"),
"schema text leaked: {errs}"
);
}
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
/// assignee, not merely signing as the by_did you pass. A signer who is not
/// the assignee is rejected even though the by_did binding passes; the
/// assignee completes.
#[sqlx::test]
async fn complete_task_requires_assignee(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let assignee = "did:key:zGQLASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let stranger = "did:key:zGQLSTRANGERBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let now = chrono::Utc::now().to_rfc3339();
let task = crate::db::AgentTask {
id: "task-g".into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
};
state.db.create_task(&task).await.expect("seed task");
state
.db
.claim_task("task-g", assignee)
.await
.expect("claim");
let schema = state.graphql_schema.as_ref();
let q = |actor: &str| {
format!(
r#"mutation {{ completeTask(id: "task-g", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
)
};
// Stranger signs as themselves and passes byDid=self (so the signer
// binding passes), but is not the assignee → rejected by authorization.
let resp = schema
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).contains("assignee"),
"a non-assignee signer must be rejected: {}",
errors(&resp)
);
// The assignee completes with no error.
let resp = schema
.execute(Request::new(q(assignee)).data(AuthenticatedDid(assignee.into())))
.await;
assert!(
errors(&resp).is_empty(),
"the assignee should complete the task: {}",
errors(&resp)
);
}
}
+12 -451
View File
@@ -12,29 +12,11 @@ impl QueryRoot {
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
let repos = db
.list_all_repos_deduped()
.list_all_repos()
.await
.map_err(crate::graphql::graphql_db_err)?;
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
// this surface does not enumerate private repos (#97). The caller DID is
// threaded onto the context by optional_signature; absent = anonymous.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = db
.list_visibility_rules_for_repos(&ids)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(repos
.into_iter()
.filter(|r| {
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
})
.map(|r| RepoType {
name: r.name,
owner_did: r.owner_did,
@@ -49,49 +31,16 @@ impl QueryRoot {
&self,
ctx: &Context<'_>,
repo: Option<String>,
#[graphql(
default = 20,
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
)]
limit: i64,
#[graphql(default = 20)] limit: i64,
) -> Result<Vec<RefUpdateType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// Gate each row on the same "/" visibility decision the repos resolver
// uses, so anonymous callers get no row for a local repo they can't read
// (#112). The shared collector applies the fail-closed gate *before* the
// limit (paging past dropped private rows) so a small limit still returns
// the latest visible events, and keeps this surface byte-identical to the
// REST feed (#114). The row slug is peer-supplied, so the pure filter
// treats it as untrusted input; remote (no local match) rows pass.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let updates =
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
.await
.map_err(crate::graphql::graphql_app_err)?;
// Resolve the trusted display owner_did per row, identical to the REST
// feed: the stored wire value is untrusted, so it is echoed only when it
// matches the canonical owner of the local repo the slug names (#P1);
// legacy None rows are attributed via an exact unique local match (#P3).
// The batch resolver issues at most one query per distinct local repo
// rather than one per event row (#P2).
let pairs: Vec<(&str, Option<&str>)> = updates
.iter()
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
.collect();
let owner_dids = db
.resolve_ref_update_owner_dids(&pairs)
let updates = db
.list_ref_updates_filtered(repo.as_deref(), limit)
.await
.map_err(crate::graphql::graphql_db_err)?;
let resolved: Vec<RefUpdateType> = updates
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(updates
.into_iter()
.zip(owner_dids)
.map(|(u, owner_did)| RefUpdateType {
.map(|u| RefUpdateType {
repo: u.repo,
ref_name: u.ref_name,
old_sha: u.old_sha,
@@ -99,10 +48,8 @@ impl QueryRoot {
pusher_did: u.pusher_did,
node_did: u.node_did,
timestamp: u.timestamp,
owner_did,
})
.collect();
Ok(resolved)
.collect())
}
async fn tasks(
@@ -110,21 +57,13 @@ impl QueryRoot {
ctx: &Context<'_>,
status: Option<String>,
assignee_did: Option<String>,
#[graphql(
default = 50,
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
)]
limit: i64,
#[graphql(default = 50)] limit: i64,
) -> Result<Vec<AgentTaskType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
// rejects with 2201W, which would otherwise trip the opaque DB path
// and write an error-level log on every probe (#250 review).
let limit = limit.clamp(0, 200);
let tasks = db
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
}
@@ -133,385 +72,7 @@ impl QueryRoot {
let t = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(t.map(AgentTaskType::from))
}
}
#[cfg(test)]
mod tests {
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
use chrono::Utc;
use sqlx::PgPool;
use std::sync::Arc;
const OWNER: &str = "did:key:z6MkOwner";
async fn db(pool: PgPool) -> Arc<Db> {
let db = Db::for_testing(pool);
db.run_migrations().await.unwrap();
Arc::new(db)
}
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
let (task_tx, _) = tokio::sync::broadcast::channel(16);
super::super::build_schema(db, ref_tx, task_tx)
}
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
RepoRecord {
id: id.into(),
name: name.into(),
owner_did: owner_did.into(),
description: None,
is_public,
default_branch: "main".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: format!("/srv/{id}"),
forked_from: None,
machine_id: None,
}
}
fn ref_row(id: &str, slug: &str) -> ReceivedRefUpdate {
ReceivedRefUpdate {
id: id.into(),
node_did: "did:key:z6MkNode".into(),
pusher_did: "did:key:z6MkPusher".into(),
repo: slug.into(),
ref_name: "refs/heads/main".into(),
old_sha: "0".repeat(40),
new_sha: "a".repeat(40),
timestamp: Utc::now().to_rfc3339(),
cert_id: None,
received_at: Utc::now().to_rfc3339(),
from_peer: "peer1".into(),
owner_did: None,
}
}
/// Count `refUpdates` rows in a GraphQL response.
fn count(resp: &async_graphql::Response) -> usize {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").expect("refUpdates key")
else {
panic!("refUpdates not a list");
};
rows.len()
}
async fn anon(schema: &super::super::GitlawbSchema, query: &str) -> async_graphql::Response {
schema.execute(async_graphql::Request::new(query)).await
}
async fn authed(
schema: &super::super::GitlawbSchema,
query: &str,
did: &str,
) -> async_graphql::Response {
schema
.execute(
async_graphql::Request::new(query)
.data(crate::auth::AuthenticatedDid(did.to_string())),
)
.await
}
// Scenario 1 — anon must not get a private local repo's row on the
// repo:Some branch. This is the load-bearing RED→GREEN case.
#[sqlx::test]
async fn ref_updates_private_repo_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
.await
.unwrap();
let schema = schema(db);
// The GraphQL `repo` arg is the raw slug DB filter, so it must equal the
// stored slug to select the row at all — this is the exact leak path.
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName newSha pusherDid } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 2 — owner still sees their own private repo's row.
#[sqlx::test]
async fn ref_updates_private_repo_kept_for_owner(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName } }"#;
assert_eq!(count(&authed(&schema, q, OWNER).await), 1);
}
// Scenario 3 — unfiltered (repo:None): anon gets only the public row.
#[sqlx::test]
async fn ref_updates_unfiltered_anon_gets_only_public(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("pub", OWNER, "openrepo", true))
.await
.unwrap();
db.create_repo(&repo("priv", OWNER, "secret", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u_pub", "z6MkOwner/openrepo"))
.await
.unwrap();
db.insert_ref_update(&ref_row("u_priv", "z6MkOwner/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo refName ownerDid } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 1);
// The one row returned must be the public repo's with owner_did echoed.
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
unreachable!()
};
let async_graphql::Value::Object(row) = &rows[0] else {
unreachable!()
};
assert_eq!(
row.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo")
);
assert_eq!(
row.get("ownerDid").unwrap(),
&async_graphql::Value::from(OWNER),
"ownerDid must fall back to the local record's owner for legacy rows"
);
}
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
#[sqlx::test]
async fn ref_updates_full_did_slug_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", "did:key:zABC", "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "did:key:zABC/widget"))
.await
.unwrap();
let schema = schema(db);
// repo:None so the slug is not the DB filter key (which is verbatim);
// the gate must still drop it.
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 5 — truncated-key fail-closed: 8-char-prefix owner form.
#[sqlx::test]
async fn ref_updates_truncated_key_slug_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", "did:key:zABCDEFGH", "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "zABCDEF/widget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 6 — remote slug (no local match) is returned to anon.
#[sqlx::test]
async fn ref_updates_remote_slug_kept_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
// Row whose slug matches no local repo (different owner + name).
db.insert_ref_update(&ref_row("u1", "zZZZOTHER/gadget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 1);
}
// Scenario 7 (#114 P2) — a small limit must page past the newest rows when
// they are private, so the older public rows are still returned. Before the
// gate moved ahead of the limit this returned 0 (the newest `limit` rows were
// all private and got filtered after the SQL LIMIT). RED→GREEN.
#[sqlx::test]
async fn ref_updates_small_limit_pages_past_newest_private(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("pub", OWNER, "openrepo", true))
.await
.unwrap();
db.create_repo(&repo("priv", OWNER, "secret", false))
.await
.unwrap();
// 3 older PUBLIC rows …
for i in 0..3 {
let mut r = ref_row(&format!("pub{i}"), "z6MkOwner/openrepo");
r.timestamp = format!("2026-07-01T10:00:0{i}+00:00");
db.insert_ref_update(&r).await.unwrap();
}
// … then 5 NEWER PRIVATE rows (the newest in the feed).
for i in 0..5 {
let mut r = ref_row(&format!("priv{i}"), "z6MkOwner/secret");
r.timestamp = format!("2026-07-01T10:00:1{i}+00:00");
db.insert_ref_update(&r).await.unwrap();
}
let schema = schema(db);
// limit 3 < the 5 newest (all private): anon must still get 3 public rows.
let q = r#"{ refUpdates(limit: 3) { repo } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 3, "paging must reach the older public rows");
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
unreachable!()
};
for row in rows {
let async_graphql::Value::Object(r) = row else {
unreachable!()
};
assert_eq!(
r.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo"),
"every returned row must be the public repo's"
);
}
}
// Scenario 8 — a quarantined mirror is withheld on the GraphQL surface too.
// Guards that the resolver keeps delegating to the shared collector (where the
// quarantine fold lives); a REST-only test would miss a resolver that stopped.
#[sqlx::test]
async fn ref_updates_quarantined_mirror_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.upsert_mirror_repo("z6MkQuar", "secret", "/tmp/q", None, true)
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 8b — the GraphQL surface also withholds a quarantined repo from an
// authenticated OWNER, not just anon. Without the collector's quarantine drop
// the owner short-circuit in visibility_check keeps the row on this surface
// too, so the REST owner test alone would not guard the resolver.
#[sqlx::test]
async fn ref_updates_quarantined_repo_dropped_for_owner(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("q1", "did:key:z6MkQuar", "secret", false))
.await
.unwrap();
db.set_repo_quarantine("q1", true).await.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&authed(&schema, q, "did:key:z6MkQuar").await), 0);
}
/// #250: anonymous GraphQL query DB failures must not leak sqlx/schema text.
#[sqlx::test]
async fn repos_query_db_error_message_is_opaque(pool: PgPool) {
let db = db(pool.clone()).await;
db.create_repo(&repo("r1", OWNER, "widget", true))
.await
.unwrap();
sqlx::query("ALTER TABLE repos DROP COLUMN is_public")
.execute(&pool)
.await
.unwrap();
let schema = schema(db);
let resp = anon(&schema, "{ repos { name ownerDid } }").await;
assert!(
!resp.errors.is_empty(),
"DB failure must surface as a GraphQL error"
);
for err in &resp.errors {
assert_eq!(
err.message,
crate::graphql::GRAPHQL_DB_ERROR_MESSAGE,
"raw DB detail leaked into GraphQL error: {}",
err.message
);
assert!(
!err.message.contains("is_public") && !err.message.contains("column"),
"schema text leaked: {}",
err.message
);
}
}
/// #250: negative tasks(limit) must not hit Postgres (and must not 500-log).
#[sqlx::test]
async fn tasks_negative_limit_clamped(pool: PgPool) {
let db = db(pool).await;
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
assert!(
resp.errors.is_empty(),
"negative limit must clamp, not fail: {:?}",
resp.errors
);
assert_eq!(count_tasks(&resp), 0);
}
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
#[sqlx::test]
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
let db = db(pool).await;
let now = Utc::now().to_rfc3339();
for i in 0..201 {
db.create_task(&crate::db::AgentTask {
id: format!("task-ceil-{i}"),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: OWNER.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now.clone(),
deadline: None,
})
.await
.unwrap();
}
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
}
fn count_tasks(resp: &async_graphql::Response) -> usize {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not a list");
};
rows.len()
}
}
@@ -11,15 +11,6 @@ pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
/// Live ref-update stream. `/graphql/ws` is mounted outside the
/// `optional_signature` layer, so this resolver has NO caller identity and
/// cannot gate per-subscriber — it relays whatever enters the broadcast
/// channel to any anonymous client. Its visibility safety therefore rests
/// entirely on the WRITE side: the push handler only sends a
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
async fn ref_updates(
&self,
ctx: &Context<'_>,
@@ -39,7 +30,6 @@ impl SubscriptionRoot {
pusher_did: ev.pusher_did,
node_did: ev.node_did,
timestamp: ev.timestamp,
owner_did: Some(ev.owner_did),
}),
_ => None,
}
-1
View File
@@ -57,7 +57,6 @@ pub struct RefUpdateType {
pub pusher_did: String,
pub node_did: String,
pub timestamp: String,
pub owner_did: Option<String>,
}
#[derive(SimpleObject, Clone)]
-650
View File
@@ -1,650 +0,0 @@
//! iCaptcha proof-of-intelligence gate.
//!
//! Spam-prone endpoints (repo creation, agent registration) can require the
//! caller to present an iCaptcha proof: a small Ed25519-signed token minted by
//! <https://icaptcha.gitlawb.com> after the caller solves an escalating
//! challenge. We verify the proof OFFLINE (no per-request call to iCaptcha)
//! using its published public key, and bind each proof to the authenticated
//! agent DID so a proof cannot be shared between identities.
//!
//! Behaviour is controlled by `ICAPTCHA_MODE`:
//! * `off` (default) — gate is inert, nothing is checked.
//! * `shadow` — verify and log would-be rejections, but always allow.
//! * `enforce` — reject requests without a valid, sufficiently-strong proof.
//!
//! Config (env):
//! ICAPTCHA_MODE off | shadow | enforce (default off)
//! ICAPTCHA_URL base URL (default https://icaptcha.gitlawb.com)
//! ICAPTCHA_PUBKEY base64url Ed25519 public key (optional; else fetched from /v1/pubkey)
//! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3)
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::http::HeaderMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::Deserialize;
use crate::error::AppError;
const PROOF_HEADER: &str = "x-icaptcha-proof";
/// How long a mirror-admission `jti` stays in the replay ledger. The direct
/// request path spends a proof against its own `exp` (it can't be presented once
/// expired). The propagation path deliberately accepts already-expired proofs,
/// so spending against `exp` would store an already-past expiry that the next
/// sweep deletes — letting the same token admit another mirror minutes later.
/// We instead retain the mirror replay record for a long, fixed window so the
/// per-node single-use property is durable.
const MIRROR_REPLAY_RETENTION_SECS: i64 = 365 * 24 * 60 * 60;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Off,
Shadow,
Enforce,
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Off => "off",
Mode::Shadow => "shadow",
Mode::Enforce => "enforce",
}
}
}
/// Parse `ICAPTCHA_MODE`. Returns `None` for unrecognized values so the caller
/// can surface the typo instead of silently disabling the gate.
fn parse_mode(s: &str) -> Option<Mode> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "off" => Some(Mode::Off),
"shadow" => Some(Mode::Shadow),
"enforce" => Some(Mode::Enforce),
_ => None,
}
}
/// Parse `ICAPTCHA_REQUIRED_LEVEL`. Defaults to 3; warns (rather than silently
/// lowering the threshold) when a non-empty value fails to parse.
fn parse_required_level() -> u32 {
const DEFAULT: u32 = 3;
match std::env::var("ICAPTCHA_REQUIRED_LEVEL") {
Ok(v) if !v.trim().is_empty() => v.trim().parse().unwrap_or_else(|_| {
tracing::warn!(
value = %v,
default = DEFAULT,
"invalid ICAPTCHA_REQUIRED_LEVEL; using default"
);
DEFAULT
}),
_ => DEFAULT,
}
}
struct Verifier {
mode: Mode,
url: String,
required_level: u32,
key: Option<VerifyingKey>,
}
static VERIFIER: OnceLock<Verifier> = OnceLock::new();
#[derive(Deserialize, Debug)]
struct ProofClaims {
sub: String,
level: u32,
exp: i64,
/// Unique proof id, consumed once so a proof cannot be replayed.
jti: String,
}
#[derive(Deserialize)]
struct Jwk {
x: String,
}
#[derive(Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn decode_key(b64url: &str) -> Option<VerifyingKey> {
let bytes = URL_SAFE_NO_PAD.decode(b64url.trim()).ok()?;
let arr: [u8; 32] = bytes.try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
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).
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.ok()?;
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
decode_key(&jwks.keys.first()?.x)
}
/// Initialize the gate from the environment. Call once at startup. Never panics;
/// if the gate is active but no key can be loaded, it stays inert and warns.
pub async fn init() {
let raw_mode = std::env::var("ICAPTCHA_MODE").unwrap_or_default();
let mode = parse_mode(&raw_mode).unwrap_or_else(|| {
tracing::warn!(value = %raw_mode, "invalid ICAPTCHA_MODE; disabling iCaptcha gate");
Mode::Off
});
let url = std::env::var("ICAPTCHA_URL")
.unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string());
let required_level = parse_required_level();
let key = if mode == Mode::Off {
None
} else {
match std::env::var("ICAPTCHA_PUBKEY") {
Ok(b64) if !b64.is_empty() => decode_key(&b64),
_ => fetch_key(&url).await,
}
};
if mode != Mode::Off {
if key.is_some() {
tracing::info!(mode = mode.as_str(), required_level, "iCaptcha gate active");
} else {
tracing::warn!(
mode = mode.as_str(),
"iCaptcha gate enabled but no public key could be loaded; staying inert"
);
}
}
let _ = VERIFIER.set(Verifier {
mode,
url,
required_level,
key,
});
}
/// Outcome of the synchronous, IO-free decision step.
#[derive(Debug)]
enum Decision {
/// Allow the request (off, shadow, inert/no-key, or verified non-enforcing).
Allow,
/// Enforce mode and verification failed; reject with this reason.
Reject(String),
/// Enforce mode and the proof verified; the caller must consume its `jti`
/// (and reject replays) before allowing, then may persist it.
Consume(VerifiedProof),
}
/// A verified proof: the raw token plus the claims we act on. Carried by a
/// [`ProofGuard`] so that, once consumed, the gated handler can persist it with
/// the created repo — letting the proof travel with the repo when it propagates
/// to peers (see [`admit_mirror`]).
#[derive(Debug)]
pub struct VerifiedProof {
token: String,
sub: String,
level: u32,
jti: String,
exp: i64,
}
impl VerifiedProof {
/// Persist this proof against a freshly-created repo so a mirroring peer can
/// re-verify it. Best-effort callers may ignore failures; the gate's
/// security does not depend on it (propagation just falls back to quarantine).
pub async fn record_for_repo(&self, db: &crate::db::Db, repo_id: &str) -> Result<(), AppError> {
db.record_repo_proof(
repo_id,
&self.token,
&self.sub,
self.level as i32,
&self.jti,
self.exp,
)
.await?;
Ok(())
}
}
/// A verified proof awaiting consumption. Verification (which rejects invalid or
/// missing proofs) is separated from consumption (which spends the single-use
/// `jti`) so a request rejected by later validation never burns a valid proof.
/// The caller must `consume()` this guard immediately before the gated write.
/// For off/shadow/inert there is nothing to consume.
#[must_use = "a verified iCaptcha proof must be consumed before the gated action"]
pub struct ProofGuard(Option<VerifiedProof>);
impl ProofGuard {
/// Spend the proof's `jti` (single-use) and return the verified proof so the
/// caller can persist it. A replay is rejected. Returns `None` when there is
/// nothing to consume (off/shadow/inert).
pub async fn consume(self, db: &crate::db::Db) -> Result<Option<VerifiedProof>, AppError> {
match self.0 {
Some(p) => {
if !db.consume_proof_jti(&p.jti, p.exp).await? {
let (url, level) = url_and_level();
return Err(AppError::IcaptchaProofRequired {
message: "iCaptcha proof already used (replay); solve a fresh challenge"
.to_string(),
url,
level,
});
}
Ok(Some(p))
}
None => Ok(None),
}
}
}
/// Verify the proof in `headers` belongs to `did`. Rejects missing/invalid
/// proofs early (enforce mode); off/shadow never reject. Returns a [`ProofGuard`]
/// the caller must `consume()` right before the gated write. Off/shadow/inert
/// yield a no-op guard that consumes nothing.
pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppError> {
let v = match VERIFIER.get() {
Some(v) => v,
None => return Ok(ProofGuard(None)), // not initialized -> inert
};
match decide(v, headers, did, now_secs()) {
Decision::Allow => Ok(ProofGuard(None)),
Decision::Reject(reason) => Err(reject_error(v, &reason)),
Decision::Consume(proof) => Ok(ProofGuard(Some(proof))),
}
}
fn reject_error(v: &Verifier, reason: &str) -> AppError {
AppError::IcaptchaProofRequired {
message: format!(
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
v.url, v.required_level, PROOF_HEADER
),
url: v.url.clone(),
level: v.required_level,
}
}
/// iCaptcha service url + required level for error responses, read from the
/// initialized verifier (falls back to defaults if somehow uninitialized).
fn url_and_level() -> (String, u32) {
VERIFIER
.get()
.map(|v| (v.url.clone(), v.required_level))
.unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3))
}
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
/// is fully unit-testable. The caller performs jti consumption for `Consume`.
fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Decision {
if v.mode == Mode::Off {
return Decision::Allow;
}
// Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable
// at startup), stay inert rather than rejecting every request. The operator
// already saw a startup warning. An iCaptcha hiccup must never break repo
// creation or registration.
if v.key.is_none() {
return Decision::Allow;
}
let token = headers.get(PROOF_HEADER).and_then(|h| h.to_str().ok());
let result = match token {
Some(t) => verify_token(v, t, did, now, true).map(|claims| (t, claims)),
None => Err("missing proof header".to_string()),
};
match result {
Ok((token, claims)) => match v.mode {
Mode::Enforce => Decision::Consume(VerifiedProof {
token: token.to_string(),
sub: claims.sub,
level: claims.level,
jti: claims.jti,
exp: claims.exp,
}),
// Shadow/Off: never reject, and do not consume (observational only).
_ => Decision::Allow,
},
Err(reason) => match v.mode {
Mode::Shadow => {
tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject");
Decision::Allow
}
Mode::Enforce => Decision::Reject(reason),
Mode::Off => Decision::Allow,
},
}
}
/// Header-extracting wrapper over [`verify_token`] (enforces expiry). The
/// production path inlines this in [`decide`]; retained as the unit-test entry
/// point that also exercises header extraction.
#[cfg(test)]
fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<ProofClaims, String> {
let proof = headers
.get(PROOF_HEADER)
.and_then(|h| h.to_str().ok())
.ok_or("missing proof header")?;
verify_token(v, proof, did, now, true)
}
/// Core verification of a raw proof token, separated for testability and reuse.
/// Checks the Ed25519 signature, the required level, and the DID binding. `exp`
/// is only enforced when `check_exp` is true: the direct request path enforces
/// freshness, but the propagation path ([`admit_mirror`]) does not — a proof has
/// usually expired by the time a repo mirrors, and the origin enforced freshness
/// at creation. `now` is unix seconds.
fn verify_token(
v: &Verifier,
token: &str,
did: &str,
now: i64,
check_exp: bool,
) -> Result<ProofClaims, String> {
let key = v.key.as_ref().ok_or("verifier has no public key")?;
let (payload, sig_b64) = token.split_once('.').ok_or("malformed proof")?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| "bad signature encoding")?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad signature length")?;
key.verify_strict(payload.as_bytes(), &sig)
.map_err(|_| "signature verification failed")?;
let claims_bytes = URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| "bad payload encoding")?;
let claims: ProofClaims = serde_json::from_slice(&claims_bytes).map_err(|_| "bad claims")?;
if check_exp && claims.exp < now {
return Err("proof expired".to_string());
}
if claims.level < v.required_level {
return Err(format!(
"level {} below required {}",
claims.level, v.required_level
));
}
if !crate::api::did_matches(did, &claims.sub) {
return Err("proof subject does not match authenticated DID".to_string());
}
Ok(claims)
}
/// Outcome of admitting a repo mirrored from a peer.
#[derive(Debug, PartialEq, Eq)]
pub enum MirrorAdmission {
/// Admit the mirror normally (off/shadow/inert, or a valid proof in enforce).
Admit,
/// Enforce mode and the mirror's proof is missing/invalid/replayed — mirror
/// it but quarantine until an operator releases it. Carries the reason.
Quarantine(String),
}
/// Decide whether to admit a repo being mirrored from a peer, given the proof
/// token the origin served (`None` if it had none or the fetch failed) and the
/// mirrored repo's owner DID.
///
/// Off/shadow/inert always admit (shadow logs the would-be quarantine and writes
/// nothing). In enforce mode the proof is re-verified offline against the local
/// public key — signature, level, and owner-DID binding, but NOT expiry — and
/// its `jti` is consumed in the local ledger, so one solved challenge admits at
/// most one mirror per node (a malicious origin cannot reuse one proof to bless
/// many spam repos here).
///
/// LIMITATION (proof is not repo-bound): an iCaptcha proof commits only to the
/// solver's DID (`sub`), never to a repo — the proof is minted before the repo
/// exists. So the guarantee enforced here is "a level≥N challenge was solved by
/// this repo's owner DID", not "the owner intended THIS repo". A malicious origin
/// that harvests a victim's public proof (served by `get_icaptcha_proof`) can use
/// it to get ONE spam repo nominally owned by that victim admitted per node — the
/// per-node `jti` single-use caps the amplification at one repo per distinct
/// harvested proof, but does not eliminate it. Fully binding a proof to a repo
/// would require the iCaptcha service to embed a repo/target identifier in the
/// signed challenge (a protocol change, tracked as future work).
pub async fn admit_mirror(
db: &crate::db::Db,
token: Option<&str>,
owner_did: &str,
) -> MirrorAdmission {
let v = match VERIFIER.get() {
Some(v) => v,
None => return MirrorAdmission::Admit, // not initialized -> inert
};
// Off, or inert because no key could be loaded: admit unconditionally.
if v.mode == Mode::Off || v.key.is_none() {
return MirrorAdmission::Admit;
}
let shadow = v.mode == Mode::Shadow;
// Quarantine in enforce; in shadow just log and admit (observational, no IO).
let quarantine = |reason: String| -> MirrorAdmission {
if shadow {
tracing::warn!(owner = %owner_did, reason, "iCaptcha (shadow) would quarantine mirror");
MirrorAdmission::Admit
} else {
MirrorAdmission::Quarantine(reason)
}
};
let token = match token {
Some(t) => t,
None => return quarantine("origin served no iCaptcha proof".to_string()),
};
match verify_token(v, token, owner_did, now_secs(), false) {
Ok(claims) => {
if shadow {
// Observational: a valid proof would admit. Do not consume, so a
// later switch to enforce still sees the jti as fresh.
return MirrorAdmission::Admit;
}
// Retain the replay record on a fixed forward window, NOT the proof's
// own (already-past) exp — otherwise the next sweep frees the jti and
// the same token could admit another mirror here.
let retain_until = now_secs().saturating_add(MIRROR_REPLAY_RETENTION_SECS);
match db.consume_proof_jti(&claims.jti, retain_until).await {
Ok(true) => MirrorAdmission::Admit,
Ok(false) => MirrorAdmission::Quarantine(
"iCaptcha proof already used to admit another mirror".to_string(),
),
Err(e) => {
tracing::warn!(owner = %owner_did, err = %e, "iCaptcha mirror ledger error; quarantining");
MirrorAdmission::Quarantine("iCaptcha proof ledger unavailable".to_string())
}
}
}
Err(reason) => quarantine(reason),
}
}
#[cfg(test)]
mod tests {
use super::*;
// Real values captured from https://icaptcha.gitlawb.com (a live proof).
const PUBKEY_X: &str = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8";
const PROOF: &str = "eyJzdWIiOiJkaWQ6a2V5Onp0ZXN0IiwibGV2ZWwiOjMsImlzcyI6ImljYXB0Y2hhIiwiaWF0IjoxNzgyNTcyODUxLCJleHAiOjE3ODI1NzMxNTEsImp0aSI6IjRiNTIyOGE1YmVkNzEyMmRlZTlmNDdmZiJ9.5UXVPZ8Eo91VnlcvgDXtW-Fx7J2jr7h535SAstQEpigxBr7FF7V6R0XB4PBDgdoBPnhdH_kVEfRPfdHPSdB0CA";
const SUB: &str = "did:key:ztest";
const IAT: i64 = 1782572851; // within the proof's validity window
fn verifier(level: u32) -> Verifier {
Verifier {
mode: Mode::Enforce,
url: "https://icaptcha.gitlawb.com".to_string(),
required_level: level,
key: decode_key(PUBKEY_X),
}
}
fn headers_with(proof: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(PROOF_HEADER, proof.parse().unwrap());
h
}
#[test]
fn accepts_a_real_proof() {
let v = verifier(3);
assert!(verify(&v, &headers_with(PROOF), SUB, IAT).is_ok());
}
#[test]
fn rejects_expired_proof() {
let v = verifier(3);
let err = verify(&v, &headers_with(PROOF), SUB, 9_999_999_999).unwrap_err();
assert!(err.contains("expired"), "{err}");
}
#[test]
fn rejects_wrong_did() {
let v = verifier(3);
let err = verify(&v, &headers_with(PROOF), "did:key:zsomeoneelse", IAT).unwrap_err();
assert!(err.contains("subject"), "{err}");
}
#[test]
fn rejects_insufficient_level() {
let v = verifier(5); // proof is level 3
let err = verify(&v, &headers_with(PROOF), SUB, IAT).unwrap_err();
assert!(err.contains("below required"), "{err}");
}
#[test]
fn rejects_tampered_signature() {
let v = verifier(3);
// Flip one base64url char in the signature so it is guaranteed different.
let (payload, sig) = PROOF.split_once('.').unwrap();
let mut chars: Vec<char> = sig.chars().collect();
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
assert!(verify(&v, &headers_with(&tampered), SUB, IAT).is_err());
}
#[test]
fn rejects_missing_header() {
let v = verifier(3);
let err = verify(&v, &HeaderMap::new(), SUB, IAT).unwrap_err();
assert!(err.contains("missing"), "{err}");
}
#[test]
fn parse_mode_accepts_documented_values_and_rejects_junk() {
assert_eq!(parse_mode(""), Some(Mode::Off));
assert_eq!(parse_mode("off"), Some(Mode::Off));
assert_eq!(parse_mode(" Shadow "), Some(Mode::Shadow));
assert_eq!(parse_mode("ENFORCE"), Some(Mode::Enforce));
// Typos must NOT silently disable the gate.
assert_eq!(parse_mode("enforced"), None);
assert_eq!(parse_mode("on"), None);
}
#[test]
fn off_mode_allows_everything() {
let mut v = verifier(3);
v.mode = Mode::Off;
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Allow
));
}
#[test]
fn enforce_without_key_stays_inert() {
// iCaptcha unreachable at startup -> no key -> must not reject.
let v = Verifier {
mode: Mode::Enforce,
url: "https://icaptcha.gitlawb.com".to_string(),
required_level: 3,
key: None,
};
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Allow
));
}
#[test]
fn enforce_with_key_rejects_missing_proof() {
let v = verifier(3);
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Reject(_)
));
}
#[test]
fn shadow_allows_despite_bad_proof() {
let mut v = verifier(3);
v.mode = Mode::Shadow;
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Allow
));
}
#[test]
fn enforce_valid_proof_requires_consuming_its_jti() {
// A verified proof under enforce must yield Consume carrying the jti, so
// the caller can spend it once and reject replays.
let v = verifier(3);
match decide(&v, &headers_with(PROOF), SUB, IAT) {
Decision::Consume(p) => {
assert_eq!(p.jti, "4b5228a5bed7122dee9f47ff");
assert_eq!(p.exp, 1782573151);
assert_eq!(p.token, PROOF, "the raw token is retained for persistence");
assert_eq!(p.sub, SUB);
}
other => panic!("expected Consume, got {other:?}"),
}
}
#[test]
fn propagation_accepts_an_expired_proof() {
// The mirror-admission path verifies sig/level/DID but NOT expiry, since a
// proof has usually expired by the time a repo propagates. The same proof
// that the direct path rejects as expired must verify here.
let v = verifier(3);
let now = 9_999_999_999; // far past the proof's exp
assert!(
verify_token(&v, PROOF, SUB, now, true).is_err(),
"direct path (check_exp=true) rejects the expired proof"
);
assert!(
verify_token(&v, PROOF, SUB, now, false).is_ok(),
"propagation path (check_exp=false) accepts it"
);
}
#[test]
fn propagation_still_enforces_signature_level_and_did() {
let v = verifier(3);
// wrong owner DID
assert!(verify_token(&v, PROOF, "did:key:zother", IAT, false).is_err());
// insufficient level
let v5 = verifier(5);
assert!(verify_token(&v5, PROOF, SUB, IAT, false).is_err());
// tampered signature
let (payload, sig) = PROOF.split_once('.').unwrap();
let mut chars: Vec<char> = sig.chars().collect();
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9 -96
View File
@@ -7,8 +7,6 @@
//! * is fetch traffic flowing? — `gitlawb_fetches_total`
//! * are signature checks passing or failing? —
//! `gitlawb_auth_successes_total` / `gitlawb_auth_failures_total`
//! * is the spent-signature replay ledger admitting or refusing writes? —
//! `gitlawb_signature_ledger_total{outcome}`
//! * is the sync worker making progress? —
//! `gitlawb_sync_queue_processed_total{status}`
//! * are webhooks reaching their endpoints? —
@@ -49,7 +47,6 @@ static PUSHES: OnceLock<IntCounterVec> = OnceLock::new();
static FETCHES: OnceLock<IntCounterVec> = OnceLock::new();
static AUTH_SUCCESSES: OnceLock<IntCounterVec> = OnceLock::new();
static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
static SIGNATURE_LEDGER: OnceLock<IntCounterVec> = OnceLock::new();
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
@@ -60,26 +57,10 @@ static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
/// more than once is a silent no-op. MUST be called from `main()` after
/// the node DID is known.
pub fn init(version: &str, node_did: &str) {
// `Once` rather than a bare `REGISTRY.get().is_some()` check: the registry
// is published last, so that check leaves a window in which a second
// concurrent caller walks into the `set(..).expect(..)` calls below and
// panics on an already-set `OnceLock`. `main` calls this once, but tests
// call it from several threads. `call_once` makes the later callers block
// and then return, which is what the `expect`s already assume.
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| init_once(version, node_did));
}
if REGISTRY.get().is_some() {
return;
}
fn init_once(version: &str, node_did: &str) {
// Guard with Once so two concurrent callers cannot both pass an unsynchronized
// `REGISTRY.get()` check and then race on `OnceLock::set(...).expect(...)`,
// panicking the loser (#192 F4). Once runs the body exactly once, so the
// `.expect("set X once")` calls below can never observe an already-set slot.
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| init_inner(version, node_did));
}
fn init_inner(version: &str, node_did: &str) {
let registry = Registry::new();
let info = IntGaugeVec::new(
@@ -152,21 +133,6 @@ fn init_inner(version: &str, node_did: &str) {
.set(auth_failures)
.expect("set AUTH_FAILURES once");
let signature_ledger = IntCounterVec::new(
Opts::new(
"gitlawb_signature_ledger_total",
"Total requests seen by the spent-signature replay ledger, by outcome",
),
&["outcome"],
)
.expect("gitlawb_signature_ledger_total definition");
registry
.register(Box::new(signature_ledger.clone()))
.expect("register gitlawb_signature_ledger_total");
SIGNATURE_LEDGER
.set(signature_ledger)
.expect("set SIGNATURE_LEDGER once");
let sync_processed = IntCounterVec::new(
Opts::new(
"gitlawb_sync_queue_processed_total",
@@ -252,17 +218,6 @@ pub fn record_fetch(repo: &str) {
}
}
/// Test-only: current `gitlawb_fetches_total` value for `repo` (0 if the registry
/// is not initialized). Lets api-layer tests assert the completed-fetch count with
/// a unique label instead of scraping the encoded text.
#[cfg(test)]
pub fn fetch_count_for_test(repo: &str) -> u64 {
FETCHES
.get()
.map(|c| c.with_label_values(&[repo]).get())
.unwrap_or(0)
}
/// Record one HTTP signature check that passed.
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
pub fn record_auth_success(route: &str) {
@@ -280,30 +235,7 @@ pub fn record_auth_failure(route: &str, reason: &str) {
}
}
/// Record one terminal outcome of the spent-signature replay ledger layer.
///
/// Every request that enters the layer lands on exactly one outcome, so the
/// series sum is the layer's request count and any one outcome is a rate over
/// it. `outcome` is a fixed set of literals chosen at the call sites in
/// `auth::consume_signature` — never a DID, keyid, or path, which would be
/// attacker-controlled and unbounded.
///
/// The sum is the LAYER's request count, not the routes' traffic. The per-client
/// flood brake (`rate_limit`) rejects upstream of this layer, so its 429s appear
/// in no series here even though they are served on the same routes. An operator
/// counting refusals has to read `rate_limited` responses separately; this
/// counter answers "what is the ledger doing", not "how many 429s went out".
pub fn record_signature_ledger(outcome: &str) {
if let Some(c) = SIGNATURE_LEDGER.get() {
c.with_label_values(&[outcome]).inc();
}
}
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped,
/// rejected, deferred}. `rejected` is terminal and caused by a guard (a
/// malformed slug, or a path resolving outside repos_dir); `deferred` leaves
/// the row pending for a later pass, so a queue stalled on an operator
/// condition is visible rather than looking idle.
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped}.
pub fn record_sync_processed(status: &str) {
if let Some(c) = SYNC_PROCESSED.get() {
c.with_label_values(&[status]).inc();
@@ -350,11 +282,11 @@ pub fn encode() -> Result<String, prometheus::Error> {
mod tests {
use super::*;
// Note: cargo runs tests in a binary in parallel via threads, and several
// tests here call init(). init() is guarded by std::sync::Once, so concurrent
// callers run the body exactly once and never race on the OnceLock setters
// (#192 F4). The encode test below is structured so it's safe to run alongside
// other tests in the same binary regardless of ordering.
// Note: these tests are not run in parallel by default (cargo runs
// tests in a binary in parallel via threads but the OnceLock guard
// means only the first init() call succeeds; subsequent ones are
// no-ops). The encode test below is structured so it's safe to run
// alongside other tests in the same binary.
#[test]
fn encode_after_init_returns_prometheus_text() {
@@ -385,24 +317,6 @@ mod tests {
);
}
/// #192 F4: `init` is idempotent and safe to call repeatedly. The panic that
/// hit concurrent callers (both passing the old unsynchronized `REGISTRY.get()`
/// check, then racing on `OnceLock::set(...).expect(...)`) is fixed by the
/// `Once` guard. That race is inherently non-deterministic to assert in the
/// shared-binary suite — a 32-thread barrier stress-test reproduces it only in
/// isolation (verified: RED before the `Once` fix, GREEN after). This guard
/// covers the deterministic half of the contract: a repeat call is a no-op,
/// never a panic, and the registry stays usable.
#[test]
fn init_is_idempotent_no_panic_on_repeat() {
init("0.0.0-a", "did:key:a");
init("0.0.0-b", "did:key:b");
assert!(
encode().is_ok(),
"registry must stay usable after a repeated init"
);
}
#[test]
fn record_helpers_are_noops_before_init() {
// These don't panic. They also don't show up in encode() output
@@ -411,7 +325,6 @@ mod tests {
record_fetch("test/repo");
record_auth_success("test/route");
record_auth_failure("test/route", "test_reason");
record_signature_ledger("test_outcome");
record_sync_processed("done");
record_webhook_delivery("ok");
observe_pack_size(1024.0);
-70
View File
@@ -39,11 +39,6 @@ pub struct RefUpdateEvent {
pub pusher_did: String,
/// Repository identifier (owner/name)
pub repo: String,
/// Full owner DID — added in #144 for display and storage; not yet
/// wired into the feed gate matcher. Optional for backward compat with
/// older peers that don't include it.
#[serde(default)]
pub owner_did: Option<String>,
/// Git ref that changed (e.g., "refs/heads/main")
pub ref_name: String,
/// SHA before the push (all-zeros for new ref)
@@ -312,7 +307,6 @@ pub async fn start(
node_did: event.node_did.clone(),
pusher_did: event.pusher_did.clone(),
repo: event.repo.clone(),
owner_did: event.owner_did.clone(),
ref_name: event.ref_name.clone(),
old_sha: event.old_sha.clone(),
new_sha: event.new_sha.clone(),
@@ -438,67 +432,3 @@ pub async fn start(
Ok(handle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_update_event_round_trip_with_owner_did() {
let event = RefUpdateEvent {
node_did: "did:key:zNode".into(),
pusher_did: "did:key:zPusher".into(),
repo: "zOwner/myrepo".into(),
owner_did: Some("did:key:zOwner".into()),
ref_name: "refs/heads/main".into(),
old_sha: "0000000000000000000000000000000000000000".into(),
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
timestamp: "2026-07-02T12:00:00Z".into(),
cert_id: None,
cid: None,
};
let json = serde_json::to_value(&event).unwrap();
// owner_did must be present in the serialized output
assert_eq!(json["owner_did"], "did:key:zOwner");
assert_eq!(json["repo"], "zOwner/myrepo");
let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into()));
}
#[test]
fn ref_update_event_backward_compat_no_owner_did() {
let old_json = serde_json::json!({
"node_did": "did:key:zNode",
"pusher_did": "did:key:zPusher",
"repo": "zOwner/myrepo",
"ref_name": "refs/heads/main",
"old_sha": "0000000000000000000000000000000000000000",
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"timestamp": "2026-07-02T12:00:00Z",
"cert_id": null,
"cid": null
});
let deserialized: RefUpdateEvent = serde_json::from_value(old_json).unwrap();
assert_eq!(deserialized.owner_did, None);
assert_eq!(deserialized.repo, "zOwner/myrepo");
}
#[test]
fn ref_update_event_backward_compat_null_owner_did() {
let with_null = serde_json::json!({
"node_did": "did:key:zNode",
"pusher_did": "did:key:zPusher",
"repo": "zOwner/myrepo",
"owner_did": null,
"ref_name": "refs/heads/main",
"old_sha": "0000000000000000000000000000000000000000",
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"timestamp": "2026-07-02T12:00:00Z",
"cert_id": null,
"cid": null
});
let deserialized: RefUpdateEvent = serde_json::from_value(with_null).unwrap();
assert_eq!(deserialized.owner_did, None);
}
}
File diff suppressed because it is too large. Load diff
+18 -819
View File
@@ -1,24 +1,15 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::{ConnectInfo, Request};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use gitlawb_core::node_denial::NodeDenial;
use serde_json::json;
use tokio::sync::Mutex;
use crate::auth::AuthenticatedDid;
/// Default ceiling on the number of distinct keys a limiter tracks. Bounds the
/// limiter's own memory so a caller that can vary the key (many DIDs, or a
/// spoofable IP header) cannot grow the map without limit.
const DEFAULT_MAX_KEYS: usize = 100_000;
#[derive(Clone)]
struct Window {
timestamps: Vec<Instant>,
@@ -29,121 +20,31 @@ pub struct RateLimiter {
state: Arc<Mutex<HashMap<String, Window>>>,
max_requests: usize,
window: Duration,
/// Hard cap on tracked keys. When full, expired keys are evicted (at most
/// once per [`sweep_interval`](Self::sweep_interval), see below); if still
/// full, a request under a *new* key is rejected rather than inserted — so
/// the map can never exceed this bound and a rejected request never
/// allocates a new entry.
max_keys: usize,
/// Timestamp of the last inline capacity sweep. The eviction scan is
/// O(max_keys), so under a distinct-key flood (when the map sits at the
/// cap) running it on every miss would serialize all traffic behind a full
/// scan. Gating it to at most once per interval amortizes that cost; the
/// background [`cleanup`](Self::cleanup) loop still reclaims on its own.
last_sweep: Arc<Mutex<Instant>>,
}
impl RateLimiter {
// Retained for tests and callers that don't need a tight key bound;
// production limiters use `new_bounded`.
#[allow(dead_code)]
pub fn new(max_requests: usize, window: Duration) -> Self {
Self::new_bounded(max_requests, window, DEFAULT_MAX_KEYS)
}
/// Like [`new`] but with an explicit cap on the number of distinct keys.
/// Production limiters keyed on client-influenced values (per-DID, per-IP)
/// set this so the limiter's own state cannot be turned into a
/// memory-exhaustion vector.
pub fn new_bounded(max_requests: usize, window: Duration, max_keys: usize) -> Self {
Self {
state: Arc::new(Mutex::new(HashMap::new())),
max_requests,
window,
max_keys: max_keys.max(1),
last_sweep: Arc::new(Mutex::new(Instant::now())),
}
}
/// How often the inline capacity sweep may run: at most once per second, or
/// once per window when the window is shorter. Bounds post-flood staleness
/// (a full map self-heals within one interval) without paying the O(max_keys)
/// scan on every capacity miss.
fn sweep_interval(&self) -> Duration {
self.window.min(Duration::from_secs(1))
}
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 {
return true;
}
async fn check(&self, key: &str) -> bool {
let now = Instant::now();
let mut state = self.state.lock().await;
// Fast path: an already-tracked key never allocates and never grows the
// map, so it is unaffected by the key cap.
if let Some(window) = state.get_mut(key) {
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
}
window.timestamps.push(now);
return true;
}
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
// cannot grow the map, and a rejected request never allocates an entry.
if state.len() >= self.max_keys {
// Reclaim expired keys, but at most once per sweep interval — the
// scan is O(max_keys) and the map sits at the cap precisely during a
// distinct-key flood, so sweeping per miss would serialize traffic
// behind a full scan. Between sweeps a new key is simply rejected.
let mut last_sweep = self.last_sweep.lock().await;
if now.duration_since(*last_sweep) >= self.sweep_interval() {
state.retain(|_, w| {
w.timestamps
.retain(|t| now.duration_since(*t) < self.window);
!w.timestamps.is_empty()
});
*last_sweep = now;
}
drop(last_sweep);
if state.len() >= self.max_keys {
return false;
}
}
state.insert(
key.to_string(),
Window {
timestamps: vec![now],
},
);
true
}
/// Non-consuming check: is this key ALREADY at its limit for the current window?
/// Unlike [`check`], it records nothing and never inserts a new key — used to shed
/// expensive preparatory work (e.g. the `/ipfs/{cid}` legacy scan's O(repos) DB
/// preload) BEFORE it runs, without perturbing the per-unit budget the consuming
/// `check` maintains (#173, F3). An unknown key or a disabled limiter is not
/// throttled. Prunes the key's expired timestamps as a side effect (keeps state
/// tidy) but adds none, so it cannot itself fill or grow the map.
pub(crate) async fn is_throttled(&self, key: &str) -> bool {
if self.max_requests == 0 {
let window = state.entry(key.to_string()).or_insert_with(|| Window {
timestamps: Vec::new(),
});
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
}
let now = Instant::now();
let mut state = self.state.lock().await;
if let Some(window) = state.get_mut(key) {
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
return window.timestamps.len() >= self.max_requests;
}
false
window.timestamps.push(now);
true
}
pub async fn cleanup(&self) {
@@ -155,118 +56,6 @@ impl RateLimiter {
!w.timestamps.is_empty()
});
}
/// Number of distinct keys currently tracked. Test-only introspection so a
/// cross-module test can assert that a sweep actually evicted expired entries
/// and observe what it reclaimed. There is no production reader.
#[cfg(test)]
pub(crate) async fn tracked_keys(&self) -> usize {
self.state.lock().await.len()
}
}
/// Per-source concurrency cap derived from the write-pool size: one resolved client
/// key (see [`client_key`]) may hold at most an eighth of the pool, so saturating it
/// takes ~8 distinct keys. Real for an IPv4 or single-address caller; a caller with a
/// routed IPv6 /64 has 2^64 keys, because `client_key` returns the full address with
/// no prefix folding. Narrowing that keying is a deferred decision, so this cap is a
/// bound per key, not a bound per operator.
///
/// Floored at 1 because the value feeds [`PerCallerConcurrency`], where a cap of 0
/// would shed EVERY receive-pack advertisement and break all pushes. The floor is
/// load-bearing at the minimum write-pool size (1), which integer-divides to 0.
pub(crate) fn per_source_push_cap(max_concurrent_git_pushes: usize) -> usize {
(max_concurrent_git_pushes / 8).max(1)
}
/// A bounded per-caller CONCURRENCY limiter — distinct from [`RateLimiter`], which
/// caps request RATE. Each caller key may hold at most `per_caller` in-flight
/// permits at once; beyond that [`try_acquire`](Self::try_acquire) returns `None`
/// and the caller sheds. Used to stop one caller (a single anonymous source-IP or
/// DID) monopolizing the served-git read pool (#174).
///
/// The key map is self-bounding: a key is removed the instant its in-flight count
/// reaches zero, so it never holds more keys than there are concurrently-active
/// callers (itself bounded by the read semaphore). A `max_keys` reject-before-insert
/// backstop guarantees a key farm can never grow the map even if that invariant
/// weakened — a NEW key at the cap is rejected WITHOUT allocating an entry (INV-15).
///
/// Uses a `std::sync::Mutex` (not the file's `tokio::sync::Mutex`) because the
/// permit's `Drop` must release synchronously; the critical section holds no await.
#[derive(Clone)]
pub struct PerCallerConcurrency {
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
per_caller: usize,
max_keys: usize,
}
/// RAII permit from [`PerCallerConcurrency::try_acquire`]. On drop it decrements
/// the caller's in-flight count and removes the key when it reaches zero.
pub struct PerCallerPermit {
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
key: String,
}
impl PerCallerConcurrency {
pub fn new(per_caller: usize, max_keys: usize) -> Self {
Self {
state: Arc::new(std::sync::Mutex::new(HashMap::new())),
per_caller: per_caller.max(1),
max_keys: max_keys.max(1),
}
}
/// Convenience constructor with the default key bound.
pub fn with_default_max_keys(per_caller: usize) -> Self {
Self::new(per_caller, DEFAULT_MAX_KEYS)
}
/// `Some(permit)` when the caller is under its cap and the map has room;
/// `None` (shed) otherwise. Reject-before-insert: a new key at `max_keys` is
/// rejected without allocating.
pub fn try_acquire(&self, key: &str) -> Option<PerCallerPermit> {
// Recover from a poisoned lock rather than panicking: the critical section
// is pure counter arithmetic and cannot itself panic, so a poisoned mutex
// would only ever come from an unrelated abort, and a slightly-off count
// self-heals as permits drop. A panic here would instead brick the limiter
// for every caller (each subsequent lock re-panics).
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
match map.get_mut(key) {
Some(count) => {
if *count >= self.per_caller {
return None;
}
*count += 1;
}
None => {
if map.len() >= self.max_keys {
return None;
}
map.insert(key.to_string(), 1);
}
}
Some(PerCallerPermit {
state: self.state.clone(),
key: key.to_string(),
})
}
#[cfg(test)]
pub fn tracked_keys(&self) -> usize {
self.state.lock().unwrap().len()
}
}
impl Drop for PerCallerPermit {
fn drop(&mut self) {
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(count) = map.get_mut(&self.key) {
*count -= 1;
if *count == 0 {
map.remove(&self.key);
}
}
}
}
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
@@ -279,168 +68,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 {
// Same response as the per-IP brake: a client cannot act on the
// difference between the two buckets, and both need the code that
// tells them apart from the ledger's 429.
return too_many_requests();
}
}
next.run(request).await
}
/// Which forwarded header (if any) the operator's edge is trusted to set. Only
/// a proxy the operator controls may be believed; a raw client can put any
/// value in `Fly-Client-IP` / `X-Forwarded-For`, so trusting them unconditionally
/// lets a flooder rotate the header and never fill a bucket. Configured via
/// `GITLAWB_TRUSTED_PROXY`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TrustedProxy {
/// No trusted proxy: ignore forwarded headers, key on the socket peer IP.
/// Safe default for direct/self-hosted nodes.
None,
/// Behind Fly's edge, which sets (and overwrites any client-supplied)
/// `Fly-Client-IP`.
Fly,
/// Behind a single reverse proxy (e.g. Caddy on the AWS image) that appends
/// the real client as the rightmost `X-Forwarded-For` hop.
XForwardedFor,
}
impl TrustedProxy {
/// Parse `GITLAWB_TRUSTED_PROXY`. Unknown/empty → `None` (trust nothing).
pub fn from_env_value(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"fly" | "fly-client-ip" => TrustedProxy::Fly,
"xff" | "x-forwarded-for" | "caddy" => TrustedProxy::XForwardedFor,
_ => TrustedProxy::None,
}
}
}
fn trimmed_nonempty(v: &str) -> Option<String> {
let t = v.trim();
(!t.is_empty()).then(|| t.to_string())
}
/// Resolve the rate-limit key for a request. In a trusted-proxy mode the
/// operator's edge header is preferred; when that header is missing or empty we
/// fall back to the socket peer address rather than skipping the limiter (a
/// malformed header must never disable the brake). With no trusted proxy the
/// socket peer is always used and forwarded headers are ignored entirely.
/// Returns `None` only when neither a trusted header nor a peer address is
/// available (e.g. a synthetic test request with no `ConnectInfo`).
pub fn client_key(
headers: &HeaderMap,
peer: Option<SocketAddr>,
trust: TrustedProxy,
) -> Option<String> {
let from_header = match trust {
TrustedProxy::None => None,
TrustedProxy::Fly => headers
.get("fly-client-ip")
.and_then(|v| v.to_str().ok())
.and_then(trimmed_nonempty),
// Rightmost hop = the value appended by the immediately-upstream trusted
// proxy. The leftmost hop is client-controlled and must not be trusted.
TrustedProxy::XForwardedFor => headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit(',').next())
.and_then(trimmed_nonempty),
};
from_header.or_else(|| peer.map(|p| p.ip().to_string()))
}
/// Per-client-IP limiter for the git push path, carrying the trusted-proxy
/// policy. A newtype so it can live in request extensions (keyed by type)
/// alongside the per-DID [`RateLimiter`]. Per-DID limits are useless against a
/// push flood from a DID farm (one throwaway DID per repo), so the push path
/// throttles on the resolved client IP instead.
#[derive(Clone)]
pub struct IpRateLimiter {
pub limiter: RateLimiter,
pub trust: TrustedProxy,
/// Exempt GET/HEAD from this bucket. The signed-write brake classifies
/// whole routers, but `write_routes` chains PUT/DELETE/GET on one path
/// (`/api/v1/repos/{owner}/{repo}/visibility`, `server.rs`), and `gl
/// visibility list` deliberately signs that owner-only GET — so without
/// this the read spends the write quota and a normal listing 429s after a
/// few writes from the same IP (one NAT egress makes the coupling vivid).
/// The method check is the same general form `consume_signature` uses for
/// its ledger skip: a read has no write side effect for the brake to
/// protect. Leave this `false` on any limiter whose job IS braking reads —
/// the `/ipfs/{cid}` brake exists precisely to cap GET-driven history
/// walks, so a blanket skip inside `rate_limit_by_ip` would delete it.
pub skip_reads: bool,
}
/// Infallible extractor for the socket peer address from `ConnectInfo`. Yields
/// `None` when the server was started without connect-info (e.g. `oneshot` in
/// tests), so a handler never 500s on its absence — the limiter simply falls
/// back per [`client_key`].
pub struct PeerAddr(pub Option<SocketAddr>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(PeerAddr(
parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0),
))
}
}
/// The shared 429 response for the flood brakes. Route-agnostic: this serves
/// the push path AND the peer-sync routes, so the message stays generic (the
/// offending path is recorded in the warn log at the call site).
///
/// Carries `X-Gitlawb-Error: rate_limited` and the same JSON shape the ledger's
/// `ledger_rejection` uses, because the brake shares 429 with the ledger's
/// `signature_ledger_full` on every route group the brake covers. Without a
/// code the only discriminator between two unrelated conditions is the
/// *absence* of a header, which is exactly what a proxy stripping unknown `X-`
/// headers produces. A client cannot act on a difference it cannot see: `gl`
/// would print "invalid JSON response" for a rate limit, and a peer's sync
/// sender would retry a brake it can never clear inside its budget.
pub fn too_many_requests() -> Response {
let code = NodeDenial::RateLimited.as_str();
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60"), ("X-Gitlawb-Error", code)],
Json(json!({ "error": code, "message": "rate limit exceeded — try again later" })),
)
.into_response()
}
/// Throttle the git push path by resolved client IP. The socket peer address is
/// read from `ConnectInfo` (see `into_make_service_with_connect_info` in
/// `main`). Only skips the limiter when no key can be resolved at all.
pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
let limiter = request.extensions().get::<IpRateLimiter>().cloned();
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0);
if let Some(limiter) = limiter {
// Opt-in read exemption (see `IpRateLimiter::skip_reads`): only a
// limiter that declares itself write-only skips GET/HEAD, so the /ipfs
// read brake and every other read-facing bucket keep braking reads.
let is_read = matches!(*request.method(), Method::GET | Method::HEAD);
if !(limiter.skip_reads && is_read) {
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();
}
}
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response();
}
}
@@ -451,98 +84,6 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
mod tests {
use super::*;
/// The brake shares 429 with the spent-signature ledger's
/// `signature_ledger_full`, so it has to be told apart by something other
/// than the status. It carries the same shape the ledger uses: an
/// `X-Gitlawb-Error` header and a JSON body whose `error` matches it.
#[tokio::test]
async fn brake_429_carries_a_machine_readable_code_and_json_body() {
let resp = too_many_requests();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
Some("60"),
);
assert_eq!(
resp.headers()
.get("x-gitlawb-error")
.and_then(|v| v.to_str().ok()),
Some("rate_limited"),
"a brake 429 must be distinguishable from a ledger 429 by its code",
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("brake body");
let body: serde_json::Value =
serde_json::from_slice(&bytes).expect("brake body must be JSON, not text/plain");
assert_eq!(body["error"], "rate_limited");
assert!(
body["message"]
.as_str()
.is_some_and(|m| m.contains("rate limit exceeded")),
"body: {body}",
);
}
#[test]
fn per_caller_concurrency_caps_one_caller_and_frees_on_drop() {
let lim = PerCallerConcurrency::new(2, 100);
let p1 = lim.try_acquire("did:key:zA").expect("first under cap");
let p2 = lim.try_acquire("did:key:zA").expect("second under cap");
assert!(
lim.try_acquire("did:key:zA").is_none(),
"a third in-flight op for the same caller sheds (over the per-caller cap)"
);
// A DIFFERENT caller is unaffected — the cap is per-caller, not global.
let _other = lim
.try_acquire("did:key:zB")
.expect("a different caller has its own budget");
drop(p1);
assert!(
lim.try_acquire("did:key:zA").is_some(),
"freeing one in-flight slot lets the same caller back in"
);
drop(p2);
}
#[test]
fn per_caller_concurrency_map_is_self_bounding_and_reject_before_insert() {
// Self-bounding: acquire+drop many distinct keys — the map never grows
// because a key is removed the instant its in-flight count hits zero.
let lim = PerCallerConcurrency::new(4, 3);
for i in 0..50 {
let _p = lim.try_acquire(&format!("k{i}"));
}
assert_eq!(
lim.tracked_keys(),
0,
"keys with zero in-flight ops are removed, so an acquire+drop flood leaves the map empty"
);
// Reject-before-insert: HOLD max_keys distinct keys, then a new key sheds
// WITHOUT growing the map past the cap (INV-15 — a rejected request never
// allocates an entry).
let held: Vec<_> = (0..3)
.map(|i| lim.try_acquire(&format!("h{i}")).unwrap())
.collect();
assert_eq!(
lim.tracked_keys(),
3,
"three distinct callers held concurrently"
);
assert!(
lim.try_acquire("h3").is_none(),
"a new key at max_keys is rejected"
);
assert_eq!(
lim.tracked_keys(),
3,
"the rejected new key did not allocate an entry (reject-before-insert)"
);
drop(held);
}
#[tokio::test]
async fn allows_within_limit() {
let limiter = RateLimiter::new(3, Duration::from_secs(60));
@@ -585,346 +126,4 @@ mod tests {
let state = limiter.state.lock().await;
assert!(state.is_empty());
}
#[tokio::test]
async fn zero_limit_disables() {
let limiter = RateLimiter::new(0, Duration::from_secs(60));
for _ in 0..1000 {
assert!(limiter.check("k").await);
}
}
// ── key-cap / memory-bound (P2) ──────────────────────────────────────
#[tokio::test]
async fn caps_tracked_keys_and_rejects_new_ones_when_full() {
// Cap of 2 distinct keys; generous request budget so rejection is due to
// the key cap, not the per-key limit.
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 2);
assert!(limiter.check("a").await);
assert!(limiter.check("b").await);
// Third distinct key would grow the map past the cap → rejected.
assert!(!limiter.check("c").await);
// The map never exceeded the cap, and the rejected key was NOT inserted.
let state = limiter.state.lock().await;
assert_eq!(state.len(), 2);
assert!(!state.contains_key("c"));
}
#[tokio::test]
async fn known_key_unaffected_by_cap() {
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 1);
assert!(limiter.check("a").await); // fills the single slot
assert!(limiter.check("a").await); // same key still served
assert!(!limiter.check("b").await); // new key rejected — cap full
}
#[tokio::test]
async fn expired_keys_evicted_to_admit_new_when_full() {
let limiter = RateLimiter::new_bounded(100, Duration::from_millis(40), 1);
assert!(limiter.check("old").await);
tokio::time::sleep(Duration::from_millis(55)).await;
// "old" is now expired; a new key triggers inline eviction and is admitted.
assert!(limiter.check("new").await);
let state = limiter.state.lock().await;
assert!(state.contains_key("new"));
assert!(!state.contains_key("old"));
}
#[tokio::test]
async fn capacity_sweep_is_amortized_within_interval() {
// The O(max_keys) eviction scan must not run on every capacity miss.
// With a 1s sweep interval, the resident (unexpired) key is never
// evicted by a burst of misses, so repeated new keys are all rejected
// without disturbing existing state.
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(1), 1);
assert!(limiter.check("resident").await);
for i in 0..100 {
assert!(
!limiter.check(&format!("flood-{i}")).await,
"new key must be rejected while the single slot is held"
);
}
let state = limiter.state.lock().await;
assert_eq!(state.len(), 1, "no flood key was ever inserted");
assert!(state.contains_key("resident"));
}
// ── client_key / trusted-proxy resolution (P1 + P2) ─────────────────
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
v.parse().unwrap(),
);
}
h
}
fn peer(s: &str) -> Option<SocketAddr> {
Some(s.parse().unwrap())
}
#[test]
fn none_mode_ignores_headers_and_uses_peer() {
// Even a well-formed Fly header is ignored without a trusted proxy.
let h = headers(&[("fly-client-ip", "203.0.113.7")]);
assert_eq!(
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::None).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn fly_mode_trusts_fly_header() {
let h = headers(&[
("fly-client-ip", "203.0.113.7"),
("x-forwarded-for", "1.2.3.4"),
]);
assert_eq!(
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::Fly).as_deref(),
Some("203.0.113.7")
);
}
#[test]
fn fly_mode_empty_header_falls_back_to_peer_not_skip() {
// Empty Fly-Client-IP must NOT collapse traffic onto Some("") nor skip
// the limiter — it falls back to the real peer.
let h = headers(&[("fly-client-ip", "")]);
assert_eq!(
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::Fly).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn xff_mode_uses_rightmost_hop_not_client_controlled_first() {
// Client prepends spoofed hops; only the rightmost (proxy-appended) is trusted.
let h = headers(&[("x-forwarded-for", "9.9.9.9, 8.8.8.8, 198.51.100.9")]);
assert_eq!(
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn xff_mode_empty_leading_hop_does_not_disable_brake() {
// "X-Forwarded-For: ,1.2.3.4" — rightmost hop is used; never None-skips.
let h = headers(&[("x-forwarded-for", ",1.2.3.4")]);
assert_eq!(
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
Some("1.2.3.4")
);
}
#[test]
fn malformed_xff_falls_back_to_peer() {
let h = headers(&[("x-forwarded-for", " , ")]);
assert_eq!(
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::XForwardedFor).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn trusted_proxy_parsing() {
assert_eq!(TrustedProxy::from_env_value("fly"), TrustedProxy::Fly);
assert_eq!(
TrustedProxy::from_env_value("X-Forwarded-For"),
TrustedProxy::XForwardedFor
);
assert_eq!(
TrustedProxy::from_env_value("caddy"),
TrustedProxy::XForwardedFor
);
assert_eq!(TrustedProxy::from_env_value(""), TrustedProxy::None);
assert_eq!(TrustedProxy::from_env_value("garbage"), TrustedProxy::None);
}
// ── middleware 429 path ─────────────────────────────────────────────
/// A minimal router with the push limiter layered over an OK handler,
/// driven via `oneshot`. `ConnectInfo` is attached to each request directly
/// (as the production make-service does) so the middleware resolves a peer.
fn ip_limited_router(limiter: IpRateLimiter) -> axum::Router {
axum::Router::new()
.route(
"/o/r/git-receive-pack",
axum::routing::post(|| async { StatusCode::OK }),
)
.layer(axum::middleware::from_fn(rate_limit_by_ip))
.layer(axum::Extension(limiter))
}
async fn post_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/o/r/git-receive-pack")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn middleware_returns_429_over_limit() {
let router = ip_limited_router(IpRateLimiter {
limiter: RateLimiter::new(2, Duration::from_secs(60)),
trust: TrustedProxy::None,
skip_reads: false,
});
let peer: SocketAddr = "203.0.113.7:5000".parse().unwrap();
// Two requests inside the budget, the third over it (shared Arc state).
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
assert_eq!(
post_from(&router, peer).await,
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn middleware_isolates_distinct_peers() {
let router = ip_limited_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::None,
skip_reads: false,
});
let a: SocketAddr = "203.0.113.1:1".parse().unwrap();
let b: SocketAddr = "203.0.113.2:1".parse().unwrap();
assert_eq!(post_from(&router, a).await, StatusCode::OK);
assert_eq!(post_from(&router, b).await, StatusCode::OK); // independent bucket
assert_eq!(post_from(&router, a).await, StatusCode::TOO_MANY_REQUESTS);
}
/// `skip_reads` mechanics, isolated from the production router: on a
/// limiter that declares itself write-only, GET and HEAD never touch the
/// bucket — they pass an already-exhausted one AND never debit it — while
/// a mutation from the same peer stays braked. On a limiter that does not
/// opt in (the /ipfs read brake), GET is braked exactly as before. The
/// production wiring (the signed-write brake on `write_routes`, whose one
/// GET is the signed visibility listing) is proven end-to-end by
/// `signed_write_brake_rejects_writes_but_serves_visibility_get` in
/// `test_support`; this pins the middleware seam it rides on.
#[tokio::test]
async fn skip_reads_exempts_get_and_head_but_still_brakes_writes() {
use tower::ServiceExt;
let router = |skip_reads: bool| {
axum::Router::new()
.route(
"/thing",
axum::routing::get(|| async { StatusCode::OK })
.head(|| async { StatusCode::OK })
.put(|| async { StatusCode::OK }),
)
.layer(axum::middleware::from_fn(rate_limit_by_ip))
.layer(axum::Extension(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::None,
skip_reads,
}))
};
let peer: SocketAddr = "203.0.113.9:5000".parse().unwrap();
let send = |router: &axum::Router, method: axum::http::Method| {
let mut req = axum::http::Request::builder()
.method(method)
.uri("/thing")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
let router = router.clone();
async move { router.oneshot(req).await.unwrap().status() }
};
// Opted-in limiter: the PUT spends the single slot...
let r = router(true);
assert_eq!(send(&r, axum::http::Method::PUT).await, StatusCode::OK);
// ...reads from the same peer still pass the exhausted bucket...
assert_eq!(send(&r, axum::http::Method::GET).await, StatusCode::OK);
assert_eq!(send(&r, axum::http::Method::HEAD).await, StatusCode::OK);
// ...and did not debit it behind the scenes some other way — the next
// mutation is still the one that gets braked.
assert_eq!(
send(&r, axum::http::Method::PUT).await,
StatusCode::TOO_MANY_REQUESTS,
"the write brake must keep braking writes while exempting reads"
);
// Non-opted-in limiter: GET is braked like any other method, so the
// exemption cannot silently spread to read-braking buckets like /ipfs.
let r = router(false);
assert_eq!(send(&r, axum::http::Method::GET).await, StatusCode::OK);
assert_eq!(
send(&r, axum::http::Method::GET).await,
StatusCode::TOO_MANY_REQUESTS,
"a limiter that did not opt in must keep braking reads"
);
}
// ── creation-route layering: IP brake runs BEFORE auth ──────────────
/// A minimal stand-in for the creation route group: an inner "auth" layer
/// that rejects every request with 401 (as signature verification would for
/// an unsigned caller), wrapped by the per-IP creation limiter as the
/// outermost layer — exactly the ordering `server::build_router` applies to
/// `/api/v1/repos` et al. Lets us assert the security-relevant property
/// (flood traffic is braked before auth burns CPU) without a database, which
/// the full `#[sqlx::test]` route test in `api::repos` cannot cover in the
/// plain unit-test pass.
fn ip_limited_over_auth_router(limiter: IpRateLimiter) -> axum::Router {
async fn always_unauthorized(_req: Request, _next: Next) -> Response {
StatusCode::UNAUTHORIZED.into_response()
}
axum::Router::new()
.route(
"/api/v1/repos",
axum::routing::post(|| async { StatusCode::CREATED }),
)
.layer(axum::middleware::from_fn(always_unauthorized))
.layer(axum::middleware::from_fn(rate_limit_by_ip))
.layer(axum::Extension(limiter))
}
async fn post_repos_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/api/v1/repos")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn creation_ip_brake_rejects_before_auth() {
// A DID farm mints a fresh signature per repo, so the per-DID limiter
// never trips; the per-IP brake is what stops the flood. It must run
// outermost, ahead of auth, so a flood is rejected with 429 before
// signature verification — otherwise every request would reach (and pay
// for) auth and merely 401.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::None,
skip_reads: false,
});
let peer: SocketAddr = "203.0.113.42:7000".parse().unwrap();
// First request passes the IP brake and reaches the (failing) auth layer.
assert_eq!(
post_repos_from(&router, peer).await,
StatusCode::UNAUTHORIZED
);
// Budget now exhausted: the IP brake short-circuits with 429 *before*
// auth — proving the limiter is the outermost layer.
assert_eq!(
post_repos_from(&router, peer).await,
StatusCode::TOO_MANY_REQUESTS,
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
);
}
}
+27 -227
View File
@@ -20,19 +20,8 @@ use crate::auth;
use crate::rate_limit;
use crate::state::AppState;
async fn graphql_handler(
State(state): State<AppState>,
auth: Option<axum::Extension<crate::auth::AuthenticatedDid>>,
req: GraphQLRequest,
) -> GraphQLResponse {
// `optional_signature` attaches the verified DID when a signature is present.
// Thread it into request-scoped GraphQL data; mutations enforce its presence
// in-resolver (N2) while queries stay open.
let mut inner = req.into_inner();
if let Some(axum::Extension(did)) = auth {
inner = inner.data(did);
}
state.graphql_schema.execute(inner).await.into()
async fn graphql_handler(State(state): State<AppState>, req: GraphQLRequest) -> GraphQLResponse {
state.graphql_schema.execute(req.into_inner()).await.into()
}
async fn graphql_playground() -> impl IntoResponse {
@@ -42,33 +31,12 @@ async fn graphql_playground() -> impl IntoResponse {
))
}
/// Applies the standard auth middleware stack to a router. Axum runs the LAST
/// `.layer` first, so the three below run in this order for every matched
/// request: `require_signature` (verifies the RFC 9421 signature, sets
/// `AuthenticatedDid` and `SignatureIdentity`), then `require_ucan_chain`
/// (reads the DID), then `consume_signature` (spends the signature).
///
/// The ledger runs last of the three on purpose. If it ran before
/// `require_ucan_chain`, a request with a good signature but a rejected UCAN
/// would burn its key and the client could not retry those exact bytes.
/// Consuming before the handler, rather than inside it, is what closes the
/// concurrent-replay race: two copies of the same signature race the ledger, not
/// the handler.
///
/// It is not the last check overall, so some rejections do still cost a ledger
/// row. Any layer added inside the router passed in runs AFTER the ledger
/// (`creation_routes` puts `rate_limit_by_did` there), and every check the
/// handler itself makes (the iCaptcha 403, ownership, existence) runs later
/// still. A request refused by the per-DID throttle, by iCaptcha, or by the
/// handler has already spent its signature and must re-sign to retry. Only a
/// per-IP brake layered outside this stack rejects a request before the ledger
/// is charged.
/// Applies the standard auth middleware pair to a router: HTTP Signature verification
/// followed by UCAN chain validation. The two layers run in this order for every
/// matched request: `require_signature` first (sets `AuthenticatedDid`), then
/// `require_ucan_chain` (reads it).
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
router
.layer(middleware::from_fn_with_state(
state.clone(),
auth::consume_signature,
))
.layer(middleware::from_fn_with_state(
state,
auth::require_ucan_chain,
@@ -81,34 +49,8 @@ pub fn build_router(state: AppState) -> Router {
let schema = state.graphql_schema.as_ref().clone();
let graphql_routes = Router::new()
.route("/graphql", get(graphql_playground).post(graphql_handler))
// Attach the verified DID to /graphql when a signature is present. The
// layer covers only routes added before it, so /graphql/ws (added after,
// read-only subscriptions) stays open.
.layer(middleware::from_fn(auth::optional_signature))
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
// ── Per-IP flood brake for the signed write routes ────────────────────
// Every signed attempt on a route wrapped in `add_auth_layers` commits a
// `consumed_signatures` row before the handler runs any authorization or
// existence check, and `require_signature` resolves the key from the did:key
// itself, so any freshly minted keypair can force that durable write on a
// repo it does not own or that does not exist. The five groups below carried
// no brake at all; this one caps them on the resolved client IP. Its own
// bucket, so a flood here cannot drain the creation, push, or peer-sync
// quotas. Each group layers it OUTSIDE `add_auth_layers` (axum runs the last
// `.layer` first) so an over-limit request is rejected before signature
// verification burns CPU and before the ledger is charged.
// `skip_reads`: `write_routes` chains PUT/DELETE/GET on the visibility
// path, and `gl visibility list` signs that owner-only GET. A write-only
// brake must not let reads spend the write quota (or a listing starts
// 429ing behind a shared NAT after a handful of writes), mirroring the
// method skip `consume_signature` applies to the ledger for the same GET.
let signed_write_ip_limiter = rate_limit::IpRateLimiter {
limiter: state.signed_write_rate_limiter.clone(),
trust: state.push_limiter_trust,
skip_reads: true,
};
// ── Task routes (write — require HTTP Signature) ───────────────────────
let task_write_routes = add_auth_layers(
Router::new()
@@ -117,29 +59,15 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(signed_write_ip_limiter.clone()));
);
// ── Task routes (read — open) ──────────────────────────────────────────
let task_read_routes = Router::new()
.route("/api/v1/tasks", get(tasks::list_tasks))
.route("/api/v1/tasks/{id}", get(tasks::get_task));
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
// single identity; the per-IP limiter (outer) caps a DID farm that mints a
// fresh throwaway did:key per repo to slip past both the per-DID limit and
// the iCaptcha gate — the mechanism behind the recurring spam-repo floods.
// The per-IP layer wraps the auth layer (outermost = runs first) so flood
// traffic is rejected before signature verification burns CPU, matching the
// push path.
// ── Rate-limited creation routes — require HTTP Signature + per-DID throttle
let limiter = state.rate_limiter.clone();
let create_ip_limiter = rate_limit::IpRateLimiter {
limiter: state.create_ip_rate_limiter.clone(),
trust: state.push_limiter_trust,
skip_reads: false,
};
let creation_routes = add_auth_layers(
Router::new()
.route("/api/v1/repos", post(repos::create_repo))
@@ -153,11 +81,9 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(limiter)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(create_ip_limiter));
);
// ── Write routes — require HTTP Signature, plus the signed-write IP brake ─
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
let write_routes = add_auth_layers(
Router::new()
.route(
@@ -221,30 +147,15 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::put(visibility::set_visibility)
.delete(visibility::remove_visibility)
.get(visibility::list_visibility),
)
.route(
"/api/v1/agents/{did}",
axum::routing::delete(agents::deregister_agent),
),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(signed_write_ip_limiter.clone()));
);
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
// routes only — all other API routes keep axum's default 2 MB cap.
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
// helper signs requests with RFC 9421 signatures using the agent's keypair.
let pack_limit = state.config.max_pack_bytes;
// Per-IP throttle wraps the auth layer (outermost = runs first): flood
// traffic is rejected before signature verification burns CPU. Per-DID
// limiting is deliberately NOT used here — a DID farm (one throwaway
// identity per repo, as in the June 2026 push flood) never trips it.
let push_limiter = rate_limit::IpRateLimiter {
limiter: state.push_rate_limiter.clone(),
trust: state.push_limiter_trust,
skip_reads: false,
};
let git_write_routes = add_auth_layers(
Router::new()
.route(
@@ -254,31 +165,12 @@ pub fn build_router(state: AppState) -> Router {
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(push_limiter));
);
// ── IPFS content-addressed retrieval and pin listing ──────────────────
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
// identity and can apply per-repo visibility (#110); anonymous callers stay
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
// stays unsigned — gating the pin index is tracked separately (#121).
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
// request can drive a full-history git walk, so the per-IP rate limiter is the
// outermost layer (rejects a flood before the walk-admission work), mirroring the
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
let ipfs_limiter = rate_limit::IpRateLimiter {
limiter: state.ipfs_rate_limiter.clone(),
trust: state.push_limiter_trust,
skip_reads: false,
};
let ipfs_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.layer(middleware::from_fn(auth::optional_signature))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(ipfs_limiter))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
// ── Arweave permanent anchors ──────────────────────────────────────────
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
@@ -308,9 +200,7 @@ pub fn build_router(state: AppState) -> Router {
post(bounties::dispute_bounty),
),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(signed_write_ip_limiter.clone()));
);
// ── Bounty routes (read — open) ──────────────────────────────────────
let bounty_read_routes = Router::new()
@@ -324,18 +214,15 @@ pub fn build_router(state: AppState) -> Router {
.route(
"/api/v1/agents/{did}/bounties",
get(bounties::agent_bounty_stats),
)
.layer(middleware::from_fn(auth::optional_signature));
);
// ── Profile routes (write — require HTTP Signature) ─────────────────
let profile_write_routes = add_auth_layers(
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(signed_write_ip_limiter.clone()));
);
// ── Issue routes (write — require HTTP Signature) ────────────────────
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
let issue_write_routes = add_auth_layers(
Router::new()
.route(
@@ -347,9 +234,7 @@ pub fn build_router(state: AppState) -> Router {
post(issues::create_issue_comment),
),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(signed_write_ip_limiter));
);
// ── Peer discovery routes ─────────────────────────────────────────────
// Peer writes accept signatures when present and can require them after a
@@ -358,43 +243,15 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/peers", get(peers::list_peers))
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer));
// /sync/trigger drives an O(peers) outbound fan-out + per-repo enqueue, so it
// ALWAYS requires a signature (both config modes) and carries a tight per-IP
// brake. A signature alone does not cap cost — a did:key farm self-registers
// (INV-10) — so the IP brake is a separate, load-bearing half. The brake is
// outermost (runs before signature verification burns CPU) and is keyed on the
// client IP before any DID is read, so DID rotation cannot bypass it.
let sync_trigger_routes = add_auth_layers(
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
limiter: state.sync_trigger_rate_limiter.clone(),
trust: state.push_limiter_trust,
skip_reads: false,
}));
// announce + notify keep their rolling-upgrade signature behavior (unsigned
// accepted until all peers upgrade), but both reach peer-write side effects —
// notify hits the same enqueue_sync sink as trigger — so they carry a per-IP
// brake too, on a SEPARATE bucket from trigger's, so an unsigned notify flood
// cannot drain the signed trigger caller's quota.
let mut peer_write_routes = Router::new()
.route("/api/v1/peers/announce", post(peers::announce))
.route("/api/v1/sync/trigger", post(peers::trigger_sync))
.route("/api/v1/sync/notify", post(peers::notify_sync));
peer_write_routes = if state.config.require_signed_peer_writes {
add_auth_layers(peer_write_routes, state.clone())
} else {
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
};
let peer_write_routes = peer_write_routes
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
limiter: state.peer_write_rate_limiter.clone(),
trust: state.push_limiter_trust,
skip_reads: false,
}));
// ── Read routes — open for public repos ───────────────────────────────
let read_routes = Router::new()
@@ -485,8 +342,7 @@ pub fn build_router(state: AppState) -> Router {
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
get(replicas::list_replicas),
)
.layer(middleware::from_fn(auth::optional_signature));
);
// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
// large pack responses from the server don't get truncated on the client side.
@@ -500,22 +356,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/repos/{owner}/{repo}/withheld-paths",
axum::routing::get(visibility::withheld_paths),
)
.route(
"/api/v1/repos/{owner}/{repo}/icaptcha-proof",
axum::routing::get(repos::get_icaptcha_proof),
)
.route(
"/api/v1/repos/{owner}/{repo}/encrypted-blobs",
axum::routing::get(crate::api::encrypted::list_encrypted_blobs),
)
.route(
"/api/v1/repos/{owner}/{repo}/encrypted-blob/{oid}",
axum::routing::get(crate::api::encrypted::get_encrypted_blob),
)
.route(
"/api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate",
axum::routing::get(crate::api::encrypted::replicate_encrypted_blobs),
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::optional_signature));
@@ -524,7 +364,6 @@ pub fn build_router(state: AppState) -> Router {
let meta_routes = Router::new()
.route("/", get(node_info))
.route("/health", get(health))
.route("/ready", get(ready))
.route("/api/v1/p2p/info", get(p2p_info))
.route("/api/v1/stats", get(stats))
.route("/api/v1/contracts", get(contracts_info));
@@ -544,7 +383,6 @@ pub fn build_router(state: AppState) -> Router {
.merge(read_routes)
.merge(peer_read_routes)
.merge(peer_write_routes)
.merge(sync_trigger_routes)
.merge(ipfs_routes)
.merge(arweave_routes)
.merge(meta_routes)
@@ -567,26 +405,6 @@ async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok" }))
}
/// Readiness = "this node can serve real traffic", and every real endpoint
/// depends on the database, so probe it rather than reporting a constant.
/// The probe gets its own short bound so a wedged pool can't hang the health
/// check for the full acquire timeout.
async fn ready(State(state): State<AppState>) -> axum::response::Response {
let probe = tokio::time::timeout(std::time::Duration::from_secs(2), state.db.ping()).await;
match probe {
Ok(Ok(())) => Json(json!({ "status": "ready" })).into_response(),
_ => (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"status": "degraded",
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})),
)
.into_response(),
}
}
async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
let p2p_peer_id = state.p2p.as_ref().map(|h| h.local_peer_id.to_string());
Json(json!({
@@ -601,31 +419,13 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
}))
}
pub(crate) async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
// Count only the repos an anonymous caller could list, so the aggregate does
// not leak the existence of private/mode-A repos (#104 count oracle). Mirror
// the listing seam (api/repos.rs): over-fetch the deduped set, batch-load the
// visibility rules, and keep rows that pass listable_at_root. The caller is
// always None — meta_routes carries no auth layer (see the route group in this
// file). Fail closed: any DB error collapses the whole count to 0 (an
// under-count never leaks existence), preserving the prior `.unwrap_or(0)`.
let repos = async {
// stats only needs the count, so use the no-stars deduped list (same
// DEDUP_CTE) and skip the repo_stars aggregation the listing path needs.
let rows = state.db.list_all_repos_deduped().await?;
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
let count = rows
.iter()
.filter(|r| {
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
})
.count() as i64;
Ok::<i64, anyhow::Error>(count)
}
.await
.unwrap_or(0);
async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
let repos = state
.db
.list_all_repos()
.await
.map(|r| r.len() as i64)
.unwrap_or(0);
let agents = state.db.count_agents().await.unwrap_or(0);
let pushes = state.db.count_pushes().await.unwrap_or(0);
Json(json!({
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -506
View File
@@ -4,9 +4,7 @@
//! based on the repo's visibility rules with a fallback to the legacy
//! `is_public` flag. It performs no I/O so it is exhaustively unit tested.
use crate::db::{RepoRecord, VisibilityRule};
use std::collections::HashMap;
use unicode_normalization::UnicodeNormalization;
use crate::db::VisibilityRule;
#[derive(Debug, PartialEq, Eq)]
pub enum Decision {
@@ -14,23 +12,11 @@ pub enum Decision {
Deny,
}
/// NFC-normalize a glob prefix or path so the matcher compares canonically
/// equivalent strings byte-for-byte. Without this, a deny rule authored NFC
/// (`é` = U+00E9) byte-compares unequal to a path committed NFD (`e` + U+0301)
/// and the blob slips past the rule on the replication path (#101). NFC, not
/// NFKC: compatibility folding (ligatures, full-width forms) would merge paths
/// the filesystem treats as distinct and over-withhold. Both sides of every
/// comparison must pass through this, or the skew just moves.
fn nfc(s: &str) -> String {
s.nfc().collect()
}
/// True if `caller` is the repo owner. Uses [`crate::api::did_matches`] so the
/// owner check is representation-agnostic within `did:key` (full `did:key:z...`
/// matches bare `z...` stored by mirror rows) while still denying cross-method
/// collisions (`did:gitlawb:z...` vs bare `z...`).
/// True if `caller` is the repo owner (matches full did:key or its short form),
/// mirroring the owner-match idiom in `api/protect.rs`.
fn is_owner(owner_did: &str, caller: &str) -> bool {
crate::api::did_matches(owner_did, caller)
let owner_short = owner_did.split(':').next_back().unwrap_or(owner_did);
caller == owner_did || caller == owner_short
}
/// The match prefix for a glob: "/" stays "/", "/secret/**" becomes "/secret".
@@ -50,17 +36,12 @@ fn glob_matches(glob: &str, path: &str) -> bool {
if prefix == "/" {
return true;
}
// Compare in NFC so an NFC rule matches a canonically-equivalent NFD path
// (and vice versa). Both sides normalized here — the single matcher seam.
let prefix = nfc(prefix);
let path = nfc(path);
path == prefix || path.starts_with(&format!("{prefix}/"))
}
/// Specificity = length of the (normalized) match prefix; longer is more
/// specific. Normalized so ranking stays consistent with `glob_matches`.
/// Specificity = length of the match prefix; longer is more specific.
fn specificity(glob: &str) -> usize {
nfc(glob_prefix(glob)).len()
glob_prefix(glob).len()
}
/// Decide whether `caller` (None = anonymous) may read `path` in a repo.
@@ -115,119 +96,6 @@ pub fn visibility_check(
}
}
/// Whether `caller` (None = anonymous) may see a repo in a listing — the `"/"`
/// visibility decision, shared by every repo-listing surface (REST list,
/// federated list, GraphQL `repos`) so they enforce one rule, not three drifting
/// copies. Not a bare `is_public` test: a repo can be `is_public=false` with a
/// root rule granting readers, or `is_public=true` with a root deny (#97).
pub fn listable_at_root(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> bool {
visibility_check(rules, is_public, owner_did, caller, "/") == Decision::Allow
}
/// Whether a single `received_ref_updates` row (identified by its peer-supplied
/// `row_repo` slug) should be shown to `caller` (None = anonymous) on the
/// cross-repo ref-updates feeds (#112 GraphQL, #114 REST).
///
/// Pure and I/O-free: both call sites load the deduped local repo set and its
/// visibility rules once per request and pass them in, so the gate logic lives
/// here and visibility.rs keeps its "no I/O" property.
///
/// The slug is written verbatim from the inbound gossip/notify message, so it is
/// untrusted input, not a canonical key. The decision is fail-closed by
/// construction: the only KEEP paths are (a) a slug with no `/` (cannot name a
/// local `owner/name` pair, so remote by definition), (b) all matched local
/// records are readable, and (c) no local record matches (remote/gossip-only).
/// Any local match the caller cannot read at root DROPs the row. There is no
/// catch-all keep on unexpected state.
///
/// Slug/record owner keys are matched prefix-tolerantly (one is a prefix of the
/// other), covering the exact short-key, the full `did:key:` form, and the
/// URL-truncated 8-char form. Prefix over-match can only over-drop a genuinely
/// remote row (fail-safe), never over-serve a private local one.
///
/// The live call sites are the #112 (GraphQL) and #114 (REST) feed handlers,
/// added in the following units; exercised by the unit tests below meanwhile.
pub fn ref_update_row_visible(
deduped: &[RepoRecord],
rules_by_repo: &HashMap<String, Vec<VisibilityRule>>,
caller: Option<&str>,
row_repo: &str,
) -> bool {
// A slug with no '/' cannot name a local owner/name pair — remote by
// definition (same branch as "matches nothing local") → KEEP.
if row_repo.rsplit_once('/').is_none() {
return true;
}
// Match each local record with the shared slug predicate (one matcher, so
// this gate and the collector's quarantine drop cannot disagree about which
// rows a repo owns), then fail closed on any matched record the caller
// cannot read at root.
for record in deduped {
if !ref_update_row_names_repo(record, row_repo) {
continue;
}
let rules = rules_by_repo
.get(&record.id)
.map(Vec::as_slice)
.unwrap_or(&[]);
if !listable_at_root(rules, record.is_public, &record.owner_did, caller) {
return false;
}
}
// Reached only if every matched local record is readable, or nothing local
// matched (remote/gossip-only). Both are the KEEP paths; there is no
// default-keep on unexpected state — an unreadable match already returned.
true
}
/// Whether `row_repo`'s peer-supplied slug names the local `record`. The single
/// match predicate shared by the feed gate ([`ref_update_row_visible`]) and the
/// quarantine hard-drop in the ref-update collector, so the two cannot diverge
/// about which rows a repo owns — a second, drifting matcher is exactly the #134
/// slug-collision class.
///
/// A record matches when the slug's `name` half is equal and one owner key is a
/// prefix of the other (prefix-tolerant: exact short key, full `did:key:` form,
/// and the URL-truncated 8-char form). Both the slug owner and the record owner
/// are reduced to their last ':'-delimited segment before comparing. The node's
/// own emitter builds the wire slug via `normalize_owner_key` (bare short key for
/// a single-segment `did:key:`, the full DID for every other method; see
/// api/repos.rs), but the stored slug is UNTRUSTED: a peer can broadcast a row
/// under any owner form, including a method-stripped `user/name`. Reducing both
/// sides to the trailing segment matches a repo's own rows whichever form keyed
/// them, and still catches an attacker-planted short slug.
///
/// This intentionally diverges from `did_matches` / `DEDUP_CTE`, which strip only
/// bare `did:key:` and keep other DID methods whole: those compare trusted,
/// canonical DIDs, while this slug is untrusted. Applying the keep-whole rule here
/// would fail open: a hostile `user/name` slug would not prefix-match a private
/// `did:web:host:user` record and would leak. The price is a fail-SAFE over-match
/// when a remote owner shares both a trailing segment and a repo name with a local
/// private repo (negligible for full did:key ids; only did:web / truncated forms
/// collide): it can hide a genuinely remote row, never serve a private one.
pub fn ref_update_row_names_repo(record: &RepoRecord, row_repo: &str) -> bool {
let Some((owner_part, name)) = row_repo.rsplit_once('/') else {
return false;
};
if record.name != name {
return false;
}
let row_key = owner_part.split(':').next_back().unwrap_or(owner_part);
let record_key = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
record_key.starts_with(row_key) || row_key.starts_with(record_key)
}
/// The subtree path globs that `caller` (None = anonymous) may NOT read, given
/// the repo's rules. Whole-repo ("/") rules are excluded: a denied whole-repo
/// read is handled by the 404 gate before a clone ever starts. Each remaining
@@ -400,85 +268,6 @@ mod tests {
);
}
#[test]
fn non_key_owner_bare_short_does_not_match() {
// A non-key DID owner (did:gitlawb:z6MkFoo) probed with the bare last
// segment (z6MkFoo) must NOT match — the old is_owner would return true
// via split(':').next_back(), which this PR tightens.
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, false, "did:gitlawb:z6MkFoo", Some("z6MkFoo"), "/"),
Decision::Deny,
"bare short must not match a non-key DID owner"
);
// The full non-key DID still matches itself.
assert_eq!(
visibility_check(
&rules,
false,
"did:gitlawb:z6MkFoo",
Some("did:gitlawb:z6MkFoo"),
"/"
),
Decision::Allow,
"full non-key DID still matches itself"
);
}
// #153 forward guard: is_owner must match across bare-key / full-did:key
// representations should someone revert to single-side normalize_owner_key.
#[test]
fn bare_owner_full_caller_allows_owner() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, false, "z6MkOwner", Some("did:key:z6MkOwner"), "/"),
Decision::Allow,
"bare owner + full did:key caller must match (guard against single-side revert)"
);
}
// #135 T1: a Mode-B rule on `/secret/**` must DENY the withheld directory's
// OWN path `/secret` (the `path == prefix` arm), not just strict descendants —
// otherwise get_by_cid's tree gate would serve the /secret tree object and leak
// its children. Pins parity with get_tree, which denies the /secret path.
#[test]
fn subtree_rule_denies_the_withheld_directory_itself() {
let reader = "did:key:z6MkReader";
let rules = [rule("/secret/**", VisibilityMode::B, &[reader])];
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/secret"),
Decision::Deny,
"anon denied at the withheld directory's OWN path /secret"
);
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/public"),
Decision::Allow,
"anon allowed at a sibling path outside the withheld subtree"
);
assert_eq!(
visibility_check(&rules, true, OWNER, Some(reader), "/secret"),
Decision::Allow,
"listed reader allowed at the withheld directory (caller-aware)"
);
}
// #153 regression: cross-method DID must still be denied even when the
// trailing segment collides with a bare owner key.
#[test]
fn cross_method_did_denied_with_bare_owner() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, false, "z6MkFoo", Some("did:gitlawb:z6MkFoo"), "/"),
Decision::Deny,
"cross-method DID must not match a bare did:key owner"
);
assert_eq!(
visibility_check(&rules, false, "z6MkFoo", Some("did:web:z6MkFoo"), "/"),
Decision::Deny,
"did:web must not match a bare did:key owner"
);
}
#[test]
fn root_rule_allows_listed_reader() {
let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
@@ -582,292 +371,4 @@ mod tests {
true
));
}
// ── ref_update_row_visible (feed gate) ──────────────────────────────────
fn rec(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
RepoRecord {
id: id.into(),
name: name.into(),
owner_did: owner_did.into(),
description: None,
is_public,
default_branch: "main".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: format!("/srv/{id}"),
forked_from: None,
machine_id: None,
}
}
fn rules_for(entries: &[(&str, &[VisibilityRule])]) -> HashMap<String, Vec<VisibilityRule>> {
entries
.iter()
.map(|(id, rs)| (id.to_string(), rs.to_vec()))
.collect()
}
#[test]
fn feed_public_local_repo_kept_for_anon() {
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", true)];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
}
#[test]
fn feed_private_local_repo_dropped_for_anon_kept_for_owner() {
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
// Anonymous → drop.
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
// Owner (full DID) → keep.
assert!(ref_update_row_visible(
&deduped,
&rules,
Some("did:key:z6MkOwner"),
"z6MkOwner/widget"
));
}
#[test]
fn feed_root_rule_reader_reincluded() {
// Private repo (is_public=false) with a root rule granting a named
// reader. Delegates to listable_at_root: anon and non-reader denied,
// named reader allowed.
let deduped = [rec("r1", OWNER, "widget", false)];
let root = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
let rules = rules_for(&[("r1", &root)]);
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
assert!(!ref_update_row_visible(
&deduped,
&rules,
Some("did:key:z6MkStranger"),
"z6MkOwner/widget"
));
assert!(ref_update_row_visible(
&deduped,
&rules,
Some("did:key:z6MkFriend"),
"z6MkOwner/widget"
));
}
#[test]
fn feed_alias_full_did_slug_dropped_for_anon() {
// Owner stored full-DID; slug also carries the full-DID form. Still
// matches (row_key normalizes to the short key) → drop. Round 1's
// string-match would have leaked this.
let deduped = [rec("r1", "did:key:zABC", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"did:key:zABC/widget"
));
}
#[test]
fn feed_truncated_key_slug_dropped_for_anon() {
// Slug carries an 8-char URL-truncated prefix of the owner key; still
// matches via prefix tolerance → drop. Round 2's get_repo path leaked.
let deduped = [rec("r1", "did:key:zABCDEFGH", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"zABCDEF/widget"
));
}
#[test]
fn feed_mirror_coexistence_private_canonical_dropped_for_anon() {
// Pure-level mirror-coexistence: the deduped set contains only the
// private canonical record for (owner,name). A matching slug drops for
// anon. (DB-level dedup survivor property is pinned separately.)
let deduped = [rec("uuid-canonical", "did:key:z6Mkwbud", "nipmod", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"z6Mkwbud/nipmod"
));
}
#[test]
fn feed_empty_owner_slug_matches_and_drops() {
// Slug "/name": empty owner_part → row_key "" → starts_with("") matches
// every same-named record. Fail-safe pin for a private repo.
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(&deduped, &rules, None, "/widget"));
}
#[test]
fn feed_one_char_owner_slug_matches_and_drops() {
// 1-char owner prefix that the private repo's key starts with → match.
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(&deduped, &rules, None, "z/widget"));
}
#[test]
fn feed_remote_slug_no_match_kept() {
// Different owner key, no prefix relation → no local match → keep.
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"zZZZOTHER/widget"
));
}
#[test]
fn feed_private_didweb_short_slug_dropped_for_anon() {
// A private did:web repo must drop its row for anon when the slug arrives
// in the short `{last-segment}/{name}` form. Post-#141 the emitter no longer
// produces this form for did:web (normalize_owner_key keeps the full DID),
// so this is the untrusted/crafted variant a peer can broadcast; the full-DID
// variant the emitter now sends is covered by the next test. A did:key-aware
// (keep-whole) rule would fail to match `alice` against `did:web:host:alice`
// and leak.
let deduped = [rec("r1", "did:web:host:alice", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"alice/widget"
));
}
#[test]
fn feed_multi_segment_did_slug_dropped_for_anon() {
// A private repo owned by a multi-segment DID must also fail closed under
// the full-DID slug form. Post-#141 this IS the form the emitter broadcasts
// (normalize_owner_key keeps non-did:key methods whole), and a peer can craft
// it too. The gate drops it: both sides reduce to the last ':' segment
// ("user"), so they match. Fail-safe regardless of which form keyed the row.
let deduped = [rec("r1", "did:web:host:user", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"did:web:host:user/widget"
));
}
#[test]
fn feed_multi_segment_did_slug_kept_for_public() {
// Symmetric keep-side: a PUBLIC multi-segment-DID repo's row must still be
// returned to anon after the last-':'-segment normalization — guards
// against a regression that over-drops legitimate did:web rows.
let deduped = [rec("r1", "did:web:host:user", "widget", true)];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"did:web:host:user/widget"
));
}
#[test]
fn feed_malformed_slug_no_slash_kept_no_panic() {
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(ref_update_row_visible(&deduped, &rules, None, "noslug"));
}
#[test]
fn feed_empty_deduped_set_keeps_any_slug() {
let deduped: [RepoRecord; 0] = [];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
assert!(ref_update_row_visible(&deduped, &rules, None, "anything"));
}
// The shared slug matcher underpinning both the feed gate and the collector's
// quarantine drop: it must recognize a repo's own row across every owner-DID
// form (bare short key, full did:key, URL-truncated prefix) and must not
// over-match a different name or an unrelated owner. The quarantine drop calls
// this directly, so its match contract is load-bearing for withholding a
// quarantined mirror from a caller who matches the mirror's owner_did.
#[test]
fn names_repo_matches_owner_forms_and_rejects_mismatches() {
// Bare short owner key (the form upsert_mirror_repo stores).
let bare = rec("m", "z6MkQuar", "secret", false);
assert!(ref_update_row_names_repo(&bare, "z6MkQuar/secret"));
// Full did:key owner; the short-segment slug still matches via last-segment.
let full = rec("c", "did:key:z6MkQuar", "secret", false);
assert!(ref_update_row_names_repo(&full, "z6MkQuar/secret"));
assert!(ref_update_row_names_repo(&full, "did:key:z6MkQuar/secret"));
// URL-truncated 8-char prefix slug → prefix-tolerant match.
let long = rec("l", "did:key:z6MkQuarLONGKEY", "secret", false);
assert!(ref_update_row_names_repo(&long, "z6MkQuar/secret"));
// Different name, different owner, and no-slash → no match.
assert!(!ref_update_row_names_repo(&bare, "z6MkQuar/other"));
assert!(!ref_update_row_names_repo(&bare, "z6MkOther/secret"));
assert!(!ref_update_row_names_repo(&bare, "noslash"));
}
// #101: a deny rule must withhold a path that denotes the same characters in
// a different Unicode normalization form. Without NFC normalization at the
// matcher seam, an NFC-authored rule byte-compares unequal to an NFD-stored
// path and the blob leaks on the replication path.
#[test]
fn matcher_withholds_across_nfc_nfd_normalization_skew() {
// "é" composed (NFC, U+00E9) in the rule; decomposed (NFD, e + U+0301)
// in the committed path.
let nfc_rule = "/s\u{00e9}cret/**";
let nfd_path = "/se\u{0301}cret/key.pem";
let rules = [rule(nfc_rule, VisibilityMode::B, &["did:key:z6MkFriend"])];
assert_eq!(
visibility_check(&rules, true, OWNER, None, nfd_path),
Decision::Deny,
"NFC-authored deny rule must withhold the canonically-equivalent NFD path"
);
// Mirror: rule authored NFD, path committed NFC.
let nfd_rule = "/se\u{0301}cret/**";
let nfc_path = "/s\u{00e9}cret/key.pem";
let rules2 = [rule(nfd_rule, VisibilityMode::B, &["did:key:z6MkFriend"])];
assert_eq!(
visibility_check(&rules2, true, OWNER, None, nfc_path),
Decision::Deny,
"NFD-authored deny rule must withhold the canonically-equivalent NFC path"
);
// A genuinely different path is still allowed (no over-withholding).
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/public/x.txt"),
Decision::Allow
);
}
}
-573
View File
@@ -1,573 +0,0 @@
//! #174 U6 — INV-22 completeness guard (rung-raising).
//!
//! INV-22: a permit held per op recovers only if every path that holds it is also
//! duration-bounded and reaps the process group before releasing admission, and every
//! detached git/blocking task carries its own admission. PR #174 fixed five paths
//! (U1-U5) that violated this. Each fix has a per-unit RED/GREEN regression; together
//! those form the five-revert matrix. This guard adds the missing piece: a source-scan
//! tripwire that fails when a NEW site reintroduces the class, or when one of the five
//! gates is removed.
//!
//! It lives in `tests/` (a separate crate) on purpose: a guard that scanned the same
//! file it lives in would match its own identifier literals and pass vacuously. Here
//! the scanned `src/` files never contain this file's literals, so each check is
//! load-bearing — reverting the named gate turns the assertion red.
//!
//! These are deliberately coarse structural checks, not a parser. They cannot prove a
//! gate is *correct* (the per-unit tests do that); they prove a gate is *present and
//! not bypassed*, which is what stops the class from silently regressing.
use std::path::Path;
fn src(rel: &str) -> String {
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(rel);
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
}
#[test]
fn inv22_concurrency_gates_present_and_not_bypassed() {
let repos = src("api/repos.rs");
let smart_http = src("git/smart_http.rs");
let vis = src("git/visibility_pack.rs");
let ipfs = src("api/ipfs.rs");
// U1 / P1-a: run_bounded_git stands the watchdog down only after confirming the
// child actually terminated (WNOWAIT), not on the raw stdout-drain EOF — otherwise
// a child that closes stdout then hangs pins the permit past the deadline. The
// probe is defined and called, so >= 2 occurrences; reverting the fix removes both.
assert!(
vis.matches("child_terminated_without_reaping").count() >= 2,
"U1/P1-a gate missing: run_bounded_git must confirm child exit via \
child_terminated_without_reaping before signalling the watchdog"
);
// U2 / P1-c: on client disconnect KillGroupOnDrop must launch a detached reaper
// that runs the full TERM/grace/KILL/reap, not a lone SIGTERM. The reaper is spawned
// via a runtime handle in Drop; `Handle::try_current` is unique to that launch (the
// timeout path already has an async context and never calls it).
assert!(
smart_http.contains("Handle::try_current"),
"U2/P1-c gate missing: KillGroupOnDrop::drop must launch the full \
TERM/grace/KILL reaper on disconnect (Handle::try_current), not a lone SIGTERM"
);
// U4 / P1-d: git_receive_pack must acquire the per-source write sub-cap before the
// global write permit. The acquire reads `state.git_write_per_caller`; comments name
// the field without the `state.` prefix, so this targets the real acquire site.
assert!(
repos.contains("state.git_write_per_caller"),
"U4/P1-d gate missing: git_receive_pack must acquire the per-source write cap \
(state.git_write_per_caller) before the global write permit"
);
// U5 / P1-e: the detached post-push encryption walk must run through the
// admission-gated helper, which is wired to the shared encrypt pool.
assert!(
repos.contains("fn withheld_recipients_gated")
&& repos.contains("state.git_encrypt_semaphore"),
"U5/P1-e gate missing: the encryption walk must run through \
withheld_recipients_gated, which acquires git_encrypt_semaphore"
);
// U1 / R2 (#173 round-10): the path-scoped filtered-pack serve must thread the
// caller's AdmissionGuard through BOTH git stages so read + per-caller admission is
// held until the pack-objects group is reaped on disconnect, closing the cap bypass
// the plain upload_pack path already fixed. Two load-bearing markers: rev-list hands
// the disarmed guard back (its tuple return type), and build_filtered_pack forwards
// that guard into the pack-objects stage (the `admission` arg after the
// "pack-objects" label). Reverting either — dropping the guard between stages, or
// passing `None` to pack-objects — trips this.
assert!(
smart_http.contains("Result<(Vec<String>, Option<AdmissionGuard>)>")
&& smart_http.contains("\"pack-objects\",\n admission,"),
"U1/R2 gate missing: build_filtered_pack must thread the AdmissionGuard through \
rev-list -> pack-objects so the permits are held until the pack-objects group \
is reaped on disconnect (the path-scoped half of #174 P1-a)"
);
// U2 / R1: a cancelled or timed-out `GET /ipfs/{cid}` must release admission only
// after the blocking work it admitted has finished, not the instant the handler
// future drops (the /ipfs half of #174 P1-a). The mechanism is the shared
// `Arc<WalkAdmission>`: both walk permits are moved into it once per request and a
// clone rides every `spawn_blocking`, so the permits release when the LAST holder
// drops. Reverting to handler-local permits trips this; the per-site clones are
// bound separately by `inv22_ipfs_walk_admission_reaches_every_blocking_site`.
//
// This previously required the permits to be owned by a detached `tokio::spawn`
// running the whole pipeline. That closed the same bypass but kept an abandoned
// request's full legacy scan running against a held slot, so the merge of #173 and
// #174 kept the Arc and dropped the detached task.
assert!(
ipfs.contains("struct WalkAdmission")
&& ipfs.contains("let admission = std::sync::Arc::new(WalkAdmission {"),
"U2/R1 gate missing: get_by_cid must move both /ipfs admission permits into a \
shared Arc<WalkAdmission> whose clones ride the blocking work, so admission is \
released only once that work completes (the /ipfs half of #174 P1-a)"
);
// U2 / KTD2 (#173 round-10): the probe/read children on the /ipfs path must be the
// duration-bounded twins (process-group teardown via run_bounded_git), not the bare
// `store::object_type` / `read_object_content` (or an unbounded `cat-file -s`) a tokio
// timeout cannot cancel — otherwise a wedged cat-file lingers and pins the held
// admission past the deadline. Reverting any twin call site back to a bare read trips
// this.
assert!(
ipfs.contains("object_type_bounded(")
&& ipfs.contains("object_size_bounded(")
&& ipfs.contains("read_object_content_bounded("),
"U2/KTD2 gate missing: the /ipfs probe+read must call the run_bounded_git-backed \
*_bounded twins so a wedged cat-file is reaped at the deadline, not left to pin \
the held /ipfs walk admission"
);
// P1-e non-bypass tripwire: the bounded recipients walk is spawn_blocking'd nowhere
// but inside withheld_recipients_gated. A second call site (count > 1) is a new
// detached git walk that skips the admission gate — exactly the class U5 closed.
assert_eq!(
repos.matches("withheld_blob_recipients_bounded").count(),
1,
"P1-e bypass: the bounded recipients walk must be invoked only inside \
withheld_recipients_gated; a new call site bypasses the encrypt-walk admission cap"
);
// U4 / P2-2: the detached post-push encryption task must be gated by the per-repo
// coalescing set (`encrypt_inflight.try_begin`) so the OUTSTANDING parked-task set is
// bounded to <=1 per repo. Removing the gate lets N rapid pushes spawn N parked
// waiters (the unbounded set U4 closed); the semaphore only caps active walks.
assert!(
repos.contains("state.encrypt_inflight.try_begin"),
"U4/P2-2 gate missing: the detached post-push encryption spawn must consult \
encrypt_inflight.try_begin to coalesce per repo (bound the outstanding-task set)"
);
// F5: coalescing must REQUEUE, not shed. The in-flight task pins only its own
// pre-spawn snapshot, so a coalesced push's tip pairs are recorded on its key and
// the task must loop-drain them (`finish_or_take_pending`) before releasing it.
// Removing the drain reverts to the silent loss: a coalesced push's pins and
// recovery copies are absent until an unrelated later push. Scan only the
// production half of the file — the u5 tests in its `mod tests` also name the
// drain call, and matching them would make this check vacuous.
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
// test-only items (the drain fault seam, the task entry point) ABOVE the production
// code these gates scan for, so splitting on the attribute truncated the production
// half above every line being checked and the gate went vacuously blind.
let repos_production = repos
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
assert!(
repos_production.contains("finish_or_take_pending"),
"F5 gate missing: the post-push encryption task must loop-drain coalesced \
pushes via finish_or_take_pending before releasing its repo key"
);
// F4: every post-receive scan helper admits itself to the shared scan pool via
// `crate::state::acquire_scan_permit` BEFORE its spawn_blocking git walk, so a
// push burst cannot accumulate unbounded concurrent scans once the write permit
// is released. Two halves, both load-bearing: the helper body must actually
// acquire the pool (state.rs sits the helper at the end of the file, so the
// definition tail contains no other `acquire_owned` to match vacuously), and
// within each scan helper the first qualified call precedes the first
// `spawn_blocking`. Severing a call site pushes the next occurrence past the
// helper's own `spawn_blocking` (or off the end of the file), turning the
// assertion red; comments name the helper without the `crate::state::` prefix,
// so this targets the real call sites.
let state_rs = src("state.rs");
let helper_def = state_rs
.find("fn acquire_scan_permit")
.expect("F4 gate missing: state.rs no longer defines acquire_scan_permit");
assert!(
state_rs[helper_def..].contains("acquire_owned"),
"F4 gate gutted: acquire_scan_permit must acquire the scan pool via acquire_owned"
);
let push_delta = src("git/push_delta.rs");
for (file_src, file, helper) in [
(&repos, "api/repos.rs", "fn replication_withheld_set"),
(&repos, "api/repos.rs", "fn fail_closed_full_scan_objects"),
(
&push_delta,
"git/push_delta.rs",
"fn resolve_candidates_for_push",
),
] {
let start = file_src
.find(helper)
.unwrap_or_else(|| panic!("{file}: `{helper}` not found"));
let tail = &file_src[start..];
let acquire = tail
.find("crate::state::acquire_scan_permit(")
.unwrap_or_else(|| {
panic!("F4 gate missing: {file} `{helper}` no longer acquires a scan permit")
});
let spawn = tail.find("spawn_blocking").unwrap_or_else(|| {
panic!("{file}: `{helper}` lost its spawn_blocking walk — update this guard")
});
assert!(
acquire < spawn,
"F4 gate bypassed: {file} `{helper}` must acquire its git_encrypt_semaphore \
permit BEFORE dispatching the blocking git scan"
);
}
}
/// F4 (repo_store advisory-unlock cancellation safety): `RepoWriteGuard::release`
/// must await `pg_advisory_unlock` while `self` still owns the pooled connection,
/// and must not mark itself `released` until that await resolves. Either shape,
/// reintroduced, re-opens the mid-unlock cancellation leak: taking the connection
/// early leaves `Drop` with `conn == None`, and setting `released = true` early
/// leaves the `Drop` backstop inert — both strand the session lock on cancellation.
///
/// Scoped to the `release` fn body: the `Drop` impl legitimately takes the
/// connection and unlocks, so a whole-file scan would match it and read as a false
/// pass. Reverting either ordering turns this red (proven load-bearing).
#[test]
fn f4_release_keeps_conn_owned_until_unlock_resolves() {
let repo_store = src("git/repo_store.rs");
let rel_start = repo_store
.find("pub async fn release(mut self")
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
let rel_end = repo_store[rel_start..]
.find("impl Drop for RepoWriteGuard")
.map(|off| rel_start + off)
.expect("F4 gate: release fn / Drop impl markers moved — update this guard");
let release_body = &repo_store[rel_start..rel_end];
let unlock = release_body
.find("pg_advisory_unlock")
.expect("F4 gate: release must still issue pg_advisory_unlock");
let before_unlock = &release_body[..unlock];
// (a) the connection must still be owned by `self` at the unlock await.
assert!(
!before_unlock.contains("self.conn.take()"),
"F4 regression: RepoWriteGuard::release takes self.conn BEFORE awaiting \
pg_advisory_unlock. A cancellation during the unlock await then strands the \
session advisory lock (Drop sees conn == None and skips its backstop). \
Unlock through the still-owned connection instead."
);
// (b) `released` must not be set before the unlock await — the other
// reintroduction shape a single-reorder check on (a) alone is blind to.
assert!(
!before_unlock.contains("released = true"),
"F4 regression: RepoWriteGuard::release sets `released = true` BEFORE awaiting \
pg_advisory_unlock. A cancellation during the await then leaves the Drop \
backstop inert (it early-returns on released). Set released only AFTER the \
unlock await resolves."
);
}
/// F6/KTD-5 (IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
/// the scarce walk permits (RAII, held for the whole request) BEFORE its metadata
/// queries, and the per-repo loop's first budget gate runs only later. So
/// both `list_repos_page_for_scan` and `list_visibility_rules_for_repos` must be clamped to the
/// remaining request budget — otherwise a query blocked in Postgres pins the walk slot
/// for the whole stall, past the budget. This scans the PRODUCTION half of `api/ipfs.rs`
/// (the `mod tests` half names the same calls in its own harness and would make the
/// check vacuous) and asserts each query call is immediately preceded by a
/// `tokio::time::timeout(` wrapper. Removing either wrapper turns this red (proven
/// load-bearing).
#[test]
fn f6_ipfs_metadata_queries_are_deadline_wrapped() {
let ipfs = src("api/ipfs.rs");
// Split at the TEST MODULE, not at the first `#[cfg(test)]`: the handler carries
// in-line `#[cfg(test)]` query counters, so splitting on the attribute cut the
// production half off above the calls this guard exists to check and the scan went
// vacuously quiet (found 0 of each rather than failing on a missing wrapper).
let production = ipfs
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
// EVERY occurrence must be wrapped, not just the first. The handler now reaches
// these queries from two places (the provenance fast path, one repo at a time, and
// the legacy scan's preload), and an exact-count check would have to be relaxed
// every time a call site is added, which is how a guard quietly stops covering the
// site that matters. Requiring all of them scales with the code instead.
for call in [
".list_repos_page_for_scan(",
".list_visibility_rules_for_repos(",
".get_repo_by_id(",
".is_repo_quarantined(",
] {
let occurrences = production.matches(call).count();
assert!(
occurrences >= 1,
"F6 guard stale: api/ipfs.rs no longer calls `{call}` — update this guard"
);
for (n, (idx, _)) in production.match_indices(call).enumerate() {
// The wrapper opens a few lines above the call (match tokio::time::timeout(
// ... remaining budget ..., <query> )); a 240-char lookback covers it
// without reaching the previous statement.
let window = &production[idx.saturating_sub(240)..idx];
assert!(
window.contains("tokio::time::timeout("),
"F6 gate missing: occurrence {n} of `{call}` (of {occurrences}) is not \
wrapped in tokio::time::timeout(...) clamped to the remaining request \
budget. An unwrapped await pins the held walk permit for the whole DB \
stall, past GITLAWB_IPFS_REQUEST_BUDGET_SECS."
);
}
}
}
/// #174 F2 / KTD-3: the detached post-receive Pinata replication task must enqueue
/// only the push's `(ref, old, new)` tuples and RE-DERIVE its object set inside the
/// worker once a pin slot frees — it must NOT move the full per-push object list into
/// the closure and hold it across the `pin_semaphore` acquire. Retaining the list makes
/// every parked task (under a slow Pinata backend) hold an MB-scale OID list, so
/// outstanding memory grows O(pushes x object-list). Coalescing/shedding the task is
/// forbidden (its per-ref effects are non-idempotent), so the fix bounds the retained
/// data, not the task count.
///
/// Two load-bearing checks, both against the PRODUCTION half of `api/repos.rs` (the
/// `mod tests` half names the same identifiers in its own harness and would make the
/// scan vacuous): (a) the closure-local `object_list_pinata` binding — the retain form —
/// must be gone; reintroducing `let object_list_pinata = object_list;` turns this red.
/// (b) the re-derivation (`pinata_object_list_for_refs`) must appear AFTER the Pinata
/// pin permit is acquired, so the object list is materialized only inside the pin-bounded
/// section and a parked task holds O(ref tuples).
#[test]
fn f2_pinata_enqueues_refs_not_retained_object_lists() {
let repos = src("api/repos.rs");
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
// test-only items (the drain fault seam, the task entry point) ABOVE the production
// code these gates scan for, so splitting on the attribute truncated the production
// half above every line being checked and the gate went vacuously blind.
let production = repos
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
// (a) the retained-list form must be gone from production.
assert!(
!production.contains("object_list_pinata"),
"F2/KTD-3 regression: the Pinata task retains a full per-push object list \
(`object_list_pinata`) across the pin-permit acquire. Enqueue only the ref \
tuples and re-derive the object set inside the worker (pinata_object_list_for_refs)."
);
// (b) the re-derivation runs AFTER the pin permit is acquired. Anchor on the
// Pinata pin-admission clone so the window is the Pinata task, not the sibling
// IPFS/encrypt spawn (which shares `pin_semaphore` but never re-derives).
let anchor = production
.find("let pin_sem_pinata")
.expect("F2 gate stale: the Pinata pin-admission clone (pin_sem_pinata) moved");
let tail = &production[anchor..];
let acquire = tail
.find(".acquire_owned()")
.expect("F2 gate: the Pinata task no longer acquires a pin permit (acquire_owned)");
let rederive = tail.find("pinata_object_list_for_refs(").expect(
"F2 gate missing: the Pinata task must re-derive its object set via \
pinata_object_list_for_refs; it can no longer move a pre-resolved list into the closure",
);
assert!(
acquire < rederive,
"F2 gate bypassed: the Pinata object-set re-derivation must run AFTER the pin \
permit is acquired, so a parked task never holds the MB-scale object list"
);
// The re-derivation is driven by the push's ref tuples (the enqueued unit), not a
// retained object list — tie "enqueue ref_updates" to the call explicitly.
assert!(
tail[rederive..].starts_with("pinata_object_list_for_refs(")
&& tail[rederive..]
.get(..400)
.map(|w| w.contains("ref_updates_clone"))
.unwrap_or(false),
"F2 gate: pinata_object_list_for_refs must re-derive from the push's ref tuples \
(ref_updates_clone), the small unit the parked task retains"
);
}
/// #174 U2 / F3 (second same-repo writer serialized until a disconnected first push's
/// git group is reaped): `git_receive_pack` must take the per-repo in-process write
/// lease and CARRY it on the write-path `AdmissionGuard` (via `.with_lease`), so the
/// lease rides `KillGroupOnDrop`'s detached reaper and frees only after the group is
/// reaped — supplementing the pg advisory lock, which `RepoWriteGuard::Drop` releases at
/// the disconnect instant. Tying the lease to `RepoWriteGuard` instead (or dropping the
/// `.with_lease` carry) reopens the race; the behavioral RED/GREEN test
/// (`f3_second_push_serialized_until_disconnected_group_reaped`) is the real bar, this
/// is the completeness tripwire. Scanned against the PRODUCTION half of `api/repos.rs`
/// (the `mod tests` half names these identifiers in its own harness).
#[test]
fn f3_second_writer_leased_until_reap() {
let repos = src("api/repos.rs");
let smart_http = src("git/smart_http.rs");
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
// test-only items (the drain fault seam, the task entry point) ABOVE the production
// code these gates scan for, so splitting on the attribute truncated the production
// half above every line being checked and the gate went vacuously blind.
let repos_production = repos
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
// The lease is acquired, then carried by the AdmissionGuard (.with_lease), before
// receive_pack runs the write. Severing any of the three turns this red.
let lease_acquire = repos_production.find("repo_write_leases").expect(
"F3 gate missing: git_receive_pack no longer takes the per-repo write lease \
(state.repo_write_leases)",
);
let with_lease = repos_production.find(".with_lease(").expect(
"F3 gate missing: the write-path AdmissionGuard no longer carries the lease \
(.with_lease). The lease must ride the disconnect reaper via the AdmissionGuard, \
NOT RepoWriteGuard (which drops at the disconnect instant, reopening F3).",
);
let receive = repos_production
.find("smart_http::receive_pack(")
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack");
assert!(
lease_acquire < with_lease && with_lease < receive,
"F3 gate bypassed: the write lease must be acquired, then carried by the \
AdmissionGuard (.with_lease), BEFORE receive_pack runs the write"
);
// The AdmissionGuard must actually hold the lease (Option<RepoWriteLease>) via a
// with_lease setter, so it travels into the detached reaper. Removing the field or
// method turns this red.
assert!(
smart_http.contains("_lease: Option<crate::state::RepoWriteLease>")
&& smart_http.contains("pub fn with_lease("),
"F3 gate missing: AdmissionGuard must hold an Option<RepoWriteLease> set via \
with_lease, so the lease rides the guard into KillGroupOnDrop's reaper"
);
}
/// #174 U1 — every blocking walk in the `/ipfs` scan carries the request's walk
/// admission.
///
/// The admission is an `Arc<WalkAdmission>` cloned into each `spawn_blocking`
/// closure, so the global + per-source permits release only when the last holder
/// drops: a client disconnect leaves the abandoned closure holding the slot, and a
/// panicking closure leaves the handler holding it.
///
/// Only the walk site runs under `state.git_bin`, so only it can be pinned by the
/// fake-git harness and mutation-verified dynamically (see
/// `get_by_cid_walk_permit_held_through_blocking_walk`). The probe and the content
/// read deliberately shell to the real `git`, which is why this structural check
/// exists: it binds all three sites, and any blocking site added to this loop
/// later, without reversing that deliberate independence.
///
/// MUTATION (RED): delete any one `Arc::clone(ctx.admission)` binding, or drop the
/// clone from inside its closure, and the count falls below three.
#[test]
fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
let ipfs = src("api/ipfs.rs");
// The shared owner must exist and be built once per request.
assert!(
ipfs.contains("struct WalkAdmission") && ipfs.contains("Arc::new(WalkAdmission {"),
"U1 gate missing: the /ipfs walk admission must be a shared WalkAdmission, \
not a handler-local permit pair"
);
// Every blocking site in the scan takes its own clone...
// `Arc::clone(ctx.admission)` in the gate, `Arc::clone(&admission)` if a site is
// ever added back in the handler body itself; count both spellings.
let clones = ipfs.matches("Arc::clone(ctx.admission)").count()
+ ipfs.matches("Arc::clone(&admission)").count();
assert!(
clones >= 3,
"U1 gate bypassed: expected an admission clone for each of the three \
/ipfs spawn_blocking sites (probe, walk, read); found {clones}. A blocking \
walk that does not hold the admission lets a disconnect or a panic free the \
slot while its git child is still running."
);
// ...and each clone is actually moved INTO the blocking closure, not merely
// created in the async frame (which would hold nothing across the join).
let held = ipfs.matches("let _admission = ").count();
assert!(
held >= 3,
"U1 gate bypassed: each admission clone must be bound inside its \
spawn_blocking closure so the blocking work owns it; found {held} of 3."
);
// The count of blocking sites is itself the thing being covered: if a fourth
// appears, it needs an admission clone too and this gate must be revisited.
let sites = ipfs.matches("spawn_blocking(move ||").count();
assert_eq!(
sites, 3,
"the /ipfs scan grew or lost a spawn_blocking site ({sites} found, expected 3); \
give any new blocking walk its own Arc::clone(&admission) and update this gate"
);
}
/// #174 U5: the post-receive replication tail is spawned at the DURABILITY BOUNDARY,
/// which is the moment receive-pack returns success, not the end of the handler and
/// not after `guard.release()`.
///
/// The tail owes this push its pins, recovery copy, and announcements. Everything
/// below the spawn stays in the cancellable request future, so anything the tail is
/// spawned after is a window where a client disconnect drops that work while the pack
/// is already durable on disk. `guard.release()` is such a window: on success it
/// awaits the Tigris upload and then the advisory unlock.
///
/// The lower bound matters just as much as the upper one: `release` runs on failure
/// too, so an ungated spawn would fire for a push git rejected, pinning and announcing
/// a half-applied repo. Above `release` the `?` on `receive_result` can no longer be
/// what gates it, so the success check is explicit and this gate binds it: the spawn
/// must sit inside `if push_succeeded`, and `release` must consume the same flag so
/// the two cannot drift apart.
///
/// This is an ordering check rather than a cancellation-race test on purpose: it is
/// the companion to `receive_pack_tail_survives_a_disconnect_during_release`, which
/// drives the actual disconnect through a parked `release`. Same instrument the F3
/// gate above uses.
///
/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below
/// `guard.release(` and the ordering assertion fails; take it out of the
/// `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");
// Production half only — the tests below name these identifiers too.
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
// test-only items (the drain fault seam, the task entry point) ABOVE the production
// code these gates scan for, so splitting on the attribute truncated the production
// half above every line being checked and the gate went vacuously blind.
let production = repos
.split("\nmod tests {")
.next()
.expect("split always yields a first chunk");
let success_flag = production
.find("let push_succeeded = receive_result.is_ok();")
.expect(
"U5 gate missing: the tail's success gate must be bound from receive_result.is_ok()",
);
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(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");
let webhook = production
.find("webhooks::fire_event(")
.expect("U5 gate stale: git_receive_pack no longer fires push webhooks");
assert!(
success_flag < gate_open && gate_open < spawn,
"U5 gate bypassed: the tail must be spawned inside `if push_succeeded`, or a \
rejected push spawns a tail that pins and announces a half-applied repo"
);
// Still inside that block: no `}` may close it between the gate and the spawn.
assert!(
!production[gate_open + "if push_succeeded {".len()..spawn].contains('}'),
"U5 gate bypassed: the tail spawn left the `if push_succeeded` block, so a \
rejected push now spawns a tail"
);
assert!(
spawn < release && spawn < touch && spawn < webhook,
"U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \
and the webhook fan-out, so a disconnect in any of those windows cannot drop \
this push's pins, recovery copy, and announcements"
);
}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more