mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 15:39:37 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0763ac2a | ||
|
|
8652b86d33 | ||
|
|
a4e2b9f3f3 | ||
|
|
61033af261 | ||
|
|
5445dda3f5 | ||
|
|
d3074a6c86 | ||
|
|
612d9d2dbe | ||
|
|
806f9027c0 | ||
|
|
ad71f79e6d | ||
|
|
90b8294c2b | ||
|
|
db3681e48d | ||
|
|
7be9f08b3d | ||
|
|
6663fed064 | ||
|
|
ac31868205 | ||
|
|
af17831429 | ||
|
|
04263dbd49 |
No files matched your search
+15
-19
@@ -8,9 +8,24 @@
|
||||
# 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
|
||||
@@ -20,23 +35,4 @@ ignore = [
|
||||
# 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)
|
||||
|
||||
# crypto-bigint 0.5.5 (secret-dependent branches in operations meant
|
||||
# to be constant-time). REACHABLE accepted risk, on two paths:
|
||||
# - alloy-signer -> elliptic-curve 0.13 -> crypto-bigint 0.5: the k256
|
||||
# PrivateKeySigner that signs transactions in `gl name`
|
||||
# (crates/gl/src/name.rs) and the node operator
|
||||
# (crates/gitlawb-node/src/operator.rs).
|
||||
# - aws-sigv4 (`sigv4a` feature, crates/gitlawb-node/Cargo.toml) ->
|
||||
# crypto-bigint 0.5 (directly and via p256 0.13): SigV4a key
|
||||
# derivation, used only for requests to endpoints that require SigV4a
|
||||
# (e.g. S3 Multi-Region Access Points).
|
||||
# Exploiting it needs local timing observation of those signing operations.
|
||||
# The fix is crypto-bigint >=0.7.6, and no compatible dependent uses it:
|
||||
# every alloy-signer release (through 2.5.0) requires elliptic-curve ^0.13,
|
||||
# and aws-sigv4 1.6.4 is the first release on crypto-bigint 0.7 (1.5.x -
|
||||
# 1.6.3 still require ^0.5.4), but it needs Rust 1.94.1, above our 1.91 MSRV
|
||||
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
|
||||
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
|
||||
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
|
||||
]
|
||||
+10
-212
@@ -1,41 +1,12 @@
|
||||
# Twigpine node — environment variables
|
||||
# gitlawb node — environment variables
|
||||
# Copy to .env and fill in your values.
|
||||
# All variables are optional unless marked REQUIRED.
|
||||
|
||||
# ── Node identity ─────────────────────────────────────────────────────────
|
||||
# Path to the node's Ed25519 keypair PEM file. Must name a key file, not a
|
||||
# directory: a trailing slash, a final `.` or `..`, and `..` traversal are
|
||||
# refused so the path cannot retarget a parent. A `~/` value must stay under
|
||||
# home after expansion. Bare `identity.pem` is still legal.
|
||||
# Path to the node's Ed25519 keypair PEM file.
|
||||
# Generate with: gl identity new
|
||||
GITLAWB_KEY=/data/keys/identity.pem
|
||||
|
||||
# Path to the node's persistent libp2p identity key file. Must include a
|
||||
# directory, because the node will not keep its p2p identity key in the working
|
||||
# directory. A value that cannot name a securable key file (a bare filename,
|
||||
# `..` traversal, a trailing slash, the filesystem root, or a `~/` path that
|
||||
# escapes home) is refused before the node binds, so the process exits.
|
||||
# Anything wrong with the storage itself (a symlinked or non-directory parent,
|
||||
# an unsafe cwd or ancestor, an unreadable or malformed key) leaves p2p off
|
||||
# while HTTP keeps serving, logged at error as p2p_identity_key_load_failed and
|
||||
# counted by the gitlawb_p2p_identity_key_load_failed metric. /health still
|
||||
# reports healthy in that state, so alert on the event or the metric. Fixing
|
||||
# the storage and restarting restores the same PeerId.
|
||||
# On Unix it is created 0600 inside a 0700 directory. A key directory that
|
||||
# grants access beyond its owner is tightened to 0700 on start; one closed too
|
||||
# far is refused with a chmod 700 remedy rather than widened. On other
|
||||
# platforms no permissions are enforced.
|
||||
# If the node logs that it tightened a key directory that was GROUP OR WORLD
|
||||
# WRITABLE, treat the key that was sitting there as possibly exposed: delete
|
||||
# it so a fresh identity is generated on the next start. An ordinary 0755
|
||||
# directory is tightened too, but others could not read a 0600 key or replace
|
||||
# its directory entry, so do not rotate for that case. A directory that was
|
||||
# merely closed too far is refused, not tightened, and no key in it was ever
|
||||
# exposed.
|
||||
# Keep it on a persistent volume so the PeerId survives redeploys.
|
||||
# Default: ~/.gitlawb/p2p.key
|
||||
#GITLAWB_P2P_KEY=/data/keys/p2p.key
|
||||
|
||||
# Publicly reachable URL of this node (used in peer announcements)
|
||||
GITLAWB_PUBLIC_URL=https://your-node.example.com
|
||||
|
||||
@@ -54,25 +25,7 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
# 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.
|
||||
GITLAWB_DB_MAX_CONNECTIONS=48
|
||||
# Maximum connections in the DEDICATED advisory-lock pool, separate from the
|
||||
# pool above. Every in-flight repo write pins one connection here for its whole
|
||||
# duration, so this is a hard ceiling on simultaneous writes node-wide: size it
|
||||
# to expected peak concurrent writers, not small. Keeping it separate is what
|
||||
# stops a push burst from starving ordinary request handlers. Budget
|
||||
# (GITLAWB_DB_MAX_CONNECTIONS + this) per node against the server's
|
||||
# max_connections, times node count, plus admin tooling.
|
||||
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=40
|
||||
# Upper bound, in seconds, on any object-storage transfer that runs while a
|
||||
# per-repo write lock is HELD (the archive download inside acquire_write and the
|
||||
# upload inside release). These were free before the lock's connection was
|
||||
# pinned to the guard; now an unbounded stall holds a lock-pool slot, and enough
|
||||
# stalls deny every write on the node. The bound applies PER SPAN and there are
|
||||
# two (the acquire-side refresh, which covers the existence check and download
|
||||
# together, and the release-side upload), so worst-case slot occupancy is about
|
||||
# twice this value plus the git work between them. Read it together with the pool
|
||||
# size above and with GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
|
||||
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS=300
|
||||
GITLAWB_DB_MAX_CONNECTIONS=20
|
||||
# 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
|
||||
@@ -132,24 +85,15 @@ GITLAWB_P2P_PORT=7546
|
||||
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
|
||||
GITLAWB_BOOTSTRAP_PEERS=
|
||||
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
|
||||
# Require a peer write to prove its DID on every transport: RFC 9421 signatures
|
||||
# on the announce/sync routes, and an Ed25519 payload signature on gossip
|
||||
# ref-update events. Keep false during rolling upgrades so existing live nodes
|
||||
# can still communicate. Upgrade every gossip-publishing peer to a build that
|
||||
# signs events before setting this true, or their updates are dropped on
|
||||
# arrival with no error visible on their side.
|
||||
# Require signatures on peer announce/sync write routes. Keep false during
|
||||
# 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
|
||||
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
|
||||
# Keep false until the repo owner is ready for owner-only writes.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=false
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
|
||||
@@ -165,161 +109,15 @@ 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.
|
||||
# worker and, on push, the repo write lock. Does NOT cover the info/refs
|
||||
# advertisement or the withheld-blob path, which remain unbounded. 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report a defect in a Twigpine crate or node behavior
|
||||
description: Report a defect in a Gitlawb crate or node behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Twigpine/node/blob/main/SECURITY.md
|
||||
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/Twigpine/node/discussions
|
||||
url: https://github.com/Gitlawb/node/discussions
|
||||
about: For usage questions and open-ended discussion.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--
|
||||
Thanks for contributing to Twigpine. Keep PRs focused on one change.
|
||||
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.
|
||||
-->
|
||||
|
||||
@@ -55,19 +55,6 @@ jobs:
|
||||
- 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
|
||||
@@ -296,35 +283,3 @@ jobs:
|
||||
|
||||
- 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
|
||||
@@ -70,15 +70,9 @@ jobs:
|
||||
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));
|
||||
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
|
||||
const touchedTests =
|
||||
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
|
||||
if (changedRust && !touchedTests) want.add("needs-tests");
|
||||
@@ -124,7 +118,7 @@ jobs:
|
||||
"",
|
||||
lines,
|
||||
"",
|
||||
"See [CONTRIBUTING.md](https://github.com/Twigpine/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
|
||||
"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 });
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
# 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 == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
|
||||
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
sync-release-lock:
|
||||
name: Sync Cargo.lock on the release PR
|
||||
needs: release-please
|
||||
if: ${{ !cancelled() && github.repository == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
|
||||
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -216,11 +216,6 @@ jobs:
|
||||
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:
|
||||
@@ -228,7 +223,13 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing — a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
@@ -310,14 +311,8 @@ jobs:
|
||||
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:
|
||||
@@ -325,7 +320,13 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
@@ -541,11 +542,6 @@ jobs:
|
||||
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:
|
||||
@@ -553,7 +549,13 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -669,7 +671,7 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Twigpine/homebrew-tap
|
||||
repository: Gitlawb/homebrew-tap
|
||||
token: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
path: tap
|
||||
persist-credentials: false
|
||||
@@ -695,8 +697,8 @@ jobs:
|
||||
mkdir -p tap/Formula
|
||||
cat > tap/Formula/gl.rb <<EOF
|
||||
class Gl < Formula
|
||||
desc "Twigpine CLI — decentralized git for AI agents and developers"
|
||||
homepage "https://twigpine.com"
|
||||
desc "Gitlawb CLI — decentralized git for AI agents and developers"
|
||||
homepage "https://gitlawb.com"
|
||||
version "${VERSION}"
|
||||
license "MIT OR Apache-2.0"
|
||||
|
||||
@@ -760,7 +762,7 @@ jobs:
|
||||
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/Twigpine/homebrew-tap.git" HEAD:main
|
||||
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)
|
||||
@@ -794,7 +796,7 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Twigpine/web
|
||||
repository: Gitlawb/web
|
||||
token: ${{ secrets.WEB_SYNC_PAT }}
|
||||
path: web
|
||||
persist-credentials: false
|
||||
@@ -837,7 +839,7 @@ jobs:
|
||||
# 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/Twigpine/node/releases#g' web/public/skill.md
|
||||
sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/node/releases#g' web/public/skill.md
|
||||
fi
|
||||
|
||||
cd web
|
||||
@@ -851,16 +853,16 @@ jobs:
|
||||
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 Twigpine ${TAG} (install scripts + version)"
|
||||
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/Twigpine/web.git" "HEAD:$branch"
|
||||
gh pr create --repo Twigpine/web --head "$branch" \
|
||||
--title "Sync Twigpine ${TAG}" \
|
||||
--body "Automated sync from Twigpine/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." \
|
||||
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 Twigpine/web --head "$branch" --state open --json number --jq 'length')" || exit 1
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
+6
-9
@@ -1,12 +1,12 @@
|
||||
# Contributing to Twigpine
|
||||
# Contributing to gitlawb
|
||||
|
||||
Thanks for your interest in contributing. Twigpine is an open protocol — agents and humans welcome.
|
||||
Thanks for your interest in contributing. gitlawb is an open protocol — agents and humans welcome.
|
||||
|
||||
## Quick start for contributors
|
||||
|
||||
```sh
|
||||
# Clone and build
|
||||
git clone https://github.com/Twigpine/node
|
||||
git clone https://github.com/Gitlawb/node
|
||||
cd node
|
||||
|
||||
# Build everything
|
||||
@@ -31,14 +31,12 @@ 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
|
||||
```
|
||||
|
||||
Smart contracts live in a separate repo: [github.com/Twigpine/contracts](https://github.com/Twigpine/contracts).
|
||||
Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://github.com/Gitlawb/contracts).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
@@ -56,7 +54,6 @@ Smart contracts live in a separate repo: [github.com/Twigpine/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
|
||||
|
||||
@@ -108,7 +105,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
+542
-927
File diff suppressed because it is too large.
Load diff
+2
-5
@@ -20,8 +20,8 @@ members = [
|
||||
edition = "2021"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["Twigpine contributors"]
|
||||
repository = "https://github.com/Twigpine/node"
|
||||
authors = ["gitlawb contributors"]
|
||||
repository = "https://github.com/gitlawb/node"
|
||||
|
||||
[workspace.dependencies]
|
||||
# async
|
||||
@@ -53,9 +53,6 @@ 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"
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ WORKDIR /data
|
||||
|
||||
ENV GITLAWB_REPOS_DIR=/data/repos \
|
||||
GITLAWB_KEY=/data/keys/identity.pem \
|
||||
GITLAWB_P2P_KEY=/data/keys/p2p.key \
|
||||
GITLAWB_HOST=0.0.0.0 \
|
||||
GITLAWB_PORT=7545 \
|
||||
GITLAWB_P2P_PORT=7546
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Twigpine contributors
|
||||
Copyright 2026 gitlawb contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Twigpine contributors
|
||||
Copyright (c) 2026 gitlawb contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Twigpine Node
|
||||
# Gitlawb Node
|
||||
|
||||
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
|
||||
|
||||
Twigpine Node is the open-source node software behind the Twigpine 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 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.
|
||||
|
||||
Twigpine 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,35 +19,33 @@ 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 |
|
||||
|---|---|
|
||||
| `gitlawb-node` | The node daemon: Axum HTTP server, git smart-HTTP, Postgres metadata, libp2p gossip, optional S3/Tigris/IPFS/Arweave/Base PoS hooks. |
|
||||
| `gl` | The Twigpine 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 Twigpine nodes. |
|
||||
| `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. |
|
||||
|
||||
---
|
||||
|
||||
## Why Twigpine Nodes?
|
||||
## Why Gitlawb Nodes?
|
||||
|
||||
Most git hosting today depends on a small number of centralized platforms. Twigpine Nodes are designed for a different model:
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Current status
|
||||
|
||||
Twigpine Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
|
||||
Gitlawb Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
|
||||
|
||||
Good today:
|
||||
|
||||
@@ -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.
|
||||
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
|
||||
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
|
||||
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
|
||||
|
||||
See:
|
||||
|
||||
@@ -87,7 +82,7 @@ See:
|
||||
The fastest path is Docker Compose. It starts a node and Postgres.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/node.git
|
||||
git clone https://github.com/Gitlawb/node.git
|
||||
cd node
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
@@ -132,10 +127,10 @@ npm install -g @gitlawb/gl
|
||||
brew install gitlawb/tap/gl
|
||||
|
||||
# curl (macOS / Linux)
|
||||
curl -fsSL https://twigpine.com/install.sh | sh
|
||||
curl -fsSL https://gitlawb.com/install.sh | sh
|
||||
|
||||
# PowerShell (Windows)
|
||||
irm https://twigpine.com/install.ps1 | iex
|
||||
irm https://gitlawb.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
@@ -178,7 +173,7 @@ gl register --node http://localhost:7545
|
||||
Create a repo:
|
||||
|
||||
```bash
|
||||
gl repo create my-repo --description "My first Twigpine repo" --node http://localhost:7545
|
||||
gl repo create my-repo --description "My first Gitlawb repo" --node http://localhost:7545
|
||||
```
|
||||
|
||||
Use the git remote helper:
|
||||
@@ -192,21 +187,21 @@ For public-network use, make sure `GITLAWB_NODE` points to the node you want. Th
|
||||
|
||||
### Full lifecycle against an iCaptcha-enforcing node
|
||||
|
||||
Public nodes (e.g. `node.twigpine.com`) require two things on writes:
|
||||
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`
|
||||
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.
|
||||
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.twigpine.com # signed + auto-solves iCaptcha
|
||||
gl repo create memlawb --node https://node.twigpine.com # signed + auto-solves iCaptcha
|
||||
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
|
||||
@@ -220,61 +215,14 @@ Notes:
|
||||
- **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);
|
||||
`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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -361,7 +309,7 @@ POST /api/v1/peers/announce
|
||||
POST /api/v1/sync/notify
|
||||
```
|
||||
|
||||
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 on those two routes, 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
|
||||
@@ -369,12 +317,6 @@ 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.
|
||||
|
||||
The flag is not HTTP-only. It also gates inbound gossip ref-update events on the libp2p mesh, which carry an Ed25519 payload signature rather than RFC 9421 headers because there are no HTTP headers to sign. Set to `true`, an unsigned gossip event is dropped. Set to `false`, an unsigned event is accepted with a warning during the rolling-upgrade window, and the write budget it consumes is charged to the forwarding peer rather than to the DID it claims, since an unsigned event's claimed DID is asserted and not proven. A present-but-invalid signature is refused in both modes; that is forgery, not an un-upgraded peer.
|
||||
|
||||
Because the flag now spans both transports, the rollout order matters in one direction: **upgrade every gossip-publishing peer to a build that signs events before you set this to `true`.** Enabling it while an old publisher is still live drops that publisher's ref-updates on arrival, and the publisher sees no error, because gossip has no response to carry one. Upgrading the HTTP peers alone is not sufficient.
|
||||
|
||||
There is a second precondition on gossip ingest that the flag does not control. A publisher's `node_did` must already exist in the receiving node's peers table, or the event is dropped as an unknown peer DID. This holds in both modes, signed and unsigned alike: a valid signature proves key possession, not membership. Rows reach that table over HTTP, either from `GITLAWB_BOOTSTRAP_PEERS` when this node contacts a bootstrap peer and records the DID it reports, or from a prior `POST /api/v1/peers/announce`. So a peer that only ever joined the libp2p mesh, with no HTTP announce and no bootstrap contact in either direction, will have its ref-updates dropped even with a good signature and the flag off. If gossip is silently not landing from a peer you can see on the mesh, check that its DID is in `GET /api/v1/peers` on the receiving side first.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -397,63 +339,22 @@ Important node settings:
|
||||
| `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. |
|
||||
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
|
||||
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
|
||||
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
|
||||
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
|
||||
| `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_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
|
||||
| `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.
|
||||
|
||||
### Upgrading: the PeerId rotates once
|
||||
|
||||
This node's libp2p identity is now a keypair generated on first start and kept
|
||||
at `GITLAWB_P2P_KEY`, rather than one derived from the node DID. Every node
|
||||
therefore gets a new PeerId once, on the first start after upgrading, and keeps
|
||||
it from then on as long as that key file survives (put it on a persistent volume
|
||||
in a container).
|
||||
|
||||
Two things to check before upgrading:
|
||||
|
||||
- Any `GITLAWB_P2P_BOOTSTRAP` multiaddr that pins a peer's old PeerId with a
|
||||
`/p2p/<PeerId>` suffix stops matching once that peer upgrades. Update the
|
||||
suffix, or drop it and let identify supply the current one. Addresses without
|
||||
the suffix keep working untouched.
|
||||
- `GITLAWB_P2P_KEY` must name a key file inside a directory, such as
|
||||
`/data/keys/p2p.key` or `./keys/p2p.key`. A bare filename is refused at
|
||||
startup, since the node will not keep its identity key in the working
|
||||
directory.
|
||||
|
||||
Peers found over `GITLAWB_BOOTSTRAP_PEERS` and the embedded seed list are
|
||||
unaffected, since those are HTTP URLs and carry no PeerId.
|
||||
|
||||
---
|
||||
|
||||
## Optional node staking
|
||||
|
||||
Twigpine Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
|
||||
Gitlawb Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
|
||||
|
||||
PoS is disabled unless these are configured:
|
||||
|
||||
@@ -523,8 +424,8 @@ Build:
|
||||
Output:
|
||||
|
||||
```txt
|
||||
dist/Twigpine Node.app
|
||||
dist/GitlawbNode-macOS.dmg
|
||||
dist/Gitlawb Node.app
|
||||
dist/Gitlawb Node.dmg
|
||||
```
|
||||
|
||||
Features:
|
||||
@@ -538,7 +439,7 @@ Features:
|
||||
Unsigned local build:
|
||||
|
||||
```bash
|
||||
xattr -cr "dist/Twigpine Node.app"
|
||||
xattr -cr "dist/Gitlawb Node.app"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -553,8 +454,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:
|
||||
|
||||
+26
-36
@@ -2,9 +2,9 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Twigpine, please **do not open a public issue**.
|
||||
If you discover a security vulnerability in gitlawb, please **do not open a public issue**.
|
||||
|
||||
Report it privately by emailing **security@twigpine.com** with:
|
||||
Report it privately by emailing **security@gitlawb.com** with:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact assessment
|
||||
@@ -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,20 +101,20 @@ 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) |
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
Twigpine is designed to be secure against:
|
||||
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
gitlawb is designed to be secure against:
|
||||
- **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
|
||||
|
||||
Twigpine is **not yet** designed to defend against:
|
||||
gitlawb is **not yet** designed to defend against:
|
||||
- A compromised node operator (node operators are trusted for their own node)
|
||||
- Sybil attacks on the DHT (trust score system mitigates, not eliminates)
|
||||
- Timing attacks on signature verification (not constant-time compared in v0.1)
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"$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",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"operator": "Twigpine",
|
||||
"operator": "Gitlawb",
|
||||
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
|
||||
"http_url": "https://node.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node2",
|
||||
"operator": "Twigpine",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node2.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node3",
|
||||
"operator": "Twigpine",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node3.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-manila",
|
||||
"operator": "Twigpine",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://manila.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 }
|
||||
@@ -19,20 +19,3 @@ 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",
|
||||
] }
|
||||
+101
-1317
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for Twigpine ref-update certs"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
@@ -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};
|
||||
@@ -105,30 +105,16 @@ impl Attestation {
|
||||
let bytes = canonical_signing_bytes(&self.type_, &self.payload, &self.cert_hash)?;
|
||||
|
||||
let vk = verifying_key_from_did_key(&self.signer)?;
|
||||
self.verify_sig_with_key(&vk, &bytes)?;
|
||||
|
||||
Ok(vk)
|
||||
}
|
||||
|
||||
/// The signature check itself, against a key the caller supplies.
|
||||
///
|
||||
/// Split out so the STRICTNESS of the check stays provable on its own.
|
||||
/// `verifying_key_from_did_key` now refuses a small-order key, so a weak
|
||||
/// signer can no longer reach this line through `verify_signature`. Without
|
||||
/// this seam there would be nothing left that fails when `verify_strict` is
|
||||
/// downgraded to the non-strict `verify`, and that regression would land
|
||||
/// silently. Check order in `verify_signature` is unchanged.
|
||||
fn verify_sig_with_key(&self, vk: &VerifyingKey, bytes: &[u8]) -> Result<()> {
|
||||
let sig_bytes: [u8; 64] = B64U
|
||||
.decode(&self.sig)
|
||||
.map_err(|e| Error::Signature(format!("base64url: {e}")))?
|
||||
.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(())
|
||||
Ok(vk)
|
||||
}
|
||||
|
||||
/// Reparse `payload` as `P`. Errors if the type discriminator does not
|
||||
@@ -195,7 +181,7 @@ fn canonical_signing_bytes(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
|
||||
fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
|
||||
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&key.to_bytes());
|
||||
@@ -216,19 +202,6 @@ fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
|
||||
"did:key must use base58btc (z-prefix): {did}"
|
||||
)));
|
||||
}
|
||||
// Refuse an oversized id before decoding. base58 decoding is quadratic in
|
||||
// its input and `signer` comes off an attacker-supplied attestation, so a
|
||||
// short request could otherwise buy a large decode. An ed25519 did:key
|
||||
// method-id is a fixed 48 characters, so this bound is slack rather than a
|
||||
// behavior change, and it only helps ahead of the decode. Mirrors the same
|
||||
// cap in gitlawb-core's Did::to_verifying_key, which this crate cannot call
|
||||
// (gitlawb-core is a dev-dependency here).
|
||||
const MAX_METHOD_ID_LEN: usize = 64;
|
||||
if method_id.len() > MAX_METHOD_ID_LEN {
|
||||
return Err(Error::Did(
|
||||
"did:key method-specific id too long".to_string(),
|
||||
));
|
||||
}
|
||||
let (base, bytes) =
|
||||
multibase::decode(method_id).map_err(|e| Error::Did(format!("multibase: {e}")))?;
|
||||
if base != multibase::Base::Base58Btc {
|
||||
@@ -244,20 +217,8 @@ fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
|
||||
let key_bytes: [u8; 32] = bytes[ED25519_MULTICODEC.len()..]
|
||||
.try_into()
|
||||
.expect("length checked above");
|
||||
let key = VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))?;
|
||||
|
||||
// `from_bytes` only decompresses, so it accepts a small-order point. This
|
||||
// crate parses did:key itself rather than going through gitlawb-core's
|
||||
// `Did`, and it cannot go through it (gitlawb-core is a dev-dependency
|
||||
// here), so the rejection is mirrored rather than inherited. Signature
|
||||
// verification is already strict, so this is defense in depth against a
|
||||
// future consumer of this parser that does not verify strictly.
|
||||
if key.is_weak() {
|
||||
return Err(Error::Did("small-order ed25519 key".to_string()));
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -593,153 +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.
|
||||
/// gitlawb-attest parses did:key itself and never routes through
|
||||
/// gitlawb-core's `Did`, which it cannot: gitlawb-core is a dev-dependency
|
||||
/// here. So the choke-point rejection has to be mirrored in this parser or
|
||||
/// this crate keeps handing out keys the rest of the system rejects.
|
||||
#[test]
|
||||
fn verifying_key_from_did_key_rejects_a_small_order_key() {
|
||||
let mut weak = [0u8; 32];
|
||||
weak[0] = 1; // compressed identity point
|
||||
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&weak);
|
||||
let did = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
|
||||
let err =
|
||||
verifying_key_from_did_key(&did).expect_err("a small-order did:key must not resolve");
|
||||
match err {
|
||||
Error::Did(msg) => assert!(
|
||||
msg.contains("small-order"),
|
||||
"rejection must name the small-order key, got: {msg}"
|
||||
),
|
||||
other => panic!("expected Error::Did, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// base58 decoding is quadratic in its input and `signer` is attacker
|
||||
/// controlled, so an oversized method-id must be refused BEFORE the decode
|
||||
/// or a hostile attestation buys a large decode for a short request. The
|
||||
/// assertion is on the length error specifically: a multibase error here
|
||||
/// would mean the cap ran too late to matter.
|
||||
#[test]
|
||||
fn verifying_key_from_did_key_refuses_an_oversized_method_id_before_decoding() {
|
||||
let oversized = format!("did:key:z{}", "1".repeat(65_536));
|
||||
let err = verifying_key_from_did_key(&oversized)
|
||||
.expect_err("an oversized method-id must be refused");
|
||||
match err {
|
||||
Error::Did(msg) => assert!(
|
||||
msg.contains("too long"),
|
||||
"must fail on the length cap, not after decoding: {msg}"
|
||||
),
|
||||
other => panic!("expected Error::Did, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strictness regression guard. The parser now refuses a small-order key,
|
||||
/// so `verify_signature` can no longer carry a weak signer down to the
|
||||
/// signature check; this drives that check directly to prove it is still
|
||||
/// STRICT. Downgrading `verify_strict` to `verify` makes this go red,
|
||||
/// which nothing else in the suite would catch.
|
||||
#[test]
|
||||
fn verify_sig_with_key_is_strict_about_a_weak_key() {
|
||||
let mut weak_key_bytes = [0u8; 32];
|
||||
weak_key_bytes[0] = 1; // compressed identity point
|
||||
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
|
||||
assert!(weak_vk.is_weak());
|
||||
|
||||
let cert_hash = sample_cert_hash();
|
||||
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
|
||||
// R = identity, S = 0: satisfies the non-strict verification equation
|
||||
// for any message under a small-order key.
|
||||
let mut forged = [0u8; 64];
|
||||
forged[0] = 1;
|
||||
att.sig = B64U.encode(forged);
|
||||
|
||||
let bytes = canonical_signing_bytes(&att.type_, &att.payload, &att.cert_hash).expect("jcs");
|
||||
let err = att
|
||||
.verify_sig_with_key(&weak_vk, &bytes)
|
||||
.expect_err("strict verification must reject a small-order key");
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_)),
|
||||
"expected a signature error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Accept control for the seam: a genuine signature still verifies through
|
||||
/// the same helper, so the guard above is not simply rejecting everything.
|
||||
#[test]
|
||||
fn verify_sig_with_key_accepts_a_genuine_signature() {
|
||||
let cert_hash = sample_cert_hash();
|
||||
let sk = SigningKey::generate(&mut OsRng);
|
||||
let att = dummy_attestation(&sk, cert_hash);
|
||||
let bytes = canonical_signing_bytes(&att.type_, &att.payload, &att.cert_hash).expect("jcs");
|
||||
att.verify_sig_with_key(&sk.verifying_key(), &bytes)
|
||||
.expect("a real signature must verify");
|
||||
}
|
||||
|
||||
/// Control for the guard above: a real did:key must still resolve.
|
||||
#[test]
|
||||
fn verifying_key_from_did_key_still_accepts_a_real_key() {
|
||||
let sk = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let vk = sk.verifying_key();
|
||||
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&vk.to_bytes());
|
||||
let did = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
|
||||
let got = verifying_key_from_did_key(&did).expect("a real did:key must resolve");
|
||||
assert_eq!(got.to_bytes(), vk.to_bytes());
|
||||
}
|
||||
|
||||
#[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. Rejection now happens at DID RESOLUTION rather than at
|
||||
// verify time: `verifying_key_from_did_key` refuses a small-order key
|
||||
// before `verify_strict` is reached, so the observed error is
|
||||
// `Error::Did`, not `Error::Signature`. `verify_strict` remains the
|
||||
// second layer for malleability cases that do not involve a weak
|
||||
// public key (a small-order R under an honest key), which this fixture
|
||||
// cannot construct and therefore no longer covers.
|
||||
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();
|
||||
match err {
|
||||
Error::Did(msg) => assert!(
|
||||
msg.contains("small-order"),
|
||||
"weak signer must be refused as a small-order key, got: {msg}"
|
||||
),
|
||||
other => panic!("expected Error::Did for a small-order signer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Pluggable provenance attachments for Twigpine ref-update certs.
|
||||
//! Pluggable provenance attachments for gitlawb ref-update certs.
|
||||
//!
|
||||
//! A `gitlawb/ref-update/v1` cert proves who pushed and where a ref moved to.
|
||||
//! It does not say how the commit was produced. This crate adds an optional
|
||||
|
||||
@@ -121,12 +121,7 @@ impl Registry {
|
||||
attestation: &Attestation,
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<VerifiedAttestation> {
|
||||
// The key that actually verified is the trust anchor for the signer a
|
||||
// consumer sees. `verify_signature` recovers it from the artifact's
|
||||
// `signer` field and rejects a field that does not match the signing
|
||||
// key, so this is the canonical DID of the key that signed — never a
|
||||
// raw artifact string that could disagree with it.
|
||||
let verified_key = attestation.verify_signature(expected_cert_hash)?;
|
||||
attestation.verify_signature(expected_cert_hash)?;
|
||||
|
||||
let fully = match self.by_type.get(attestation.type_.as_str()) {
|
||||
Some(v) => {
|
||||
@@ -143,7 +138,7 @@ impl Registry {
|
||||
|
||||
Ok(VerifiedAttestation {
|
||||
type_: attestation.type_.clone(),
|
||||
signer: crate::attestation::did_key_from_verifying_key(&verified_key),
|
||||
signer: attestation.signer.clone(),
|
||||
cert_hash: attestation.cert_hash.clone(),
|
||||
fully_verified: fully,
|
||||
})
|
||||
@@ -176,7 +171,7 @@ impl Registry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::attestation::{did_key_from_verifying_key, Attestation, AttestationPayload};
|
||||
use crate::attestation::{Attestation, AttestationPayload};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -379,52 +374,4 @@ mod tests {
|
||||
let err = reg.verify(&att, cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
/// The signer a consumer sees must be the canonical DID of the key that
|
||||
/// actually verified, never a raw artifact string that could disagree.
|
||||
/// `verify_signature` derives the key from the artifact's `signer` field
|
||||
/// and the parser is canonical (base58btc, exact length, ed25519
|
||||
/// multicodec), so a forged field fails verification outright: the
|
||||
/// must-not here is that no `VerifiedAttestation` ever carries a signer
|
||||
/// that did not verify.
|
||||
#[test]
|
||||
fn verified_signer_is_anchored_to_the_key_that_verified() {
|
||||
let signer = fresh();
|
||||
let other = fresh();
|
||||
assert_ne!(
|
||||
signer.verifying_key().to_bytes(),
|
||||
other.verifying_key().to_bytes(),
|
||||
"fixture precondition: distinct signing keys"
|
||||
);
|
||||
|
||||
let cert_hash = sample_hash();
|
||||
let reg = Registry::new();
|
||||
|
||||
// Control: an honest artifact reports the signer's canonical DID, and
|
||||
// it agrees with the artifact field.
|
||||
let att = signed_demo(&signer, cert_hash, "ok");
|
||||
let v = reg.verify(&att, cert_hash).unwrap();
|
||||
let expected = did_key_from_verifying_key(&signer.verifying_key());
|
||||
assert_eq!(v.signer, expected, "signer must be the verified key's DID");
|
||||
assert_eq!(v.signer, att.signer, "honest artifact: field and key agree");
|
||||
|
||||
// Must-not, both directions: a signer field naming a DIFFERENT key
|
||||
// than the one that signed must fail verification, so the artifact
|
||||
// string can never redirect the reported signer.
|
||||
let mut forged = signed_demo(&signer, cert_hash, "ok");
|
||||
forged.signer = did_key_from_verifying_key(&other.verifying_key());
|
||||
let err = reg.verify(&forged, cert_hash).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_)),
|
||||
"a signer field that does not match the signing key must fail, got {err:?}"
|
||||
);
|
||||
|
||||
let mut forged_rev = signed_demo(&other, cert_hash, "ok");
|
||||
forged_rev.signer = did_key_from_verifying_key(&signer.verifying_key());
|
||||
let err_rev = reg.verify(&forged_rev, cert_hash).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_rev, Error::Signature(_)),
|
||||
"mismatched signer field must fail verification, got {err_rev:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -31,9 +30,3 @@ 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"]
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Decentralized Identifier (DID) types for the Twigpine network.
|
||||
//! Decentralized Identifier (DID) types for the gitlawb network.
|
||||
//!
|
||||
//! Supported methods:
|
||||
//! - `did:key` — ephemeral, derived directly from an Ed25519 public key
|
||||
//! - `did:web` — anchored to a domain
|
||||
//! - `did:gitlawb` — native, anchored to the libp2p DHT
|
||||
//!
|
||||
//! The canonical DID for a Twigpine actor is `did:key` during bootstrap
|
||||
//! The canonical DID for a gitlawb actor is `did:key` during bootstrap
|
||||
//! and migrates to `did:gitlawb` once DHT anchoring is live.
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
@@ -71,8 +71,7 @@ impl Did {
|
||||
|
||||
/// Resolve the Ed25519 verifying key from a `did:key`.
|
||||
///
|
||||
/// Returns `Err` if the DID is not a `did:key`, the key bytes are malformed,
|
||||
/// or the key is a small-order (weak) point.
|
||||
/// Returns `Err` if the DID is not a `did:key` or the key bytes are invalid.
|
||||
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
|
||||
if !self.is_did_key() {
|
||||
return Err(Error::InvalidDid(format!(
|
||||
@@ -81,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()))?;
|
||||
|
||||
@@ -107,25 +93,7 @@ impl Did {
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
|
||||
|
||||
let key =
|
||||
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
// `from_bytes` only decompresses, so it accepts a small-order point.
|
||||
// Such a key satisfies the verification equation for any message, and
|
||||
// every X25519 shared secret derived from it is the all-zero key: the
|
||||
// order-1 and order-2 points convert to the all-zero Montgomery u
|
||||
// directly, and the order-4 and order-8 points are annihilated by the
|
||||
// scalar clamping instead. `is_weak` covers the whole torsion set.
|
||||
// Mixed-order points are correctly NOT rejected, since clamping clears
|
||||
// the cofactor component. Rejecting
|
||||
// at resolution is what makes this the choke point: every consumer that
|
||||
// resolves a DID through here inherits the rejection, and a recipient
|
||||
// that cannot resolve already fails closed downstream.
|
||||
if key.is_weak() {
|
||||
return Err(Error::InvalidDid("small-order ed25519 key".to_string()));
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Return the full DID string as a `&str`.
|
||||
@@ -171,7 +139,7 @@ impl From<Did> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A DID Document for a Twigpine actor.
|
||||
/// A DID Document for a gitlawb actor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DidDocument {
|
||||
#[serde(rename = "@context")]
|
||||
@@ -340,84 +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");
|
||||
}
|
||||
|
||||
/// A `did:key` can encode a small-order (weak) Ed25519 point. Such a key
|
||||
/// satisfies the verification equation for any message and, converted to
|
||||
/// Montgomery form for X25519, yields the all-zero shared secret, so an
|
||||
/// envelope sealed to it is decryptable by anyone. Resolution is the choke
|
||||
/// point every `Did`-based consumer shares, so it fails closed here.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_a_small_order_key() {
|
||||
// The compressed identity point, the canonical small-order key.
|
||||
let mut weak = [0u8; 32];
|
||||
weak[0] = 1;
|
||||
let mut prefixed = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
prefixed.extend_from_slice(ED25519_MULTICODEC);
|
||||
prefixed.extend_from_slice(&weak);
|
||||
let did: Did = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("a small-order did:key is still well-formed");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("a small-order did:key must not resolve");
|
||||
match err {
|
||||
Error::InvalidDid(msg) => assert!(
|
||||
msg.contains("small-order"),
|
||||
"rejection must name the small-order key, got: {msg}"
|
||||
),
|
||||
other => panic!("expected Error::InvalidDid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,6 @@ 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;
|
||||
|
||||
// Every X25519 shared secret derived from a small-order point is the
|
||||
// all-zero key: order-1 and order-2 convert to the all-zero Montgomery u
|
||||
// directly, order-4 and order-8 are annihilated by the scalar clamping in
|
||||
// x25519_secret_from_seed. Either way the per-recipient wrap could be
|
||||
// rebuilt by anyone. Resolution already refuses such a key (see
|
||||
// Did::to_verifying_key); this guard covers the SEAL side on its own terms
|
||||
// for a caller that obtained the key some other way. The open side
|
||||
// (open_blob's attacker-supplied `eph`) skips any entry whose exchange with
|
||||
// this reader yields the all-zero shared secret
|
||||
// (`yields_all_zero_shared_secret`), regardless of whether the bytes decode
|
||||
// to an Edwards point.
|
||||
if vk.is_weak() {
|
||||
return Err(anyhow::anyhow!("verifying key is a small-order point"));
|
||||
}
|
||||
|
||||
let edwards = CompressedEdwardsY::from_slice(vk.as_bytes())
|
||||
.ok()
|
||||
.and_then(|c| c.decompress())
|
||||
@@ -34,26 +18,6 @@ fn x25519_public(vk: &VerifyingKey) -> Result<[u8; 32]> {
|
||||
Ok(edwards.to_montgomery().to_bytes())
|
||||
}
|
||||
|
||||
/// True when the X25519 exchange between an attacker-supplied `u` and this
|
||||
/// reader's scalar yields the all-zero shared secret.
|
||||
///
|
||||
/// Asked of the RESULT, not of the encoding, and that distinction is the whole
|
||||
/// guard. The previous version decompressed `u` to an Edwards point and asked
|
||||
/// `is_small_order`, which reports "safe" for any low-order encoding that does
|
||||
/// not decompress at all. Measured across the seven standard low-order
|
||||
/// encodings, six were caught and `u = p - 1` was not: it fails to decompress,
|
||||
/// so the check returned false, and the exchange is still the all-zero secret.
|
||||
/// An entry built on it unwraps for every reader.
|
||||
///
|
||||
/// Enumerating encodings cannot be exhaustive, because the attack depends on the
|
||||
/// shared secret rather than on the bytes that produced it, and twist and
|
||||
/// non-canonical inputs keep arriving. Asking the result needs no list.
|
||||
fn yields_all_zero_shared_secret(u: &[u8; 32], scalar: &[u8; 32]) -> bool {
|
||||
use curve25519_dalek::montgomery::MontgomeryPoint;
|
||||
|
||||
MontgomeryPoint(*u).mul_clamped(*scalar).to_bytes() == [0u8; 32]
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -85,13 +49,6 @@ use serde::{Deserialize, Serialize};
|
||||
const MAGIC: &[u8] = b"GLENC";
|
||||
const VERSION: u8 = 2;
|
||||
|
||||
/// Upper bound on the blinded recipient list. The envelope author controls
|
||||
/// this count and `open_blob` pays one X25519 exchange per entry before it can
|
||||
/// answer "not a recipient", so the count has to be capped rather than
|
||||
/// optimized (#419). Legitimate envelopes carry a handful of authorized
|
||||
/// readers, so 256 is far above real use.
|
||||
const MAX_RECIPIENTS: usize = 256;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Recipient {
|
||||
eph: String, // base64 ephemeral x25519 pubkey (32B)
|
||||
@@ -111,14 +68,6 @@ pub fn seal_blob(plaintext: &[u8], recipients: &[VerifyingKey]) -> Result<Vec<u8
|
||||
if recipients.is_empty() {
|
||||
return Err(anyhow::anyhow!("seal_blob: no recipients"));
|
||||
}
|
||||
// Same bound open_blob enforces: an envelope over the cap could never be
|
||||
// opened, so refuse to seal it (#419).
|
||||
if recipients.len() > MAX_RECIPIENTS {
|
||||
return Err(anyhow::anyhow!(
|
||||
"seal_blob: {} recipients, over the {MAX_RECIPIENTS} cap",
|
||||
recipients.len()
|
||||
));
|
||||
}
|
||||
let mut content_key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut content_key);
|
||||
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
|
||||
@@ -177,20 +126,9 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
|
||||
let header: Header =
|
||||
serde_json::from_slice(envelope.get(p..p + hlen).context("truncated header")?)
|
||||
.context("decode header")?;
|
||||
// Reject before the recipient scan: every entry below is an X25519
|
||||
// exchange, and a non-recipient would run the whole list (#419).
|
||||
if header.recipients.len() > MAX_RECIPIENTS {
|
||||
return Err(anyhow::anyhow!(
|
||||
"envelope carries {} recipients, over the {MAX_RECIPIENTS} cap",
|
||||
header.recipients.len()
|
||||
));
|
||||
}
|
||||
let body = &envelope[p + hlen..];
|
||||
|
||||
// The raw scalar is kept alongside the box secret so the exchange can be
|
||||
// tested BEFORE a box is built from an attacker-supplied ephemeral.
|
||||
let my_x_scalar = x25519_secret_from_seed(&keypair.to_seed());
|
||||
let my_x = XSecret::from(*my_x_scalar);
|
||||
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
|
||||
@@ -202,12 +140,7 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
|
||||
.ok()
|
||||
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
|
||||
{
|
||||
// An ephemeral whose exchange with this reader is the all-zero
|
||||
// shared secret would unwrap for anyone, so the entry is skipped.
|
||||
// Tested on the exchange itself: an encoding-shaped check misses
|
||||
// every low-order input that does not decompress to Edwards.
|
||||
Some(b) if !yields_all_zero_shared_secret(&b, &my_x_scalar) => XPublic::from(b),
|
||||
Some(_) => continue,
|
||||
Some(b) => XPublic::from(b),
|
||||
None => continue,
|
||||
};
|
||||
// from_slice panics on a wrong length, and the envelope is attacker
|
||||
@@ -360,436 +293,4 @@ mod tests {
|
||||
header["nonce"] = bad_nonce;
|
||||
assert!(open_blob(&reframe(&header), &reader).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_cap_recipient_list_is_rejected_before_the_scan() {
|
||||
// #419: the envelope author controls the recipient count and each entry
|
||||
// costs an X25519 exchange, so the list is capped. Reframe a valid
|
||||
// envelope with MAX_RECIPIENTS + 1 junk entries.
|
||||
let reader = Keypair::generate();
|
||||
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
|
||||
|
||||
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 mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
|
||||
let junk = serde_json::json!({
|
||||
"eph": B64.encode([0u8; 32]),
|
||||
"nonce": B64.encode([0u8; 24]),
|
||||
"wrap": B64.encode([0u8; 48]),
|
||||
});
|
||||
header["recipients"] = serde_json::Value::Array(vec![junk.clone(); MAX_RECIPIENTS + 1]);
|
||||
|
||||
let hj = serde_json::to_vec(&header).unwrap();
|
||||
let mut fat = Vec::new();
|
||||
fat.extend_from_slice(MAGIC);
|
||||
fat.push(VERSION);
|
||||
fat.extend_from_slice(&(hj.len() as u32).to_le_bytes());
|
||||
fat.extend_from_slice(&hj);
|
||||
fat.extend_from_slice(body);
|
||||
|
||||
let err = open_blob(&fat, &reader).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("recipients"),
|
||||
"expected the recipient-cap error, got: {err}"
|
||||
);
|
||||
|
||||
// Exactly at the cap the envelope is still processed end to end.
|
||||
header["recipients"] = serde_json::Value::Array(vec![junk; MAX_RECIPIENTS]);
|
||||
let hj = serde_json::to_vec(&header).unwrap();
|
||||
let mut at_cap = Vec::new();
|
||||
at_cap.extend_from_slice(MAGIC);
|
||||
at_cap.push(VERSION);
|
||||
at_cap.extend_from_slice(&(hj.len() as u32).to_le_bytes());
|
||||
at_cap.extend_from_slice(&hj);
|
||||
at_cap.extend_from_slice(body);
|
||||
let err = open_blob(&at_cap, &reader).unwrap_err();
|
||||
assert!(
|
||||
!err.to_string().contains("over the"),
|
||||
"at-cap envelope must reach the scan, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seal_refuses_a_recipient_list_over_the_cap() {
|
||||
// An envelope over the cap can never be opened, so seal must reject it
|
||||
// instead of minting one.
|
||||
let keys: Vec<VerifyingKey> = (0..MAX_RECIPIENTS + 1)
|
||||
.map(|_| Keypair::generate().verifying_key())
|
||||
.collect();
|
||||
let err = seal_blob(b"blob", &keys).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("recipients"),
|
||||
"expected the recipient-cap error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every standard low-order X25519 encoding must be detected by
|
||||
/// `yields_all_zero_shared_secret`, including encodings that do not
|
||||
/// decompress to an Edwards point (notably `u = p - 1`). The open side
|
||||
/// rejects on the exchange result, not on decode shape, because an
|
||||
/// encoding-shaped check misses non-decompressing low-order inputs while
|
||||
/// still yielding the all-zero shared secret.
|
||||
#[test]
|
||||
fn all_zero_shared_secret_is_detected_for_every_standard_low_order_encoding() {
|
||||
// The seven standard X25519 low-order encodings, driven as a set rather
|
||||
// than as the two that happen to decompress. The encoding-shaped check
|
||||
// this replaced reported `p - 1` as safe, and its exchange is all-zero.
|
||||
let mut one = [0u8; 32];
|
||||
one[0] = 1;
|
||||
let hex = |h: &str| -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).unwrap();
|
||||
}
|
||||
out
|
||||
};
|
||||
let vectors = [
|
||||
("u=0", [0u8; 32]),
|
||||
("u=1", one),
|
||||
(
|
||||
"order8-a",
|
||||
hex("e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"),
|
||||
),
|
||||
(
|
||||
"order8-b",
|
||||
hex("5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"),
|
||||
),
|
||||
(
|
||||
"p-1",
|
||||
hex("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
|
||||
),
|
||||
(
|
||||
"p",
|
||||
hex("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
|
||||
),
|
||||
(
|
||||
"p+1",
|
||||
hex("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
|
||||
),
|
||||
];
|
||||
let kp = Keypair::generate();
|
||||
let scalar = x25519_secret_from_seed(&kp.to_seed());
|
||||
for (name, u) in vectors {
|
||||
assert!(
|
||||
yields_all_zero_shared_secret(&u, &scalar),
|
||||
"{name} is a low-order encoding and must be detected"
|
||||
);
|
||||
}
|
||||
|
||||
// And the other direction, or a guard that returned true always would
|
||||
// pass everything above while making the envelope unopenable.
|
||||
let real_u = x25519_public(&kp.verifying_key()).unwrap();
|
||||
assert!(
|
||||
!yields_all_zero_shared_secret(&real_u, &scalar),
|
||||
"a real key must not be treated as low-order"
|
||||
);
|
||||
}
|
||||
|
||||
/// The open side must skip an entry whose header-supplied `eph` is a
|
||||
/// low-order point: such an entry forces the all-zero shared secret and
|
||||
/// would unwrap for any reader. A poisoned entry among honest ones must
|
||||
/// not DoS the envelope — the honest entry still opens.
|
||||
#[test]
|
||||
fn open_blob_skips_a_low_order_ephemeral_and_still_opens_honest_entry() {
|
||||
use curve25519_dalek::montgomery::MontgomeryPoint;
|
||||
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..];
|
||||
|
||||
// A low-order Montgomery u (u = 0) encoded as the entry's eph.
|
||||
let low_order_u = MontgomeryPoint([0u8; 32]).to_bytes();
|
||||
let poisoned = serde_json::json!({
|
||||
"eph": B64.encode(low_order_u),
|
||||
"nonce": B64.encode([0u8; 24]),
|
||||
"wrap": B64.encode([0u8; 32]),
|
||||
});
|
||||
|
||||
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 mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
|
||||
// Prepend the poisoned entry. The reader's honest entry must still
|
||||
// unwrap, so the envelope opens despite the low-order entry.
|
||||
let recipients = header["recipients"].as_array_mut().unwrap();
|
||||
recipients.insert(0, poisoned);
|
||||
assert_eq!(
|
||||
open_blob(&reframe(&header), &reader).unwrap(),
|
||||
b"private blob contents",
|
||||
"the honest entry must still open despite a low-order eph entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Must-not for the open-side guard: an envelope whose ONLY entry is built
|
||||
/// on a low-order ephemeral must not open for any reader. Without the
|
||||
/// guard the all-zero shared secret unwraps for everyone, so the attacker
|
||||
/// controls the plaintext and the envelope's confidentiality is void.
|
||||
#[test]
|
||||
fn open_blob_rejects_a_poisoned_only_envelope() {
|
||||
use chacha20poly1305::{aead::Aead, KeyInit, XChaCha20Poly1305, XNonce};
|
||||
use crypto_box::aead::AeadCore;
|
||||
use curve25519_dalek::montgomery::MontgomeryPoint;
|
||||
|
||||
// Build a poisoned envelope from scratch: one recipient entry whose
|
||||
// eph is the low-order u = 0. The wrap is the encryption of a content
|
||||
// key under the all-zero shared secret, so without the guard any
|
||||
// reader's ChaChaBox against u = 0 decrypts it.
|
||||
let mut content_key: [u8; 32] = Default::default();
|
||||
OsRng.fill_bytes(&mut content_key);
|
||||
let attacker_x = XSecret::generate(&mut OsRng);
|
||||
let zero_box = ChaChaBox::new(&XPublic::from(<[u8; 32]>::default()), &attacker_x);
|
||||
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
|
||||
let wrap = zero_box.encrypt(&nonce, &content_key[..]).unwrap();
|
||||
|
||||
let mut body_nonce: [u8; 24] = Default::default();
|
||||
OsRng.fill_bytes(&mut body_nonce);
|
||||
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
|
||||
let body = body_cipher
|
||||
.encrypt(
|
||||
XNonce::from_slice(&body_nonce),
|
||||
b"attacker-chosen plaintext".as_slice(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let header = serde_json::json!({
|
||||
"alg": "xchacha20poly1305",
|
||||
"nonce": B64.encode(body_nonce),
|
||||
"recipients": [{
|
||||
"eph": B64.encode(MontgomeryPoint([0u8; 32]).to_bytes()),
|
||||
"nonce": B64.encode(nonce),
|
||||
"wrap": B64.encode(wrap),
|
||||
}],
|
||||
});
|
||||
let header_json = serde_json::to_vec(&header).unwrap();
|
||||
let mut env = Vec::new();
|
||||
env.extend_from_slice(MAGIC);
|
||||
env.push(VERSION);
|
||||
env.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
|
||||
env.extend_from_slice(&header_json);
|
||||
env.extend_from_slice(&body);
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let err = open_blob(&env, &reader).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not a recipient"),
|
||||
"a poisoned-only envelope must not open for anyone, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same must-not, driven with the low-order encoding that does NOT map
|
||||
/// to an Edwards point.
|
||||
///
|
||||
/// This is the case an encoding-shaped guard cannot see. `u = p - 1` is a
|
||||
/// legal X25519 input whose exchange is the all-zero secret for any scalar,
|
||||
/// and `MontgomeryPoint::to_edwards` returns None for it, so a check written
|
||||
/// as "decompress, then ask is_small_order" reports it as safe. Measured
|
||||
/// across the seven standard low-order encodings: six are caught that way
|
||||
/// and this one is not.
|
||||
///
|
||||
/// Found by a cross-family review of the head that first added the guard,
|
||||
/// and the comment beside that guard asserted this case was already handled
|
||||
/// by a decode path that does not exist. That is why the fix rejects on the
|
||||
/// RESULT of the exchange rather than on a list of encodings: the result is
|
||||
/// what the attack actually depends on, and it needs no enumeration to be
|
||||
/// exhaustive.
|
||||
#[test]
|
||||
fn open_blob_rejects_a_non_edwards_low_order_ephemeral() {
|
||||
// No blanket `use ...::Aead` here. Two aead versions are reachable, and a
|
||||
// blanket import silently resolved the wrap through the one open_blob does
|
||||
// NOT decrypt with, producing a ciphertext that could never open. The test
|
||||
// then passed with the guard, without it, and with the original pre-fix
|
||||
// guard alike, proving nothing. Every call below names its trait.
|
||||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
|
||||
use crypto_box::aead::AeadCore;
|
||||
|
||||
// u = p - 1 = 2^255 - 20, little endian.
|
||||
let mut eph = [0xffu8; 32];
|
||||
eph[0] = 0xec;
|
||||
eph[31] = 0x7f;
|
||||
|
||||
let mut content_key: [u8; 32] = Default::default();
|
||||
OsRng.fill_bytes(&mut content_key);
|
||||
let attacker_x = XSecret::generate(&mut OsRng);
|
||||
let zero_box = ChaChaBox::new(&XPublic::from(<[u8; 32]>::default()), &attacker_x);
|
||||
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
|
||||
let wrap = crypto_box::aead::Aead::encrypt(&zero_box, &nonce, &content_key[..]).unwrap();
|
||||
|
||||
let mut body_nonce: [u8; 24] = Default::default();
|
||||
OsRng.fill_bytes(&mut body_nonce);
|
||||
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
|
||||
let body = chacha20poly1305::aead::Aead::encrypt(
|
||||
&body_cipher,
|
||||
XNonce::from_slice(&body_nonce),
|
||||
b"attacker-chosen plaintext".as_slice(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let header = serde_json::json!({
|
||||
"alg": "xchacha20poly1305",
|
||||
"nonce": B64.encode(body_nonce),
|
||||
"recipients": [{
|
||||
"eph": B64.encode(eph),
|
||||
"nonce": B64.encode(nonce),
|
||||
"wrap": B64.encode(wrap),
|
||||
}],
|
||||
});
|
||||
let header_json = serde_json::to_vec(&header).unwrap();
|
||||
let mut env = Vec::new();
|
||||
env.extend_from_slice(MAGIC);
|
||||
env.push(VERSION);
|
||||
env.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
|
||||
env.extend_from_slice(&header_json);
|
||||
env.extend_from_slice(&body);
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let err = open_blob(&env, &reader).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not a recipient"),
|
||||
"an envelope whose only entry uses a non-Edwards low-order eph must not \
|
||||
open for anyone, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The compressed identity point: a well-formed Ed25519 encoding that is
|
||||
/// small-order, so every shared secret derived from it is all-zero.
|
||||
fn weak_verifying_key() -> VerifyingKey {
|
||||
let mut weak = [0u8; 32];
|
||||
weak[0] = 1;
|
||||
let vk = VerifyingKey::from_bytes(&weak).expect("identity point decompresses");
|
||||
assert!(vk.is_weak(), "fixture precondition: key is small-order");
|
||||
vk
|
||||
}
|
||||
|
||||
/// A small-order recipient key converts to Montgomery u = 0, and X25519
|
||||
/// against u = 0 is the all-zero shared secret for ANY scalar, so the
|
||||
/// wrapping box is reconstructable with no secret at all. Reject at the
|
||||
/// primitive so the seal side is safe regardless of how the caller
|
||||
/// obtained the key.
|
||||
#[test]
|
||||
fn x25519_public_rejects_a_small_order_key() {
|
||||
let weak_vk = weak_verifying_key();
|
||||
|
||||
assert!(
|
||||
x25519_public(&weak_vk).is_err(),
|
||||
"a small-order key must not yield an x25519 public key"
|
||||
);
|
||||
}
|
||||
|
||||
/// Control: a legitimate key still converts, and to a non-zero u.
|
||||
#[test]
|
||||
fn x25519_public_still_accepts_a_real_key() {
|
||||
let kp = Keypair::generate();
|
||||
let u = x25519_public(&kp.verifying_key()).expect("a real key must convert");
|
||||
assert_ne!(u, [0u8; 32], "a real key must not map to the zero point");
|
||||
}
|
||||
|
||||
/// The guard has to propagate: no envelope may be produced for a weak
|
||||
/// recipient even when the DID choke point is bypassed entirely.
|
||||
#[test]
|
||||
fn seal_blob_refuses_a_small_order_recipient() {
|
||||
let weak_vk = weak_verifying_key();
|
||||
|
||||
assert!(
|
||||
seal_blob(b"withheld", &[weak_vk]).is_err(),
|
||||
"sealing to a weak recipient must fail"
|
||||
);
|
||||
|
||||
// And a mixed set must fail too: one weak recipient exposes the shared
|
||||
// content key, so a partial envelope is not an acceptable outcome.
|
||||
let honest = Keypair::generate();
|
||||
assert!(
|
||||
seal_blob(b"withheld", &[honest.verifying_key(), weak_vk]).is_err(),
|
||||
"a mixed honest+weak recipient set must fail closed, not seal partially"
|
||||
);
|
||||
}
|
||||
|
||||
/// Control for the two above: the ordinary seal/open round trip is intact.
|
||||
#[test]
|
||||
fn legit_seal_open_round_trip_still_works() {
|
||||
let reader = Keypair::generate();
|
||||
let env = seal_blob(b"withheld blob", &[reader.verifying_key()]).expect("seal");
|
||||
assert_eq!(open_blob(&env, &reader).expect("open"), b"withheld blob");
|
||||
}
|
||||
|
||||
/// The defect this fix exists to close, kept as an executable regression
|
||||
/// rather than an assertion about absence. It runs the real attack: craft a
|
||||
/// small-order did:key, get it into a recipient set alongside an honest
|
||||
/// reader, then rebuild the wrapping box from the all-zero shared secret
|
||||
/// that a small-order recipient forces. If any guard regresses, this does
|
||||
/// not merely fail, it fails printing the plaintext it recovered.
|
||||
#[test]
|
||||
fn attacker_cannot_recover_plaintext_via_a_weak_recipient() {
|
||||
use crate::did::Did;
|
||||
use std::str::FromStr;
|
||||
|
||||
let weak_vk = weak_verifying_key();
|
||||
|
||||
// Layer 1: the attacker's did:key string must not resolve at all.
|
||||
let weak_did = Did::from_verifying_key(&weak_vk).to_string();
|
||||
assert!(
|
||||
Did::from_str(&weak_did)
|
||||
.expect("still a well-formed did:key")
|
||||
.to_verifying_key()
|
||||
.is_err(),
|
||||
"a small-order did:key must not resolve"
|
||||
);
|
||||
|
||||
// Layer 2: even handed the key directly, sealing must refuse. One weak
|
||||
// recipient would expose the single shared content key, so the honest
|
||||
// reader's blob would be readable by anyone.
|
||||
let honest = Keypair::generate();
|
||||
let secret_plaintext = b"WITHHELD BLOB CONTENTS";
|
||||
let envelope = match seal_blob(secret_plaintext, &[honest.verifying_key(), weak_vk]) {
|
||||
Err(_) => return, // no envelope exists, nothing to attack
|
||||
Ok(env) => env,
|
||||
};
|
||||
|
||||
// Only reachable if a guard regressed. Run the attack and report what
|
||||
// it got, so the failure names the actual exposure.
|
||||
let mut p = MAGIC.len() + 1;
|
||||
let hlen = u32::from_le_bytes(envelope[p..p + 4].try_into().unwrap()) as usize;
|
||||
p += 4;
|
||||
let header: serde_json::Value = serde_json::from_slice(&envelope[p..p + hlen]).unwrap();
|
||||
let body = &envelope[p + hlen..];
|
||||
let body_nonce = B64.decode(header["nonce"].as_str().unwrap()).unwrap();
|
||||
|
||||
// X25519 against u = 0 is the all-zero shared secret for any scalar, so
|
||||
// the sealer's box is reconstructable with a key the attacker picks.
|
||||
let zero_box = ChaChaBox::new(
|
||||
&XPublic::from(<[u8; 32]>::default()),
|
||||
&XSecret::generate(&mut OsRng),
|
||||
);
|
||||
for r in header["recipients"].as_array().unwrap() {
|
||||
let n = B64.decode(r["nonce"].as_str().unwrap()).unwrap();
|
||||
let w = B64.decode(r["wrap"].as_str().unwrap()).unwrap();
|
||||
if let Ok(content_key) = zero_box.decrypt(n.as_slice().into(), w.as_slice()) {
|
||||
let cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
|
||||
let recovered = cipher
|
||||
.decrypt(XNonce::from_slice(&body_nonce), body)
|
||||
.expect("body decrypts once the content key is out");
|
||||
panic!(
|
||||
"REGRESSION: attacker recovered plaintext with no private key: {:?}",
|
||||
String::from_utf8_lossy(&recovered)
|
||||
);
|
||||
}
|
||||
}
|
||||
panic!("an envelope was sealed to a small-order recipient; the seal guard regressed");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! HTTP Signatures (RFC 9421) for gitlawb.
|
||||
//!
|
||||
//! Every write request to a Twigpine node must be signed by the actor's
|
||||
//! Every write request to a gitlawb node must be signed by the actor's
|
||||
//! Ed25519 private key. The signature covers:
|
||||
//! - `@method` — HTTP method (uppercase)
|
||||
//! - `@path` — request path and query
|
||||
@@ -14,13 +14,13 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use chrono::Utc;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The component identifiers covered by every Twigpine signature.
|
||||
/// The component identifiers covered by every gitlawb signature.
|
||||
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
|
||||
|
||||
/// The three headers produced by RFC 9421 signing.
|
||||
@@ -67,20 +67,11 @@ impl HttpSignature {
|
||||
let components_str = &rest[open + 1..close];
|
||||
let params_str = &rest[close + 1..]; // starts with ';'
|
||||
|
||||
// RFC 9421 §2.1: a component identifier appears at most once. Gitlawb
|
||||
// only supports bare names; parameters are rejected so reordered
|
||||
// equivalent forms cannot bypass duplicate detection as distinct strings.
|
||||
let mut components = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for token in components_str.split_whitespace() {
|
||||
let name = parse_covered_component(token)?;
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"duplicate covered component '{name}' in Signature-Input"
|
||||
)));
|
||||
}
|
||||
components.push(name);
|
||||
}
|
||||
// "\"@method\" \"@path\" \"content-digest\"" → ["@method", "@path", "content-digest"]
|
||||
let components: Vec<String> = components_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.trim_matches('"').to_string())
|
||||
.collect();
|
||||
|
||||
let params = parse_params(params_str)?;
|
||||
|
||||
@@ -122,48 +113,15 @@ impl HttpSignature {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject a `created` timestamp outside the acceptance window, which is
|
||||
/// ASYMMETRIC: up to 5 minutes into the past, but only 60 seconds into the
|
||||
/// future.
|
||||
///
|
||||
/// The two directions are two comparisons rather than one distance, because
|
||||
/// they are not the same event. A signature that arrives late is ordinary:
|
||||
/// clocks run slow, requests queue, and networks are not instant, so the
|
||||
/// past side keeps the full 300-second allowance. A signature stamped ahead
|
||||
/// of this node's clock is not ordinary, and folding both sides into an
|
||||
/// `abs()` let a signer extend its own validity: signed at T with
|
||||
/// `created = T + 300`, it stayed acceptable until roughly T + 600, so the
|
||||
/// effective window was double the one this function advertises. The
|
||||
/// forward tolerance exists only to absorb honest drift between two hosts,
|
||||
/// which is what 60 seconds buys; it is the same allowance the gossip
|
||||
/// path's freshness check uses for the same reason.
|
||||
/// Reject if the `created` timestamp is more than 5 minutes from now.
|
||||
pub fn check_created(&self) -> Result<()> {
|
||||
const MAX_AGE_SECS: i64 = 300;
|
||||
const MAX_FUTURE_SECS: i64 = 60;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
// Saturating, not plain subtraction. `created` is parsed as an
|
||||
// unrestricted i64 straight from the Signature-Input header, so a
|
||||
// sender picks it: at i64::MIN the past-side subtraction overflows,
|
||||
// which panics a debug build and wraps a release one into an absurd
|
||||
// value that can read as acceptable. Saturating gives the answer the
|
||||
// check wants at both extremes, since a timestamp that far away is
|
||||
// refused by whichever side it saturates toward.
|
||||
let age = now.saturating_sub(self.created);
|
||||
if age > MAX_AGE_SECS {
|
||||
let skew = (now - self.created).abs();
|
||||
if skew > 300 {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"clock skew too large: created {age}s in the past (max {MAX_AGE_SECS}s)"
|
||||
"clock skew too large: {skew}s (max 300s)"
|
||||
)));
|
||||
}
|
||||
|
||||
let ahead = self.created.saturating_sub(now);
|
||||
if ahead > MAX_FUTURE_SECS {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"clock skew too large: created {ahead}s in the future (max {MAX_FUTURE_SECS}s)"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -245,33 +203,6 @@ pub fn compute_content_digest(body: &[u8]) -> String {
|
||||
format!("sha-256=:{}:", STANDARD.encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Parse one covered-component token from Signature-Input's parenthesized list.
|
||||
///
|
||||
/// Gitlawb only supports bare component names (`"@method"`, `"@path"`,
|
||||
/// `"content-digest"`). RFC 9421 permits parameters on identifiers; they are
|
||||
/// rejected rather than accepted in a form `missing_components()` and
|
||||
/// `build_signing_string()` cannot handle consistently.
|
||||
fn parse_covered_component(token: &str) -> Result<String> {
|
||||
let token = token.trim();
|
||||
let open = token
|
||||
.strip_prefix('"')
|
||||
.ok_or_else(|| Error::HttpSignature("component must be a quoted string".into()))?;
|
||||
let close = open
|
||||
.find('"')
|
||||
.ok_or_else(|| Error::HttpSignature("unterminated component string".into()))?;
|
||||
let name = &open[..close];
|
||||
if name.is_empty() {
|
||||
return Err(Error::HttpSignature("empty component name".into()));
|
||||
}
|
||||
let trailing = open[close + 1..].trim();
|
||||
if !trailing.is_empty() {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"component parameters are not supported on '{name}'"
|
||||
)));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Parse `;key="value";key2=value` parameter string into a map.
|
||||
fn parse_params(s: &str) -> Result<HashMap<String, String>> {
|
||||
let mut map = HashMap::new();
|
||||
@@ -341,91 +272,6 @@ mod tests {
|
||||
assert!(missing.contains(&"content-digest"));
|
||||
}
|
||||
|
||||
/// RFC 9421 §2.1: a component identifier must not appear twice in the
|
||||
/// covered list. Repeating one is not a way to say anything, but it does
|
||||
/// repeat a line in the signing string, so the size of what a verifier
|
||||
/// builds (and what a node persists alongside a claim) is set by how many
|
||||
/// times the caller chose to write the same name.
|
||||
#[test]
|
||||
fn parse_rejects_component_parameters() {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest";sf);keyid="{did}";alg="ed25519";created=1000"#
|
||||
);
|
||||
let err = HttpSignature::parse(
|
||||
&sig_input,
|
||||
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
|
||||
)
|
||||
.expect_err("parameterized components must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("component parameters"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_reordered_equivalent_component_parameters() {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let sig_input = format!(
|
||||
r#"sig1=("content-digest";sf;tr "content-digest";tr;sf "@method" "@path");keyid="{did}";alg="ed25519";created=1000"#
|
||||
);
|
||||
let err = HttpSignature::parse(
|
||||
&sig_input,
|
||||
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
|
||||
)
|
||||
.expect_err("reordered equivalent parameters must not bypass as distinct components");
|
||||
assert!(
|
||||
err.to_string().contains("component parameters"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_many_distinct_components_completes_quickly() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let names: Vec<String> = (0..20_000).map(|i| format!("\"c{i}\"")).collect();
|
||||
let inner = names.join(" ");
|
||||
let sig_input = format!(r#"sig1=({inner});keyid="{did}";alg="ed25519";created=1000"#);
|
||||
let start = Instant::now();
|
||||
let result = HttpSignature::parse(
|
||||
&sig_input,
|
||||
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"duplicate tracking must stay linear, took {elapsed:?}"
|
||||
);
|
||||
assert!(result.is_ok(), "distinct names should parse: {:?}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_duplicate_components() {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
|
||||
);
|
||||
let err = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:")
|
||||
.expect_err("a repeated component must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("duplicate"),
|
||||
"the error must name the duplication, got: {err}"
|
||||
);
|
||||
|
||||
// The control: the same list without the repeat still parses.
|
||||
let ok = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
|
||||
);
|
||||
HttpSignature::parse(&ok, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:")
|
||||
.expect("a distinct component list must still parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_end_to_end() {
|
||||
use crate::identity::verify;
|
||||
@@ -489,75 +335,6 @@ mod tests {
|
||||
assert!(sig.check_created().is_err());
|
||||
}
|
||||
|
||||
/// Build a parseable signature whose `created` is `offset` seconds from now
|
||||
/// (negative for the past), so a test can pin one side of the window.
|
||||
fn sig_created_at_offset(offset: i64) -> HttpSignature {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let created = Utc::now().timestamp() + offset;
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
|
||||
);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
}
|
||||
|
||||
/// Build a signature at an ABSOLUTE `created`, so a test can drive the
|
||||
/// extremes an offset from now cannot reach.
|
||||
fn sig_created_absolute(created: i64) -> HttpSignature {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
|
||||
);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
}
|
||||
|
||||
/// `created` is parsed as an unrestricted i64 from a header the sender
|
||||
/// writes, so the window check has to survive both ends of the type. Before
|
||||
/// the saturating form this panicked with "attempt to subtract with
|
||||
/// overflow" on the past side, which is a debug-build crash any client
|
||||
/// could trigger by sending one header value.
|
||||
#[test]
|
||||
fn extreme_created_values_are_refused_rather_than_overflowing() {
|
||||
for created in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
|
||||
let err = sig_created_absolute(created)
|
||||
.check_created()
|
||||
.expect_err("an extreme created must be refused, not accepted");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("past") || msg.contains("future"),
|
||||
"created={created} must be refused by a named direction, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_301s_in_past_rejected() {
|
||||
let err = sig_created_at_offset(-301).check_created().unwrap_err();
|
||||
assert!(err.to_string().contains("past"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_299s_in_past_accepted() {
|
||||
assert!(sig_created_at_offset(-299).check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_61s_in_future_rejected() {
|
||||
let err = sig_created_at_offset(61).check_created().unwrap_err();
|
||||
assert!(err.to_string().contains("future"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_30s_in_future_accepted() {
|
||||
assert!(sig_created_at_offset(30).check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_exactly_now_accepted() {
|
||||
assert!(sig_created_at_offset(0).check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_signature_passes_clock_skew() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Keypair generation, storage, and signing.
|
||||
//!
|
||||
//! A Twigpine identity is an Ed25519 keypair. The public key encodes into a
|
||||
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
|
||||
//! `did:key` DID. The private key is stored as PKCS#8 PEM on disk.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
@@ -11,7 +11,7 @@ use zeroize::Zeroizing;
|
||||
use crate::did::Did;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// An Ed25519 keypair that is the root identity for a Twigpine actor.
|
||||
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
|
||||
#[derive(Clone)]
|
||||
pub struct Keypair {
|
||||
signing_key: SigningKey,
|
||||
@@ -78,9 +78,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)
|
||||
}
|
||||
|
||||
@@ -208,28 +209,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();
|
||||
|
||||
@@ -5,16 +5,7 @@ pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
// `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;
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Redirect policy shared by every Twigpine 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 Twigpine 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
|
||||
// Twigpine 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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! UCAN (User Controlled Authorization Networks) — capability token types.
|
||||
//!
|
||||
//! UCANs let a DID delegate specific capabilities to another DID,
|
||||
//! with optional expiry and revocation. Twigpine uses UCANs for:
|
||||
//! with optional expiry and revocation. gitlawb uses UCANs for:
|
||||
//! - Delegating push access to a branch to a CI agent
|
||||
//! - Granting a reviewer the ability to approve PRs
|
||||
//! - Bootstrap tokens issued at registration
|
||||
@@ -57,7 +57,7 @@ impl Capability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known Twigpine capability strings.
|
||||
/// Well-known gitlawb capability strings.
|
||||
pub mod caps {
|
||||
pub const GIT_PUSH: &str = "git/push";
|
||||
pub const GIT_FETCH: &str = "git/fetch";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -33,7 +33,6 @@ sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chron
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
bytes = "1"
|
||||
libc = "0.2"
|
||||
zeroize = "1"
|
||||
cid = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -45,14 +44,14 @@ dirs-next = "2"
|
||||
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
|
||||
unicode-normalization = "0.1"
|
||||
reqwest = { workspace = true }
|
||||
libp2p-core = "0.44"
|
||||
libp2p-gossipsub = "0.50.1"
|
||||
libp2p-identify = "0.48"
|
||||
libp2p-identity = { version = "0.3", features = ["ed25519", "peerid", "rand"] }
|
||||
libp2p-kad = "0.49"
|
||||
libp2p-quic = { version = "0.14", features = ["tokio"] }
|
||||
libp2p-swarm = { version = "0.48", features = ["macros", "tokio"] }
|
||||
multiaddr = "0.19"
|
||||
libp2p-core = "0.43"
|
||||
libp2p-gossipsub = "0.49.4"
|
||||
libp2p-identify = "0.47"
|
||||
libp2p-identity = { version = "0.2", features = ["ed25519", "peerid", "rand"] }
|
||||
libp2p-kad = "0.48"
|
||||
libp2p-quic = { version = "0.13", features = ["tokio"] }
|
||||
libp2p-swarm = { version = "0.47", features = ["macros", "tokio"] }
|
||||
multiaddr = "0.18"
|
||||
futures = "0.3"
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
aws-config = { version = "1", features = ["behavior-version-latest"] }
|
||||
@@ -73,11 +72,8 @@ alloy = { version = "1", default-features = false, features = [
|
||||
"network",
|
||||
"rpc-types-eth",
|
||||
] }
|
||||
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
|
||||
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
|
||||
# on which timer fired instead of on wall clock.
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+143
-9720
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,7 @@ pub mod replicas;
|
||||
pub mod repos;
|
||||
pub mod resolve;
|
||||
pub mod stars;
|
||||
pub mod status;
|
||||
pub mod tasks;
|
||||
pub mod visibility;
|
||||
pub mod webhooks;
|
||||
@@ -67,17 +68,14 @@ pub(crate) async fn authorize_repo_read(
|
||||
/// 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.
|
||||
///
|
||||
/// The collapse itself lives in exactly one place, [`crate::db::normalize_owner_key`],
|
||||
/// which is also the function the stored `owner_did` / `authorizing_did` columns
|
||||
/// are normalized through and which `OWNER_KEY_CASE_SQL` mirrors byte for byte.
|
||||
/// Two identities match when they normalize to the same key; a second copy of the
|
||||
/// rule here is how the Rust gate and the SQL filters would drift apart.
|
||||
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
|
||||
crate::db::normalize_owner_key(a) == crate::db::normalize_owner_key(b)
|
||||
}
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
@@ -92,25 +90,6 @@ pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a branch name using the configured git binary (storage boundaries).
|
||||
pub(crate) fn validate_git_ref_with_git(
|
||||
git_bin: &str,
|
||||
name: &str,
|
||||
) -> std::result::Result<(), crate::git::store::GitRefValidationError> {
|
||||
crate::git::store::validate_git_ref_with_git(git_bin, name)
|
||||
}
|
||||
|
||||
/// Map ref-validation failures to the correct HTTP surface: malformed caller
|
||||
/// input stays 400; an inability to run git stays on the git/server-error path.
|
||||
pub(crate) fn map_git_ref_validation_error(
|
||||
err: crate::git::store::GitRefValidationError,
|
||||
) -> AppError {
|
||||
match err {
|
||||
crate::git::store::GitRefValidationError::Invalid(msg) => AppError::BadRequest(msg),
|
||||
crate::git::store::GitRefValidationError::GitUnavailable(msg) => AppError::Git(msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod did_tests {
|
||||
use super::did_matches;
|
||||
@@ -194,6 +173,7 @@ mod authz_guard {
|
||||
let events = include_str!("events.rs");
|
||||
let tasks = include_str!("tasks.rs");
|
||||
let stars = include_str!("stars.rs");
|
||||
let status = include_str!("status/mod.rs");
|
||||
let protect = include_str!("protect.rs");
|
||||
let visibility = include_str!("visibility.rs");
|
||||
let profiles = include_str!("profiles.rs");
|
||||
@@ -214,6 +194,13 @@ mod authz_guard {
|
||||
// (existence hiding) and require_repo_owner guards the owner half.
|
||||
(webhooks, "list_webhooks", "authorize_repo_read("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
// Same two-half shape as list_webhooks: authorize_repo_read runs
|
||||
// first (a quarantined or unreadable repo gets the missing-repo
|
||||
// not-found, never a 403 that would confirm existence), then
|
||||
// require_repo_owner 403s a non-owner of a readable repo. Both halves
|
||||
// are pinned, because dropping either changes what a stranger learns.
|
||||
(status, "create_status", "authorize_repo_read("),
|
||||
(status, "create_status", "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)
|
||||
@@ -227,19 +214,28 @@ mod authz_guard {
|
||||
(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).
|
||||
// get_by_cid gates each iterated repo row directly via visibility_check
|
||||
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
|
||||
(ipfs, "get_by_cid", "visibility_check("),
|
||||
// #94 sibling read surfaces: gate private-repo metadata on read
|
||||
// visibility (public repos stay anonymous; private repos 404).
|
||||
(replicas, "list_replicas", "authorize_repo_read("),
|
||||
(protect, "list_protected_branches", "authorize_repo_read("),
|
||||
(labels, "list_labels", "authorize_repo_read("),
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// The catch-up poll surface gates before any push-event row is read,
|
||||
// so a stranger cannot use it to learn that a private repo exists or
|
||||
// is being pushed to.
|
||||
(events, "list_repo_push_events", "authorize_repo_read("),
|
||||
// The status read gates before any claim data is loaded, so its deny
|
||||
// is the repo's own not-found rather than an existence oracle over
|
||||
// which commits were reported on.
|
||||
(status, "commit_status", "authorize_repo_read("),
|
||||
// The rollup gates on the same helper before the pull request row is
|
||||
// loaded, so a caller who cannot read the repo cannot learn which
|
||||
// pull request numbers exist on it — and the fallback's branch
|
||||
// resolve and head persist sit behind that same gate.
|
||||
(status, "pull_request_status", "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("),
|
||||
@@ -273,22 +269,6 @@ mod authz_guard {
|
||||
"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!(
|
||||
@@ -298,573 +278,6 @@ mod authz_guard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Completeness fence over the GraphQL mutation surface: the mirror of
|
||||
/// `every_in_scope_mutation_has_its_gate` for `graphql/mutation.rs`. Kept a
|
||||
/// SEPARATE guard with its own table (never folded into the REST rows above):
|
||||
/// a guard over a heterogeneous set must classify per member, and these
|
||||
/// same-named task rows belong to the GraphQL surface, not `api/tasks.rs`.
|
||||
///
|
||||
/// COMPLETENESS comes from the engine's own SDL (`schema.sdl()`), parsed as a
|
||||
/// structured AST (#219), NOT introspection and NOT a text parse of source.
|
||||
/// This matters: async-graphql OMITS `#[graphql(visible = false)]` fields from
|
||||
/// introspection while leaving them executable, so an introspection-sourced fence
|
||||
/// could be defeated by a hidden mutation. SDL includes those fields, so no
|
||||
/// visibility flag (and, via the AST parse, no brace-in-description quirk) can hide
|
||||
/// a live mutation. The SDL field set must equal the registered set: a new field with
|
||||
/// no row fails (a repo-write cannot slip in unlisted); a registered name absent from
|
||||
/// the schema fails (renamed or removed). The set is also cross-checked against
|
||||
/// introspection, so any field present in SDL but hidden from introspection fails
|
||||
/// named as a forbidden hidden mutation.
|
||||
///
|
||||
/// The MARKER check is the only source-scraped part, and it is not the
|
||||
/// completeness guarantee. Three honest limits, so a green check is not misread
|
||||
/// as full authorization coverage:
|
||||
/// 1. A present marker is not load-bearingness (the gate may not redden on a
|
||||
/// hostile probe: the #203 present-but-vacuous case). The adversarial tests
|
||||
/// in `graphql/mutation.rs` are the load-bearing layer.
|
||||
/// 2. A present marker is not gate-correctness (it may be the wrong bucket for
|
||||
/// the operation's resource: a repo-write registered as signer-self would
|
||||
/// pass, so INV-1 review remains the defense).
|
||||
/// 3. The marker check scrapes the comment-masked, brace-bounded resolver body;
|
||||
/// a `{`/`}` or a `did_matches(` inside a STRING literal is not masked, so it
|
||||
/// could mis-slice that body or satisfy the marker vacuously (the same
|
||||
/// vacuous-marker class as limit 1). Completeness is unaffected: it comes
|
||||
/// from the SDL AST, not from this scrape.
|
||||
#[test]
|
||||
fn every_graphql_mutation_has_its_gate() {
|
||||
// (Rust snake name, schema field in camelCase, expected gate markers). The
|
||||
// four are Bucket C signer-self: `/graphql` uses optional_signature, so each
|
||||
// resolver must call `require_signer(ctx)?` and bind the acting DID with
|
||||
// `did_matches(`. A repo-write would be Bucket A (`require_repo_owner(`).
|
||||
// Same-named REST rows live in the guard above against `api/tasks.rs`.
|
||||
let registered: &[(&str, &str, &[&str])] = &[
|
||||
(
|
||||
"create_task",
|
||||
"createTask",
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
),
|
||||
(
|
||||
"claim_task",
|
||||
"claimTask",
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
),
|
||||
(
|
||||
"complete_task",
|
||||
"completeTask",
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
),
|
||||
(
|
||||
"fail_task",
|
||||
"failTask",
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
),
|
||||
];
|
||||
|
||||
// Completeness + hidden-mutation cross-check against the REAL schema (#219),
|
||||
// delegated to the generic `assert_mutation_fence` so the SDL sourcing and the
|
||||
// cross-check are ALSO exercised by a synthetic hidden schema in
|
||||
// `fence_rejects_a_hidden_mutation` (INV-21). Because the real MutationRoot has no
|
||||
// hidden field today, that synthetic test — not a hypothetical production canary —
|
||||
// is what turns reverting the SDL source or deleting the cross-check RED.
|
||||
{
|
||||
use crate::graphql::mutation::MutationRoot;
|
||||
use crate::graphql::query::QueryRoot;
|
||||
use crate::graphql::subscription::SubscriptionRoot;
|
||||
let schema =
|
||||
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
|
||||
let registered_camel: Vec<&str> = registered.iter().map(|(_, c, _)| *c).collect();
|
||||
assert_mutation_fence(&schema, ®istered_camel);
|
||||
}
|
||||
|
||||
// Marker present (source scrape; the vacuity limit, backstopped by the
|
||||
// adversarial tests): each resolver body carries every required gate marker.
|
||||
let masked = mask_comments(include_str!("../graphql/mutation.rs"));
|
||||
for (snake, _, markers) in registered {
|
||||
let body = resolver_body(&masked, snake);
|
||||
for marker in *markers {
|
||||
assert!(
|
||||
body.contains(marker),
|
||||
"GraphQL mutation `{snake}` is missing its gate marker `{marker}`: gate removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version-safety self-check (#219, INV-21): the fence's completeness guarantee rests
|
||||
/// on `schema.sdl()` INCLUDING `visible = false` fields while introspection omits
|
||||
/// them. That is observed async-graphql behavior, not a documented contract, so pin
|
||||
/// it here: a synthetic schema with a hidden mutation must have that field in its
|
||||
/// SDL-derived set and NOT in its introspection-derived set. If a future
|
||||
/// async-graphql upgrade ever filters SDL by visibility, the first assertion flips
|
||||
/// RED — the assumption is load-bearing, not trusted.
|
||||
#[test]
|
||||
fn sdl_source_sees_hidden_mutations_that_introspection_omits() {
|
||||
use async_graphql::{EmptySubscription, Object, Schema};
|
||||
struct Q;
|
||||
#[Object]
|
||||
impl Q {
|
||||
async fn ping(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
struct M;
|
||||
#[Object]
|
||||
impl M {
|
||||
async fn visible_mut(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
#[graphql(visible = false)]
|
||||
async fn hidden_mut(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
let schema = Schema::build(Q, M, EmptySubscription).finish();
|
||||
|
||||
// SDL source (the fix): includes the hidden field.
|
||||
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
|
||||
assert!(
|
||||
sdl_fields.iter().any(|f| f == "hiddenMut"),
|
||||
"SDL-sourced set MUST include the visible=false mutation; if this is RED, \
|
||||
async-graphql started filtering SDL by visibility and the fence's \
|
||||
completeness guarantee is broken: {sdl_fields:?}"
|
||||
);
|
||||
|
||||
// Introspection (the fence's own helper): OMITS the hidden field. This is the
|
||||
// exact gap the fix closes — an introspection-sourced fence never sees `hiddenMut`.
|
||||
let introspected = introspection_mutation_fields(&schema);
|
||||
assert!(
|
||||
!introspected.iter().any(|f| f == "hiddenMut"),
|
||||
"introspection is expected to OMIT the visible=false mutation (that is the \
|
||||
gap the SDL source closes); got {introspected:?}"
|
||||
);
|
||||
|
||||
// The exact diff the fence computes: hidden ∈ (sdl \ introspection).
|
||||
let hidden: Vec<&String> = sdl_fields
|
||||
.iter()
|
||||
.filter(|f| !introspected.iter().any(|i| i == *f))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
hidden,
|
||||
vec!["hiddenMut"],
|
||||
"the SDL-vs-introspection diff must surface exactly the hidden mutation"
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-21 wiring guard (#219): drives the generic `assert_mutation_fence` — the SAME
|
||||
/// helper the real fence calls — against a synthetic schema whose mutation is
|
||||
/// `visible = false` and IS registered, so completeness passes and ONLY the
|
||||
/// hidden-from-introspection cross-check can fail. This is what makes the fence's SDL
|
||||
/// sourcing load-bearing without a production canary: reverting the source to
|
||||
/// introspection (the hidden field then absent from the SDL set) or deleting the
|
||||
/// cross-check removes this panic (or changes its message), turning the
|
||||
/// `should_panic` test RED. The real MutationRoot has no hidden field, so nothing on
|
||||
/// the real schema could catch that regression.
|
||||
#[test]
|
||||
#[should_panic(expected = "hidden from introspection")]
|
||||
fn fence_rejects_a_hidden_mutation() {
|
||||
use async_graphql::{EmptySubscription, Object, Schema};
|
||||
struct Q;
|
||||
#[Object]
|
||||
impl Q {
|
||||
async fn ping(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
struct M;
|
||||
#[Object]
|
||||
impl M {
|
||||
async fn visible_mut(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
#[graphql(visible = false)]
|
||||
async fn hidden_mut(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
let schema = Schema::build(Q, M, EmptySubscription).finish();
|
||||
// Both fields registered, so completeness passes and only the cross-check fires.
|
||||
assert_mutation_fence(&schema, &["visibleMut", "hiddenMut"]);
|
||||
}
|
||||
|
||||
/// Deprecated-mutation guard (#219): a `#[graphql(deprecation = "...")]` mutation is
|
||||
/// executable and present in SDL, but GraphQL introspection omits it by default. The
|
||||
/// fence's introspection helper uses `includeDeprecated: true`, so a deprecated (but
|
||||
/// visible) mutation is NOT mistaken for a hidden one. With it registered, the fence
|
||||
/// must NOT panic. Remove `includeDeprecated: true` and this goes RED — the deprecated
|
||||
/// field would look hidden and false-red as a forbidden visible=false mutation.
|
||||
#[test]
|
||||
fn fence_admits_a_deprecated_mutation() {
|
||||
use async_graphql::{EmptySubscription, Object, Schema};
|
||||
struct Q;
|
||||
#[Object]
|
||||
impl Q {
|
||||
async fn ping(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
struct M;
|
||||
#[Object]
|
||||
impl M {
|
||||
async fn visible_mut(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
#[graphql(deprecation = "use visibleMut")]
|
||||
async fn deprecated_mut(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
let schema = Schema::build(Q, M, EmptySubscription).finish();
|
||||
// Must not panic: the deprecated field is visible (present in introspection with
|
||||
// includeDeprecated) and registered, so it is neither hidden nor unclassified.
|
||||
assert_mutation_fence(&schema, &["visibleMut", "deprecatedMut"]);
|
||||
}
|
||||
|
||||
/// Completeness + hidden-mutation cross-check for a BUILT schema (#219). Generic over
|
||||
/// the schema type so the real fence AND the synthetic `fence_rejects_a_hidden_mutation`
|
||||
/// / `fence_admits_a_deprecated_mutation` tests drive the SAME sourcing and cross-check.
|
||||
/// Completeness is sourced from SDL (which includes `visible = false` fields); the
|
||||
/// cross-check fails any SDL field hidden from introspection. This genericity is the
|
||||
/// INV-21 fix: because the real MutationRoot has no hidden field, only a synthetic
|
||||
/// hidden schema through THIS helper makes reverting the SDL source or removing the
|
||||
/// cross-check RED, rather than leaving the class latent behind a production canary.
|
||||
fn assert_mutation_fence<Q, M, S>(
|
||||
schema: &async_graphql::Schema<Q, M, S>,
|
||||
registered_camel: &[&str],
|
||||
) where
|
||||
Q: async_graphql::ObjectType + 'static,
|
||||
M: async_graphql::ObjectType + 'static,
|
||||
S: async_graphql::SubscriptionType + 'static,
|
||||
{
|
||||
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
|
||||
let introspected = introspection_mutation_fields(schema);
|
||||
// No SDL field may be hidden from introspection (a visible=false mutation).
|
||||
for f in &sdl_fields {
|
||||
assert!(
|
||||
introspected.iter().any(|i| i == f),
|
||||
"GraphQL mutation `{f}` is in the schema but hidden from introspection \
|
||||
(a `#[graphql(visible = false)]` field): hidden mutations are forbidden \
|
||||
by this fence — make it visible so its classification is enforceable, or \
|
||||
remove it"
|
||||
);
|
||||
}
|
||||
// Completeness both directions against the registered (camelCase) set.
|
||||
for f in &sdl_fields {
|
||||
assert!(
|
||||
registered_camel.contains(&f.as_str()),
|
||||
"unclassified GraphQL mutation `{f}`: add it to \
|
||||
every_graphql_mutation_has_its_gate with a deliberate gate \
|
||||
(`require_repo_owner(` for a repo-write, `did_matches(` for a task op)"
|
||||
);
|
||||
}
|
||||
for c in registered_camel {
|
||||
assert!(
|
||||
sdl_fields.iter().any(|f| f == c),
|
||||
"registered GraphQL mutation `{c}` is not in the schema \
|
||||
(renamed or removed); update the table"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutation field names via the engine's introspection, run against a passed
|
||||
/// schema. STRUCTURED (names arrive as data). Uses `fields(includeDeprecated: true)`:
|
||||
/// GraphQL's default omits `@deprecated` fields, but a `#[graphql(deprecation = "...")]`
|
||||
/// mutation is still executable and present in SDL, so without this the cross-check
|
||||
/// above would false-red a deprecated mutation as a hidden (visible=false) one. Only a
|
||||
/// real visibility filter should trip that arm; deprecation is a lifecycle marker.
|
||||
fn introspection_mutation_fields<Q, M, S>(
|
||||
schema: &async_graphql::Schema<Q, M, S>,
|
||||
) -> Vec<String>
|
||||
where
|
||||
Q: async_graphql::ObjectType + 'static,
|
||||
M: async_graphql::ObjectType + 'static,
|
||||
S: async_graphql::SubscriptionType + 'static,
|
||||
{
|
||||
use async_graphql::Value;
|
||||
let resp = tokio::runtime::Runtime::new()
|
||||
.expect("tokio runtime")
|
||||
.block_on(schema.execute(
|
||||
"{ __schema { mutationType { fields(includeDeprecated: true) { name } } } }",
|
||||
));
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"schema introspection failed: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
let mut names = Vec::new();
|
||||
if let Value::Object(root) = &resp.data {
|
||||
if let Some(Value::Object(sc)) = root.get("__schema") {
|
||||
if let Some(Value::Object(mt)) = sc.get("mutationType") {
|
||||
if let Some(Value::List(field_list)) = mt.get("fields") {
|
||||
for field in field_list {
|
||||
if let Value::Object(fo) = field {
|
||||
if let Some(Value::String(n)) = fo.get("name") {
|
||||
names.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The mutation field names sourced from the built schema's SDL — the completeness
|
||||
/// source (#219). Builds the same DB-free schema as the introspection helper and runs
|
||||
/// its `.sdl()` (sync, no runtime needed) through [`mutation_field_names_from_sdl`].
|
||||
/// SDL includes `#[graphql(visible = false)]` fields, so this source cannot be
|
||||
/// defeated by hiding a mutation from introspection.
|
||||
fn graphql_mutation_fields_from_sdl() -> Vec<String> {
|
||||
use crate::graphql::mutation::MutationRoot;
|
||||
use crate::graphql::query::QueryRoot;
|
||||
use crate::graphql::subscription::SubscriptionRoot;
|
||||
let schema =
|
||||
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
|
||||
mutation_field_names_from_sdl(&schema.sdl())
|
||||
}
|
||||
|
||||
/// The mutation type's field names extracted from an async-graphql SDL string via
|
||||
/// the engine's OWN parser AST (`async_graphql::parser::parse_schema`), NOT
|
||||
/// introspection. This is the completeness source (#219): unlike introspection,
|
||||
/// which async-graphql filters by `visible` (a `#[graphql(visible = false)]`
|
||||
/// mutation is executable but ABSENT from introspection), `schema.sdl()` includes
|
||||
/// every registered field, so a hidden mutation still appears here and must carry a
|
||||
/// classified row. Parsing the AST (not a brace/line text scan) means a `}` inside a
|
||||
/// field description cannot mis-slice the type block. camelCase, as async-graphql
|
||||
/// emits. An empty result on a navigation miss is safe: the set-equality in
|
||||
/// `every_graphql_mutation_has_its_gate` turns "no fields" into a loud failure for
|
||||
/// every registered name, never a vacuous pass.
|
||||
fn mutation_field_names_from_sdl(sdl: &str) -> Vec<String> {
|
||||
use async_graphql::parser::parse_schema;
|
||||
use async_graphql::parser::types::{TypeKind, TypeSystemDefinition};
|
||||
|
||||
let doc = parse_schema(sdl).expect("async-graphql SDL must parse");
|
||||
// The mutation root type name: from the `schema { mutation: X }` block if
|
||||
// present, else the GraphQL default "Mutation".
|
||||
let mutation_type = doc
|
||||
.definitions
|
||||
.iter()
|
||||
.find_map(|def| match def {
|
||||
TypeSystemDefinition::Schema(s) => s
|
||||
.node
|
||||
.mutation
|
||||
.as_ref()
|
||||
.map(|m| m.node.as_str().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| "Mutation".to_string());
|
||||
// The fields of that object type.
|
||||
doc.definitions
|
||||
.iter()
|
||||
.find_map(|def| match def {
|
||||
TypeSystemDefinition::Type(t) if t.node.name.node.as_str() == mutation_type => {
|
||||
match &t.node.kind {
|
||||
TypeKind::Object(obj) => Some(
|
||||
obj.fields
|
||||
.iter()
|
||||
.map(|f| f.node.name.node.as_str().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdl_extractor_includes_a_hidden_visible_false_field() {
|
||||
// A field that would be hidden from introspection must still be listed from SDL.
|
||||
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
|
||||
type QueryRoot { ping: Int! }\n\
|
||||
type MutationRoot { createTask: Int! hiddenMut: Int! }\n";
|
||||
let fields = mutation_field_names_from_sdl(sdl);
|
||||
assert!(
|
||||
fields.iter().any(|f| f == "createTask"),
|
||||
"visible field must be listed: {fields:?}"
|
||||
);
|
||||
assert!(
|
||||
fields.iter().any(|f| f == "hiddenMut"),
|
||||
"a visible=false field (present in SDL, hidden from introspection) MUST be \
|
||||
listed by the completeness source: {fields:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdl_extractor_is_not_fooled_by_a_brace_in_a_field_description() {
|
||||
// A `}` inside a field description must not truncate the type block (the AST
|
||||
// parse is immune to the text-parse quirk the old design feared).
|
||||
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
|
||||
type QueryRoot { ping: Int! }\n\
|
||||
type MutationRoot {\n\
|
||||
\"\"\"a description with a } brace and { another\"\"\"\n\
|
||||
createTask: Int!\n\
|
||||
claimTask: Int!\n\
|
||||
}\n";
|
||||
let fields = mutation_field_names_from_sdl(sdl);
|
||||
assert!(
|
||||
fields.iter().any(|f| f == "createTask") && fields.iter().any(|f| f == "claimTask"),
|
||||
"both fields must be extracted despite a brace in a description: {fields:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdl_extractor_returns_empty_for_a_query_only_schema() {
|
||||
let sdl = "schema { query: QueryRoot }\ntype QueryRoot { ping: Int! }\n";
|
||||
assert!(
|
||||
mutation_field_names_from_sdl(sdl).is_empty(),
|
||||
"a schema with no mutation root yields no mutation fields (no panic)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The comment-masked body of `async fn <snake>` in `graphql/mutation.rs`, bounded
|
||||
/// to the resolver's OWN braces (its `{ .. }`), for the marker check. Brace-matching
|
||||
/// on the comment-masked source means a `}` in a trailing helper cannot extend the
|
||||
/// body and a comment marker does not count. A `{`/`}` inside a STRING literal is
|
||||
/// not masked, a documented marker-check-only residual (limit 3); completeness does
|
||||
/// not use this.
|
||||
fn resolver_body(masked_src: &str, snake: &str) -> String {
|
||||
let needle = format!("async fn {snake}(");
|
||||
let start = masked_src.find(&needle).unwrap_or_else(|| {
|
||||
panic!("resolver `async fn {snake}(` not found in graphql/mutation.rs")
|
||||
});
|
||||
let open = start
|
||||
+ masked_src[start..]
|
||||
.find('{')
|
||||
.expect("resolver body opening brace");
|
||||
let mut depth = 0i32;
|
||||
let mut end = masked_src.len();
|
||||
for (i, c) in masked_src[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = open + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
masked_src[open..end].to_string()
|
||||
}
|
||||
|
||||
/// `src` with `//` line comments and `/* */` block comments (nested) blanked to
|
||||
/// spaces, WITHOUT opening a comment inside a `"..."` string (so a `"https://"` does
|
||||
/// not erase the rest of its line). String CONTENTS are left intact, so a brace or
|
||||
/// a gate marker inside a string is not masked; for the marker check that is a
|
||||
/// documented vacuity residual (limit 3), and completeness does not use this at
|
||||
/// all. Char literals and raw/byte strings are not tracked (absent in resolver
|
||||
/// bodies); a `'"'` char or a raw string is the remaining narrow residual.
|
||||
fn mask_comments(src: &str) -> String {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut i = 0;
|
||||
let mut block_depth = 0usize;
|
||||
let mut in_string = false;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
let next = chars.get(i + 1).copied();
|
||||
if block_depth > 0 {
|
||||
if c == '/' && next == Some('*') {
|
||||
block_depth += 1;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
} else if c == '*' && next == Some('/') {
|
||||
block_depth -= 1;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(if c == '\n' { '\n' } else { ' ' });
|
||||
i += 1;
|
||||
}
|
||||
} else if in_string {
|
||||
out.push(c);
|
||||
if c == '\\' {
|
||||
if let Some(n) = next {
|
||||
out.push(n);
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
if c == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '"' {
|
||||
in_string = true;
|
||||
out.push('"');
|
||||
i += 1;
|
||||
} else if c == '/' && next == Some('/') {
|
||||
while i < chars.len() && chars[i] != '\n' {
|
||||
out.push(' ');
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '/' && next == Some('*') {
|
||||
block_depth = 1;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The SDL field set (the completeness source of truth, #219) is exactly the
|
||||
/// registered mutations. If async-graphql adds or renames a field, this reddens
|
||||
/// and forces the `every_graphql_mutation_has_its_gate` table to be updated. Uses
|
||||
/// SDL, not introspection: introspection omits `visible = false` fields, so an
|
||||
/// introspection-based pin here would go green while a hidden mutation existed.
|
||||
#[test]
|
||||
fn schema_lists_exactly_the_registered_mutations() {
|
||||
let mut fields = graphql_mutation_fields_from_sdl();
|
||||
fields.sort();
|
||||
assert_eq!(
|
||||
fields,
|
||||
vec!["claimTask", "completeTask", "createTask", "failTask"],
|
||||
"the SDL mutation field set is the authoritative discovered set"
|
||||
);
|
||||
}
|
||||
|
||||
/// The marker check is bounded to a resolver's OWN body and is comment- and
|
||||
/// string-aware: a `//` inside a `"https://"` string does not erase a real gate on
|
||||
/// that line (no false-red), and a trailing module helper's `did_matches(` does not
|
||||
/// rescue a resolver whose own gate was removed (no false-green).
|
||||
#[test]
|
||||
fn resolver_body_is_bounded_and_comment_and_string_aware() {
|
||||
let src = "\
|
||||
impl MutationRoot {
|
||||
async fn ok(&self) -> Result<u32> {
|
||||
let _ = (\"see https://x/y\", did_matches(a, b)); // URL string then gate, one line
|
||||
Ok(0)
|
||||
}
|
||||
async fn last(&self) -> Result<u32> {
|
||||
// did_matches( only in a comment
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
fn helper() { let _ = did_matches(z, z); }
|
||||
#[cfg(test)]
|
||||
mod tests { async fn t() { let _ = did_matches(x, y); } }
|
||||
";
|
||||
let masked = mask_comments(src);
|
||||
assert!(
|
||||
resolver_body(&masked, "ok").contains("did_matches("),
|
||||
"a `//` inside a string must not erase the real gate on that line"
|
||||
);
|
||||
assert!(
|
||||
!resolver_body(&masked, "last").contains("did_matches("),
|
||||
"the marker check must be bounded to the resolver's own body (a trailing \
|
||||
helper's marker must not rescue a resolver whose gate was removed)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1096,6 +509,7 @@ mod tests { async fn t() { let _ = did_matches(x, y); } }
|
||||
(include_str!("replicas.rs"), "replicas.rs"),
|
||||
(include_str!("repos.rs"), "repos.rs"),
|
||||
(include_str!("stars.rs"), "stars.rs"),
|
||||
(include_str!("status/mod.rs"), "status/mod.rs"),
|
||||
(include_str!("visibility.rs"), "visibility.rs"),
|
||||
(include_str!("webhooks.rs"), "webhooks.rs"),
|
||||
];
|
||||
|
||||
@@ -17,39 +17,9 @@ use gitlawb_core::did::Did;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::{PeerWriteAuthority, PeerWriteDenied};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Map an `upsert_peer` failure onto its response class.
|
||||
///
|
||||
/// The boundary's denials arrive inside an anyhow chain, so they are downcast
|
||||
/// back out the way `From<anyhow::Error> for AppError` already recovers sqlx
|
||||
/// errors. Without this a refusal renders as a 500, which is the mis-rendered
|
||||
/// denial this gate exists to avoid. An authority refusal is 403; naming a DID
|
||||
/// method that can never authenticate, or a DID whose verifying key cannot be
|
||||
/// resolved, is a fact about the input's form, so both are the 400 validation
|
||||
/// class, and the unresolvable one carries its own code so it reads the same to
|
||||
/// a client as the auth middleware's answer for an unresolvable keyid. The match
|
||||
/// stays exhaustive with no wildcard: the compile error a new denial causes here
|
||||
/// is what finds every mapping site.
|
||||
fn peer_write_error(err: anyhow::Error) -> AppError {
|
||||
match err.downcast::<PeerWriteDenied>() {
|
||||
Ok(denied) => match &denied {
|
||||
PeerWriteDenied::UnsupportedDidMethod { .. } => {
|
||||
AppError::BadRequest(denied.to_string())
|
||||
}
|
||||
PeerWriteDenied::UnresolvableDid { .. } => {
|
||||
AppError::UnresolvableDid(denied.to_string())
|
||||
}
|
||||
PeerWriteDenied::UnprovenRepoint { .. } | PeerWriteDenied::ProofDidMismatch { .. } => {
|
||||
AppError::Forbidden(denied.to_string())
|
||||
}
|
||||
},
|
||||
Err(other) => AppError::from(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AnnounceRequest {
|
||||
/// The DID of the announcing node
|
||||
@@ -118,11 +88,11 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
Some(h) => h.to_ascii_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
// Drop trailing dots (FQDN root): `localhost.` resolves the same as
|
||||
// Drop a single trailing dot (FQDN root): `localhost.` resolves the same as
|
||||
// `localhost`, so normalize before the suffix/equality checks below.
|
||||
// Strip to fixation rather than once, or `localhost..` reduces to
|
||||
// `localhost.` and matches neither the equality nor the suffix check.
|
||||
host.truncate(host.trim_end_matches('.').len());
|
||||
if let Some(stripped) = host.strip_suffix('.') {
|
||||
host = stripped.to_string();
|
||||
}
|
||||
if host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".local")
|
||||
@@ -167,13 +137,8 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
|
||||
// (site-local, deprecated by RFC 3879 and so never a legitimate
|
||||
// peer address, but still routable on networks that kept it).
|
||||
if (s[0] & 0xfe00) == 0xfc00
|
||||
|| (s[0] & 0xffc0) == 0xfe80
|
||||
|| (s[0] & 0xffc0) == 0xfec0
|
||||
{
|
||||
// fc00::/7 (unique-local) or fe80::/10 (link-local)
|
||||
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
|
||||
@@ -188,13 +153,6 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !host.contains('.') {
|
||||
// A name with no dot at all. The `.local` / `.internal` rules above are
|
||||
// suffix checks, so a single label slips both, and the resolver decides
|
||||
// what it means by appending a search domain we cannot see from here.
|
||||
// Only reachable when the host did not parse as an IP literal, which is
|
||||
// what keeps bracketed IPv6 (also dotless) on the accepting path.
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -229,30 +187,18 @@ pub async fn announce(
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
|
||||
// The extension's DID is cryptographically derived, never body-supplied:
|
||||
// auth/mod.rs resolves the verifying key FROM the keyid DID itself, so what
|
||||
// is carried through to the upsert boundary below is a proof, not a claim.
|
||||
let proven_did: Option<String> = if let Some(Extension(auth)) = auth {
|
||||
// 403, not 400: this caller proved control of some DID and spent that
|
||||
// proof on a different DID's row, which is a refusal of authority over
|
||||
// the targeted row: the same denial as an unsigned repoint, and two
|
||||
// callers denied for the same reason must not get different response
|
||||
// classes. The 400 class stays for input-form failures: a malformed
|
||||
// DID, a non-public URL, a self-announce, and a DID method that can
|
||||
// never authenticate.
|
||||
if let Some(Extension(auth)) = auth {
|
||||
if auth.0 != announced_did.to_string() {
|
||||
return Err(AppError::Forbidden(
|
||||
return Err(AppError::BadRequest(
|
||||
"Signature keyid must match announced DID".into(),
|
||||
));
|
||||
}
|
||||
Some(auth.0)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
did = %announced_did,
|
||||
"accepted unsigned peer announce; set GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true after all peers upgrade"
|
||||
);
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the URL is a public http(s) endpoint. The announce route is
|
||||
// reachable unauthenticated (until all peers sign), so without this an
|
||||
@@ -282,20 +228,7 @@ pub async fn announce(
|
||||
));
|
||||
}
|
||||
|
||||
// The authority declaration, not a re-statement of the check above: the
|
||||
// boundary carries the proven DID and re-checks it against the row itself,
|
||||
// so the keyid comparison above is an early rejection rather than the only
|
||||
// gate. The other writer (the bootstrap announce-back in main.rs) never
|
||||
// reaches this handler at all.
|
||||
let authority = match proven_did.as_deref() {
|
||||
Some(did) => PeerWriteAuthority::Proven(did),
|
||||
None => PeerWriteAuthority::Unproven,
|
||||
};
|
||||
state
|
||||
.db
|
||||
.upsert_peer(&req.did, &req.http_url, authority)
|
||||
.await
|
||||
.map_err(peer_write_error)?;
|
||||
state.db.upsert_peer(&req.did, &req.http_url).await?;
|
||||
|
||||
tracing::info!(did = %req.did, url = %req.http_url, "peer announced");
|
||||
|
||||
@@ -510,15 +443,21 @@ pub async fn ping_peer(
|
||||
.find(|p| p.did == did)
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("peer {did} not found")))?;
|
||||
|
||||
// Use DB-aware readiness rather than process liveness: `/health` stays 200
|
||||
// during a mid-life database outage, while `/ready` reports 503. A 404-only
|
||||
// fallback keeps pre-readiness peers compatible. The shared helper also
|
||||
// preserves the no-redirect SSRF guard on peer-controlled URLs.
|
||||
let ok = crate::ping_peer_readiness(&state.http_client, &peer.http_url).await;
|
||||
// Async ping
|
||||
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
|
||||
// Use the shared no-redirect client: bare `reqwest::get` follows redirects,
|
||||
// so a peer could answer with `302 -> http://127.0.0.1/` and turn the ping
|
||||
// into an SSRF probe.
|
||||
let ok = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
|
||||
let _ = state.db.mark_peer_ping(&did, ok).await;
|
||||
|
||||
// This anonymous diagnostic endpoint is intentionally read-only. The
|
||||
// periodic gossip loop owns the persisted federation-reachability gate and
|
||||
// requires consecutive failures before marking a peer unreachable.
|
||||
Ok(Json(serde_json::json!({
|
||||
"did": did,
|
||||
"http_url": peer.http_url,
|
||||
@@ -532,7 +471,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accepts_public_https_and_http() {
|
||||
assert!(is_public_http_url("https://node.twigpine.com"));
|
||||
assert!(is_public_http_url("https://node.gitlawb.com"));
|
||||
assert!(is_public_http_url("https://manila.gitlawb.com/"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545"));
|
||||
}
|
||||
@@ -556,7 +495,7 @@ mod tests {
|
||||
"http://[::1]:7545",
|
||||
"http://gitlawb-node.internal:7545",
|
||||
"http://my-node.local/",
|
||||
"ftp://node.twigpine.com",
|
||||
"ftp://node.gitlawb.com",
|
||||
"not-a-url",
|
||||
"",
|
||||
// Trailing-dot FQDN of an internal host.
|
||||
@@ -589,46 +528,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dotless_single_label_hosts() {
|
||||
// `.local` and `.internal` are suffix checks, so a name with no dot at
|
||||
// all never matches one. A single-label name is completed by whatever
|
||||
// search domain the resolver is configured with, which is not a
|
||||
// destination we can reason about from the URL.
|
||||
for bad in [
|
||||
"http://local/",
|
||||
"http://internal/",
|
||||
"http://intranet/",
|
||||
"http://wpad/",
|
||||
"http://metadata/",
|
||||
"http://local./",
|
||||
"http://internal.:7545",
|
||||
] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_deprecated_site_local_v6() {
|
||||
// fec0::/10, deprecated by RFC 3879. The link-local mask (0xffc0 ==
|
||||
// 0xfe80) does not cover it: 0xfec0 & 0xffc0 is 0xfec0.
|
||||
for bad in ["http://[fec0::1]/", "http://[feff:ffff::1]:7545"] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_repeated_trailing_root_dots() {
|
||||
// Stripping a single root dot leaves `localhost.`, which matches
|
||||
// neither the equality nor the suffix check. The subdomain form
|
||||
// (`node.localhost..`) is not asserted here: `.localhost` is not yet a
|
||||
// rejected suffix on this base, so that case belongs to whichever
|
||||
// change adds it rather than to this one.
|
||||
for bad in ["http://localhost../", "http://localhost...:7545"] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_6to4_and_nat64_wrapping_public_v4() {
|
||||
// Fold-and-recheck stays consistent with the mapped/compatible handling:
|
||||
@@ -665,7 +564,7 @@ mod tests {
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::post;
|
||||
use axum::{middleware, Extension, Router};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
@@ -871,84 +770,12 @@ mod tests {
|
||||
let did = Keypair::generate().did().to_string();
|
||||
state
|
||||
.db
|
||||
.upsert_peer(
|
||||
&did,
|
||||
"https://peer.example.com",
|
||||
crate::db::PeerWriteAuthority::Unproven,
|
||||
)
|
||||
.upsert_peer(&did, "https://peer.example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
did
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn manual_ping_uses_readiness_without_mutating_federation_gate(pool: PgPool) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(503)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut state = test_state(pool.clone()).await;
|
||||
state.http_client =
|
||||
Arc::new(crate::build_http_client().expect("production HTTP client should build"));
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO peers (did, http_url, last_seen, last_ping_ok, announced_at)
|
||||
VALUES ($1, $2, $3, TRUE, $3)",
|
||||
)
|
||||
.bind(&did)
|
||||
.bind(server.url())
|
||||
.bind(now)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = Router::new()
|
||||
.route("/api/v1/peers/{did}/ping", get(super::ping_peer))
|
||||
.with_state(state.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/api/v1/peers/{did}/ping"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["reachable"], false);
|
||||
ready.assert_async().await;
|
||||
health.assert_async().await;
|
||||
|
||||
let peer = state
|
||||
.db
|
||||
.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|peer| peer.did == did)
|
||||
.unwrap();
|
||||
assert!(
|
||||
peer.last_ping_ok,
|
||||
"an anonymous manual sample must not rewrite the federation gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_notify_unsigned_flood_is_throttled(pool: PgPool) {
|
||||
// notify still accepts an unsigned known-peer notification (rolling-upgrade
|
||||
@@ -1328,57 +1155,6 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// U1 (#273). An unsigned announce must not repoint an existing peer's row.
|
||||
///
|
||||
/// Kills the unconditional `ON CONFLICT(did) DO UPDATE SET http_url = $2` in
|
||||
/// `Db::upsert_peer`. The failure message carries BOTH observed values,
|
||||
/// because the gate is the observed rewrite (2xx AND the attacker URL
|
||||
/// stored), not merely a failing assertion: a 429 from the peer-write brake,
|
||||
/// a fixture URL `is_public_http_url` rejects, or a DB error would all read
|
||||
/// as "assertion failed" while proving nothing about this defect.
|
||||
#[sqlx::test]
|
||||
async fn unsigned_announce_cannot_repoint_an_existing_peer(pool: PgPool) {
|
||||
const HONEST: &str = "https://honest-peer.example.com";
|
||||
const ATTACKER: &str = "https://attacker.example.com";
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
state
|
||||
.db
|
||||
.upsert_peer(&did, HONEST, crate::db::PeerWriteAuthority::Unproven)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored_url = |peers: Vec<crate::db::PeerRecord>| {
|
||||
peers.into_iter().find(|p| p.did == did).map(|p| p.http_url)
|
||||
};
|
||||
assert_eq!(
|
||||
stored_url(state.db.list_peers().await.unwrap()).as_deref(),
|
||||
Some(HONEST),
|
||||
"seed did not take; the case below would pass vacuously"
|
||||
);
|
||||
|
||||
// Clone before build_router consumes state, so the row can be read back.
|
||||
let db = state.db.clone();
|
||||
let body = format!(r#"{{"did":"{did}","http_url":"{ATTACKER}"}}"#);
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.77:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let stored = stored_url(db.list_peers().await.unwrap())
|
||||
.expect("peer row vanished; neither the allowed nor the denied outcome");
|
||||
|
||||
assert_eq!(
|
||||
stored, HONEST,
|
||||
"unsigned announce repointed an existing peer (observed status {status}, stored http_url {stored})"
|
||||
);
|
||||
}
|
||||
|
||||
// Build a request carrying a REAL RFC 9421 signature (not just an injected
|
||||
// extension), so it passes require_signature on the full production router.
|
||||
fn real_signed_trigger(peer: &str) -> Request<Body> {
|
||||
@@ -1489,607 +1265,4 @@ mod tests {
|
||||
"a 429 on a sync route must not say 'push': {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── #273: the announce authority contract ─────────────────────────────────
|
||||
//
|
||||
// U4 drives the handler mounted alone, because signed_request_as injects an
|
||||
// AuthenticatedDid extension without a real signature and require_signature
|
||||
// would reject that on the full router. U5 then proves the same properties
|
||||
// through build_router with REAL RFC 9421 signatures, where a signature can
|
||||
// be pointed at a row it does not own.
|
||||
|
||||
const OWNER_URL: &str = "https://owner-peer.example.com";
|
||||
const MOVED_URL: &str = "https://moved-peer.example.com";
|
||||
const HIJACK_URL: &str = "https://hijacker.example.com";
|
||||
const WEB_DID: &str = "did:web:squatter.example.com";
|
||||
const KEYID_MISMATCH: &str = "Signature keyid must match announced DID";
|
||||
|
||||
/// Every column a consumer sees, so a case that claims "unchanged" compares
|
||||
/// the whole row rather than the one field it happened to think of.
|
||||
type PeerSnapshot = (String, String, Option<String>, bool, String);
|
||||
|
||||
async fn snapshot(db: &crate::db::Db, did: &str) -> Option<PeerSnapshot> {
|
||||
db.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.did == did)
|
||||
.map(|p| {
|
||||
(
|
||||
p.did,
|
||||
p.http_url,
|
||||
p.last_seen,
|
||||
p.last_ping_ok,
|
||||
p.announced_at,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed a peer row that has EARNED reachability, asserting the seed took,
|
||||
/// so no case below can pass vacuously against a row that was never written
|
||||
/// or against a flag that was already false. Without the granted ping a
|
||||
/// "must be unreachable after this" assertion compares false to false and
|
||||
/// stays green with the production code that clears the flag removed.
|
||||
async fn seed_peer_row(state: &AppState, did: &str, url: &str) -> PeerSnapshot {
|
||||
state
|
||||
.db
|
||||
.upsert_peer(did, url, crate::db::PeerWriteAuthority::Unproven)
|
||||
.await
|
||||
.expect("seed");
|
||||
state.db.mark_peer_ping(did, true).await.expect("seed ping");
|
||||
let seeded = snapshot(&state.db, did).await.expect("seed did not take");
|
||||
assert_eq!(seeded.1, url, "seed did not take");
|
||||
assert!(
|
||||
seeded.3,
|
||||
"the seeded row must start reachable, or a later unreachable assertion is vacuous"
|
||||
);
|
||||
seeded
|
||||
}
|
||||
|
||||
/// Status, error code, and message together, so a denial is asserted by its
|
||||
/// exact class and stated reason rather than by "not 2xx".
|
||||
async fn status_and_error(resp: axum::response::Response) -> (StatusCode, String, String) {
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"error body must be JSON: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
});
|
||||
let field = |k: &str| v[k].as_str().unwrap_or_default().to_string();
|
||||
(status, field("error"), field("message"))
|
||||
}
|
||||
|
||||
fn announce_body(did: &str, url: &str) -> String {
|
||||
format!(r#"{{"did":"{did}","http_url":"{url}"}}"#)
|
||||
}
|
||||
|
||||
fn announce_only(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/v1/peers/announce", post(super::announce))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn announce_as(did: &str, body: &str) -> Request<Body> {
|
||||
signed_request_as(
|
||||
did,
|
||||
Method::POST,
|
||||
"/api/v1/peers/announce",
|
||||
Body::from(body.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// U4 scenario 1, and the first test the keyid branch has ever had: a caller
|
||||
/// who proved control of one DID must not announce another's. Kills
|
||||
/// neutralizing the handler's keyid comparison, and kills demoting this
|
||||
/// denial back to the 400 validation class where the unsigned repoint below
|
||||
/// is a 403 for the same reason.
|
||||
#[sqlx::test]
|
||||
async fn announce_keyid_mismatch_is_a_403_denial(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let hijacker = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(&hijacker, &announce_body(&victim, HIJACK_URL)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"a proof spent on another DID's row is an authority refusal: {message}"
|
||||
);
|
||||
assert_eq!(code, "forbidden", "denial body must name the 403 class");
|
||||
assert!(
|
||||
message.contains(KEYID_MISMATCH),
|
||||
"the denial must name the keyid mismatch, got {message:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&state.db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a refused announce"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenario 2: the allowed signed case. A caller whose keyid IS the row
|
||||
/// repoints it. Kills a gate that refuses proven writes too.
|
||||
#[sqlx::test]
|
||||
async fn announce_with_matching_keyid_repoints_its_own_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate().did().to_string();
|
||||
seed_peer_row(&state, &owner, OWNER_URL).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(&owner, &announce_body(&owner, MOVED_URL)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let after = snapshot(&state.db, &owner).await.expect("row vanished");
|
||||
assert_eq!(after.1, MOVED_URL, "a proven repoint must land");
|
||||
}
|
||||
|
||||
/// U4 scenario 3: an unsigned caller cannot move an existing row, and the
|
||||
/// refusal renders as a 403 with its reason, never as a 500 or as success.
|
||||
#[sqlx::test]
|
||||
async fn announce_unsigned_repoint_is_a_403_denial(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&victim, HIJACK_URL),
|
||||
"203.0.113.90:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"an unproven repoint is an authority refusal: {message}"
|
||||
);
|
||||
assert_eq!(code, "forbidden", "denial body must name the 403 class");
|
||||
assert!(
|
||||
message.contains("unproven announce cannot change an existing peer"),
|
||||
"the denial must name the repoint refusal, got {message:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&state.db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a refused repoint"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenarios 4 and 5, the allowed unsigned class: discovery of an unseen
|
||||
/// did:key still inserts, and an honest peer re-announcing the IDENTICAL URL
|
||||
/// still refreshes liveness. The URL is reused byte for byte because the
|
||||
/// comparison is exact, so a trailing slash would make this pass as a
|
||||
/// repoint rejection instead. Kills a gate widened until it breaks
|
||||
/// discovery or honest re-announces.
|
||||
#[sqlx::test]
|
||||
async fn announce_unsigned_insert_and_identical_url_reannounce_are_accepted(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let body = announce_body(&did, OWNER_URL);
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.91:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "discovery must stay open");
|
||||
let before = snapshot(&state.db, &did).await.expect("row not inserted");
|
||||
assert_eq!(before.1, OWNER_URL);
|
||||
assert!(!before.3, "a never-probed peer inserts unreachable");
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.92:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"an identical-URL re-announce is a liveness signal, not a repoint"
|
||||
);
|
||||
let after = snapshot(&state.db, &did).await.expect("row vanished");
|
||||
assert_eq!(after.1, OWNER_URL, "URL untouched");
|
||||
assert_eq!(after.3, before.3, "reachability untouched");
|
||||
assert_eq!(after.4, before.4, "announced_at untouched");
|
||||
let parse = |s: &Option<String>| {
|
||||
chrono::DateTime::parse_from_rfc3339(s.as_deref().expect("last_seen is set")).unwrap()
|
||||
};
|
||||
assert!(
|
||||
parse(&after.2) > parse(&before.2),
|
||||
"a same-URL re-announce must advance last_seen"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenario 6 (R8): a DID method that can never authenticate is refused
|
||||
/// in the 400 validation class, with nothing written. This one IS about the
|
||||
/// input's form, not the caller's authority, which is why it is not a 403.
|
||||
#[sqlx::test]
|
||||
async fn announce_of_a_did_web_is_a_400_validation_failure(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(WEB_DID, OWNER_URL),
|
||||
"203.0.113.93:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unsupported DID method is a validation failure: {message}"
|
||||
);
|
||||
assert_eq!(code, "bad_request");
|
||||
assert!(
|
||||
message.contains("methodNotSupported"),
|
||||
"the rejection must carry the methodNotSupported shape, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, WEB_DID).await.is_none(),
|
||||
"a rejected announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sibling of the case above, and the one it must not swallow: the
|
||||
/// method IS did:key, so methodNotSupported would be a false statement
|
||||
/// about the input. The value is the W3C secp256k1 did:key test vector,
|
||||
/// which decodes cleanly and is simply not an ed25519 key. Still the 400
|
||||
/// validation class, still nothing written.
|
||||
#[sqlx::test]
|
||||
async fn announce_of_an_unresolvable_did_key_is_a_400_with_the_cause(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme";
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(did, OWNER_URL),
|
||||
"203.0.113.99:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unresolvable did:key is a validation failure: {message}"
|
||||
);
|
||||
assert_eq!(
|
||||
code, "unresolvable_did",
|
||||
"the code must name the cause, the same way the signed path does for a keyid it \
|
||||
cannot resolve; a client distinguishing this from the other 400s on this route \
|
||||
must not have to substring-match the message: {message:?}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("cannot resolve DID"),
|
||||
"the rejection must report resolution, not method support, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, did).await.is_none(),
|
||||
"a rejected announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
// ── U5: the same matrix through the production router, real signatures ────
|
||||
|
||||
/// An announce carrying a REAL RFC 9421 signature, signed AS `kp` while the
|
||||
/// body names whatever DID the caller chooses. That split is the point: it
|
||||
/// is what lets a valid signature be pointed at a row it does not own,
|
||||
/// which is the RUSTSEC-2022-0009 shape.
|
||||
fn real_signed_announce(kp: &Keypair, body: &str, peer: &str) -> Request<Body> {
|
||||
let path = "/api/v1/peers/announce";
|
||||
let s = sign_request(kp, "POST", path, body.as_bytes());
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(path)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("content-digest", s.content_digest)
|
||||
.header("signature-input", s.signature_input)
|
||||
.header("signature", s.signature)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
|
||||
req
|
||||
}
|
||||
|
||||
/// U5 scenario 1: the lowest-privilege caller there is, fully
|
||||
/// unauthenticated, still seeds an unseen did:key row through the whole
|
||||
/// production stack.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_announce_inserts_an_unseen_did(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&did, OWNER_URL),
|
||||
"203.0.113.94:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let row = snapshot(&db, &did).await.expect("row not inserted");
|
||||
assert_eq!(row.1, OWNER_URL);
|
||||
}
|
||||
|
||||
/// U5 scenario 2: the defect this change closes, driven end to end. An
|
||||
/// unsigned repoint is refused with its reason and the row does not move.
|
||||
/// Kills reverting the unproven arm to an unconditional DO UPDATE.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_announce_cannot_repoint_an_existing_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&victim, HIJACK_URL),
|
||||
"203.0.113.95:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The row assertion leads: the property is that the row does not move.
|
||||
// A response-class regression is a second, separate failure below.
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
snapshot(&db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a refused unsigned repoint \
|
||||
(observed status {status})"
|
||||
);
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "observed body: {message}");
|
||||
assert_eq!(code, "forbidden");
|
||||
}
|
||||
|
||||
/// U5 scenario 3: the allowed signed case through the real verification
|
||||
/// middleware. The row was seeded under this keypair's own did:key, so the
|
||||
/// signature names the row it moves.
|
||||
#[sqlx::test]
|
||||
async fn router_signature_from_the_rows_own_did_repoints_it(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
seed_peer_row(&state, &owner_did, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(real_signed_announce(
|
||||
&owner,
|
||||
&announce_body(&owner_did, MOVED_URL),
|
||||
"203.0.113.96:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a real signature from the row's own DID must be admitted"
|
||||
);
|
||||
let row = snapshot(&db, &owner_did).await.expect("row vanished");
|
||||
assert_eq!(row.1, MOVED_URL, "a proven repoint must land");
|
||||
assert!(
|
||||
!row.3,
|
||||
"a repointed peer must re-earn reachability even when the repoint is signed"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 scenario 4, the RUSTSEC-2022-0009 shape: a signature that verifies
|
||||
/// perfectly, spent on a row it does not own. The key must be bound to the
|
||||
/// ROW, not to anything the request carries. The assertion names the keyid
|
||||
/// denial specifically, because the boundary's own DID comparison would
|
||||
/// also refuse this write, so a status-only assertion would stay green with
|
||||
/// the handler branch neutralized.
|
||||
#[sqlx::test]
|
||||
async fn router_signature_from_another_did_cannot_repoint_this_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let hijacker = Keypair::generate();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(real_signed_announce(
|
||||
&hijacker,
|
||||
&announce_body(&victim, HIJACK_URL),
|
||||
"203.0.113.97:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"a valid signature over another DID's row must be refused: {message}"
|
||||
);
|
||||
assert_eq!(code, "forbidden");
|
||||
assert!(
|
||||
message.contains(KEYID_MISMATCH),
|
||||
"the denial must name the keyid mismatch, got {message:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a mismatched signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 scenario 5: an honest unsigned peer re-announcing the identical URL
|
||||
/// through the full stack refreshes last_seen and moves nothing else. The
|
||||
/// seeded string is reused byte for byte, since the comparison is exact.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_identical_url_reannounce_refreshes_last_seen(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &did, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&did, OWNER_URL),
|
||||
"203.0.113.98:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let after = snapshot(&db, &did).await.expect("row vanished");
|
||||
assert_eq!(after.1, OWNER_URL, "URL untouched");
|
||||
assert_eq!(after.3, before.3, "reachability untouched");
|
||||
assert_eq!(after.4, before.4, "announced_at untouched");
|
||||
let parse = |s: &Option<String>| {
|
||||
chrono::DateTime::parse_from_rfc3339(s.as_deref().expect("last_seen is set")).unwrap()
|
||||
};
|
||||
assert!(
|
||||
parse(&after.2) > parse(&before.2),
|
||||
"a same-URL re-announce must advance last_seen"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 scenario 6 (R8) through the full stack: a did:web announce is refused
|
||||
/// in the validation class and writes nothing. Such a row could never be
|
||||
/// corrected by anyone, since only a did:key can ever satisfy the proven
|
||||
/// path. Kills neutralizing the method gate on the unproven arm.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_announce_of_a_did_web_is_refused(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(WEB_DID, OWNER_URL),
|
||||
"203.0.113.99:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unsupported DID method must be refused: {message}"
|
||||
);
|
||||
assert_eq!(code, "bad_request");
|
||||
assert!(
|
||||
message.contains("methodNotSupported"),
|
||||
"the rejection must carry the methodNotSupported shape, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&db, WEB_DID).await.is_none(),
|
||||
"a rejected announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5, flag-on mode: with require_signed_peer_writes the write group moves
|
||||
/// under add_auth_layers, so the unsigned caller dies at the auth layer with
|
||||
/// a 401 instead of reaching the handler's 403. The authority rules
|
||||
/// themselves do not change: a signature over another DID's row is still
|
||||
/// refused, and the row's own key still moves it. The flag is applied BEFORE
|
||||
/// build_router, which reads it at build time.
|
||||
#[sqlx::test]
|
||||
async fn announce_authority_matrix_in_signed_writes_mode(pool: PgPool) {
|
||||
let mut state = test_state(pool).await;
|
||||
require_signed_peer_writes(&mut state);
|
||||
let owner = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let hijacker = Keypair::generate();
|
||||
let before = seed_peer_row(&state, &owner_did, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
let router = crate::server::build_router(state);
|
||||
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&owner_did, HIJACK_URL),
|
||||
"203.0.113.100:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"with the flag on, an unsigned announce dies at the auth layer"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&db, &owner_did).await.expect("row vanished"),
|
||||
before,
|
||||
"the row must be byte-identical after the 401"
|
||||
);
|
||||
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(real_signed_announce(
|
||||
&hijacker,
|
||||
&announce_body(&owner_did, HIJACK_URL),
|
||||
"203.0.113.101:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (status, _, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"a signature over another DID's row is still refused: {message}"
|
||||
);
|
||||
assert!(message.contains(KEYID_MISMATCH), "got {message:?}");
|
||||
assert_eq!(
|
||||
snapshot(&db, &owner_did).await.expect("row vanished"),
|
||||
before,
|
||||
"the row must be byte-identical after the mismatched signature"
|
||||
);
|
||||
|
||||
let resp = router
|
||||
.oneshot(real_signed_announce(
|
||||
&owner,
|
||||
&announce_body(&owner_did, MOVED_URL),
|
||||
"203.0.113.102:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the row's own key still moves it with the flag on"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&db, &owner_did).await.expect("row vanished").1,
|
||||
MOVED_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,17 +50,6 @@ pub async fn create_pr(
|
||||
let target_branch = req
|
||||
.target_branch
|
||||
.unwrap_or_else(|| record.default_branch.clone());
|
||||
|
||||
// Validate both refs before they are stored, since both are later
|
||||
// interpolated into git argv (git diff / worktree add / merge). Validate the
|
||||
// RESOLVED target, not just a caller-supplied one: create_repo also gates
|
||||
// default_branch, but validating here as well means a PR can never feed the
|
||||
// git sink an unchecked ref even if a default was poisoned by an older row
|
||||
// or a future path that skips create_repo's gate.
|
||||
crate::api::validate_git_ref_with_git(&state.git_bin, &req.source_branch)
|
||||
.map_err(crate::api::map_git_ref_validation_error)?;
|
||||
crate::api::validate_git_ref_with_git(&state.git_bin, &target_branch)
|
||||
.map_err(crate::api::map_git_ref_validation_error)?;
|
||||
let number = state.db.next_pr_number(&record.id).await?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
@@ -76,6 +65,7 @@ pub async fn create_pr(
|
||||
status: "open".to_string(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
head_commit: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -224,9 +214,22 @@ pub async fn merge_pr(
|
||||
.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;
|
||||
|
||||
// The source head this merge is about to consume, read under the write lock.
|
||||
// The PR row above was loaded before the lock, so a push landing in between
|
||||
// already moved the branch and the row's own `head_commit` is stale; the
|
||||
// value frozen on the merged PR has to be what was merged. `None` when the
|
||||
// ref cannot be resolved, which leaves whatever the push path last stored.
|
||||
let merged_source_head = store::list_refs(&disk_path).ok().and_then(|refs| {
|
||||
let want = format!("refs/heads/{}", pr.source_branch);
|
||||
refs.into_iter()
|
||||
.find(|(name, _)| *name == want)
|
||||
.map(|(_, sha)| sha)
|
||||
});
|
||||
|
||||
let merge_result = store::merge_branch(
|
||||
&disk_path,
|
||||
&pr.target_branch,
|
||||
@@ -236,14 +239,14 @@ pub async fn merge_pr(
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
// Short-circuit on a refused publish before the PR is marked merged and
|
||||
// before the webhook fires. Both are irreversible announcements of a merge
|
||||
// commit that only exists on this node's disk.
|
||||
guard.release(merge_result.is_ok()).await.into_result()?;
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
state.db.merge_pr(&pr.id, &merger_did).await?;
|
||||
state
|
||||
.db
|
||||
.merge_pr(&pr.id, &merger_did, merged_source_head.as_deref())
|
||||
.await?;
|
||||
let _ = state.db.touch_repo(&record.id).await;
|
||||
|
||||
webhooks::fire_event(
|
||||
@@ -439,104 +442,158 @@ pub async fn list_comments(
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
|
||||
/// pool must shed 503 + Retry-After.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use sqlx::PgPool;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use std::process::Command;
|
||||
use tower::ServiceExt;
|
||||
|
||||
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,
|
||||
}
|
||||
const OWNER_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
|
||||
const STALE_SHA: &str = "9999999999999999999999999999999999999999";
|
||||
|
||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "t")
|
||||
.env("GIT_AUTHOR_EMAIL", "[email protected]")
|
||||
.env("GIT_COMMITTER_NAME", "t")
|
||||
.env("GIT_COMMITTER_EMAIL", "[email protected]")
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[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;
|
||||
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)),
|
||||
std::time::Duration::from_secs(300),
|
||||
/// A bare repo with `main` and a `feature` branch one commit ahead.
|
||||
/// Returns the bare path and the real `feature` tip.
|
||||
fn bare_repo_with_feature(
|
||||
root: &std::path::Path,
|
||||
owner_slug: &str,
|
||||
) -> (std::path::PathBuf, String) {
|
||||
let work = root.join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
git(&work, &["init", "-q", "-b", "main", "."]);
|
||||
std::fs::write(work.join("a.txt"), "one").unwrap();
|
||||
git(&work, &["add", "."]);
|
||||
git(&work, &["commit", "-qm", "one"]);
|
||||
git(&work, &["checkout", "-qb", "feature"]);
|
||||
std::fs::write(work.join("b.txt"), "two").unwrap();
|
||||
git(&work, &["add", "."]);
|
||||
git(&work, &["commit", "-qm", "two"]);
|
||||
git(&work, &["checkout", "-q", "main"]);
|
||||
|
||||
let bare_dir = root.join(owner_slug);
|
||||
std::fs::create_dir_all(&bare_dir).unwrap();
|
||||
let bare = bare_dir.join("demo.git");
|
||||
git(
|
||||
root,
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
"--bare",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
|
||||
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();
|
||||
let tip = Command::new("git")
|
||||
.args(["rev-parse", "refs/heads/feature"])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let tip = String::from_utf8_lossy(&tip.stdout).trim().to_string();
|
||||
assert_eq!(tip.len(), 40, "feature tip must resolve");
|
||||
(bare, tip)
|
||||
}
|
||||
|
||||
/// Merging freezes the stored head at the source commit the merge actually
|
||||
/// consumed. The PR row is loaded before the write lock is taken, so a push
|
||||
/// that lands in between leaves the row's own `head_commit` stale; seeding a
|
||||
/// value that matches nothing on disk stands in for that race, and the
|
||||
/// merged PR must still come out pointing at the real `feature` tip.
|
||||
#[sqlx::test]
|
||||
async fn merge_stamps_the_source_head_it_merged_over_a_racing_value(pool: sqlx::PgPool) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let owner_slug = OWNER_DID.replace([':', '/'], "_");
|
||||
let (bare, feature_tip) = bare_repo_with_feature(tmp.path(), &owner_slug);
|
||||
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store =
|
||||
crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool);
|
||||
|
||||
let now = Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "demo".into(),
|
||||
owner_did: OWNER_DID.into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: bare.to_string_lossy().into_owned(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
|
||||
let pr = PullRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo.id.clone(),
|
||||
number: 1,
|
||||
title: "add b".into(),
|
||||
body: None,
|
||||
author_did: OWNER_DID.into(),
|
||||
source_branch: "feature".into(),
|
||||
target_branch: "main".into(),
|
||||
status: "open".into(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
head_commit: None,
|
||||
created_at: now.to_rfc3339(),
|
||||
updated_at: now.to_rfc3339(),
|
||||
};
|
||||
state.db.create_pr(&pr).await.unwrap();
|
||||
// The stale value the racing push scenario leaves on the row.
|
||||
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,
|
||||
})
|
||||
.set_open_pr_heads(&repo.id, "feature", STALE_SHA)
|
||||
.await
|
||||
.expect("seed open PR");
|
||||
.unwrap();
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
let db = state.db.clone();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
|
||||
post(merge_pr),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(crate::test_support::signed_request_as(
|
||||
OWNER_DID,
|
||||
Method::POST,
|
||||
"/api/v1/repos/z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH/demo/pulls/1/merge",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
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();
|
||||
let merged = db.get_pr(&repo.id, 1).await.unwrap().unwrap();
|
||||
assert_eq!(merged.status, "merged");
|
||||
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"
|
||||
);
|
||||
|
||||
let _ = 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()
|
||||
merged.head_commit,
|
||||
Some(feature_tip),
|
||||
"the merge must stamp the source head it consumed, not the racing value"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1497
-10022
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
//! DID resolution — look up a node by its Twigpine DID.
|
||||
//! DID resolution — look up a node by its gitlawb DID.
|
||||
//!
|
||||
//! First checks the local peers table (HTTP-layer peer discovery),
|
||||
//! then queries the Kademlia DHT via the libp2p swarm.
|
||||
@@ -14,7 +14,7 @@ use crate::state::AppState;
|
||||
|
||||
/// GET /api/v1/resolve/{did}
|
||||
///
|
||||
/// Resolves a Twigpine DID to an HTTP URL and p2p multiaddr.
|
||||
/// Resolves a gitlawb DID to an HTTP URL and p2p multiaddr.
|
||||
/// Checks local peer cache first, then falls back to Kademlia DHT lookup.
|
||||
pub async fn resolve_did(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
//! Commit status claims: the write path and the combined read.
|
||||
//!
|
||||
//! POST /api/v1/repos/:owner/:repo/statuses/:sha — append one claim (owner only)
|
||||
//! GET /api/v1/repos/:owner/:repo/commits/:sha/status — the combined projection
|
||||
//!
|
||||
//! Claims are append-only: a producer reporting twice for the same context
|
||||
//! leaves both rows and the visible status is a projection over the history.
|
||||
//! Reporting twice means two REQUESTS, though. An exact repeat of one already
|
||||
//! accepted is answered with 200 and the row it wrote, not a second row: the
|
||||
//! projection elects the highest `seq`, so a replayed claim would not duplicate
|
||||
//! a verdict but overturn the one that superseded it.
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::StatusClaim;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Claims for one (repo, commit, producer, context). Generous for a real
|
||||
/// producer; a producer past it is misbehaving.
|
||||
const MAX_CLAIMS_PER_TUPLE: i64 = 100;
|
||||
/// Distinct contexts for one (repo, commit). The context string is caller-chosen
|
||||
/// and free-form, so the tuple cap alone bounds nothing.
|
||||
const MAX_CONTEXTS_PER_COMMIT: i64 = 50;
|
||||
/// Claim rows for one repo within the trailing window the db layer counts over.
|
||||
/// The commit SHA is caller-chosen and never existence-checked, so a writer at
|
||||
/// the two caps above can still fan out over fresh 40-hex strings without this
|
||||
/// one. It bounds the RATE of that fan-out rather than a lifetime total, because
|
||||
/// nothing prunes the table: a lifetime bound would close the surface on a repo
|
||||
/// permanently while answering with a status every client retries.
|
||||
const MAX_CLAIMS_PER_REPO_PER_WINDOW: i64 = 10_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateStatusRequest {
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// The wire state set (KTD-1): GitHub's four commit-status states, so absence
|
||||
/// stays distinguishable without a fifth value no client understands.
|
||||
const CLAIM_STATES: [&str; 4] = ["error", "failure", "pending", "success"];
|
||||
|
||||
/// Bounds on the signature material this write path persists verbatim. Every one
|
||||
/// of the four is caller-influenced: the headers come off the request, the
|
||||
/// signing string grows with the component list the caller's Signature-Input
|
||||
/// chose, and the body is the caller's. Without a bound the claim log's row size
|
||||
/// is set by whoever writes to it, so these are explicit and generous rather than
|
||||
/// implicit and absent. A conforming `sign_request` produces roughly 100, 200 and
|
||||
/// 400 bytes for the first three.
|
||||
const MAX_SIGNATURE_CHARS: usize = 512;
|
||||
const MAX_SIGNATURE_INPUT_CHARS: usize = 1024;
|
||||
const MAX_SIGNING_STRING_CHARS: usize = 4096;
|
||||
/// The body is a `CreateStatusRequest`, whose own fields are already capped at
|
||||
/// roughly 3.3 KB in total by the three limits below; this leaves room for JSON
|
||||
/// framing and nothing more.
|
||||
///
|
||||
/// Enforced twice, at two different moments, because the handler's check alone
|
||||
/// is not the bound it advertises: `require_signature` buffers the whole body
|
||||
/// before any handler runs, so by the time [`create_status`] measures it the
|
||||
/// allocation has already happened. `build_router` applies the same number as a
|
||||
/// `RequestBodyLimitLayer` at the transport, outside the auth layers, which is
|
||||
/// what makes it a bound on what is ALLOCATED and not only on what is STORED.
|
||||
/// The handler keeps its own check regardless — it is the one that holds for a
|
||||
/// handler reached by any other route table, and it is what the material size
|
||||
/// tests drive.
|
||||
pub(crate) const MAX_REQUEST_BODY_BYTES: usize = 8192;
|
||||
|
||||
const MAX_CONTEXT_CHARS: usize = 255;
|
||||
const MAX_TARGET_URL_CHARS: usize = 2048;
|
||||
const MAX_DESCRIPTION_CHARS: usize = 1024;
|
||||
|
||||
/// The 201 body for an accepted claim: what the client needs to identify the row
|
||||
/// it just wrote, and nothing else.
|
||||
///
|
||||
/// Deliberately not the stored [`StatusClaim`]. That struct carries the signature
|
||||
/// material, and serializing it echoed the signature, the signing string and the
|
||||
/// whole request body back on every write — the body as a JSON array of integers.
|
||||
/// Write-time provenance belongs in the row, not on the wire, which is the same
|
||||
/// line [`StatusEntry`] draws on the read side.
|
||||
#[derive(Serialize)]
|
||||
pub struct CreatedStatus {
|
||||
pub id: String,
|
||||
/// The database-assigned ordering key, so the client can name its own row.
|
||||
pub seq: i64,
|
||||
pub repo_id: String,
|
||||
pub commit_sha: String,
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub producer_did: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl CreatedStatus {
|
||||
fn from_claim(claim: StatusClaim, seq: i64) -> Self {
|
||||
Self {
|
||||
id: claim.id,
|
||||
seq,
|
||||
repo_id: claim.repo_id,
|
||||
commit_sha: claim.commit_sha,
|
||||
state: claim.state,
|
||||
context: claim.context,
|
||||
target_url: claim.target_url,
|
||||
description: claim.description,
|
||||
producer_did: claim.producer_did,
|
||||
created_at: claim.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/statuses/:sha
|
||||
pub async fn create_status(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, sha)): Path<(String, String, String)>,
|
||||
material: Option<Extension<crate::auth::SignatureMaterial>>,
|
||||
Json(req): Json<CreateStatusRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatedStatus>)> {
|
||||
// Read-visibility first, then owner, and the order is the security property.
|
||||
// authorize_repo_read denies a quarantined repo before the visibility gate and
|
||||
// answers with the repo's own not-found, byte-identical to a missing repo, so
|
||||
// a caller who cannot read the repo cannot learn it exists. Loading the repo
|
||||
// and comparing the owner would answer 403 there, turning this endpoint into
|
||||
// an existence oracle. require_repo_owner then 403s a non-owner of a repo the
|
||||
// caller can read, where existence is not secret.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(&auth.0), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let commit_sha = normalize_sha(&sha)?;
|
||||
let claim_state = validate_state(&req.state)?;
|
||||
let context = validate_context(&req.context)?;
|
||||
let target_url = validate_target_url(req.target_url.as_deref())?;
|
||||
let description = validate_description(req.description.as_deref())?;
|
||||
|
||||
// KTD-5: the signature material is captured here because the request carries
|
||||
// the only copy, and `signing_string` is the exact byte sequence the
|
||||
// middleware verified. Its absence means this handler was reached without
|
||||
// `require_signature` — a server misconfiguration (a route group that lost
|
||||
// its auth layer, a handler mounted somewhere new), not anything the client
|
||||
// did, so it is a 500 and no row is written. Storing an empty payload instead
|
||||
// would leave every claim from that moment on unverifiable with nothing going
|
||||
// red, which is exactly the absence-renders-as-success shape.
|
||||
let Some(Extension(material)) = material else {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"status claim write reached without verified signature material — \
|
||||
the route is not behind require_signature"
|
||||
)));
|
||||
};
|
||||
// The body is carried only behind the `PersistsSignedBody` marker, which
|
||||
// this route's group applies outside the auth layers. Its absence means the
|
||||
// marker did not reach the middleware, because the layer was dropped or
|
||||
// reordered. That is the same class of misconfiguration as the branch above
|
||||
// and gets the same answer. Storing an empty body instead would write claims
|
||||
// that carry a signature over a digest of bytes nobody kept, which is
|
||||
// exactly the case `stored_claim_re_verifies_and_a_tampered_row_does_not`
|
||||
// exists to make impossible.
|
||||
let Some(body) = material.body.clone() else {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"status claim write reached with signature material carrying no request body: \
|
||||
the route lost its persist-body marker or applied it inside the auth layers"
|
||||
)));
|
||||
};
|
||||
// Bounded before anything is written. The material is verified, which says
|
||||
// the caller holds the key, not that what they signed is a reasonable size.
|
||||
bound("signature", material.signature.len(), MAX_SIGNATURE_CHARS)?;
|
||||
bound(
|
||||
"signature_input",
|
||||
material.signature_input.len(),
|
||||
MAX_SIGNATURE_INPUT_CHARS,
|
||||
)?;
|
||||
bound(
|
||||
"signing_string",
|
||||
material.signing_string.len(),
|
||||
MAX_SIGNING_STRING_CHARS,
|
||||
)?;
|
||||
bound("request body", body.len(), MAX_REQUEST_BODY_BYTES)?;
|
||||
// After the bounds, so the scan below walks a signing string of known size.
|
||||
require_body_bound_signature(&material, &body)?;
|
||||
|
||||
let digest = request_digest(&material, &body);
|
||||
let (signature, signature_input, signing_string, request_body) = (
|
||||
material.signature,
|
||||
material.signature_input,
|
||||
material.signing_string,
|
||||
body.to_vec(),
|
||||
);
|
||||
|
||||
let claim = StatusClaim {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
// Ignored on insert: the database assigns the ordering key (KTD-3).
|
||||
seq: 0,
|
||||
repo_id: record.id.clone(),
|
||||
commit_sha,
|
||||
state: claim_state,
|
||||
context,
|
||||
target_url,
|
||||
description,
|
||||
// Both are the owner identity today (KTD-5); they split when delegated
|
||||
// capabilities land.
|
||||
producer_did: auth.0.clone(),
|
||||
authorizing_did: auth.0,
|
||||
signature,
|
||||
signature_input,
|
||||
signing_string,
|
||||
request_body,
|
||||
request_digest: digest,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
let caps = crate::db::ClaimCaps {
|
||||
per_tuple: MAX_CLAIMS_PER_TUPLE,
|
||||
contexts_per_commit: MAX_CONTEXTS_PER_COMMIT,
|
||||
per_repo_window: MAX_CLAIMS_PER_REPO_PER_WINDOW,
|
||||
};
|
||||
match state.db.insert_status_claim_capped(&claim, &caps).await? {
|
||||
crate::db::ClaimInsert::Inserted(seq) => Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatedStatus::from_claim(claim, seq)),
|
||||
)),
|
||||
// Already recorded, so 200 and the original row rather than 201 and a
|
||||
// second one. The claim the caller gets back is the stored one, id and
|
||||
// seq included, which is what makes a retry safe to treat as success.
|
||||
crate::db::ClaimInsert::AlreadyRecorded(existing) => {
|
||||
let seq = existing.seq;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(CreatedStatus::from_claim(*existing, seq)),
|
||||
))
|
||||
}
|
||||
crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!(
|
||||
"claim limit reached for {which}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a claim whose signature does not cover the body about to be stored.
|
||||
///
|
||||
/// A claim IS provenance, so the signature stored beside it has to bind the
|
||||
/// bytes stored beside it. It does not do that directly: `signing_string`
|
||||
/// reaches the body only through a `content-digest` line. And a caller can sign
|
||||
/// a content-digest the request then never carries. `require_signature` rebuilds
|
||||
/// the covered values from the request, so an ABSENT `Content-Digest` header
|
||||
/// makes the covered digest the empty string on the signing side and the
|
||||
/// verifying side alike; the Ed25519 check passes over method, path and an empty
|
||||
/// digest, and any body at all rides along. The row that write produces asserts
|
||||
/// a provenance that cannot be re-verified: the signing string covers a digest
|
||||
/// of bytes that are not the ones in `request_body`.
|
||||
///
|
||||
/// The predicate here is the write-time form of the one `re_verify` runs against
|
||||
/// a stored row in the tests — recompute the digest of the bytes about to be
|
||||
/// persisted and require the signing string to carry exactly that line — so what
|
||||
/// this route accepts and what a third party can later re-verify are the same
|
||||
/// question, asked at both ends.
|
||||
///
|
||||
/// This is a route property, checked where the claim becomes history, and it is
|
||||
/// deliberately not the only line of defence: requiring the header in
|
||||
/// `require_signature` closes the same hole one layer earlier and for every
|
||||
/// signed route, and that change belongs there rather than here because no other
|
||||
/// route's correctness should depend on the status handler. The two agree and
|
||||
/// neither substitutes for the other — without the middleware's requirement the
|
||||
/// empty digest is still accepted everywhere else, and without this one a later
|
||||
/// relaxation of the middleware would silently resume writing unverifiable
|
||||
/// history.
|
||||
///
|
||||
/// A 400, not a 500: the material is exactly what the client signed, and what
|
||||
/// they signed does not cover what they sent.
|
||||
fn require_body_bound_signature(
|
||||
material: &crate::auth::SignatureMaterial,
|
||||
body: &[u8],
|
||||
) -> Result<()> {
|
||||
let covered = format!(
|
||||
"\"content-digest\": {}",
|
||||
gitlawb_core::http_sig::compute_content_digest(body)
|
||||
);
|
||||
// Line-exact rather than a substring search: `build_signing_string` emits one
|
||||
// component per line, and a `contains` would also be satisfied by the digest
|
||||
// appearing INSIDE some other covered component's value, which proves
|
||||
// nothing about what the signature covered.
|
||||
if material.signing_string.lines().any(|line| line == covered) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::BadRequest(
|
||||
"the signature does not cover this request body: send a Content-Digest header \
|
||||
computed over the body and name \"content-digest\" among the signature's covered \
|
||||
components"
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The identity of one signed write, as a hex sha-256.
|
||||
///
|
||||
/// All four inputs together, because a replay is the same bytes arriving twice:
|
||||
/// the signature, the input that names what it covers, the canonical string it
|
||||
/// was verified over, and the body that string covers through a content-digest.
|
||||
/// The signature alone would nearly do (it covers the other three transitively),
|
||||
/// but hashing what is actually stored means the digest describes the row rather
|
||||
/// than a claim about it.
|
||||
///
|
||||
/// Each field is length-prefixed so no two different requests can concatenate to
|
||||
/// the same bytes: without it, a signature ending in some prefix of the next
|
||||
/// field would collide with the shorter signature that absorbed it.
|
||||
///
|
||||
/// This is not a nonce, and deliberately so. A nonce table needs pruning and a
|
||||
/// pruning window is a second replay window; the claim row IS the record of what
|
||||
/// was accepted, so uniqueness on it is self-maintaining.
|
||||
fn request_digest(material: &crate::auth::SignatureMaterial, body: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
for part in [
|
||||
material.signature.as_bytes(),
|
||||
material.signature_input.as_bytes(),
|
||||
material.signing_string.as_bytes(),
|
||||
body,
|
||||
] {
|
||||
h.update((part.len() as u64).to_be_bytes());
|
||||
h.update(part);
|
||||
}
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
|
||||
/// One reported context in the combined response. Signature material is
|
||||
/// deliberately absent: it is write-time provenance, not read-surface data.
|
||||
#[derive(Serialize)]
|
||||
pub struct StatusEntry {
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub producer_did: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// The combined commit status. `state` never leaves the four-value set (KTD-1),
|
||||
/// so absence is carried by `total_count` 0 with the pending state rather than a
|
||||
/// fifth value, and `reported_only` (R19) says out loud that the state covers the
|
||||
/// contexts that reported, not every check a caller expected.
|
||||
#[derive(Serialize)]
|
||||
pub struct CombinedStatus {
|
||||
pub state: String,
|
||||
pub sha: String,
|
||||
pub total_count: usize,
|
||||
pub statuses: Vec<StatusEntry>,
|
||||
pub reported_only: bool,
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/commits/:sha/status
|
||||
///
|
||||
/// The auth extension is optional and MUST be last in the extractor list: the
|
||||
/// route group carries `optional_signature`, so an unsigned caller reaches here
|
||||
/// with no `AuthenticatedDid` at all and a public repo still answers.
|
||||
pub async fn commit_status(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, sha)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<CombinedStatus>> {
|
||||
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
|
||||
// The gate runs first and on the requested path, before any claim data is
|
||||
// touched. Its deny is the repo's own not-found, byte-identical to a missing
|
||||
// repo, so the status surface cannot answer "does this repo exist".
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let commit_sha = normalize_sha(&sha)?;
|
||||
let statuses = project_claims(&state, &record, &commit_sha).await?;
|
||||
|
||||
Ok(Json(CombinedStatus {
|
||||
state: combined_state(&statuses).to_string(),
|
||||
sha: commit_sha,
|
||||
total_count: statuses.len(),
|
||||
statuses,
|
||||
reported_only: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The projection, in one place. Both read surfaces call it, so R3's "the rollup
|
||||
/// is derived by the same projection as the commit read" holds by construction
|
||||
/// rather than by two implementations happening to agree.
|
||||
///
|
||||
/// KTD-5: current authorization, not write-time authorization. An ownership
|
||||
/// transfer drops the prior owner's claims from the projection while the
|
||||
/// append-only history keeps them.
|
||||
async fn project_claims(
|
||||
state: &AppState,
|
||||
record: &crate::db::RepoRecord,
|
||||
commit_sha: &str,
|
||||
) -> Result<Vec<StatusEntry>> {
|
||||
let claims = state
|
||||
.db
|
||||
.latest_status_claims(&record.id, commit_sha, &record.owner_did)
|
||||
.await?;
|
||||
Ok(claims
|
||||
.into_iter()
|
||||
.map(|c| StatusEntry {
|
||||
state: c.state,
|
||||
context: c.context,
|
||||
target_url: c.target_url,
|
||||
description: c.description,
|
||||
producer_did: c.producer_did,
|
||||
created_at: c.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The pull request head rollup (R11). `state` stays inside the four wire values
|
||||
/// whatever happened to the head (KTD-1): an unresolvable head is carried by
|
||||
/// `head_resolved`, alongside the pull request's own state, so a client can tell
|
||||
/// "the head could not be resolved" from "the head resolved and nothing reported"
|
||||
/// without a fifth state value no client understands.
|
||||
#[derive(Serialize)]
|
||||
pub struct PullRequestStatus {
|
||||
pub number: i64,
|
||||
/// The pull request's own state: open, closed, or merged.
|
||||
pub pull_request_state: String,
|
||||
pub head_resolved: bool,
|
||||
/// The target commit, absent exactly when `head_resolved` is false.
|
||||
pub sha: Option<String>,
|
||||
pub state: String,
|
||||
pub total_count: usize,
|
||||
pub statuses: Vec<StatusEntry>,
|
||||
pub reported_only: bool,
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/pulls/:number/status
|
||||
///
|
||||
/// Same optional-auth shape as the commit read, and the auth extension MUST stay
|
||||
/// last in the extractor list.
|
||||
pub async fn pull_request_status(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<PullRequestStatus>> {
|
||||
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
|
||||
// The gate runs first, on the requested path, before the pull request row is
|
||||
// loaded. Its deny is the repo's own not-found, byte-identical to a missing
|
||||
// repo. Once it passes, a missing pull request number is the plain not-found:
|
||||
// existence is no longer secret.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
let head = rollup_head(&state, &record, &pr).await?;
|
||||
|
||||
let statuses = match &head {
|
||||
Some(sha) => project_claims(&state, &record, sha).await?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(Json(PullRequestStatus {
|
||||
number: pr.number,
|
||||
pull_request_state: pr.status,
|
||||
head_resolved: head.is_some(),
|
||||
sha: head,
|
||||
state: combined_state(&statuses).to_string(),
|
||||
total_count: statuses.len(),
|
||||
statuses,
|
||||
reported_only: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Branch-head lookups performed by the rollup fallback, counted so a test can
|
||||
/// assert WORK DONE rather than only the answer returned. A fallback that
|
||||
/// re-resolved on every read and then discarded the answer would be invisible in
|
||||
/// the response and visible here.
|
||||
#[cfg(test)]
|
||||
pub(crate) static BRANCH_RESOLVES: std::sync::atomic::AtomicUsize =
|
||||
std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// The rollup's target commit (KTD-4).
|
||||
///
|
||||
/// The stored head wins whenever it is set. It is frozen at close or merge, so a
|
||||
/// closed or merged pull request keeps the commit the decision was made against,
|
||||
/// and only an OPEN pull request with no stored head falls back to the branch.
|
||||
///
|
||||
/// The fallback is one database read: the latest push this node recorded for the
|
||||
/// pull request's source branch, from `repo_push_events`. It deliberately does
|
||||
/// NOT go through `git::store` ref listing, which needs an acquired repository
|
||||
/// path — on a cold node that downloads the whole repository from object storage,
|
||||
/// and this read group carries no rate limiter, so an anonymous caller could
|
||||
/// drive repeated downloads with a URL.
|
||||
///
|
||||
/// `repo_push_events` is the source rather than `branch_cids` because
|
||||
/// `record_push_events` writes it for every ref update unconditionally, whereas
|
||||
/// the sole writer of `branch_cids` sits behind a pin CID. A node with no object
|
||||
/// pinning configured never writes that table, so a resolve keyed on it could
|
||||
/// never answer there and every open pull request without a stored head reported
|
||||
/// `head_resolved: false` forever. The residual limit is that only pushes taken
|
||||
/// after this shipped have rows, so a branch last pushed before then still does
|
||||
/// not resolve.
|
||||
///
|
||||
/// The resolved head is persisted, which makes an unauthenticated GET write. That
|
||||
/// is only acceptable because the write is self-limiting: it fires exactly when
|
||||
/// `head_commit` is absent and it fills it, and the fill is conditioned on that
|
||||
/// same absence in SQL, so the PERSIST happens at most once per pull request.
|
||||
/// The resolve attempt itself is not once-only — a pull request whose branch does
|
||||
/// not resolve stores nothing, so there is nothing to short-circuit on and the
|
||||
/// lookup runs again on every read until it succeeds.
|
||||
async fn rollup_head(
|
||||
state: &AppState,
|
||||
record: &crate::db::RepoRecord,
|
||||
pr: &crate::db::PullRequest,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(stored) = pr.head_commit.clone() {
|
||||
return Ok(Some(stored));
|
||||
}
|
||||
if pr.status != "open" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// The push path records the full ref while a pull request stores the bare
|
||||
// branch name, the same mismatch `crate::api::repos::branch_from_ref`
|
||||
// reconciles on the write side; this is that mapping run backwards. The empty
|
||||
// branch is excluded for the same reason it is there: `refs/heads/` is not a
|
||||
// branch and nothing can have written it.
|
||||
if pr.source_branch.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ref_name = format!("refs/heads/{}", pr.source_branch);
|
||||
#[cfg(test)]
|
||||
BRANCH_RESOLVES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let resolved = state
|
||||
.db
|
||||
.latest_push_sha_for_ref(&record.id, &ref_name)
|
||||
.await?
|
||||
// A recorded head that is not a commit SHA is no target at all, and
|
||||
// storing it would leave the rollup pointing at nothing forever.
|
||||
.and_then(|sha| normalize_sha(&sha).ok());
|
||||
|
||||
let Some(sha) = resolved else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Best effort, and deliberately not `?`. The head is already resolved and the
|
||||
// response does not depend on this write landing, so a transient database
|
||||
// failure here must not turn a served read into a 500; the next read resolves
|
||||
// again and re-attempts the fill. Same catch-and-log the sibling writes on the
|
||||
// push path use (`update_open_pr_heads`, `record_push_events`).
|
||||
if let Err(e) = state.db.set_pr_head_if_absent(&pr.id, &sha).await {
|
||||
tracing::warn!(
|
||||
err = %e,
|
||||
pr_id = %pr.id,
|
||||
"failed to persist a resolved pull request head; serving the resolved head anyway"
|
||||
);
|
||||
}
|
||||
Ok(Some(sha))
|
||||
}
|
||||
|
||||
/// KTD-1's fold: any error or failure yields failure, else any pending yields
|
||||
/// pending, else success. An empty set is pending, never success — total absence
|
||||
/// of a verdict is its own state and must not read as a pass (R10).
|
||||
fn combined_state(statuses: &[StatusEntry]) -> &'static str {
|
||||
if statuses.is_empty() {
|
||||
return "pending";
|
||||
}
|
||||
if statuses
|
||||
.iter()
|
||||
.any(|s| s.state == "error" || s.state == "failure")
|
||||
{
|
||||
"failure"
|
||||
} else if statuses.iter().any(|s| s.state == "pending") {
|
||||
"pending"
|
||||
} else {
|
||||
"success"
|
||||
}
|
||||
}
|
||||
|
||||
/// 40 hex characters, lowercased. The SHA is never existence-checked (KTD-7):
|
||||
/// a claim describes a commit object, and the object may not have arrived yet.
|
||||
fn normalize_sha(sha: &str) -> Result<String> {
|
||||
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Ok(sha.to_ascii_lowercase())
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
"commit sha must be exactly 40 hexadecimal characters".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// One size bound on persisted signature material, measured in bytes.
|
||||
fn bound(what: &str, len: usize, max: usize) -> Result<()> {
|
||||
if len > max {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{what} must be at most {max} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_state(state: &str) -> Result<String> {
|
||||
if CLAIM_STATES.contains(&state) {
|
||||
Ok(state.to_string())
|
||||
} else {
|
||||
Err(AppError::BadRequest(format!(
|
||||
"state must be one of {}",
|
||||
CLAIM_STATES.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The context is the projection key, so it is trimmed, bounded, and required to
|
||||
/// be free of control characters.
|
||||
fn validate_context(context: &str) -> Result<String> {
|
||||
let trimmed = context.trim();
|
||||
if trimmed.is_empty() || trimmed.chars().count() > MAX_CONTEXT_CHARS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"context must be 1 to {MAX_CONTEXT_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(AppError::BadRequest(
|
||||
"context must not contain control characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn validate_target_url(url: Option<&str>) -> Result<Option<String>> {
|
||||
let Some(url) = url else { return Ok(None) };
|
||||
if url.chars().count() > MAX_TARGET_URL_CHARS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"target_url must be at most {MAX_TARGET_URL_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
Ok(Some(url.to_string()))
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
"target_url must be an http or https URL".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_description(description: Option<&str>) -> Result<Option<String>> {
|
||||
match description {
|
||||
Some(d) if d.chars().count() > MAX_DESCRIPTION_CHARS => Err(AppError::BadRequest(format!(
|
||||
"description must be at most {MAX_DESCRIPTION_CHARS} characters"
|
||||
))),
|
||||
other => Ok(other.map(str::to_string)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
+195
-274
@@ -17,6 +17,53 @@ use crate::state::AppState;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// The RFC 9421 material this request was actually verified against, injected
|
||||
/// alongside [`AuthenticatedDid`] by `require_signature`.
|
||||
///
|
||||
/// A handler that records a signed claim as history has to store it: the header
|
||||
/// values and the canonical signing string are gone once the request ends, and a
|
||||
/// stored claim nobody can re-verify is not history. `signing_string` is the exact
|
||||
/// byte sequence the Ed25519 verification succeeded over, not a rebuild — a
|
||||
/// handler reconstructing it from headers would be verifying a different string
|
||||
/// than the middleware did.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SignatureMaterial {
|
||||
/// The `Signature` header value.
|
||||
pub signature: String,
|
||||
/// The `Signature-Input` header value.
|
||||
pub signature_input: String,
|
||||
/// The canonical bytes the signature covered.
|
||||
pub signing_string: String,
|
||||
/// The buffered request body, on the routes that persist it. The signing
|
||||
/// string covers it only through the content-digest, so without these bytes
|
||||
/// a stored claim can be shown to carry *a* valid signature over *some*
|
||||
/// digest and nothing more. Carried here because the body is consumed
|
||||
/// downstream by the extractor and cannot be recovered afterwards.
|
||||
///
|
||||
/// `Option`, and populated only behind [`PersistsSignedBody`], because a
|
||||
/// route that never stores the body has no use for a second handle to it.
|
||||
/// The unlike-sized field is this one: the three above are header-derived
|
||||
/// and bounded by the server's header limit, while a receive-pack POST
|
||||
/// carries up to GITLAWB_MAX_PACK_BYTES (2 GB by default). `Bytes`, not
|
||||
/// `Vec<u8>`, so the handle the marked routes do take is a refcount bump
|
||||
/// over the buffer the middleware already holds rather than a copy.
|
||||
pub body: Option<axum::body::Bytes>,
|
||||
}
|
||||
|
||||
/// Marks a route group whose handler persists the signed request body.
|
||||
///
|
||||
/// Applied as an extension layer OUTSIDE the auth layers, so it is on the
|
||||
/// request before [`require_signature`] decides whether to carry the body. A
|
||||
/// marker applied inside them is never seen, and the failure is silent: the
|
||||
/// handler still compiles, tests that inject material by hand still pass, and
|
||||
/// production stores nothing. The status write handler refuses an absent body
|
||||
/// for that reason.
|
||||
///
|
||||
/// A marker rather than a path check because `require_signature` must not learn
|
||||
/// which URL persists claims; that knowledge belongs at the route table.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PersistsSignedBody;
|
||||
|
||||
/// Whether `caller` is authorized to push to `record`.
|
||||
///
|
||||
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
|
||||
@@ -34,38 +81,6 @@ use gitlawb_core::http_sig::{
|
||||
};
|
||||
use gitlawb_core::identity::verify;
|
||||
|
||||
/// Per-route-group ceiling on how much request body [`require_signature`] will
|
||||
/// buffer BEFORE it has authenticated anything.
|
||||
///
|
||||
/// This is middleware state rather than a constant because a single number cannot
|
||||
/// serve both a 2 GB git push and a 2 MB profile update: the git write group
|
||||
/// passes `GITLAWB_MAX_PACK_BYTES`, every other signed group passes
|
||||
/// [`crate::server::SIGNED_BODY_LIMIT`]. Making it a required piece of state means
|
||||
/// a new signed route group cannot be wired up without choosing a limit.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SignatureBodyLimit(pub usize);
|
||||
|
||||
/// The hint for an unresolvable DID, chosen by method. The hint is only true
|
||||
/// for a method we have no resolver for: a did:key that parsed and then failed
|
||||
/// on its key material (a small-order key, wrong multicodec, wrong length)
|
||||
/// would be sent looking for the wrong problem by the alpha-support wording.
|
||||
fn unresolvable_did_hint(key_id: &Did) -> &'static str {
|
||||
if key_id.is_did_key() {
|
||||
"the DID is a did:key whose key material did not resolve"
|
||||
} else {
|
||||
"only did:key is supported in alpha"
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound the echoed keyid in the unresolvable-DID message. `key_id` is
|
||||
/// unauthenticated request input, so reflecting it back at whatever length the
|
||||
/// caller chose would amplify attacker input into the response body. The bound
|
||||
/// is slack (a did:key method-id is a fixed 48 characters) and only defends
|
||||
/// the echo, not the resolution.
|
||||
fn bounded_did_echo(key_id: &str) -> String {
|
||||
key_id.chars().take(96).collect()
|
||||
}
|
||||
|
||||
/// Axum middleware that enforces HTTP Signature authentication (RFC 9421).
|
||||
///
|
||||
/// Every write request must carry:
|
||||
@@ -80,52 +95,20 @@ fn bounded_did_echo(key_id: &str) -> String {
|
||||
/// 4. Resolves the did:key to an Ed25519 VerifyingKey
|
||||
/// 5. Rebuilds the signing string and verifies the Ed25519 signature
|
||||
/// 6. Verifies Content-Digest matches the request body
|
||||
pub async fn require_signature(
|
||||
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
verify_signature(limit, request, next).await
|
||||
}
|
||||
|
||||
/// The signature check itself, shared by [`require_signature`] and the optional
|
||||
/// variant. `limit` bounds the pre-auth buffer; see [`SignatureBodyLimit`].
|
||||
async fn verify_signature(limit: usize, request: Request, next: Next) -> Response {
|
||||
// Buffer the body so we can verify content-digest and pass it downstream.
|
||||
//
|
||||
// The buffer is bounded, because this runs BEFORE any identity check: an
|
||||
// unauthenticated caller with no DID, no signature and no repo reaches this
|
||||
// line. Nothing upstream supplies the bound. On the git write routes
|
||||
// `RequestBodyLimitLayer` sits on the inner router, and a later `.layer()` is
|
||||
// the OUTER service, so this middleware runs first and sees the raw body. On
|
||||
// every other signed group axum's 2 MB `DefaultBodyLimit` is enforced by the
|
||||
// `Bytes`/`Json` extractors through a request extension, which collecting the
|
||||
// body here bypasses. `Limited` stops reading at the limit rather than reading
|
||||
// everything and then measuring it, which is the property that matters.
|
||||
pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
// Buffer the body so we can verify content-digest and pass it downstream
|
||||
let (parts, body) = request.into_parts();
|
||||
let body_bytes = match http_body_util::Limited::new(body, limit).collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
// Over the limit: a distinct status from the 401 an unsigned request gets
|
||||
// and the 400 below, so a client can tell "too big" from "not authenticated"
|
||||
// and from "your body did not arrive".
|
||||
Err(e) if e.is::<http_body_util::LengthLimitError>() => return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({
|
||||
"error": "body_too_large",
|
||||
"message": format!("request body exceeds the {limit}-byte limit for this route"),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
let body_bytes =
|
||||
match body.collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "unreadable_body", "message": "could not read request body" }),
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
.into_response(),
|
||||
};
|
||||
|
||||
let sig_input = parts
|
||||
.headers
|
||||
@@ -184,7 +167,7 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
|
||||
COVERED_COMPONENTS.join(", "),
|
||||
missing.join(", ")
|
||||
),
|
||||
"hint": "See https://twigpine.com/agents#authentication",
|
||||
"hint": "See https://gitlawb.com/agents#authentication",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -209,14 +192,11 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "unresolvable_did",
|
||||
"message": format!(
|
||||
"cannot resolve DID '{}': {e}",
|
||||
bounded_did_echo(&sig.key_id.to_string())
|
||||
),
|
||||
"hint": unresolvable_did_hint(&sig.key_id),
|
||||
"message": format!("cannot resolve DID '{}': {e}", sig.key_id),
|
||||
"hint": "only did:key is supported in alpha",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,34 +209,17 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
|
||||
// Required, never defaulted. The signing string is rebuilt from the request,
|
||||
// so an absent header would make the covered `content-digest` empty on both
|
||||
// sides: verification would pass and the digest-versus-body comparison below
|
||||
// would have nothing to compare, leaving a signed request free to carry a
|
||||
// body its signature never covered. `sign_request` always emits the header,
|
||||
// so refusing here costs no conforming client anything.
|
||||
let content_digest = match parts
|
||||
let content_digest = parts
|
||||
.headers
|
||||
.get("content-digest")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(v) => v.to_string(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "missing_content_digest",
|
||||
"message": "Content-Digest header is required on a signed request",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let mut request_values: HashMap<String, String> = HashMap::new();
|
||||
request_values.insert("@method".to_string(), method);
|
||||
request_values.insert("@path".to_string(), path_and_query);
|
||||
request_values.insert("content-digest".to_string(), content_digest.clone());
|
||||
request_values.insert("content-digest".to_string(), content_digest);
|
||||
|
||||
// The @signature-params value is the part of Signature-Input after "sig1="
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input);
|
||||
@@ -301,43 +264,60 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Verify Content-Digest matches the actual request body. Unconditional: the
|
||||
// header's presence was established above, so there is no branch here that
|
||||
// skips the comparison.
|
||||
if content_digest != compute_content_digest(&body_bytes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "content_digest_mismatch",
|
||||
"message": "Content-Digest does not match request body",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
// Verify Content-Digest matches the actual request body
|
||||
if let Some(claimed) = parts
|
||||
.headers
|
||||
.get("content-digest")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
let actual = compute_content_digest(&body_bytes);
|
||||
if claimed != actual {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "content_digest_mismatch",
|
||||
"message": "Content-Digest does not match request body",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(did = %sig.key_id, "✓ authenticated request");
|
||||
|
||||
// The body travels only where it is stored. On every other signed route the
|
||||
// middleware's handle would keep the buffer alive for the rest of the layer
|
||||
// chain, until the handler's own extractor consumes the request.
|
||||
let body = parts
|
||||
.extensions
|
||||
.get::<PersistsSignedBody>()
|
||||
.map(|_| body_bytes.clone());
|
||||
|
||||
let material = SignatureMaterial {
|
||||
signature: sig_header,
|
||||
signature_input: sig_input,
|
||||
signing_string,
|
||||
body,
|
||||
};
|
||||
|
||||
let mut request = Request::from_parts(parts, Body::from(body_bytes));
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedDid(sig.key_id.to_string()));
|
||||
// Carry the verified material forward for handlers that persist a signed
|
||||
// claim; none of it can be recovered once the request is gone.
|
||||
request.extensions_mut().insert(material);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
|
||||
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
|
||||
/// continue when no signature attempt was made.
|
||||
pub async fn optional_signature(
|
||||
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
pub async fn optional_signature(request: Request, next: Next) -> Response {
|
||||
let has_signature_headers = request.headers().contains_key("signature-input")
|
||||
|| request.headers().contains_key("signature");
|
||||
if has_signature_headers {
|
||||
// Same pre-auth buffer, same bound: presenting signature headers is not
|
||||
// authentication, so this path is as reachable as the required one.
|
||||
return verify_signature(limit, request, next).await;
|
||||
return require_signature(request, next).await;
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -465,7 +445,7 @@ fn human_detected(message: &str) -> impl IntoResponse {
|
||||
"error": "not_an_agent",
|
||||
"message": message,
|
||||
"hint": "gl identity new && gl register",
|
||||
"docs": "https://twigpine.com/agents",
|
||||
"docs": "https://gitlawb.com/agents",
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -508,58 +488,6 @@ mod tests {
|
||||
assert!(validate_ucan_chain(&token, &node_did, &agent_did).is_ok());
|
||||
}
|
||||
|
||||
/// The two-branch hint selection is the point of the unresolvable-DID
|
||||
/// change: a did:key that failed on its key material must be told the key
|
||||
/// material did not resolve, and any other method must be told only did:key
|
||||
/// is supported. A regression that drops the is_did_key() check would send
|
||||
/// a did:key caller after the wrong problem, so both branches are pinned.
|
||||
#[test]
|
||||
fn unresolvable_did_hint_distinguishes_key_material_from_method() {
|
||||
// A real did:key whose key material fails to resolve (small-order):
|
||||
// key-material hint.
|
||||
let mut weak = [0u8; 32];
|
||||
weak[0] = 1; // compressed identity point
|
||||
let weak_vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).unwrap();
|
||||
let weak_did = Did::from_verifying_key(&weak_vk);
|
||||
assert!(
|
||||
weak_did.to_verifying_key().is_err(),
|
||||
"fixture precondition: small-order did:key must not resolve"
|
||||
);
|
||||
assert_eq!(
|
||||
unresolvable_did_hint(&weak_did),
|
||||
"the DID is a did:key whose key material did not resolve"
|
||||
);
|
||||
|
||||
// A method with no resolver: alpha-support hint.
|
||||
let web = Did::web("example.com");
|
||||
assert!(
|
||||
web.to_verifying_key().is_err(),
|
||||
"fixture precondition: did:web must not resolve locally"
|
||||
);
|
||||
assert_eq!(
|
||||
unresolvable_did_hint(&web),
|
||||
"only did:key is supported in alpha"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unresolvable-DID message echoes the keyid, which is unauthenticated
|
||||
/// request input. The echoed value must be bounded so a hostile caller
|
||||
/// cannot amplify arbitrary-length input into the response body.
|
||||
#[test]
|
||||
fn unresolvable_did_message_bounds_the_echoed_keyid() {
|
||||
let long_keyid = format!("did:key:z{}", "A".repeat(2000));
|
||||
let shown = bounded_did_echo(&long_keyid);
|
||||
assert!(
|
||||
shown.len() <= 96,
|
||||
"echoed keyid must be bounded, got {} chars",
|
||||
shown.len()
|
||||
);
|
||||
assert!(
|
||||
!shown.contains(&"A".repeat(1000)),
|
||||
"the full keyid must not survive into the message"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ucan_chain_wrong_issuer() {
|
||||
let node = Keypair::generate();
|
||||
@@ -627,7 +555,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()
|
||||
@@ -654,39 +581,107 @@ mod tests {
|
||||
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)),
|
||||
close_issue_rate_limiter: RateLimiter::new(120, 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)),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports what the signature middleware put in `SignatureMaterial.body`,
|
||||
/// so a test can tell "captured" from "deliberately not captured" apart
|
||||
/// from "the middleware never ran".
|
||||
async fn report_body(material: Option<axum::Extension<SignatureMaterial>>) -> String {
|
||||
match material {
|
||||
Some(axum::Extension(m)) => match m.body {
|
||||
Some(bytes) => format!("captured:{}", bytes.len()),
|
||||
None => "absent".to_string(),
|
||||
},
|
||||
None => "no-material".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_post(path: &str, body: &[u8]) -> Request {
|
||||
let kp = Keypair::generate();
|
||||
let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", path, body);
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(axum::body::Body::from(body.to_vec()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The body handle travels only on a route that asked for it.
|
||||
///
|
||||
/// Both arms drive a genuinely signed request through `require_signature`;
|
||||
/// the only difference is the [`PersistsSignedBody`] marker layer. A
|
||||
/// middleware that captured unconditionally would make both arms read
|
||||
/// `captured`, and one that never read the marker would make both read
|
||||
/// `absent`, so the pair pins the decision rather than the mechanism.
|
||||
#[tokio::test]
|
||||
async fn the_body_is_carried_only_when_the_route_marks_itself_as_persisting_it() {
|
||||
let body = br#"{"state":"success"}"#;
|
||||
|
||||
// The marker layer is added AFTER the middleware layer, which is what
|
||||
// makes it the outer of the two and therefore the one that runs first.
|
||||
// This is the same relative position `build_router` uses on the status
|
||||
// write group, where the marker sits outside `add_auth_layers`.
|
||||
let marked = Router::new()
|
||||
.route("/", axum::routing::post(report_body))
|
||||
.layer(middleware::from_fn(require_signature))
|
||||
.layer(axum::Extension(PersistsSignedBody));
|
||||
let resp = marked.oneshot(signed_post("/", body)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(seen.to_vec()).unwrap(),
|
||||
format!("captured:{}", body.len()),
|
||||
"a route carrying the persist marker must receive the buffered body"
|
||||
);
|
||||
|
||||
let unmarked = Router::new()
|
||||
.route("/", axum::routing::post(report_body))
|
||||
.layer(middleware::from_fn(require_signature));
|
||||
let resp = unmarked.oneshot(signed_post("/", body)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(seen.to_vec()).unwrap(),
|
||||
"absent",
|
||||
"a signed route that never persists the body must not be handed a handle to it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The marker is read from the request the middleware was given, and the
|
||||
/// request it hands downstream still carries it. Both halves matter: the
|
||||
/// first is the capture decision, the second is that rebuilding the request
|
||||
/// from its parts does not drop extensions a later layer may want.
|
||||
#[tokio::test]
|
||||
async fn the_marker_survives_the_request_rebuild() {
|
||||
async fn report_marker(marker: Option<axum::Extension<PersistsSignedBody>>) -> String {
|
||||
match marker {
|
||||
Some(_) => "marked".to_string(),
|
||||
None => "unmarked".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::post(report_marker))
|
||||
.layer(middleware::from_fn(require_signature))
|
||||
.layer(axum::Extension(PersistsSignedBody));
|
||||
let resp = app.oneshot(signed_post("/", b"{}")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(seen.to_vec()).unwrap(),
|
||||
"marked",
|
||||
"the marker must reach the handler, so the middleware saw it too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn require_ucan_chain_no_header_passes_through() {
|
||||
let state = make_test_state(Keypair::generate().did());
|
||||
@@ -774,78 +769,4 @@ mod tests {
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(body_json["error"], "invalid_ucan");
|
||||
}
|
||||
|
||||
/// A request that signs `content-digest` as the EMPTY string and then sends
|
||||
/// no Content-Digest header at all must be refused before any handler runs.
|
||||
///
|
||||
/// This is the whole point of requiring the header rather than defaulting it:
|
||||
/// the middleware rebuilds the signing string from the request, so an absent
|
||||
/// header makes the covered digest empty on both sides. The Ed25519 check
|
||||
/// passes, the body-versus-digest comparison is skipped for want of a header,
|
||||
/// and a signed request carries a body the signature never covered.
|
||||
#[tokio::test]
|
||||
async fn signed_request_without_content_digest_is_rejected() {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static REACHED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let created = chrono::Utc::now().timestamp();
|
||||
let signature_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
|
||||
);
|
||||
let sig_params_value = &signature_input["sig1=".len()..];
|
||||
|
||||
let mut values: HashMap<String, String> = HashMap::new();
|
||||
values.insert("@method".to_string(), "POST".to_string());
|
||||
values.insert("@path".to_string(), "/x".to_string());
|
||||
// Exactly what the middleware derives when the header is absent.
|
||||
values.insert("content-digest".to_string(), String::new());
|
||||
let signing_string =
|
||||
build_signing_string(COVERED_COMPONENTS, sig_params_value, &values).unwrap();
|
||||
let signature = format!(
|
||||
"sig1=:{}:",
|
||||
STANDARD.encode(kp.sign(signing_string.as_bytes()).to_bytes())
|
||||
);
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/x",
|
||||
axum::routing::post(|| async {
|
||||
REACHED.store(true, Ordering::SeqCst);
|
||||
StatusCode::OK
|
||||
}),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
require_signature,
|
||||
));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/x")
|
||||
.header("signature-input", signature_input)
|
||||
.header("signature", signature)
|
||||
.body(Body::from("a body no signature covers"))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"a signed request with no Content-Digest header must be refused"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
body["error"], "missing_content_digest",
|
||||
"the refusal must name the absent Content-Digest header"
|
||||
);
|
||||
assert!(
|
||||
!REACHED.load(Ordering::SeqCst),
|
||||
"the handler must never see a request whose body no signature covers"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Embedded seed list of public Twigpine network nodes.
|
||||
//! Embedded seed list of public Gitlawb network nodes.
|
||||
//!
|
||||
//! This module parses `bootstrap-peers.json` (embedded at compile time) and
|
||||
//! merges its contents into the runtime config so a fresh `docker compose up`
|
||||
|
||||
+17
-1917
File diff suppressed because it is too large.
Load diff
+2107
-2422
File diff suppressed because it is too large.
Load diff
@@ -69,12 +69,6 @@ fn resolve_all_recipients(dids: &BTreeSet<String>) -> Result<Vec<VerifyingKey>,
|
||||
enum SealPlan {
|
||||
/// An existing envelope already covers exactly this recipient set; nothing to do.
|
||||
SkipUnchanged,
|
||||
/// The stored tag matches, but the stored reader set contains a DID that
|
||||
/// must not resolve (e.g. a small-order did:key sealed before the choke
|
||||
/// point landed). The old envelope is already public and cannot be
|
||||
/// withdrawn, but it must be surfaced for operator action rather than
|
||||
/// silently skipped as unchanged.
|
||||
SkipUnresolvableStored(Vec<String>),
|
||||
/// 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
|
||||
@@ -96,30 +90,15 @@ enum SealPlan {
|
||||
/// 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.
|
||||
///
|
||||
/// A matching stored tag is normally a skip, but the set is still resolved
|
||||
/// before skipping: a reader that used to resolve and now must not (a
|
||||
/// small-order did:key sealed before the choke point landed) is surfaced as
|
||||
/// [`SealPlan::SkipUnresolvableStored`] instead of being silently treated as
|
||||
/// unchanged. The old envelope is already public and cannot be withdrawn, but
|
||||
/// the operator must be able to see that a persisted envelope was sealed to a
|
||||
/// weak recipient.
|
||||
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()) {
|
||||
// The stored envelope already covers exactly this set, so there is
|
||||
// nothing to re-seal. Resolve anyway so a now-unresolvable reader in an
|
||||
// unchanged set is surfaced rather than hidden by the tag match.
|
||||
match resolve_all_recipients(dids) {
|
||||
Ok(_) => SealPlan::SkipUnchanged,
|
||||
Err(unresolved) => SealPlan::SkipUnresolvableStored(unresolved),
|
||||
}
|
||||
} else {
|
||||
match resolve_all_recipients(dids) {
|
||||
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
|
||||
Ok(keys) => SealPlan::Seal { keys, tag },
|
||||
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,21 +131,6 @@ pub async fn encrypt_and_pin(
|
||||
// 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::SkipUnresolvableStored(unresolved) => {
|
||||
// A stored envelope already covers this exact reader set, and
|
||||
// the set now contains a DID that must not resolve (a
|
||||
// small-order did:key sealed before the choke point landed).
|
||||
// The old envelope is already public and cannot be withdrawn
|
||||
// or re-sealed; surface it so the operator can act.
|
||||
let sample: Vec<&String> = unresolved.iter().take(3).collect();
|
||||
tracing::warn!(
|
||||
oid = %oid,
|
||||
unresolved_count = unresolved.len(),
|
||||
unresolved_sample = ?sample,
|
||||
"stored envelope was sealed to a recipient that must not resolve; operator action may be required"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
SealPlan::SkipNoRecipients => {
|
||||
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
|
||||
continue;
|
||||
@@ -206,7 +170,7 @@ pub async fn encrypt_and_pin(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope).await {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
Ok(_) => {
|
||||
tracing::warn!(oid = %oid, "pin_git_object returned no cid; skipping");
|
||||
@@ -242,49 +206,6 @@ mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
/// A did:key encoding a small-order point. Well-formed, indistinguishable
|
||||
/// from a real one by eye, and refused at resolution since the choke-point
|
||||
/// guard landed.
|
||||
fn weak_did_key() -> String {
|
||||
let mut weak = [0u8; 32];
|
||||
weak[0] = 1; // compressed identity point
|
||||
let vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).expect("decompresses");
|
||||
Did::from_verifying_key(&vk).to_string()
|
||||
}
|
||||
|
||||
/// The #47 fail-closed arm already refuses to seal to a partial recipient
|
||||
/// set. Because a small-order did:key no longer RESOLVES, it lands in the
|
||||
/// unresolved set and that existing arm catches it: nothing is sealed, and
|
||||
/// no new code path was added to make that happen.
|
||||
#[test]
|
||||
fn weak_recipient_falls_into_the_existing_fail_closed_arm() {
|
||||
let weak = weak_did_key();
|
||||
let mut dids = BTreeSet::new();
|
||||
dids.insert(did_key(9));
|
||||
dids.insert(weak.clone());
|
||||
|
||||
match plan_seal(&[0u8; 32], &dids, None) {
|
||||
SealPlan::SkipUnresolvable(unresolved) => assert!(
|
||||
unresolved.contains(&weak),
|
||||
"the weak DID must be named in the unresolved set, got {unresolved:?}"
|
||||
),
|
||||
other => panic!("expected SkipUnresolvable, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Must-not-over-reject control: an all-legitimate recipient set still seals.
|
||||
#[test]
|
||||
fn legitimate_recipient_set_still_seals() {
|
||||
let mut dids = BTreeSet::new();
|
||||
dids.insert(did_key(9));
|
||||
dids.insert(did_key(11));
|
||||
|
||||
match plan_seal(&[0u8; 32], &dids, None) {
|
||||
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2, "both recipients resolve"),
|
||||
other => panic!("expected Seal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn did_key(seed: u8) -> String {
|
||||
let vk = SigningKey::from_bytes(&[seed; 32]).verifying_key();
|
||||
Did::from_verifying_key(&vk).to_string()
|
||||
@@ -427,43 +348,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// P1 remediation (review): an unchanged reader set is normally a skip, but
|
||||
/// the skip must not hide a recipient that now fails to resolve. A blob
|
||||
/// sealed to a small-order did:key before the choke-point guard landed keeps
|
||||
/// its old CID and stays decryptable-without-a-private-key unless the
|
||||
/// matching-tag path surfaces it. This pins that the stored-tag match still
|
||||
/// resolves the set instead of short-circuiting.
|
||||
#[test]
|
||||
fn plan_seal_surfaces_unresolvable_recipient_in_unchanged_reader_set() {
|
||||
let weak = weak_did_key();
|
||||
let mut dids = BTreeSet::new();
|
||||
dids.insert(did_key(9));
|
||||
dids.insert(weak.clone());
|
||||
let stored = recipients_tag(&SEED, &dids);
|
||||
|
||||
match plan_seal(&SEED, &dids, Some(&stored)) {
|
||||
SealPlan::SkipUnresolvableStored(unresolved) => assert!(
|
||||
unresolved.contains(&weak),
|
||||
"the weak DID must be named in the unresolved set, got {unresolved:?}"
|
||||
),
|
||||
other => panic!("expected SkipUnresolvableStored, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Degenerate control: an empty set with a matching stored tag stays a
|
||||
/// silent skip. The state is unreachable in practice (a seal is never
|
||||
/// recorded for an empty reader set), so this pins only that the
|
||||
/// resolve-on-match path must not turn it into a re-seal or a warning.
|
||||
#[test]
|
||||
fn plan_seal_unchanged_empty_set_still_skips() {
|
||||
let empty = BTreeSet::new();
|
||||
let empty_tag = recipients_tag(&SEED, &empty);
|
||||
assert!(matches!(
|
||||
plan_seal(&SEED, &empty, Some(&empty_tag)),
|
||||
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
|
||||
|
||||
@@ -35,49 +35,18 @@ pub enum AppError {
|
||||
#[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("repository is busy")]
|
||||
RepoBusy,
|
||||
|
||||
#[error("repository is temporarily unavailable")]
|
||||
RepoUnavailable,
|
||||
|
||||
#[error("repository write was fenced by a concurrent publish")]
|
||||
RepoWriteFenced,
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
@@ -91,14 +60,6 @@ pub enum AppError {
|
||||
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 {
|
||||
@@ -119,31 +80,7 @@ impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
match err.downcast::<sqlx::Error>() {
|
||||
Ok(sql) => AppError::Db(sql),
|
||||
// Lock contention is transient and ordinary, so it must not land as a
|
||||
// 500. The internal message names the owner slug and repo, so the
|
||||
// variant carries nothing: the detail stays in the log at the raise
|
||||
// site and the client gets a fixed retryable body.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
|
||||
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
|
||||
Ok(_) => AppError::RepoBusy,
|
||||
// Same reasoning one rung down: a refused under-lock refresh is a
|
||||
// transient storage condition, and its internal message names the
|
||||
// owner slug and repo, so the variant carries nothing.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
|
||||
Ok(_) => AppError::RepoUnavailable,
|
||||
// And one more rung: a publish the store refused twice is
|
||||
// transient in the same way, and the retry is the client's
|
||||
// to make. The variant carries nothing for the same reason
|
||||
// as the two above.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
|
||||
{
|
||||
Ok(_) => AppError::RepoWriteFenced,
|
||||
Err(err) => AppError::Internal(err),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Err(err) => AppError::Internal(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,123 +132,35 @@ impl IntoResponse for AppError {
|
||||
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
|
||||
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::UnresolvableDid(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "unresolvable_did", msg.clone())
|
||||
}
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", 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()),
|
||||
// 503 with a FIXED body: the caller should retry, and must not be told
|
||||
// which repo is contended or for how long.
|
||||
AppError::RepoBusy => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_busy",
|
||||
"repository is busy — retry".into(),
|
||||
),
|
||||
// 503 with a FIXED body for the same reason: the caller should retry, and
|
||||
// must not be told which repo could not be refreshed or why.
|
||||
AppError::RepoUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_unavailable",
|
||||
"repository is temporarily unavailable, retry".into(),
|
||||
),
|
||||
// 503 with a FIXED body again, and its own code: the caller should
|
||||
// retry, but the condition is not contention, so a client that
|
||||
// distinguishes them should be able to. The body must not say which
|
||||
// repo lost its publish or to whom.
|
||||
AppError::RepoWriteFenced => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_write_fenced",
|
||||
"repository changed underneath this write, retry".into(),
|
||||
),
|
||||
AppError::Db(e) if db_unavailable(e) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
DB_UNAVAILABLE_CODE,
|
||||
DB_UNAVAILABLE_MESSAGE.into(),
|
||||
),
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
AppError::Internal(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
e.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
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();
|
||||
// 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 { .. }
|
||||
| AppError::RepoBusy
|
||||
| AppError::RepoUnavailable
|
||||
| AppError::RepoWriteFenced
|
||||
) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
axum::http::HeaderValue::from_static("1"),
|
||||
);
|
||||
}
|
||||
resp
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,120 +182,4 @@ mod tests {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
|
||||
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
|
||||
for err in [
|
||||
AppError::RepoBusy,
|
||||
AppError::RepoUnavailable,
|
||||
AppError::RepoWriteFenced,
|
||||
] {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,27 +61,6 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a single issue ref while the write guard still holds the advisory lock,
|
||||
/// after a definite publish refusal. Best-effort: a failed delete leaves a
|
||||
/// local-only orphan, which is still better than telling the client to retry into a
|
||||
/// duplicate id. Not used on `UploadUnknowable`, where the PUT may still land.
|
||||
pub fn delete_issue_ref(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
issue_id: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<()> {
|
||||
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
|
||||
crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["update-ref", "-d", &ref_name],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all issue refs and return their JSON content.
|
||||
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
|
||||
// List all refs under refs/gitlawb/issues/
|
||||
@@ -129,18 +108,6 @@ pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
|
||||
/// Returns Ok(Some(full_id)) on unique match, Ok(None) if not found,
|
||||
/// Err if the prefix is ambiguous (matches more than one issue).
|
||||
pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<String>> {
|
||||
// The id comes from the request path and is spliced into a git revision and a
|
||||
// for-each-ref pattern below. Issue ids are UUIDs, so accept only ASCII
|
||||
// alphanumerics and '-': anything else (`^{blob}`, `@{0}`, `*`, `/`, `..`) is
|
||||
// revision or glob syntax, never an issue id, and is reported as not found.
|
||||
if id_or_prefix.is_empty()
|
||||
|| !id_or_prefix
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Try exact match first — fast path for callers passing the full UUID.
|
||||
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
|
||||
let check = Command::new("git")
|
||||
@@ -282,35 +249,6 @@ mod tests {
|
||||
assert_eq!(resolved, Some(full_id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rejects_revision_syntax() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "abc12345-0000-0000-0000-000000000000";
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Each of these resolves to (or globs over) the issue blob in git, but none
|
||||
// is an issue id, so the lookup must not accept it.
|
||||
for crafted in [
|
||||
"abc12345-0000-0000-0000-000000000000^{blob}",
|
||||
"abc12345-0000-0000-0000-000000000000^{}",
|
||||
"abc12345-0000-0000-0000-000000000000~0",
|
||||
"*",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_issue_id(dir.path(), crafted).unwrap(),
|
||||
None,
|
||||
"{crafted:?}"
|
||||
);
|
||||
assert_eq!(get_issue(dir.path(), crafted).unwrap(), None, "{crafted:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_prefix_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -340,27 +278,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().to_string().contains("ambiguous"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_issue_ref_removes_a_created_ref() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "eee88888-0000-0000-0000-000000000000";
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"eee88888-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
delete_issue_ref(
|
||||
"git",
|
||||
dir.path(),
|
||||
full_id,
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolve_issue_id(dir.path(), full_id).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_issue_via_prefix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod issues;
|
||||
pub mod publish;
|
||||
pub mod push_delta;
|
||||
pub mod repo_store;
|
||||
pub mod smart_http;
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
//! The publication boundary: WHO wrote, and HOW FAR that write got.
|
||||
//!
|
||||
//! Four lifecycle defects on this branch shared one root cause. A resource was
|
||||
//! tracked by a coarse state — "release started", "the path exists", "a row with
|
||||
//! this name exists" — when the safety question was about the IDENTITY and the
|
||||
//! PUBLICATION STAGE of one specific write attempt. A tail replicated refs from
|
||||
//! an attempt that had not dispatched a PUT; a read served a tree whose
|
||||
//! generation nobody had confirmed; a fork claimed a concurrent attempt's row and
|
||||
//! deleted a successor's object; a response-loss failure was compensated as if it
|
||||
//! proved non-publication.
|
||||
//!
|
||||
//! This module is the vocabulary that makes those questions answerable, and it is
|
||||
//! deliberately free of any object-storage type:
|
||||
//!
|
||||
//! - [`PublishAttemptId`] — minted before the request is built, carried with the
|
||||
//! bytes as user metadata, read back off the store to decide whether what is
|
||||
//! published is THIS attempt's work.
|
||||
//! - [`PublishStage`] / [`PublishStageCell`] — how far one attempt got, observable
|
||||
//! from outside the future that is performing it, so a CANCELLED attempt can
|
||||
//! still be classified.
|
||||
//! - [`UploadError`] — what the client KNOWS about dispatch and commit, not just
|
||||
//! which HTTP status came back. Destructive compensation is licensed only by
|
||||
//! [`UploadError::proves_not_published`].
|
||||
//!
|
||||
//! # Porting note (#79)
|
||||
//!
|
||||
//! PR #79 (`feat/storage-abstraction`) deletes `git/tigris.rs` and replaces it
|
||||
//! with a `BlobStore`/`RepoArchive` layer. Everything in this file is
|
||||
//! backend-agnostic on purpose and is meant to survive that swap unchanged: a new
|
||||
//! backend supplies its own error classifier (the one in `tigris.rs` is a single
|
||||
//! private function) and keeps these types as the contract its callers read.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A durable identity for ONE publish attempt.
|
||||
///
|
||||
/// The point is reconciliation. A conditional PUT whose response was lost leaves
|
||||
/// the client unable to say whether its bytes committed; an attempt id stored
|
||||
/// alongside those bytes turns that into a question the store can answer, because
|
||||
/// "is the published object mine?" is decidable where "did my request succeed?"
|
||||
/// is not.
|
||||
///
|
||||
/// Fork creation passes the DB row id it is about to insert, so the object, the
|
||||
/// on-disk clone and the database row are all stamped with the same attempt
|
||||
/// identity and every cleanup can be made conditional on still owning all three.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishAttemptId(String);
|
||||
|
||||
impl PublishAttemptId {
|
||||
/// A fresh identity for an attempt that has nothing else to be named after.
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
/// Name the attempt after a caller-owned identity — fork creation uses the
|
||||
/// `record.id` it is about to insert, which is what ties the object back to
|
||||
/// the exact row rather than to the logical owner/name.
|
||||
pub fn from_owned(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PublishAttemptId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PublishAttemptId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The object user-metadata key an attempt id travels in.
|
||||
///
|
||||
/// S3 lowercases user-metadata keys, so this must already be lowercase or the
|
||||
/// read back would never match what was written.
|
||||
pub const ATTEMPT_METADATA_KEY: &str = "gitlawb-attempt";
|
||||
|
||||
/// The precondition an upload is fenced on.
|
||||
///
|
||||
/// Object storage is the only place a fence can hold. Dropping the future of an
|
||||
/// in-flight PUT does not cancel the request the server is already processing,
|
||||
/// so no amount of local locking stops an abandoned writer's bytes from landing
|
||||
/// after a successor has published. A conditional PUT the store itself refuses
|
||||
/// is what actually stops it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum UploadPrecondition {
|
||||
/// Publish only if the stored object is still the generation we observed.
|
||||
IfMatch(String),
|
||||
/// Publish only if nothing is stored under the key yet.
|
||||
IfAbsent,
|
||||
/// No fence. Last writer wins.
|
||||
Unconditional,
|
||||
}
|
||||
|
||||
/// What the store holds under a key right now.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoredGeneration {
|
||||
/// The generation a later conditional upload can fence itself on.
|
||||
pub etag: Option<String>,
|
||||
/// The attempt that published it, when the object carries our metadata.
|
||||
/// `None` for anything written by something that does not stamp attempts
|
||||
/// (an operator upload, a pre-attempt-id archive).
|
||||
pub attempt: Option<String>,
|
||||
}
|
||||
|
||||
impl StoredGeneration {
|
||||
/// Is what the store holds the work of `attempt`?
|
||||
///
|
||||
/// This is the whole reconciliation primitive. It is deliberately a
|
||||
/// three-way question collapsed to a boolean only at the point of use: an
|
||||
/// object with no attempt metadata answers `false`, because "somebody else's
|
||||
/// bytes" and "bytes nobody stamped" license exactly the same caution.
|
||||
pub fn belongs_to(&self, attempt: &PublishAttemptId) -> bool {
|
||||
self.attempt.as_deref() == Some(attempt.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt of a publish the store ACKNOWLEDGED.
|
||||
///
|
||||
/// Only produced on a response the client actually read, so holding one is proof
|
||||
/// of publication in a way that "the upload future returned" is not.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UploadReceipt {
|
||||
pub attempt: PublishAttemptId,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
/// How far ONE publish attempt got.
|
||||
///
|
||||
/// Observable through [`PublishStageCell`] from outside the future doing the
|
||||
/// work, which is the property the whole design turns on: a handler future that
|
||||
/// is DROPPED never returns an outcome, so the only way to classify a cancelled
|
||||
/// attempt is to read the stage it had reached when it was abandoned.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PublishStage {
|
||||
/// Nothing has been attempted.
|
||||
Idle,
|
||||
/// No object-storage backend is configured, so there is no publication to
|
||||
/// confirm and the local write IS the durable copy. Distinct from `Idle`,
|
||||
/// which means an attempt was possible and had not started: a cancellation
|
||||
/// under `NoBackend` loses nothing, while a cancellation under `Idle` means
|
||||
/// a publish that could have happened never did.
|
||||
NoBackend,
|
||||
/// The archive is being built. The request has not been constructed, let
|
||||
/// alone sent. A cancellation here is a DEFINITE "no publication was
|
||||
/// attempted" — not an ambiguous in-flight PUT.
|
||||
PreparingArchive,
|
||||
/// The request is on the wire. Its fate is unknown until the store answers,
|
||||
/// and a cancellation here does NOT stop the server processing it.
|
||||
PutDispatched { attempt: PublishAttemptId },
|
||||
/// The store acknowledged the write. This is the ONLY stage that licenses
|
||||
/// replication, a 2xx, or treating the local tree as durable.
|
||||
Published {
|
||||
attempt: PublishAttemptId,
|
||||
etag: Option<String>,
|
||||
},
|
||||
/// The store definitively did not publish this attempt (a refused
|
||||
/// precondition, or a failure that proves the bytes never committed).
|
||||
Refused,
|
||||
/// The attempt may or may not have committed and has not been reconciled.
|
||||
/// Destructive compensation is NEVER licensed from here.
|
||||
Ambiguous { attempt: PublishAttemptId },
|
||||
}
|
||||
|
||||
impl PublishStage {
|
||||
/// Did this attempt reach a state where the store may hold its bytes?
|
||||
///
|
||||
/// True from dispatch onward. `false` is what makes deleting the attempt's
|
||||
/// object or local tree safe.
|
||||
pub fn may_have_published(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
PublishStage::PutDispatched { .. }
|
||||
| PublishStage::Published { .. }
|
||||
| PublishStage::Ambiguous { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// The attempt whose fate is unresolved, when there is one. This is what a
|
||||
/// reconciliation HEAD is compared against.
|
||||
pub fn unresolved_attempt(&self) -> Option<&PublishAttemptId> {
|
||||
match self {
|
||||
PublishStage::PutDispatched { attempt } | PublishStage::Ambiguous { attempt } => {
|
||||
Some(attempt)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`PublishStage`] an in-flight upload writes and an outside observer reads.
|
||||
///
|
||||
/// `std::sync::Mutex` rather than an async lock on purpose: every write is a
|
||||
/// field assignment with no await inside, and the reader that matters most runs
|
||||
/// inside a `Drop` impl, where an async lock cannot be awaited at all.
|
||||
#[derive(Debug)]
|
||||
pub struct PublishStageCell(Mutex<PublishStage>);
|
||||
|
||||
impl PublishStageCell {
|
||||
pub fn new() -> Self {
|
||||
Self::seeded(PublishStage::Idle)
|
||||
}
|
||||
|
||||
pub fn seeded(stage: PublishStage) -> Self {
|
||||
Self(Mutex::new(stage))
|
||||
}
|
||||
|
||||
pub fn set(&self, stage: PublishStage) {
|
||||
*self.0.lock().expect("publish stage mutex poisoned") = stage;
|
||||
}
|
||||
|
||||
pub fn get(&self) -> PublishStage {
|
||||
self.0.lock().expect("publish stage mutex poisoned").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PublishStageCell {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the client knows about whether a request it could not complete ever
|
||||
/// reached the wire.
|
||||
///
|
||||
/// The classifier that produces this is the ONLY backend-aware code in the
|
||||
/// publish path; a pluggable store (#79) supplies its own and every caller below
|
||||
/// keeps working off [`UploadError`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DispatchKnowledge {
|
||||
/// The request could not be built or sent. The store cannot hold these bytes.
|
||||
NeverSent,
|
||||
/// The request may have been sent, and may have committed.
|
||||
MaybeSent,
|
||||
}
|
||||
|
||||
/// Why an upload did not publish, split by what it leaves the caller ENTITLED TO
|
||||
/// DO — not by which HTTP status came back.
|
||||
///
|
||||
/// The split exists because the previous shape ("precondition lost" vs "some
|
||||
/// other error") made every caller treat a lost response as proof of failure.
|
||||
/// Smithy timeout, dispatch and response errors all allow that the request was
|
||||
/// sent and committed: a server can accept a complete conditional PUT and lose
|
||||
/// or corrupt the response before the client observes success. Compensating that
|
||||
/// as a definite failure deletes state that IS published.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UploadError {
|
||||
/// The store explicitly refused the fence (412, or 409 under create-only).
|
||||
/// DEFINITE: this attempt did not publish, and a successor did.
|
||||
#[error("upload precondition lost (HTTP {status})")]
|
||||
PreconditionLost { status: u16 },
|
||||
/// The attempt provably never committed: it failed before the request could
|
||||
/// be dispatched, or the store answered a definite client-side refusal.
|
||||
/// Destructive compensation is safe here and ONLY here.
|
||||
#[error("upload did not reach the store: {0:#}")]
|
||||
NotPublished(#[source] anyhow::Error),
|
||||
/// The request may have been dispatched and may have committed; the client
|
||||
/// never learned which. The attempt id is carried so the caller can ask the
|
||||
/// store rather than guess.
|
||||
#[error("upload outcome is unknowable (attempt {attempt}): {source:#}")]
|
||||
Ambiguous {
|
||||
attempt: PublishAttemptId,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl UploadError {
|
||||
/// May this caller destroy state that would be needed if the write HAD
|
||||
/// landed — delete the object, drop the only local clone, invalidate the
|
||||
/// cache?
|
||||
///
|
||||
/// Only a proven non-publication says yes. Every new variant must default to
|
||||
/// `false`, which is why this is a match on the safe arms rather than a
|
||||
/// negation of the unsafe one.
|
||||
pub fn proves_not_published(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
UploadError::PreconditionLost { .. } | UploadError::NotPublished(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_definite_outcomes_license_destructive_compensation() {
|
||||
assert!(UploadError::PreconditionLost { status: 412 }.proves_not_published());
|
||||
assert!(
|
||||
UploadError::NotPublished(anyhow::anyhow!("no route to host")).proves_not_published()
|
||||
);
|
||||
assert!(
|
||||
!UploadError::Ambiguous {
|
||||
attempt: PublishAttemptId::new(),
|
||||
source: anyhow::anyhow!("response body truncated"),
|
||||
}
|
||||
.proves_not_published(),
|
||||
"a response-loss failure must never license deleting state the write may own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stage_may_have_published_only_from_dispatch_onward() {
|
||||
let attempt = PublishAttemptId::new();
|
||||
assert!(!PublishStage::Idle.may_have_published());
|
||||
assert!(!PublishStage::NoBackend.may_have_published());
|
||||
assert!(
|
||||
!PublishStage::PreparingArchive.may_have_published(),
|
||||
"compression has not constructed a request, let alone sent one"
|
||||
);
|
||||
assert!(!PublishStage::Refused.may_have_published());
|
||||
assert!(PublishStage::PutDispatched {
|
||||
attempt: attempt.clone()
|
||||
}
|
||||
.may_have_published());
|
||||
assert!(PublishStage::Ambiguous {
|
||||
attempt: attempt.clone()
|
||||
}
|
||||
.may_have_published());
|
||||
assert!(PublishStage::Published {
|
||||
attempt,
|
||||
etag: None
|
||||
}
|
||||
.may_have_published());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unstamped_object_never_belongs_to_an_attempt() {
|
||||
let attempt = PublishAttemptId::new();
|
||||
assert!(StoredGeneration {
|
||||
etag: Some("\"e\"".into()),
|
||||
attempt: Some(attempt.as_str().to_string()),
|
||||
}
|
||||
.belongs_to(&attempt));
|
||||
assert!(
|
||||
!StoredGeneration {
|
||||
etag: Some("\"e\"".into()),
|
||||
attempt: None,
|
||||
}
|
||||
.belongs_to(&attempt),
|
||||
"bytes nobody stamped must not be claimed by this attempt"
|
||||
);
|
||||
assert!(!StoredGeneration {
|
||||
etag: Some("\"e\"".into()),
|
||||
attempt: Some("someone-else".into()),
|
||||
}
|
||||
.belongs_to(&attempt));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
//! objects the reachable withheld set never classified, so subtracting that set
|
||||
//! is not enough — a dangling private blob would slip through (#99). The caller
|
||||
//! signals a full scan via [`PinCandidateSet::full_scan`] and must then apply
|
||||
//! the fail-closed blob-and-tree filter (`visibility_pack::replicable_objects_fail_closed`)
|
||||
//! the fail-closed blob filter (`visibility_pack::replicable_objects_fail_closed`)
|
||||
//! instead of the plain reachable-only subtraction.
|
||||
//!
|
||||
//! Because the pin candidate set needs only the OID *set* (never the per-path
|
||||
@@ -29,9 +29,9 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// Env var that forces the push path to always full-scan, bypassing the delta
|
||||
/// optimization (KTD7 kill-switch). Reuses the already-tested fallback branch
|
||||
@@ -64,21 +64,11 @@ pub enum PinCandidates {
|
||||
/// Fail-closed: any condition where the introduced set cannot be safely
|
||||
/// determined returns [`PinCandidates::FullScanRequired`] rather than a partial
|
||||
/// set, so the caller full-scans instead of silently under-pinning.
|
||||
pub fn resolve_push_delta(
|
||||
repo_path: &Path,
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> PinCandidates {
|
||||
resolve_push_delta_inner(
|
||||
repo_path,
|
||||
new_tips,
|
||||
old_tips,
|
||||
force_full_scan(),
|
||||
git_bin,
|
||||
deadline,
|
||||
)
|
||||
pub fn resolve_push_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> PinCandidates {
|
||||
// KTD7 kill-switch: force the (already-tested) full-scan fallback. The env
|
||||
// read is split out from the pure logic so the resolver stays unit-testable
|
||||
// without touching process-global state.
|
||||
resolve_push_delta_inner(repo_path, new_tips, old_tips, force_full_scan())
|
||||
}
|
||||
|
||||
/// Whether the force-full-scan kill-switch env var is set.
|
||||
@@ -93,8 +83,6 @@ fn resolve_push_delta_inner(
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
force_full_scan: bool,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> PinCandidates {
|
||||
if force_full_scan {
|
||||
tracing::debug!("{FORCE_FULL_SCAN_ENV} set — forcing full scan");
|
||||
@@ -114,7 +102,7 @@ fn resolve_push_delta_inner(
|
||||
// commit). Bare `cat-file -t` returns `tag` for an annotated tag, and
|
||||
// `for-each-ref %(*objecttype)` peels only one level — neither is correct.
|
||||
for tip in new_tips {
|
||||
match peeled_object_type(repo_path, tip, git_bin, deadline) {
|
||||
match peeled_object_type(repo_path, tip) {
|
||||
Some(t) if t == "commit" => {}
|
||||
other => {
|
||||
tracing::debug!(
|
||||
@@ -127,7 +115,7 @@ fn resolve_push_delta_inner(
|
||||
}
|
||||
}
|
||||
|
||||
match rev_list_delta(repo_path, new_tips, old_tips, git_bin, deadline) {
|
||||
match rev_list_delta(repo_path, new_tips, old_tips) {
|
||||
Ok(oids) => PinCandidates::Delta(oids),
|
||||
Err(e) => {
|
||||
tracing::debug!(err = %e, "push-delta rev-list failed — forcing full scan");
|
||||
@@ -138,43 +126,41 @@ fn resolve_push_delta_inner(
|
||||
|
||||
/// Return the fully-peeled object type of `sha` (e.g. `commit`, `tree`,
|
||||
/// `blob`), or `None` if the object is missing/unpeelable or git errored.
|
||||
fn peeled_object_type(
|
||||
repo_path: &Path,
|
||||
sha: &str,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
let peel = format!("{sha}^{{}}");
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "-t", &peel],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)
|
||||
.ok()?;
|
||||
Some(String::from_utf8_lossy(&out).trim().to_string())
|
||||
fn peeled_object_type(repo_path: &Path, sha: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["cat-file", "-t", &format!("{sha}^{{}}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Run `git rev-list --objects --no-object-names <new> --not <old>` and return
|
||||
/// the bare OID set. Decides on `status.success()` *before* parsing stdout, so
|
||||
/// a walk that prints a valid prefix then errors mid-walk is discarded.
|
||||
fn rev_list_delta(
|
||||
repo_path: &Path,
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<String>> {
|
||||
fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Result<Vec<String>> {
|
||||
let mut args: Vec<&str> = vec!["rev-list", "--objects", "--no-object-names"];
|
||||
args.extend_from_slice(new_tips);
|
||||
if !old_tips.is_empty() {
|
||||
args.push("--not");
|
||||
args.extend_from_slice(old_tips);
|
||||
}
|
||||
let out =
|
||||
crate::git::visibility_pack::run_bounded_git(git_bin, &args, repo_path, b"", deadline)?;
|
||||
let stdout = String::from_utf8_lossy(&out);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git rev-list for push delta")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git rev-list failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
@@ -189,19 +175,23 @@ fn rev_list_delta(
|
||||
/// reconciliation sweep relies on. It returns *all* objects (including
|
||||
/// unreachable/dangling ones), which is what the sweep needs to catch
|
||||
/// stragglers — do not swap it for a reachability walk.
|
||||
pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<Vec<String>> {
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&[
|
||||
pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let stdout = String::from_utf8_lossy(&out);
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
@@ -211,26 +201,25 @@ pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> R
|
||||
|
||||
/// Like [`list_all_objects`] but pairs each OID with its object type, via
|
||||
/// `--batch-check='%(objectname) %(objecttype)'`. The pin path's fail-closed
|
||||
/// filter needs to tell blobs (content, withholdable) and trees (content,
|
||||
/// withholdable since #135/#173) from commits/tags (structural, never withheld)
|
||||
/// without typing the candidate list itself.
|
||||
pub fn list_all_objects_with_type(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&[
|
||||
/// filter needs to tell blobs (content, withholdable) from commits/trees
|
||||
/// (structural, never withheld) without typing the candidate list itself.
|
||||
pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname) %(objecttype)",
|
||||
],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let stdout = String::from_utf8_lossy(&out);
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
@@ -245,48 +234,20 @@ pub fn list_all_objects_with_type(
|
||||
/// Every blob OID in the repo, including unreachable/dangling ones. The
|
||||
/// fail-closed pin filter drops any candidate blob absent from the reachable,
|
||||
/// visibility-allowed set; a dangling private blob is in this set but not the
|
||||
/// allowed set, so it never replicates (#99). Production uses
|
||||
/// [`all_blob_and_tree_oids`] (one typed pass); this wrapper stays for tests.
|
||||
#[cfg(test)]
|
||||
pub fn all_blob_oids(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<HashSet<String>> {
|
||||
Ok(all_blob_and_tree_oids(repo_path, git_bin, deadline)?.0)
|
||||
}
|
||||
|
||||
/// Every blob OID and every tree OID in the repo, including unreachable/dangling
|
||||
/// ones, from one typed enumeration. The fail-closed pin filter drops any
|
||||
/// candidate blob or tree absent from the matching reachable visibility-allowed
|
||||
/// set; a dangling or withheld object is in this universe but not the allowed
|
||||
/// set, so it never replicates (#99, #172).
|
||||
pub fn all_blob_and_tree_oids(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<(HashSet<String>, HashSet<String>)> {
|
||||
let mut blobs = HashSet::new();
|
||||
let mut trees = HashSet::new();
|
||||
for (oid, ty) in list_all_objects_with_type(repo_path, git_bin, deadline)? {
|
||||
match ty.as_str() {
|
||||
"blob" => {
|
||||
blobs.insert(oid);
|
||||
}
|
||||
"tree" => {
|
||||
trees.insert(oid);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((blobs, trees))
|
||||
/// allowed set, so it never replicates (#99).
|
||||
pub fn all_blob_oids(repo_path: &Path) -> Result<HashSet<String>> {
|
||||
Ok(list_all_objects_with_type(repo_path)?
|
||||
.into_iter()
|
||||
.filter(|(_, ty)| ty == "blob")
|
||||
.map(|(oid, _)| oid)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The pin candidate OIDs for a push plus whether they came from a full-repo
|
||||
/// scan. `full_scan` is true when the delta could not be used and the whole
|
||||
/// object DB (including dangling objects) was enumerated — the caller must then
|
||||
/// apply the fail-closed blob-and-tree filter, because the reachable-only withheld set
|
||||
/// cannot classify dangling blobs or trees (#99, #172).
|
||||
/// apply the fail-closed blob filter, because the reachable-only withheld set
|
||||
/// cannot classify dangling blobs (#99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinCandidateSet {
|
||||
pub candidates: Vec<String>,
|
||||
@@ -307,68 +268,22 @@ pub struct PinCandidateSet {
|
||||
/// it can never leak because the withheld/fail-closed filter still runs on
|
||||
/// whatever set is returned. `full_scan` rides on the returned set so the caller
|
||||
/// knows when the dangling-inclusive filter is required.
|
||||
///
|
||||
/// `scan_sem` is the post-receive scan admission pool (`git_encrypt_semaphore`,
|
||||
/// #174 F4): both git-spawning stages — the per-tip `cat-file` probe + delta
|
||||
/// rev-list, and the full-scan fallback — run under ONE permit held for the
|
||||
/// whole blocking scan. The deletion-only fast path (no new tips) spawns no git
|
||||
/// and never parks.
|
||||
///
|
||||
/// `force_full_scan` forces the full-scan fallback regardless of the tips — the
|
||||
/// coalesced-drain `PendingWork::FullScan` marker (#174 F5). It composes with
|
||||
/// the env kill-switch (either forces), and it disqualifies the empty-tips fast
|
||||
/// path: a forced-scan call with no tips must still enumerate the repo, never
|
||||
/// silently resolve to an empty delta (which would pin nothing and lose the
|
||||
/// drained pushes' work — the F5 bug shape).
|
||||
pub async fn resolve_candidates_for_push(
|
||||
scan_sem: std::sync::Arc<tokio::sync::Semaphore>,
|
||||
repo_path: PathBuf,
|
||||
new_tips: Vec<String>,
|
||||
old_tips: Vec<String>,
|
||||
git_bin: String,
|
||||
timeout: Duration,
|
||||
force_full_scan: bool,
|
||||
) -> PinCandidateSet {
|
||||
let force = force_full_scan || self::force_full_scan();
|
||||
// No-git fast path: a deletion-only push resolves to an empty delta without
|
||||
// spawning any git child, so it must not park on the scan pool. A forced
|
||||
// full scan (caller flag or kill-switch) disqualifies it — the scan spawns git.
|
||||
if new_tips.is_empty() && !force {
|
||||
tracing::info!(delta = 0usize, repo = %repo_path.display(), "pin candidate set from push delta");
|
||||
return PinCandidateSet {
|
||||
candidates: Vec::new(),
|
||||
full_scan: false,
|
||||
};
|
||||
}
|
||||
// Scan admission (#174 F4): DEFER, never shed — a dropped scan would
|
||||
// silently under-pin this push. The permit moves into the blocking closure
|
||||
// so a started scan always completes holding it. Residuals at
|
||||
// `acquire_scan_permit`.
|
||||
let permit =
|
||||
crate::state::acquire_scan_permit(scan_sem, &repo_path, "pin-candidate scan").await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
// ONE shared deadline for the whole scan, per jatmn ("the same deadline").
|
||||
let deadline = Instant::now() + timeout;
|
||||
let new_refs: Vec<&str> = new_tips.iter().map(String::as_str).collect();
|
||||
let old_refs: Vec<&str> = old_tips.iter().map(String::as_str).collect();
|
||||
// `force` already folds in the env kill-switch, so the forced arm skips the
|
||||
// delta machinery outright; the unforced arm goes through the normal
|
||||
// resolver (whose own env read is false here by construction).
|
||||
let resolved = if force {
|
||||
tracing::debug!("full scan forced (coalesced-drain marker or kill-switch)");
|
||||
PinCandidates::FullScanRequired
|
||||
} else {
|
||||
resolve_push_delta(&repo_path, &new_refs, &old_refs, &git_bin, deadline)
|
||||
};
|
||||
match resolved {
|
||||
match resolve_push_delta(&repo_path, &new_refs, &old_refs) {
|
||||
PinCandidates::Delta(objs) => {
|
||||
tracing::info!(delta = objs.len(), repo = %repo_path.display(), "pin candidate set from push delta");
|
||||
PinCandidateSet { candidates: objs, full_scan: false }
|
||||
}
|
||||
PinCandidates::FullScanRequired => {
|
||||
tracing::warn!(repo = %repo_path.display(), "pin delta unavailable (non-commit tip, git error, or force-full-scan) — full-scan fallback");
|
||||
match list_all_objects(&repo_path, &git_bin, deadline) {
|
||||
match list_all_objects(&repo_path) {
|
||||
Ok(objs) => PinCandidateSet { candidates: objs, full_scan: true },
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_path.display(), err = %e, "full-scan fallback failed; pinning nothing this push (reconciliation sweep backstops)");
|
||||
@@ -389,13 +304,8 @@ pub async fn resolve_candidates_for_push(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn td() -> std::time::Instant {
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(600)
|
||||
}
|
||||
|
||||
/// Minimal git helper for building test repos.
|
||||
struct Repo {
|
||||
_td: TempDir,
|
||||
@@ -453,10 +363,9 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let got: HashSet<String> =
|
||||
delta(resolve_push_delta(&repo.path, &[&c2], &[&c1], "git", td()))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c2], &[&c1]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
// The new blob b.txt and commit c2 are in the delta; the old blob a.txt
|
||||
// and commit c1 are not.
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
@@ -474,7 +383,7 @@ mod tests {
|
||||
// genuinely new objects, never fewer.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[], "git", td()))
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&c1));
|
||||
@@ -490,15 +399,9 @@ mod tests {
|
||||
// Rewrite history: reset to base, commit a different file.
|
||||
repo.git(&["reset", "-q", "--hard", &base]);
|
||||
let new_tip = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(
|
||||
&repo.path,
|
||||
&[&new_tip],
|
||||
&[&old_tip],
|
||||
"git",
|
||||
td(),
|
||||
))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&new_tip], &[&old_tip]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&new_tip), "new tip in delta");
|
||||
assert!(got.contains(&repo.rev(&format!("{new_tip}:c.txt"))));
|
||||
// No error; force-push computes new-minus-old cleanly.
|
||||
@@ -510,7 +413,7 @@ mod tests {
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
// All updates were deletions => new_tips empty after the caller strips zeros.
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[ZERO], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[], &[ZERO]),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
@@ -521,7 +424,7 @@ mod tests {
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&blob], &[], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[&blob], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a blob tip must force full scan (rev-list would exit 0 and walk it)"
|
||||
);
|
||||
@@ -533,7 +436,7 @@ mod tests {
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tree], &[], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[&tree], &[]),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
@@ -546,7 +449,7 @@ mod tests {
|
||||
repo.git(&["tag", "-a", "treetag", "-m", "x", &tree]);
|
||||
let tag = repo.rev("treetag");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tag], &[], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[&tag], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"annotated tag peeling to a tree must force full scan"
|
||||
);
|
||||
@@ -562,7 +465,7 @@ mod tests {
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&t2], &[], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[&t2], &[]),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
@@ -577,7 +480,7 @@ mod tests {
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
repo.git(&["tag", "-a", "rel", "-m", "release", &c1]);
|
||||
let tag = repo.rev("rel");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[], "git", td()))
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -598,7 +501,7 @@ mod tests {
|
||||
let t1 = repo.rev("t1");
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[], "git", td()))
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -613,16 +516,9 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let set = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![c2.clone()],
|
||||
vec![c1.clone()],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let set =
|
||||
resolve_candidates_for_push(repo.path.clone(), vec![c2.clone()], vec![c1.clone()])
|
||||
.await;
|
||||
assert!(!set.full_scan, "happy-path delta is not a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
@@ -640,206 +536,20 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
let all: HashSet<String> = list_all_objects(&repo.path, "git", td())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let set = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![blob],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let all: HashSet<String> = list_all_objects(&repo.path).unwrap().into_iter().collect();
|
||||
let set = resolve_candidates_for_push(repo.path.clone(), vec![blob], vec![]).await;
|
||||
assert!(set.full_scan, "non-commit tip is signalled as a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
assert_eq!(got, all, "non-commit tip falls back to full repo scan");
|
||||
}
|
||||
|
||||
/// F4 defer proof 2: `resolve_candidates_for_push`'s git stages (the per-tip
|
||||
/// cat-file type probe + delta rev-list, and the full-scan fallback) run under a
|
||||
/// scan-admission permit: with a zero-permit pool the call parks and spawns no
|
||||
/// git; once a permit is available the SAME call runs (defer, not shed). On
|
||||
/// ungated code the git runs regardless of the pool (RED).
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_defers_when_scan_pool_exhausted() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let marker = dir.path().join("git.ran");
|
||||
// Fake git records ANY invocation; cat-file reports a commit tip so the
|
||||
// delta stage proceeds, rev-list yields an empty delta.
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
format!(
|
||||
"#!/bin/sh\necho ran >> \"{}\"\ncase \"$1\" in\n cat-file) echo commit ;;\n *) : ;;\nesac\nexit 0\n",
|
||||
marker.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
let git_bin = fake.to_str().unwrap().to_string();
|
||||
let tip = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
|
||||
|
||||
let sem: Arc<Semaphore> = Arc::new(Semaphore::new(0));
|
||||
let blocked = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
resolve_candidates_for_push(
|
||||
sem.clone(),
|
||||
dir.path().to_path_buf(),
|
||||
vec![tip.clone()],
|
||||
vec![],
|
||||
git_bin.clone(),
|
||||
Duration::from_secs(5),
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"the pin-candidate scan must defer (park on admission) when the pool is exhausted"
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"the scan's git must not spawn while its admission permit is unavailable (F4)"
|
||||
);
|
||||
|
||||
// Release admission: the SAME scan now runs (defer, not shed).
|
||||
sem.add_permits(1);
|
||||
let set = resolve_candidates_for_push(
|
||||
sem,
|
||||
dir.path().to_path_buf(),
|
||||
vec![tip],
|
||||
vec![],
|
||||
git_bin,
|
||||
Duration::from_secs(5),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"once admission is available the deferred scan runs its git"
|
||||
);
|
||||
assert!(!set.full_scan, "commit tip + empty rev-list is a delta");
|
||||
assert!(set.candidates.is_empty());
|
||||
}
|
||||
|
||||
/// F4 fast-path negative arm: a deletion-only push (no new tips, kill-switch
|
||||
/// off) computes its empty delta without spawning ANY git child, so it must
|
||||
/// complete without acquiring from the scan pool — even at zero permits. The
|
||||
/// per-tip cat-file probe means every push with a non-empty new tip DOES spawn
|
||||
/// git, so this is the only genuinely git-free stage.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_no_git_fast_path_skips_admission() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let marker = dir.path().join("git.ran");
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
format!("#!/bin/sh\necho ran >> \"{}\"\nexit 0\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
|
||||
let set = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
resolve_candidates_for_push(
|
||||
Arc::new(Semaphore::new(0)),
|
||||
dir.path().to_path_buf(),
|
||||
vec![],
|
||||
vec!["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string()],
|
||||
fake.to_str().unwrap().to_string(),
|
||||
Duration::from_secs(5),
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the no-new-tips fast path must not park on the scan pool");
|
||||
assert_eq!(
|
||||
set,
|
||||
PinCandidateSet {
|
||||
candidates: Vec::new(),
|
||||
full_scan: false
|
||||
}
|
||||
);
|
||||
assert!(!marker.exists(), "the fast path must spawn no git at all");
|
||||
}
|
||||
|
||||
/// #174 F5: the coalesced-drain FullScan marker is signalled via the explicit
|
||||
/// `force_full_scan` flag, and a flagged call with NO tips must still enumerate
|
||||
/// the whole repo (full_scan=true, non-empty candidates). The RED arm of the
|
||||
/// encoding question: if the marker were encoded as a plain empty-tips call
|
||||
/// (flag off — the fast path), the drain would resolve to an empty delta and
|
||||
/// pin nothing, silently losing the coalesced pushes' work.
|
||||
#[tokio::test]
|
||||
async fn forced_full_scan_with_no_tips_enumerates_the_repo() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let all: HashSet<String> = list_all_objects(&repo.path, "git", td())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(!all.is_empty(), "fixture repo has objects");
|
||||
|
||||
let set = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(set.full_scan, "the forced call is signalled as a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
assert_eq!(
|
||||
got, all,
|
||||
"a forced full scan with no tips enumerates the repo — never an empty delta"
|
||||
);
|
||||
|
||||
// The discriminator: the SAME empty-tips call without the flag is the
|
||||
// deletion-only fast path (empty delta, pin nothing). The two must differ,
|
||||
// or the marker encoding has collapsed into the silent-loss shape.
|
||||
let unforced = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(!unforced.full_scan);
|
||||
assert!(unforced.candidates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_oid_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let bogus = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[bogus], &[], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[bogus], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a missing/corrupt tip OID must force full scan"
|
||||
);
|
||||
@@ -854,7 +564,7 @@ mod tests {
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let old_tree = repo.rev(&format!("{c1}^{{tree}}"));
|
||||
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree], "git", td());
|
||||
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree]);
|
||||
match result {
|
||||
PinCandidates::FullScanRequired => {} // safe: caller full-scans
|
||||
PinCandidates::Delta(objs) => {
|
||||
@@ -873,15 +583,10 @@ mod tests {
|
||||
// branch2 from base advances independently
|
||||
repo.git(&["checkout", "-q", "-b", "branch2", &base]);
|
||||
let b2 = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(
|
||||
&repo.path,
|
||||
&[&b1, &b2],
|
||||
&[&base, &base],
|
||||
"git",
|
||||
td(),
|
||||
))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got: HashSet<String> =
|
||||
delta(resolve_push_delta(&repo.path, &[&b1, &b2], &[&base, &base]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&b1), "branch1 new commit");
|
||||
assert!(got.contains(&b2), "branch2 new commit");
|
||||
}
|
||||
@@ -890,7 +595,7 @@ mod tests {
|
||||
fn empty_repo_no_tips_yields_empty_delta() {
|
||||
let repo = Repo::new();
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[], "git", td()),
|
||||
resolve_push_delta(&repo.path, &[], &[]),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
@@ -904,12 +609,12 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
assert_eq!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], true, "git", td()),
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], true),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
// And with the flag off, the same push yields a Delta.
|
||||
assert!(matches!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], false, "git", td()),
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], false),
|
||||
PinCandidates::Delta(_)
|
||||
));
|
||||
}
|
||||
@@ -919,7 +624,7 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
repo.commit_file("b.txt", "two\n");
|
||||
let all = list_all_objects(&repo.path, "git", td()).unwrap();
|
||||
let all = list_all_objects(&repo.path).unwrap();
|
||||
// 2 commits + 2 trees + 2 blobs = 6 objects.
|
||||
assert_eq!(all.len(), 6, "got: {all:?}");
|
||||
}
|
||||
@@ -937,7 +642,7 @@ mod tests {
|
||||
std::fs::write(repo.path.join("orphan.bin"), b"dangling\n").unwrap();
|
||||
let dangling = repo.git(&["hash-object", "-w", "orphan.bin"]);
|
||||
|
||||
let blobs = all_blob_oids(&repo.path, "git", td()).unwrap();
|
||||
let blobs = all_blob_oids(&repo.path).unwrap();
|
||||
assert!(blobs.contains(&reachable_blob), "reachable blob present");
|
||||
assert!(
|
||||
blobs.contains(&dangling),
|
||||
@@ -947,7 +652,7 @@ mod tests {
|
||||
assert!(!blobs.contains(&tree), "tree is not a blob");
|
||||
|
||||
// The typed lister tags each object; spot-check the dangling blob's type.
|
||||
let typed = list_all_objects_with_type(&repo.path, "git", td()).unwrap();
|
||||
let typed = list_all_objects_with_type(&repo.path).unwrap();
|
||||
assert!(
|
||||
typed
|
||||
.iter()
|
||||
@@ -955,96 +660,4 @@ mod tests {
|
||||
"dangling object is typed as a blob"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn list_all_objects_times_out_on_a_hung_git_instead_of_blocking() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
// Fake git: hang on cat-file (bounded to 30s so a broken test can't wedge).
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
"#!/bin/sh\ncase \"$1\" in\n cat-file) i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n *) : ;;\nesac\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
let git_bin = fake.to_str().unwrap().to_string();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = dir.path().to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(list_all_objects(
|
||||
&path,
|
||||
&git_bin,
|
||||
Instant::now() + Duration::from_millis(150),
|
||||
));
|
||||
});
|
||||
let res = rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"list_all_objects must return within the watchdog budget, not block on a hung git",
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"a hung git must make list_all_objects error out, not hang"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 finding 2: the DELTA-path children — `peeled_object_type` (cat-file),
|
||||
/// `rev_list_delta` (rev-list) — and `list_all_objects_with_type` (cat-file) are
|
||||
/// the remaining candidate-discovery execs (the common push path). Each must
|
||||
/// return within the watchdog budget on a hung git rather than block and pin the
|
||||
/// write permit. Revert any one to a bare `Command::output()` and its arm blocks
|
||||
/// past the recv budget (RED).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn delta_path_exec_fns_time_out_on_a_hung_git() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
// Fake git hangs on BOTH cat-file and rev-list (bounded 30s so a broken
|
||||
// test can't leak a permanent orphan or wedge the suite).
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
"#!/bin/sh\ncase \"$1\" in\n cat-file|rev-list) i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n *) : ;;\nesac\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
let git_bin = fake.to_str().unwrap().to_string();
|
||||
let path = dir.path().to_path_buf();
|
||||
|
||||
// Run `f` on a thread and require it to RETURN (not block) within 10s.
|
||||
fn returns_within<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"a delta-path exec fn must return within the watchdog budget, not block on a hung git",
|
||||
)
|
||||
}
|
||||
let dl = || Instant::now() + Duration::from_millis(150);
|
||||
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
|
||||
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
|
||||
assert!(
|
||||
returns_within(move || peeled_object_type(&p, sha, &g, d)).is_none(),
|
||||
"peeled_object_type must time out to None on a hung cat-file"
|
||||
);
|
||||
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
|
||||
assert!(
|
||||
returns_within(move || rev_list_delta(&p, &[sha], &[], &g, d)).is_err(),
|
||||
"rev_list_delta must error out on a hung rev-list"
|
||||
);
|
||||
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
|
||||
assert!(
|
||||
returns_within(move || list_all_objects_with_type(&p, &g, d)).is_err(),
|
||||
"list_all_objects_with_type must error out on a hung cat-file"
|
||||
);
|
||||
}
|
||||
}
|
||||
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
@@ -5,121 +5,17 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// The publication vocabulary lives in `git::publish`, which carries no
|
||||
// object-storage types, and is re-exported here so existing `git::tigris::…`
|
||||
// imports keep resolving. #79 deletes this file; `git::publish` is what survives
|
||||
// the swap, and the only backend-aware code below is `classify_dispatch`.
|
||||
pub use super::publish::{
|
||||
DispatchKnowledge, PublishAttemptId, PublishStage, PublishStageCell, StoredGeneration,
|
||||
UploadError, UploadPrecondition, UploadReceipt, ATTEMPT_METADATA_KEY,
|
||||
};
|
||||
|
||||
/// What happened to a conditional, attempt-guarded delete.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AttemptDelete {
|
||||
/// The object was this attempt's and is gone.
|
||||
Deleted,
|
||||
/// Nothing is stored under the key.
|
||||
Absent,
|
||||
/// Something is stored, and it is NOT this attempt's work. Left alone.
|
||||
NotOurs,
|
||||
}
|
||||
|
||||
/// THE ONLY BACKEND-AWARE CLASSIFIER in the publish path.
|
||||
///
|
||||
/// It answers one question: does this failure PROVE the request never committed?
|
||||
/// Anything short of proof is [`DispatchKnowledge::MaybeSent`], because the
|
||||
/// caller's next move on a definite failure is destructive (delete the object,
|
||||
/// drop the only local clone, invalidate the cache) and being wrong there
|
||||
/// destroys published state.
|
||||
///
|
||||
/// - An HTTP status means the server answered. A 4xx is a refusal it took
|
||||
/// BEFORE storing anything, so it proves non-publication. A 5xx does not: the
|
||||
/// store can commit and then fail to report it.
|
||||
/// - No status at all means no response was read. Only a construction failure
|
||||
/// proves the request never left; Smithy's timeout, dispatch and response
|
||||
/// errors all allow that the bytes arrived and committed while the answer was
|
||||
/// lost, corrupted, or never parsed.
|
||||
/// - `SdkError` is `#[non_exhaustive]`, so the fallback arm must be the CAUTIOUS
|
||||
/// one. A future variant defaulting to "definitely failed" would silently
|
||||
/// re-open exactly the class this closes.
|
||||
///
|
||||
/// #79 replaces `tigris.rs` with a `BlobStore` layer; this function is the piece
|
||||
/// each backend reimplements, and nothing above it changes.
|
||||
fn classify_put_failure<E, R>(
|
||||
err: &aws_sdk_s3::error::SdkError<E, R>,
|
||||
status: Option<u16>,
|
||||
) -> DispatchKnowledge {
|
||||
if let Some(status) = status {
|
||||
return if (400..500).contains(&status) {
|
||||
DispatchKnowledge::NeverSent
|
||||
} else {
|
||||
DispatchKnowledge::MaybeSent
|
||||
};
|
||||
}
|
||||
match err {
|
||||
aws_sdk_s3::error::SdkError::ConstructionFailure(_) => DispatchKnowledge::NeverSent,
|
||||
_ => DispatchKnowledge::MaybeSent,
|
||||
}
|
||||
}
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Wrapper around the S3 client with the configured bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct TigrisClient {
|
||||
s3: S3Client,
|
||||
bucket: String,
|
||||
/// Test-only seam: when set, the blocking compression inside `upload_tracked`
|
||||
/// parks on this barrier. That is the ONLY window in which a publish attempt
|
||||
/// can be cancelled with `PublishStage::PreparingArchive` still holding, and
|
||||
/// it is unreachable from outside without a seam because compression of a
|
||||
/// test-sized repo finishes in microseconds.
|
||||
#[cfg(test)]
|
||||
compress_gate: Option<Arc<BlockingGate>>,
|
||||
}
|
||||
|
||||
/// A gate a BLOCKING thread parks on until a test opens it.
|
||||
///
|
||||
/// A condvar rather than a held `MutexGuard`: the test's assertions run while the
|
||||
/// gate is shut, and holding a `std::sync::MutexGuard` across those awaits is
|
||||
/// both a lint violation and a real hazard. Here the test owns no guard at all —
|
||||
/// it flips a flag and notifies.
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
pub struct BlockingGate {
|
||||
open: Mutex<bool>,
|
||||
opened: std::sync::Condvar,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl BlockingGate {
|
||||
/// A gate that starts SHUT.
|
||||
pub fn shut() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn wait(&self) {
|
||||
let mut open = self.open.lock().expect("compression gate poisoned");
|
||||
while !*open {
|
||||
open = self
|
||||
.opened
|
||||
.wait(open)
|
||||
.expect("compression gate poisoned while waiting");
|
||||
}
|
||||
}
|
||||
|
||||
/// Let every parked compression through. Call this at teardown so the
|
||||
/// blocking thread is not stranded for the life of the process.
|
||||
pub fn open(&self) {
|
||||
*self.open.lock().expect("compression gate poisoned") = true;
|
||||
self.opened.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl TigrisClient {
|
||||
@@ -132,48 +28,9 @@ impl TigrisClient {
|
||||
Ok(Self {
|
||||
s3,
|
||||
bucket: bucket.to_string(),
|
||||
#[cfg(test)]
|
||||
compress_gate: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a client pointed at an arbitrary endpoint, for tests.
|
||||
///
|
||||
/// The production constructor reads the endpoint and credentials from the
|
||||
/// environment, which a test cannot steer without mutating process-global
|
||||
/// state. This takes both explicitly so a test can aim the client at a
|
||||
/// closed port and get a prompt transport error out of `exists()`.
|
||||
///
|
||||
/// `RetryConfig::disabled()` is load-bearing, not tidiness: the SDK's default
|
||||
/// policy retries a connection refusal with backoff, which turns each failing
|
||||
/// call into seconds of waiting.
|
||||
#[cfg(test)]
|
||||
pub fn for_testing_with_endpoint(bucket: &str, endpoint: &str) -> Self {
|
||||
use aws_sdk_s3::config::{retry::RetryConfig, Credentials, Region};
|
||||
|
||||
let config = aws_sdk_s3::config::Config::builder()
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(Credentials::new("test", "test", None, None, "test"))
|
||||
.region(Region::new("auto"))
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
Self {
|
||||
s3: S3Client::from_conf(config),
|
||||
bucket: bucket.to_string(),
|
||||
compress_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: park this client's blocking compression on `gate` until the
|
||||
/// holder releases it, so a cancellation can be aimed at
|
||||
/// [`PublishStage::PreparingArchive`] rather than at the PUT.
|
||||
#[cfg(test)]
|
||||
pub fn with_compress_gate(mut self, gate: Arc<BlockingGate>) -> Self {
|
||||
self.compress_gate = Some(gate);
|
||||
self
|
||||
}
|
||||
|
||||
/// 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")
|
||||
@@ -201,325 +58,34 @@ impl TigrisClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the ETag of a repo archive, or `None` when nothing is stored under
|
||||
/// the key. The ETag identifies the generation a later conditional upload
|
||||
/// can fence itself on.
|
||||
///
|
||||
/// Separate from `exists` rather than folded into it: `exists` has callers
|
||||
/// that only want the boolean, and widening its return type would churn
|
||||
/// every one of them for no benefit.
|
||||
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
|
||||
Ok(self
|
||||
.head_generation(owner_slug, repo_name)
|
||||
.await?
|
||||
.map(|g| g.etag)
|
||||
.unwrap_or(None))
|
||||
}
|
||||
|
||||
/// The full stored generation: the ETag AND the attempt that published it.
|
||||
///
|
||||
/// The attempt half is what makes a lost response recoverable. An ETag alone
|
||||
/// answers "has the generation moved", which every writer sees the same way;
|
||||
/// the attempt id answers "are the published bytes MINE", which is the
|
||||
/// question a client whose PUT response vanished actually needs answered.
|
||||
pub async fn head_generation(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Option<StoredGeneration>> {
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
|
||||
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
match self
|
||||
.s3
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => {
|
||||
let etag = out
|
||||
.e_tag()
|
||||
.context(format!("tigris HEAD {key}: hit carried no ETag"))?
|
||||
.to_string();
|
||||
let attempt = out
|
||||
.metadata()
|
||||
.and_then(|m| m.get(ATTEMPT_METADATA_KEY))
|
||||
.cloned();
|
||||
Ok(Some(StoredGeneration {
|
||||
etag: Some(etag),
|
||||
attempt,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
|
||||
|
||||
/// Did `attempt`'s bytes land? The reconciliation an ambiguous dispatch owes
|
||||
/// before anything downstream may treat it as confirmation.
|
||||
///
|
||||
/// `Ok(false)` is deliberately NOT "the PUT failed": an abandoned request may
|
||||
/// still be in flight, so a negative answer licenses refusing and retrying,
|
||||
/// never deleting. Only `Ok(true)` upgrades an unresolved attempt to
|
||||
/// published.
|
||||
pub async fn attempt_landed(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
attempt: &PublishAttemptId,
|
||||
) -> Result<bool> {
|
||||
Ok(self
|
||||
.head_generation(owner_slug, repo_name)
|
||||
.await?
|
||||
.is_some_and(|g| g.belongs_to(attempt)))
|
||||
}
|
||||
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive,
|
||||
/// fenced by `precondition`, under a freshly minted attempt identity.
|
||||
pub async fn upload(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
precondition: UploadPrecondition,
|
||||
) -> std::result::Result<UploadReceipt, UploadError> {
|
||||
self.upload_tracked(
|
||||
owner_slug,
|
||||
repo_name,
|
||||
local_path,
|
||||
precondition,
|
||||
PublishAttemptId::new(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The full form: a caller-chosen attempt identity, and a stage cell the
|
||||
/// upload reports its progress into.
|
||||
///
|
||||
/// The stage cell is the answer to "a dropped future never returns an
|
||||
/// outcome". Compression runs inside `spawn_blocking` and the conditional PUT
|
||||
/// is not even constructed until it finishes, so a handler cancelled during
|
||||
/// compression definitely never attempted publication — but nothing could
|
||||
/// observe that, because the only report was the return value of a future
|
||||
/// that no longer exists. Marking [`PublishStage::PreparingArchive`] before
|
||||
/// the blocking call and [`PublishStage::PutDispatched`] immediately before
|
||||
/// `send()` makes that boundary readable from outside.
|
||||
pub async fn upload_tracked(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
precondition: UploadPrecondition,
|
||||
attempt: PublishAttemptId,
|
||||
stage: Option<&PublishStageCell>,
|
||||
) -> std::result::Result<UploadReceipt, UploadError> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %local_path.display(), attempt = %attempt, "uploading repo to tigris");
|
||||
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::PreparingArchive);
|
||||
}
|
||||
|
||||
// Create tar.zst in memory. Nothing has been sent at this point and
|
||||
// nothing can be: the request below is not constructed until this
|
||||
// returns. A failure or a cancellation here is a definite
|
||||
// non-publication.
|
||||
let compressed = {
|
||||
// Create tar.zst in memory
|
||||
let archive_bytes = tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
#[cfg(test)]
|
||||
let gate = self.compress_gate.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Test-only seam: park INSIDE the blocking compression, which is
|
||||
// the window a handler can be cancelled in while
|
||||
// `PublishStage::PreparingArchive` still holds.
|
||||
#[cfg(test)]
|
||||
if let Some(gate) = gate {
|
||||
// Blocks until the test opens the gate.
|
||||
gate.wait();
|
||||
}
|
||||
compress_repo(&local_path)
|
||||
})
|
||||
.await
|
||||
};
|
||||
let archive_bytes = match compressed {
|
||||
Ok(Ok(bytes)) => bytes,
|
||||
Ok(Err(e)) => {
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::Refused);
|
||||
}
|
||||
return Err(UploadError::NotPublished(e.context("compressing repo")));
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::Refused);
|
||||
}
|
||||
return Err(UploadError::NotPublished(
|
||||
anyhow::Error::new(e).context("tar task panicked"),
|
||||
));
|
||||
}
|
||||
};
|
||||
move || compress_repo(&local_path)
|
||||
})
|
||||
.await
|
||||
.context("tar task panicked")?
|
||||
.context("compressing repo")?;
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
|
||||
|
||||
let mut req = self
|
||||
.s3
|
||||
self.s3
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
// The attempt identity travels WITH the bytes. That is what makes a
|
||||
// lost response recoverable: the client can HEAD the key afterwards
|
||||
// and ask whether the published object is its own work, which is
|
||||
// decidable, instead of asking whether its request succeeded, which
|
||||
// is not.
|
||||
.metadata(ATTEMPT_METADATA_KEY, attempt.as_str())
|
||||
.body(body)
|
||||
.content_type("application/zstd");
|
||||
match &precondition {
|
||||
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
|
||||
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
|
||||
UploadPrecondition::Unconditional => {}
|
||||
}
|
||||
.content_type("application/zstd")
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris PUT {key}"))?;
|
||||
|
||||
// From HERE the bytes may reach the store. Everything downstream that
|
||||
// could destroy state has to treat this stage as "maybe published".
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::PutDispatched {
|
||||
attempt: attempt.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let sent = req.send().await;
|
||||
let out = match sent {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
// `PutObjectError` models no PreconditionFailed variant (its arms are
|
||||
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
|
||||
// TooManyParts, Unhandled), so a refused precondition arrives as
|
||||
// `Unhandled` and matching the enum would classify it as a generic
|
||||
// failure. The raw HTTP status off the response is the only place the
|
||||
// answer actually lives.
|
||||
//
|
||||
// Read it via `raw_response()`, not a `ServiceError`-only match: the
|
||||
// SDK exposes the raw response for BOTH `ServiceError` and
|
||||
// `ResponseError`, and a refused conditional PUT whose error body the
|
||||
// SDK cannot parse (malformed XML, premature close) surfaces as
|
||||
// `ResponseError`. Matching only `ServiceError` would classify that
|
||||
// unparsable 409/412 as a generic failure, and `RepoWriteGuard::release`
|
||||
// would log-and-succeed instead of taking the supersede retry,
|
||||
// acknowledging a write that was definitively not published.
|
||||
let status = e.raw_response().map(|raw| raw.status().as_u16());
|
||||
// 412 is always a lost precondition. 409 is one only when we asked
|
||||
// for create-only, which is how S3-compatible stores report "the key
|
||||
// already exists". Everything else, 404 included, is a real failure:
|
||||
// archive keys are never deleted by the write path, so a 404 here
|
||||
// means something permanent like a missing bucket or a misrouted
|
||||
// endpoint, and reporting that as a lost precondition would tell a
|
||||
// caller to expect a successor that does not exist.
|
||||
let lost = match status {
|
||||
Some(412) => true,
|
||||
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
|
||||
_ => false,
|
||||
};
|
||||
if lost {
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::Refused);
|
||||
}
|
||||
return Err(UploadError::PreconditionLost {
|
||||
status: status.expect("a lost precondition came from a status"),
|
||||
});
|
||||
}
|
||||
let knowledge = classify_put_failure(&e, status);
|
||||
let ctx = anyhow::Error::new(e).context(format!("tigris PUT {key}"));
|
||||
return Err(match knowledge {
|
||||
DispatchKnowledge::NeverSent => {
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::Refused);
|
||||
}
|
||||
UploadError::NotPublished(ctx)
|
||||
}
|
||||
DispatchKnowledge::MaybeSent => {
|
||||
warn!(
|
||||
key = %key,
|
||||
attempt = %attempt,
|
||||
status = ?status,
|
||||
"tigris PUT failed WITHOUT proving it did not commit — the outcome \
|
||||
is ambiguous and must be reconciled, not compensated"
|
||||
);
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::Ambiguous {
|
||||
attempt: attempt.clone(),
|
||||
});
|
||||
}
|
||||
UploadError::Ambiguous {
|
||||
attempt: attempt.clone(),
|
||||
source: ctx,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let etag = out.e_tag().map(str::to_string);
|
||||
if let Some(stage) = stage {
|
||||
stage.set(PublishStage::Published {
|
||||
attempt: attempt.clone(),
|
||||
etag: etag.clone(),
|
||||
});
|
||||
}
|
||||
info!(key = %key, attempt = %attempt, "uploaded repo to tigris");
|
||||
Ok(UploadReceipt { attempt, etag })
|
||||
}
|
||||
|
||||
/// Delete a repo archive ONLY while it is still `attempt`'s work.
|
||||
///
|
||||
/// The unconditional delete this replaces used the logical owner/name as its
|
||||
/// cleanup authority, so a failed attempt compensating late could erase the
|
||||
/// object a SUCCESSOR had already published under the same name and already
|
||||
/// returned 201 for. A second name lookup before the delete only narrows that
|
||||
/// window; reading the attempt id off the object and fencing the delete on
|
||||
/// the generation it came from closes it.
|
||||
pub async fn delete_if_attempt_matches(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
attempt: &PublishAttemptId,
|
||||
) -> Result<AttemptDelete> {
|
||||
let Some(generation) = self.head_generation(owner_slug, repo_name).await? else {
|
||||
return Ok(AttemptDelete::Absent);
|
||||
};
|
||||
if !generation.belongs_to(attempt) {
|
||||
return Ok(AttemptDelete::NotOurs);
|
||||
}
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
let mut req = self.s3.delete_object().bucket(&self.bucket).key(&key);
|
||||
// The If-Match guard is what makes this atomic rather than merely
|
||||
// narrowed: between the HEAD above and this call a successor can publish,
|
||||
// and the store refusing on the moved generation is the only thing that
|
||||
// stops the delete landing on their object.
|
||||
if let Some(etag) = generation.etag.as_deref() {
|
||||
req = req.if_match(etag);
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(_) => Ok(AttemptDelete::Deleted),
|
||||
Err(e) => {
|
||||
// A refused conditional delete means the generation moved: the
|
||||
// object is no longer ours, which is a successful outcome for a
|
||||
// guard whose whole job is not to touch somebody else's bytes.
|
||||
if e.raw_response()
|
||||
.map(|raw| raw.status().as_u16())
|
||||
.is_some_and(|s| s == 412 || s == 409)
|
||||
{
|
||||
return Ok(AttemptDelete::NotOurs);
|
||||
}
|
||||
Err(anyhow::anyhow!("tigris conditional DELETE {key}: {e}"))
|
||||
}
|
||||
}
|
||||
info!(key = %key, "uploaded repo to tigris");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract to local disk.
|
||||
@@ -527,32 +93,10 @@ impl TigrisClient {
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
self.download_to(owner_slug, repo_name, local_path, true, swap_authority)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract it, returning the
|
||||
/// directory that was populated.
|
||||
///
|
||||
/// `publish` controls whether the extract is swapped into `target` in place
|
||||
/// (the live-path mutation used by writes; returns `target`) or unpacked
|
||||
/// into a fresh temp directory under `target`'s parent (a non-mutating
|
||||
/// snapshot read; returns the temp dir, which the caller owns and cleans
|
||||
/// up). The snapshot form never touches the live repo path.
|
||||
pub async fn download_to(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
target: &super::repo_store::ValidatedRepoDiskPath,
|
||||
publish: bool,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
) -> Result<DownloadExtract> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
|
||||
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
|
||||
|
||||
let resp = self
|
||||
.s3
|
||||
@@ -570,59 +114,31 @@ impl TigrisClient {
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
|
||||
// The snapshot temp dir is decided HERE, in the async layer, before the
|
||||
// extraction runs. Cleanup ownership moves into the blocking task so a
|
||||
// cancelled async future cannot drop it while extraction is still running.
|
||||
let snapshot_tmp = if publish {
|
||||
None
|
||||
} else {
|
||||
let parent = target.parent().context("snapshot path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.context("snapshot path has no file name")?
|
||||
.to_string_lossy();
|
||||
Some(parent.join(format!(
|
||||
".{file_name}.tmp-snapshot.{}",
|
||||
uuid::Uuid::new_v4()
|
||||
)))
|
||||
};
|
||||
|
||||
// Extract tar.zst to a directory.
|
||||
let extracted = tokio::task::spawn_blocking({
|
||||
let target = target.clone();
|
||||
let snapshot_tmp = snapshot_tmp.clone();
|
||||
move || -> Result<DownloadExtract> {
|
||||
let result = (|| -> Result<DownloadExtract> {
|
||||
if publish {
|
||||
decompress_repo(&data, &target, swap_authority.as_ref())?;
|
||||
return Ok(DownloadExtract::Published(()));
|
||||
}
|
||||
// Non-mutating snapshot: unpack into the temp dir decided above.
|
||||
// The live repo path is never touched.
|
||||
let tmp_dir = snapshot_tmp.expect("snapshot path was decided above");
|
||||
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
|
||||
let unpack = (|| -> Result<()> {
|
||||
let decoder = zstd::stream::Decoder::new(&data[..])?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = unpack {
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(DownloadExtract::Snapshot(TempSnapshotDir { path: tmp_dir }))
|
||||
})();
|
||||
result
|
||||
}
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || decompress_repo(&data, &local_path)
|
||||
})
|
||||
.await
|
||||
.context("extract task panicked")?
|
||||
.context("extracting repo")?;
|
||||
|
||||
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
|
||||
Ok(extracted)
|
||||
info!(key = %key, path = %local_path.display(), "downloaded repo from tigris");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a repo archive from Tigris.
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
self.s3
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris DELETE {key}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +161,7 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
/// `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.
|
||||
pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
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
|
||||
@@ -658,35 +174,6 @@ pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
|
||||
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
|
||||
/// before the outer future resumes still removes the directory.
|
||||
pub(crate) struct TempSnapshotDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempSnapshotDir {
|
||||
pub(crate) fn into_repo_snapshot(self) -> super::repo_store::RepoSnapshot {
|
||||
let path = self.path.clone();
|
||||
std::mem::forget(self);
|
||||
super::repo_store::RepoSnapshot::from_owned_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempSnapshotDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TigrisClient::download_to`] produced.
|
||||
pub enum DownloadExtract {
|
||||
/// Published into the validated live repo path.
|
||||
Published(()),
|
||||
/// Unpacked into a throwaway temp dir that cleans up on drop until adopted.
|
||||
Snapshot(TempSnapshotDir),
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
@@ -694,16 +181,11 @@ pub enum DownloadExtract {
|
||||
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
|
||||
/// good existing copy at `local_path` — on failure we discard the temp dir and
|
||||
/// leave `local_path` exactly as it was.
|
||||
fn decompress_repo(
|
||||
data: &[u8],
|
||||
local_path: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<&Arc<AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
let live = local_path.as_path();
|
||||
let parent = live.parent().context("repo path has no parent")?;
|
||||
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
let parent = local_path.parent().context("repo path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
|
||||
let file_name = live
|
||||
let file_name = local_path
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
@@ -728,218 +210,17 @@ fn decompress_repo(
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Swap through the validated-path helper so CodeQL sees the barrier before the
|
||||
// remove/rename sink (`rust/path-injection`).
|
||||
super::repo_store::swap_extracted_into_validated_repo(local_path, &tmp_dir, swap_authority)?;
|
||||
// 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");
|
||||
if local_path.exists() {
|
||||
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
|
||||
}
|
||||
std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use futures::FutureExt;
|
||||
|
||||
/// The envs the probe needs, all of them, or it does not run.
|
||||
///
|
||||
/// `AWS_ENDPOINT_URL_S3` is included on purpose: without it the SDK resolves
|
||||
/// to real AWS S3, and a probe that passed there would say nothing about
|
||||
/// Tigris.
|
||||
fn probe_env() -> Option<String> {
|
||||
if std::env::var("GITLAWB_TIGRIS_PROBE").ok().as_deref() != Some("1") {
|
||||
return None;
|
||||
}
|
||||
for name in [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_ENDPOINT_URL_S3",
|
||||
] {
|
||||
if std::env::var(name).is_err() {
|
||||
eprintln!("tigris conditional-write probe: {name} is unset, skipping");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
match std::env::var("GITLAWB_TIGRIS_BUCKET") {
|
||||
Ok(b) if !b.is_empty() => Some(b),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"tigris conditional-write probe: GITLAWB_TIGRIS_BUCKET is unset, skipping"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One conditional PUT, reported as the status that REFUSED it, or `None`
|
||||
/// when the store accepted the write.
|
||||
///
|
||||
/// Accepted is the interesting answer here, not an error: it means the
|
||||
/// endpoint ignored the header we fenced on.
|
||||
async fn conditional_put(
|
||||
s3: &S3Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
body: &'static [u8],
|
||||
if_match: Option<&str>,
|
||||
if_none_match: Option<&str>,
|
||||
) -> Result<Option<u16>, String> {
|
||||
let mut req = s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(body));
|
||||
if let Some(v) = if_match {
|
||||
req = req.if_match(v);
|
||||
}
|
||||
if let Some(v) = if_none_match {
|
||||
req = req.if_none_match(v);
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => match e.raw_response() {
|
||||
// Same raw-response rule as `upload`: a refused conditional PUT
|
||||
// whose body the SDK cannot parse surfaces as `ResponseError`, and
|
||||
// the status has to come off the raw response for both variants.
|
||||
// Without it, an unparsable 409/412 would report "no HTTP
|
||||
// response" here and skip the supersede retry.
|
||||
Some(raw) => Ok(Some(raw.status().as_u16())),
|
||||
None => Err(format!("conditional PUT {key}: no HTTP response: {e}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe body, written to RETURN its failures rather than panic on
|
||||
/// them, so the caller's cleanup is reached on every arm.
|
||||
async fn conditional_write_probe(s3: &S3Client, bucket: &str, key: &str) -> Result<(), String> {
|
||||
// 1. A plain PUT under a throwaway key.
|
||||
let seeded = s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"probe-one"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("seeding PUT {key}: {e}"))?;
|
||||
|
||||
// 2. Its ETag, which is the generation the next arm fences against.
|
||||
let etag = seeded
|
||||
.e_tag()
|
||||
.ok_or_else(|| format!("seeding PUT {key} returned no ETag"))?
|
||||
.to_string();
|
||||
|
||||
// 3. A deliberately wrong If-Match. A store honoring it answers 412.
|
||||
let wrong = format!("\"{}\"", "0".repeat(32));
|
||||
if etag.trim_matches('"') == wrong.trim_matches('"') {
|
||||
return Err(format!(
|
||||
"the seeded ETag {etag} collides with the deliberately wrong one, \
|
||||
so this arm would prove nothing"
|
||||
));
|
||||
}
|
||||
match conditional_put(s3, bucket, key, b"probe-two", Some(&wrong), None).await? {
|
||||
Some(412) => {}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"a stale If-Match must be refused with 412, the endpoint answered {status}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"a stale If-Match was ACCEPTED: this endpoint does not honor If-Match, so \
|
||||
the release fence cannot hold here"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If-None-Match `*` over the object that now exists. This arm matters
|
||||
// MORE than the one above. An ignored If-Match eventually surfaces as
|
||||
// odd behavior, because a stale writer overwrites and someone notices
|
||||
// the lost tree. An ignored If-None-Match just returns 200, so a publish
|
||||
// that should have been fenced lands with no error anywhere: the silent
|
||||
// no-op the bucket-type caveat on this test describes.
|
||||
match conditional_put(s3, bucket, key, b"probe-three", None, Some("*")).await? {
|
||||
// Either status is a pass, and the asymmetry with the If-Match arm
|
||||
// above mirrors `upload`'s classifier exactly: 412 is always a lost
|
||||
// precondition, and 409 is one too when we asked for create-only.
|
||||
// AWS documents 409 for a create-only conflict racing a delete, so a
|
||||
// store answering it is enforcing the precondition and we already
|
||||
// handle it. Pinning 412 alone here would fail the probe against a
|
||||
// backend that is behaving correctly, which sends whoever runs it
|
||||
// chasing a fault that is not there.
|
||||
Some(412) | Some(409) => {}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"create-only over an existing object must be refused with 412 or 409, \
|
||||
the endpoint answered {status}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"If-None-Match * was ACCEPTED over an existing object: this endpoint does \
|
||||
not honor create-only, so a fenced publish lands silently"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Probe the REAL Tigris endpoint for the conditional-write semantics the
|
||||
/// release fence depends on.
|
||||
///
|
||||
/// UNTIL THIS IS RUN AGAINST REAL CREDENTIALS, the fence is verified against
|
||||
/// vendor documentation and an in-process mock, not against the backend it
|
||||
/// runs on. The mock implements the semantics we believe Tigris has; it
|
||||
/// cannot tell us whether Tigris actually has them.
|
||||
///
|
||||
/// The bucket matters, not just the endpoint. Tigris documents conditional
|
||||
/// operations as supported on Single-region and Multi-region buckets only.
|
||||
/// Global and Dual-region buckets are eventually consistent, and a
|
||||
/// conditional PUT evaluated against a stale replica would make the fence a
|
||||
/// silent no-op rather than an error. So point `GITLAWB_TIGRIS_BUCKET` at a
|
||||
/// throwaway bucket of the SAME type production uses.
|
||||
///
|
||||
/// Ignored by default and additionally gated on `GITLAWB_TIGRIS_PROBE=1`,
|
||||
/// because it writes to a real bucket and costs real requests. Run with:
|
||||
/// `GITLAWB_TIGRIS_PROBE=1 cargo test -p gitlawb-node --bin gitlawb-node
|
||||
/// tigris_honors_conditional_writes -- --ignored --nocapture`
|
||||
#[tokio::test]
|
||||
#[ignore = "writes to a real Tigris bucket; needs GITLAWB_TIGRIS_PROBE=1 plus credentials"]
|
||||
async fn tigris_honors_conditional_writes() {
|
||||
let Some(bucket) = probe_env() else {
|
||||
eprintln!(
|
||||
"tigris conditional-write probe: skipped. Set GITLAWB_TIGRIS_PROBE=1, \
|
||||
GITLAWB_TIGRIS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and \
|
||||
AWS_ENDPOINT_URL_S3 to run it."
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
||||
let s3 = S3Client::new(&config);
|
||||
// A fresh key per run, so a probe that somehow orphaned an object on an
|
||||
// earlier run cannot change what this one observes.
|
||||
let key = format!("probe/conditional-write-{}.bin", uuid::Uuid::new_v4());
|
||||
|
||||
// CLEANUP MUST RUN ON EVERY ARM, and a failing assertion is precisely
|
||||
// the case this probe exists to catch, so the delete cannot sit after
|
||||
// the checks. The body returns its failures rather than panicking, and
|
||||
// `catch_unwind` covers the panic an SDK call could still raise; either
|
||||
// way the delete below is reached before the verdict is re-raised.
|
||||
let outcome = std::panic::AssertUnwindSafe(conditional_write_probe(&s3, &bucket, &key))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
if let Err(e) = s3.delete_object().bucket(&bucket).key(&key).send().await {
|
||||
eprintln!("tigris conditional-write probe: cleanup of {key} failed: {e}");
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(msg)) => panic!("tigris conditional-write probe: {msg}"),
|
||||
Err(payload) => std::panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,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))
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ impl MutationRoot {
|
||||
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(),
|
||||
@@ -108,7 +108,7 @@ impl MutationRoot {
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.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(
|
||||
@@ -118,7 +118,7 @@ impl MutationRoot {
|
||||
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(),
|
||||
@@ -149,7 +149,7 @@ impl MutationRoot {
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.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(
|
||||
@@ -160,7 +160,7 @@ impl MutationRoot {
|
||||
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(),
|
||||
@@ -186,110 +186,47 @@ mod tests {
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// N2: every GraphQL mutation requires a verified signer and binds the acting
|
||||
/// DID to it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// 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 each_mutation_requires_and_binds_signer(pool: PgPool) {
|
||||
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 actor = "did:key:zGQLACTORAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let other = "did:key:zGQLOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let cases: Vec<(&str, String)> = vec![
|
||||
(
|
||||
"createTask",
|
||||
format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write" }}) {{ id }} }}"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"claimTask",
|
||||
format!(
|
||||
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{actor}") {{ id }} }}"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"completeTask",
|
||||
format!(
|
||||
r#"mutation {{ completeTask(id: "no-such-task", byDid: "{actor}", input: {{}}) {{ id }} }}"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"failTask",
|
||||
format!(
|
||||
r#"mutation {{ failTask(id: "no-such-task", byDid: "{actor}", input: {{}}) {{ id }} }}"#
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, q) in &cases {
|
||||
let resp = schema.execute(Request::new(q.as_str())).await;
|
||||
assert!(
|
||||
errors(&resp).contains("authentication required"),
|
||||
"{name}: unsigned mutation must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q.as_str()).data(AuthenticatedDid(other.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("authenticated signer"),
|
||||
"{name}: DID mismatch must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q.as_str()).data(AuthenticatedDid(actor.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"{name}: matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
if *name == "claimTask" {
|
||||
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 assignee = "did:key:zASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let q = format!(
|
||||
r#"mutation {{
|
||||
createTask(
|
||||
delegatorDid: "{delegator}",
|
||||
input: {{ kind: "build", capability: "repo:write" }}
|
||||
) {{ id }}
|
||||
}}"#
|
||||
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{assignee}") {{ id }} }}"#
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(delegator.into())))
|
||||
|
||||
// 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 (any remaining
|
||||
// error is the missing task, not an auth error).
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(assignee.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}"
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ impl QueryRoot {
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
|
||||
// this surface does not enumerate private repos (#97). The caller DID is
|
||||
@@ -27,7 +27,7 @@ impl QueryRoot {
|
||||
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()
|
||||
@@ -71,7 +71,7 @@ impl QueryRoot {
|
||||
let updates =
|
||||
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
// 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
|
||||
@@ -86,7 +86,7 @@ impl QueryRoot {
|
||||
let owner_dids = db
|
||||
.resolve_ref_update_owner_dids(&pairs)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
let resolved: Vec<RefUpdateType> = updates
|
||||
.into_iter()
|
||||
@@ -110,21 +110,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,7 +125,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))
|
||||
}
|
||||
}
|
||||
@@ -427,91 +419,4 @@ mod tests {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+25
-3504
File diff suppressed because it is too large.
Load diff
+108
-2710
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
//! Prometheus metrics for the Twigpine node.
|
||||
//! Prometheus metrics for the gitlawb node.
|
||||
//!
|
||||
//! Exposes a small, deliberately conservative set of counters and histograms
|
||||
//! that cover the questions an operator actually asks of a live node:
|
||||
@@ -11,11 +11,6 @@
|
||||
//! `gitlawb_sync_queue_processed_total{status}`
|
||||
//! * are webhooks reaching their endpoints? —
|
||||
//! `gitlawb_webhook_deliveries_total{result}`
|
||||
//! * is inbound gossip being admitted or shed, and for which reason?
|
||||
//! `gitlawb_gossip_ingest_events_total{outcome}`
|
||||
//! * is the gossip replay seen-set full, and therefore admitting events it
|
||||
//! cannot deduplicate?
|
||||
//! `gitlawb_gossip_replay_guard_saturated_total`
|
||||
//! * how big are the packs we're sending and receiving? —
|
||||
//! `gitlawb_pack_size_bytes`
|
||||
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
|
||||
@@ -38,8 +33,8 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use prometheus::{
|
||||
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
|
||||
Registry, TextEncoder,
|
||||
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
|
||||
TextEncoder,
|
||||
};
|
||||
|
||||
/// The single, process-wide metrics registry. Initialized by [`init`].
|
||||
@@ -54,30 +49,18 @@ static AUTH_SUCCESSES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static GOSSIP_REPLAY_GUARD_SATURATED: OnceLock<IntCounter> = OnceLock::new();
|
||||
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
|
||||
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
/// 1 when the node started without its p2p identity because the key storage
|
||||
/// could not be used. The node keeps serving HTTP in that state and /health
|
||||
/// stays green, so without this the outage is visible only to whoever reads
|
||||
/// the startup log.
|
||||
static P2P_KEY_LOAD_FAILED: OnceLock<IntGauge> = OnceLock::new();
|
||||
|
||||
/// One-time initializer. Builds the registry, registers every metric,
|
||||
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
|
||||
/// 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) {
|
||||
// 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));
|
||||
}
|
||||
if REGISTRY.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
fn init_inner(version: &str, node_did: &str) {
|
||||
let registry = Registry::new();
|
||||
|
||||
let info = IntGaugeVec::new(
|
||||
@@ -180,33 +163,6 @@ fn init_inner(version: &str, node_did: &str) {
|
||||
.set(webhook_deliveries)
|
||||
.expect("set WEBHOOK_DELIVERIES once");
|
||||
|
||||
let gossip_ingest = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_gossip_ingest_events_total",
|
||||
"Total inbound gossip ref-update events and what the ingest path decided about each",
|
||||
),
|
||||
&["outcome"],
|
||||
)
|
||||
.expect("gitlawb_gossip_ingest_events_total definition");
|
||||
registry
|
||||
.register(Box::new(gossip_ingest.clone()))
|
||||
.expect("register gitlawb_gossip_ingest_events_total");
|
||||
GOSSIP_INGEST
|
||||
.set(gossip_ingest)
|
||||
.expect("set GOSSIP_INGEST once");
|
||||
|
||||
let gossip_replay_guard_saturated = IntCounter::with_opts(Opts::new(
|
||||
"gitlawb_gossip_replay_guard_saturated_total",
|
||||
"Inbound gossip ref-updates admitted without being recorded because the replay seen-set was at capacity",
|
||||
))
|
||||
.expect("gitlawb_gossip_replay_guard_saturated_total definition");
|
||||
registry
|
||||
.register(Box::new(gossip_replay_guard_saturated.clone()))
|
||||
.expect("register gitlawb_gossip_replay_guard_saturated_total");
|
||||
GOSSIP_REPLAY_GUARD_SATURATED
|
||||
.set(gossip_replay_guard_saturated)
|
||||
.expect("set GOSSIP_REPLAY_GUARD_SATURATED once");
|
||||
|
||||
let pack_size = Histogram::with_opts(
|
||||
HistogramOpts::new(
|
||||
"gitlawb_pack_size_bytes",
|
||||
@@ -241,18 +197,6 @@ fn init_inner(version: &str, node_did: &str) {
|
||||
.set(peers_connected)
|
||||
.expect("set PEERS_CONNECTED once");
|
||||
|
||||
let p2p_key_load_failed = IntGauge::with_opts(Opts::new(
|
||||
"gitlawb_p2p_identity_key_load_failed",
|
||||
"1 if the node is running without p2p because its identity key could not be loaded",
|
||||
))
|
||||
.expect("gitlawb_p2p_identity_key_load_failed definition");
|
||||
registry
|
||||
.register(Box::new(p2p_key_load_failed.clone()))
|
||||
.expect("register gitlawb_p2p_identity_key_load_failed");
|
||||
P2P_KEY_LOAD_FAILED
|
||||
.set(p2p_key_load_failed)
|
||||
.expect("set P2P_KEY_LOAD_FAILED once");
|
||||
|
||||
REGISTRY
|
||||
.set(registry)
|
||||
.expect("set REGISTRY once (init must be called exactly once)");
|
||||
@@ -274,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) {
|
||||
@@ -321,66 +254,6 @@ pub fn record_webhook_delivery(result: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record what the gossip ingest path decided about one inbound ref-update.
|
||||
/// `outcome` ∈ {accepted, unsigned_admitted, write_failed, rejected,
|
||||
/// source_rate_limited, author_rate_limited, unsigned_source_rate_limited,
|
||||
/// replayed, stale_timestamp}.
|
||||
///
|
||||
/// `accepted` is reserved for signature-verified events. An unsigned event that
|
||||
/// survives the rolling-upgrade window is `unsigned_admitted`, so the
|
||||
/// authenticated-admission rate is not silently padded by legacy compatibility
|
||||
/// traffic; an operator can tell whether the fleet still relies on the
|
||||
/// compatibility allowance.
|
||||
///
|
||||
/// The three shed reasons stay separate labels rather than one `rate_limited`
|
||||
/// because they answer different operator questions: `source_rate_limited` is a
|
||||
/// forwarding peer over the pre-parse brake, `unsigned_source_rate_limited` is
|
||||
/// that same forwarder relaying unproven traffic, and `author_rate_limited`
|
||||
/// names a principal a signature actually proved. Collapsing them would make a
|
||||
/// flood of unauthenticated garbage indistinguishable from one registered peer
|
||||
/// pushing too hard, which is exactly the distinction an alert needs.
|
||||
///
|
||||
/// The label is the variant, never the free-form reason string that
|
||||
/// `Rejected`/`WriteFailed` carry: those are attacker-influenced and would blow
|
||||
/// up the label cardinality of a process-wide registry.
|
||||
pub fn record_gossip_ingest(outcome: &str) {
|
||||
if let Some(c) = GOSSIP_INGEST.get() {
|
||||
c.with_label_values(&[outcome]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one inbound gossip ref-update that the replay seen-set could not
|
||||
/// record because it was at capacity.
|
||||
///
|
||||
/// Label-less on purpose: there is one degradation mode and nothing about it
|
||||
/// varies per event, so a label would only add cardinality.
|
||||
///
|
||||
/// This is an alert-worthy signal, not a debug counter, and it is the ONLY way
|
||||
/// the degraded state is visible. At capacity the guard fails open, which means
|
||||
/// the event is admitted and `gitlawb_gossip_ingest_events_total{outcome=
|
||||
/// "accepted"}` counts it exactly as it counts a healthy admission. Fail-open is
|
||||
/// the right policy (a saturated set that dropped all fresh gossip would convert
|
||||
/// a loud resource attack into quiet mesh-wide censorship), but it is not a free
|
||||
/// one: while this counter is moving, a captured signature can again be replayed
|
||||
/// against the freshness window's full 10 minutes, spending a `peer_exists`
|
||||
/// round trip and a debit from the victim author's budget on every replay.
|
||||
pub fn record_gossip_replay_guard_saturated() {
|
||||
if let Some(c) = GOSSIP_REPLAY_GUARD_SATURATED.get() {
|
||||
c.inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: current `gitlawb_gossip_replay_guard_saturated_total` value (0 if
|
||||
/// the registry is not initialized). Lets the seen-set tests assert the
|
||||
/// saturation signal fires without scraping the encoded text.
|
||||
#[cfg(test)]
|
||||
pub fn replay_guard_saturated_count_for_test() -> u64 {
|
||||
GOSSIP_REPLAY_GUARD_SATURATED
|
||||
.get()
|
||||
.map(|c| c.get())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Record a pack body size observation (bytes).
|
||||
pub fn observe_pack_size(bytes: f64) {
|
||||
if let Some(h) = PACK_SIZE.get() {
|
||||
@@ -395,13 +268,6 @@ pub fn set_peers_connected(count: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record whether the node is running without its p2p identity.
|
||||
pub fn set_p2p_key_load_failed(failed: bool) {
|
||||
if let Some(g) = P2P_KEY_LOAD_FAILED.get() {
|
||||
g.set(i64::from(failed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the registry as the standard Prometheus text exposition format.
|
||||
/// Returns an error if `init` was never called.
|
||||
pub fn encode() -> Result<String, prometheus::Error> {
|
||||
@@ -420,11 +286,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() {
|
||||
@@ -455,24 +321,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
|
||||
@@ -483,12 +331,6 @@ mod tests {
|
||||
record_auth_failure("test/route", "test_reason");
|
||||
record_sync_processed("done");
|
||||
record_webhook_delivery("ok");
|
||||
// `record_gossip_replay_guard_saturated` is deliberately NOT called
|
||||
// here. It is a label-less process-wide counter and the seen-set test
|
||||
// that asserts it asserts a before/after delta, so an increment from a
|
||||
// second test running concurrently in this binary would be a flake with
|
||||
// no diagnostic value.
|
||||
record_gossip_ingest("accepted");
|
||||
observe_pack_size(1024.0);
|
||||
set_peers_connected(0);
|
||||
}
|
||||
|
||||
+61
-10388
File diff suppressed because it is too large.
Load diff
+13
-1470
File diff suppressed because it is too large.
Load diff
@@ -121,45 +121,6 @@ impl RateLimiter {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
/// The configured key ceiling. Tests use it to assert that a limiter a
|
||||
/// production factory builds is actually bounded, which flooding the real
|
||||
/// ceiling in distinct keys would be far too slow to show.
|
||||
#[cfg(test)]
|
||||
pub fn max_keys(&self) -> usize {
|
||||
self.max_keys
|
||||
}
|
||||
|
||||
/// The configured per-key request cap. Tests use it to assert that a
|
||||
/// limiter a production factory builds carries the cap it is documented
|
||||
/// with; re-asserting the constant on its own cannot tell whether the
|
||||
/// factory passed that constant or a different one.
|
||||
#[cfg(test)]
|
||||
pub fn max_requests(&self) -> usize {
|
||||
self.max_requests
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -169,118 +130,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 {
|
||||
@@ -439,63 +288,6 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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));
|
||||
|
||||
@@ -14,7 +14,7 @@ use tracing::Level;
|
||||
|
||||
use crate::api::{
|
||||
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles,
|
||||
protect, pulls, register, replicas, repos, resolve, stars, tasks, visibility, webhooks,
|
||||
protect, pulls, register, replicas, repos, resolve, stars, status, tasks, visibility, webhooks,
|
||||
};
|
||||
use crate::auth;
|
||||
use crate::rate_limit;
|
||||
@@ -42,37 +42,17 @@ async fn graphql_playground() -> impl IntoResponse {
|
||||
))
|
||||
}
|
||||
|
||||
/// Pre-auth body-buffer ceiling for every signed route group except the git write
|
||||
/// routes. Deliberately equal to axum's `DefaultBodyLimit` (2 MB), which is what
|
||||
/// these groups' `Bytes`/`Json` extractors already enforce: that default works
|
||||
/// through a request extension the extractors consult, and `require_signature`
|
||||
/// collects the raw body itself, so it never saw the cap. Matching the number
|
||||
/// keeps this change behavior-preserving on the response path (nothing that used
|
||||
/// to be accepted starts failing) and purely a memory fix.
|
||||
pub const SIGNED_BODY_LIMIT: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// `body_limit` bounds the request body `require_signature` buffers before it has
|
||||
/// authenticated anything (see [`auth::SignatureBodyLimit`]): `max_pack_bytes` for
|
||||
/// the git write group, [`SIGNED_BODY_LIMIT`] for every other group.
|
||||
fn add_auth_layers(
|
||||
router: Router<AppState>,
|
||||
state: AppState,
|
||||
body_limit: usize,
|
||||
) -> Router<AppState> {
|
||||
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
|
||||
router
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
auth::require_ucan_chain,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(body_limit),
|
||||
auth::require_signature,
|
||||
))
|
||||
.layer(middleware::from_fn(auth::require_signature))
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
@@ -83,10 +63,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// 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_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
@@ -97,7 +74,6 @@ 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(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Task routes (read — open) ──────────────────────────────────────────
|
||||
@@ -131,7 +107,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
|
||||
.layer(axum::Extension(limiter)),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(create_ip_limiter));
|
||||
@@ -206,9 +181,66 @@ pub fn build_router(state: AppState) -> Router {
|
||||
axum::routing::delete(agents::deregister_agent),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Status claim writes — signature, plus the per-DID throttle AND the
|
||||
// per-IP flood brake `creation_routes` carries. Not in `write_routes`, which
|
||||
// has neither: every call here appends a row, so an unthrottled writer grows
|
||||
// the claim log on demand and the three per-repo caps would be the only bound.
|
||||
let status_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/statuses/{sha}",
|
||||
post(status::create_status),
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
|
||||
.layer(axum::Extension(state.rate_limiter.clone())),
|
||||
state.clone(),
|
||||
)
|
||||
// The route's advertised body bound, applied at the transport. It sits
|
||||
// OUTSIDE the auth layers because `require_signature` collects and hashes
|
||||
// the whole body before any handler runs: the handler's own check measures
|
||||
// an allocation that has already happened, so without this layer the 8 KiB
|
||||
// figure bounded what gets stored and not what gets buffered, and
|
||||
// concurrent requests multiplied exactly the cost it was written to
|
||||
// prevent. It sits INSIDE the rate limiters so an oversized request is
|
||||
// still counted by the per-IP brake before it is refused.
|
||||
//
|
||||
// A declared Content-Length over the limit is answered 413 without reading
|
||||
// a byte; a body that declares nothing (or understates) is truncated at the
|
||||
// limit and fails where it is read, which `require_signature` reports as an
|
||||
// unreadable body.
|
||||
.layer(RequestBodyLimitLayer::new(status::MAX_REQUEST_BODY_BYTES))
|
||||
// The one route that persists the signed request body, so the one route
|
||||
// whose signature material carries it. Applied OUTSIDE `add_auth_layers`
|
||||
// (outermost = runs first) because `require_signature` reads this marker to
|
||||
// decide whether to carry the body; a layer added inside the auth pair runs
|
||||
// after the middleware and is never seen. `create_status` refuses an absent
|
||||
// body rather than storing an empty column, so getting this order wrong
|
||||
// fails loudly instead of quietly writing unverifiable claims.
|
||||
.layer(axum::Extension(auth::PersistsSignedBody))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.create_ip_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
}));
|
||||
|
||||
// ── Status claim reads — open, behind `optional_signature` so the handler
|
||||
// sees the caller when one signs and still answers a public repo for an
|
||||
// anonymous reader. Without the layer the handler's optional auth extension
|
||||
// is None for EVERY caller, including the owner, and every private repo
|
||||
// reads as not-found.
|
||||
let status_read_routes = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/commits/{sha}/status",
|
||||
get(status::commit_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/status",
|
||||
get(status::pull_request_status),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// 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
|
||||
@@ -231,7 +263,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit)),
|
||||
state.clone(),
|
||||
pack_limit,
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(push_limiter));
|
||||
@@ -241,23 +272,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// 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,
|
||||
};
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(ipfs_limiter))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
@@ -288,7 +305,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(bounties::dispute_bounty),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Bounty routes (read — open) ──────────────────────────────────────
|
||||
@@ -304,16 +320,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/agents/{did}/bounties",
|
||||
get(bounties::agent_bounty_stats),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
.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(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
|
||||
@@ -328,7 +340,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(issues::create_issue_comment),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Peer discovery routes ─────────────────────────────────────────────
|
||||
@@ -347,7 +358,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let sync_trigger_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
@@ -364,12 +374,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/v1/peers/announce", post(peers::announce))
|
||||
.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(), SIGNED_BODY_LIMIT)
|
||||
add_auth_layers(peer_write_routes, state.clone())
|
||||
} else {
|
||||
peer_write_routes.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
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))
|
||||
@@ -424,6 +431,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/events",
|
||||
get(events::list_repo_events),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/push-events",
|
||||
get(events::list_repo_push_events),
|
||||
)
|
||||
.route("/api/v1/agents", get(agents::list_agents))
|
||||
.route("/api/v1/agents/{did}", get(agents::show_agent))
|
||||
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
|
||||
@@ -468,10 +479,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
get(replicas::list_replicas),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
.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.
|
||||
@@ -503,13 +511,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit))
|
||||
// `pack_limit`, not SIGNED_BODY_LIMIT: git-upload-pack POSTs live here and
|
||||
// the group has deliberately raised its ceiling, so the pre-auth buffer for
|
||||
// a signed fetch negotiation must match rather than narrow it.
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(pack_limit),
|
||||
auth::optional_signature,
|
||||
));
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// ── Meta ──────────────────────────────────────────────────────────────
|
||||
let meta_routes = Router::new()
|
||||
@@ -529,6 +531,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(profile_write_routes)
|
||||
.merge(creation_routes)
|
||||
.merge(write_routes)
|
||||
.merge(status_write_routes)
|
||||
.merge(status_read_routes)
|
||||
.merge(git_write_routes)
|
||||
.merge(git_read_routes)
|
||||
.merge(issue_write_routes)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -605,7 +605,7 @@ async fn replicate_encrypted_blobs(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match crate::ipfs_pin::pin_git_object(ipfs_api, &blob.oid, &envelope, None).await {
|
||||
match crate::ipfs_pin::pin_git_object(ipfs_api, &blob.oid, &envelope).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
if cid != blob.cid {
|
||||
warn!(oid = %blob.oid, expected = %blob.cid, got = %cid, "replicated envelope CID mismatch; skipping record");
|
||||
|
||||
+189
-14346
File diff suppressed because it is too large.
Load diff
@@ -437,31 +437,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// #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]
|
||||
|
||||
@@ -1,574 +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(self")
|
||||
.or_else(|| 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"
|
||||
);
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
//! Which p2p key-storage failures stop the node, and which leave HTTP up.
|
||||
//!
|
||||
//! The two domains are decided by different code at different phases, so the
|
||||
//! only honest proof is at the process boundary: spawn the real binary and
|
||||
//! watch what it does. A helper's returned error says nothing about whether
|
||||
//! `main` treated it as fatal.
|
||||
//!
|
||||
//! Every row also asserts the key tree is byte-identical afterwards. A refusal
|
||||
//! that mutates storage on its way out is its own defect, and a returned error
|
||||
//! cannot show that.
|
||||
//!
|
||||
//! No database: `DATABASE_URL` points at a closed port on purpose, so a row
|
||||
//! that reaches the degraded server proves the outcome is observable without
|
||||
//! Postgres. That is what makes the port-zero no-I/O guarantee testable at all.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wall clock, never an iteration count: a fixed loop count starves on a slow
|
||||
/// or loaded machine and turns a real pass into a flake.
|
||||
const DEADLINE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Kills the child on every exit path, including a panicking assertion.
|
||||
///
|
||||
/// Without this a failing row leaves a node behind that retries its database
|
||||
/// connection forever, holding the row's tempdir open. The first run of this
|
||||
/// file did exactly that, and the leak is invisible until something else in
|
||||
/// the suite gets slow.
|
||||
struct ChildGuard(Child);
|
||||
|
||||
impl ChildGuard {
|
||||
/// The tracing subscriber writes to STDOUT; only anyhow's final error
|
||||
/// print goes to stderr. A row that watches one stream sees half the
|
||||
/// story, so both are read.
|
||||
fn take_stdout(&mut self) -> std::process::ChildStdout {
|
||||
self.0.stdout.take().expect("stdout piped")
|
||||
}
|
||||
|
||||
fn take_stderr(&mut self) -> std::process::ChildStderr {
|
||||
self.0.stderr.take().expect("stderr piped")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// `(path, is_dir, is_symlink, mode, len)` for everything under `root`, sorted.
|
||||
/// Taken before and after each row so a refusal that creates, chmods, or
|
||||
/// deletes anything fails the row.
|
||||
fn snapshot(root: &Path) -> Vec<String> {
|
||||
fn walk(dir: &Path, out: &mut Vec<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let Ok(md) = std::fs::symlink_metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
out.push(format!(
|
||||
"{} dir={} link={} mode={:04o} len={}",
|
||||
p.display(),
|
||||
md.is_dir(),
|
||||
md.is_symlink(),
|
||||
md.permissions().mode() & 0o7777,
|
||||
if md.is_file() { md.len() } else { 0 }
|
||||
));
|
||||
if md.is_dir() && !md.is_symlink() {
|
||||
walk(&p, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(root, &mut out);
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
struct Row {
|
||||
/// Sandbox the row owns: HOME, repos, cwd and the key tree all live here.
|
||||
home: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// The only directory the no-mutation assertions watch.
|
||||
fn tree(&self) -> PathBuf {
|
||||
self.home.path().join("keytree")
|
||||
}
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn new() -> Row {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700))
|
||||
.expect("chmod home");
|
||||
// Created here, before any row takes its `before` snapshot, so the
|
||||
// no-mutation assertions measure what the NODE did and not what this
|
||||
// harness set up.
|
||||
std::fs::create_dir_all(home.path().join("repos")).expect("repos dir");
|
||||
// The p2p key tree lives in its own subdirectory so the no-mutation
|
||||
// assertions can watch exactly it. HOME also holds `.gitlawb`, which
|
||||
// the node creates for its NODE identity on every start; measuring the
|
||||
// whole home would read that legitimate write as a p2p storage
|
||||
// mutation.
|
||||
let tree = home.path().join("keytree");
|
||||
std::fs::create_dir(&tree).expect("key tree");
|
||||
std::fs::set_permissions(&tree, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("chmod key tree");
|
||||
Row { home }
|
||||
}
|
||||
|
||||
fn spawn(&self, p2p_key: &str, p2p_port: &str, cwd: &Path) -> ChildGuard {
|
||||
self.spawn_env(p2p_key, p2p_port, cwd, &[])
|
||||
}
|
||||
|
||||
fn spawn_env(
|
||||
&self,
|
||||
p2p_key: &str,
|
||||
p2p_port: &str,
|
||||
cwd: &Path,
|
||||
extra: &[(&str, &str)],
|
||||
) -> ChildGuard {
|
||||
let repos = self.home.path().join("repos");
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gitlawb-node"));
|
||||
cmd.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
// The subscriber is built from the default env filter, so with a
|
||||
// cleared environment the node logs nothing at all and every
|
||||
// assertion here reads as a timeout with empty output. The rows
|
||||
// are about which log lines appear, so the filter is part of the
|
||||
// fixture, not incidental setup.
|
||||
.env("RUST_LOG", "info")
|
||||
.env("HOME", self.home.path())
|
||||
.env("GITLAWB_REPOS_DIR", &repos)
|
||||
.env("GITLAWB_HOST", "127.0.0.1")
|
||||
// Ephemeral: the real port is read back from the ready log.
|
||||
.env("GITLAWB_PORT", "0")
|
||||
// A closed port, so the node stays on the degraded path for the
|
||||
// whole row instead of ever reaching a database.
|
||||
.env("DATABASE_URL", "postgres://127.0.0.1:1/nonexistent")
|
||||
.env("GITLAWB_P2P_PORT", p2p_port)
|
||||
.env("GITLAWB_P2P_KEY", p2p_key)
|
||||
.env("GITLAWB_METRICS_ADDR", "")
|
||||
.current_dir(cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (k, v) in extra {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.spawn().map(ChildGuard).expect("spawn gitlawb-node")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the child's stderr until `needle` appears, the stream ends, or the
|
||||
/// deadline passes. Returns everything read.
|
||||
///
|
||||
/// The read runs on its own thread feeding a channel, and the deadline is
|
||||
/// enforced with `recv_timeout`. Checking elapsed time between blocking
|
||||
/// `read_line` calls does NOT work: a child that goes quiet blocks the read
|
||||
/// forever and the check never runs, so the deadline looks present and cannot
|
||||
/// fire. The first version of this file did that and hung for minutes on a row
|
||||
/// that was supposed to fail in fifteen seconds.
|
||||
fn read_until(child: &mut ChildGuard, needle: &str) -> (bool, String) {
|
||||
read_until_all(child, &[needle])
|
||||
}
|
||||
|
||||
/// Read until EVERY needle has appeared, the stream ends, or the deadline
|
||||
/// passes.
|
||||
///
|
||||
/// Waiting on a single line is a race here: the degraded server logs "ready"
|
||||
/// from a spawned task while the p2p gate logs its verdict from the main task,
|
||||
/// so whichever the reader stops at first can leave the other uncaptured. That
|
||||
/// made this file pass or fail depending on scheduling.
|
||||
fn read_until_all(child: &mut ChildGuard, needles: &[&str]) -> (bool, String) {
|
||||
let stdout = child.take_stdout();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut acc = String::new();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if needles.iter().all(|n| acc.contains(n)) {
|
||||
return (true, acc);
|
||||
}
|
||||
let left = match DEADLINE.checked_sub(start.elapsed()) {
|
||||
Some(d) if !d.is_zero() => d,
|
||||
_ => return (false, acc),
|
||||
};
|
||||
match rx.recv_timeout(left) {
|
||||
Ok(line) => acc.push_str(&line),
|
||||
// Sender gone: the stream ended. One last check, then give up.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return (needles.iter().all(|n| acc.contains(n)), acc)
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return (false, acc),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_exit(child: &mut ChildGuard) -> (std::process::ExitStatus, String) {
|
||||
// Both streams: the refusal reason is anyhow's stderr print, while
|
||||
// "binding HTTP listener" is a tracing line on stdout, and the fatal rows
|
||||
// assert on one of each.
|
||||
let mut out = String::new();
|
||||
let mut o = child.take_stdout();
|
||||
let mut e = child.take_stderr();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = o.read_to_string(&mut buf);
|
||||
buf
|
||||
});
|
||||
let _ = e.read_to_string(&mut out);
|
||||
let status = child.0.wait().expect("wait");
|
||||
out.push_str(&reader.join().unwrap_or_default());
|
||||
(status, out)
|
||||
}
|
||||
|
||||
/// Strip ANSI escapes: the subscriber colourises even when piped, which would
|
||||
/// otherwise sit between `addr=` and the value.
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
for c2 in chars.by_ref() {
|
||||
if c2.is_ascii_alphabetic() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A lexically invalid value must stop the process before the listener binds.
|
||||
fn assert_fatal_before_bind(row: &Row, key: &str, cwd: &Path) {
|
||||
let before = snapshot(&row.tree());
|
||||
let mut child = row.spawn(key, "7546", cwd);
|
||||
let (status, err) = wait_for_exit(&mut child);
|
||||
|
||||
assert!(
|
||||
!status.success(),
|
||||
"key={key:?} must exit non-zero\n--- stderr ---\n{err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("invalid configuration"),
|
||||
"key={key:?} must fail as invalid configuration\n--- stderr ---\n{err}"
|
||||
);
|
||||
assert!(
|
||||
!err.contains("binding HTTP listener"),
|
||||
"key={key:?} must be refused BEFORE the listener binds\n--- stderr ---\n{err}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&row.tree()),
|
||||
before,
|
||||
"key={key:?}: a configuration refusal must not touch the key tree"
|
||||
);
|
||||
}
|
||||
|
||||
/// A live storage fault must leave HTTP serving with p2p off, and must not
|
||||
/// mutate the tree on its way to that verdict.
|
||||
fn assert_degrades_with_http_up(row: &Row, key: &str, cwd: &Path, label: &str) {
|
||||
let before = snapshot(&row.tree());
|
||||
let mut child = row.spawn(key, "7546", cwd);
|
||||
let (found, log) = read_until_all(
|
||||
&mut child,
|
||||
&[
|
||||
"degraded HTTP server ready",
|
||||
"failed to load p2p identity key",
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
found,
|
||||
"{label}: the node must bind and serve while p2p is off\n--- stderr ---\n{log}"
|
||||
);
|
||||
// The operator docs promise a specific signal, so pin it here: the level,
|
||||
// the stable event name, and the message. A degrade that logs at warn or
|
||||
// renames the event silently breaks the alert those docs tell operators to
|
||||
// build, and asserting only the prose would not notice.
|
||||
let clean_log = strip_ansi(&log);
|
||||
let signal = clean_log
|
||||
.lines()
|
||||
.find(|l| l.contains("failed to load p2p identity key"))
|
||||
.unwrap_or_else(|| panic!("{label}: the p2p failure must be logged\n{clean_log}"));
|
||||
assert!(
|
||||
signal.contains("ERROR"),
|
||||
"{label}: the p2p failure must be logged at error level, got: {signal}"
|
||||
);
|
||||
assert!(
|
||||
signal.contains("p2p_identity_key_load_failed"),
|
||||
"{label}: the p2p failure must carry the documented event name, got: {signal}"
|
||||
);
|
||||
|
||||
let clean = strip_ansi(&log);
|
||||
let addr = clean
|
||||
.lines()
|
||||
.find(|l| l.contains("degraded HTTP server ready"))
|
||||
.and_then(|l| l.rsplit("addr=").next())
|
||||
.map(|a| {
|
||||
a.trim()
|
||||
.trim_start_matches("Some(")
|
||||
.trim_matches(|c| c == '"' || c == ')')
|
||||
})
|
||||
.and_then(|a| a.parse::<std::net::SocketAddr>().ok())
|
||||
.unwrap_or_else(|| panic!("{label}: could not parse the bound address from {clean}"));
|
||||
|
||||
// A served response, any status: the degraded server answers 503, which is
|
||||
// still proof the port is up and answering.
|
||||
let mut sock = std::net::TcpStream::connect(addr).expect("connect to the degraded server");
|
||||
sock.set_read_timeout(Some(DEADLINE)).unwrap();
|
||||
sock.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
||||
.expect("send request");
|
||||
let mut resp = String::new();
|
||||
let _ = sock.read_to_string(&mut resp);
|
||||
assert!(
|
||||
resp.starts_with("HTTP/1.1 "),
|
||||
"{label}: the degraded server must answer, got {resp:?}"
|
||||
);
|
||||
|
||||
drop(child);
|
||||
assert_eq!(
|
||||
snapshot(&row.tree()),
|
||||
before,
|
||||
"{label}: a storage refusal must not mutate the key tree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexically_invalid_key_paths_stop_the_node_before_it_binds() {
|
||||
for key in [
|
||||
"p2p.key",
|
||||
"./p2p.key",
|
||||
"a/../p2p.key",
|
||||
"../p2p.key",
|
||||
"/p2p.key",
|
||||
"keys/",
|
||||
"~/",
|
||||
"~//etc/p2p.key",
|
||||
"~/../x/p2p.key",
|
||||
] {
|
||||
let row = Row::new();
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_fatal_before_bind(&row, key, &cwd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_storage_faults_leave_http_up_with_p2p_off() {
|
||||
// Symlinked final parent.
|
||||
{
|
||||
let row = Row::new();
|
||||
let real = row.tree().join("real");
|
||||
std::fs::create_dir(&real).unwrap();
|
||||
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let link = row.tree().join("keys");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
let key = link.join("p2p.key");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "symlinked parent");
|
||||
}
|
||||
|
||||
// Regular file where the key directory should be.
|
||||
{
|
||||
let row = Row::new();
|
||||
let file = row.tree().join("keys");
|
||||
std::fs::write(&file, b"not a directory").unwrap();
|
||||
let key = file.join("p2p.key");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "regular-file parent");
|
||||
}
|
||||
|
||||
// Unreadable parent: an inspection error, the purest instance of a live
|
||||
// fault that used to be reported as invalid configuration.
|
||||
{
|
||||
let row = Row::new();
|
||||
let parent = row.tree().join("locked");
|
||||
std::fs::create_dir(&parent).unwrap();
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let key = parent.join("keys").join("p2p.key");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "unreadable parent");
|
||||
// Restore so the tempdir can be cleaned up.
|
||||
let _ = std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
// World-writable non-sticky ancestor: already the degrade class today, kept
|
||||
// so the row set covers both sides of the policy.
|
||||
{
|
||||
let row = Row::new();
|
||||
let anc = row.tree().join("open");
|
||||
std::fs::create_dir(&anc).unwrap();
|
||||
std::fs::set_permissions(&anc, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let key = anc.join("keys").join("p2p.key");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_degrades_with_http_up(&row, key.to_str().unwrap(), &cwd, "world-writable ancestor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_zero_does_no_key_storage_io_at_all() {
|
||||
// A lexically invalid path that would be fatal with p2p enabled.
|
||||
{
|
||||
let row = Row::new();
|
||||
let before = snapshot(&row.tree());
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
let mut child = row.spawn("p2p.key", "0", &cwd);
|
||||
let (found, log) = read_until(&mut child, "p2p disabled");
|
||||
assert!(
|
||||
found,
|
||||
"port zero must bypass key handling entirely\n--- stderr ---\n{log}"
|
||||
);
|
||||
drop(child);
|
||||
assert_eq!(
|
||||
snapshot(&row.tree()),
|
||||
before,
|
||||
"port zero must not touch the key tree"
|
||||
);
|
||||
}
|
||||
|
||||
// A hostile tree that would degrade with p2p enabled.
|
||||
{
|
||||
let row = Row::new();
|
||||
let real = row.tree().join("real");
|
||||
std::fs::create_dir(&real).unwrap();
|
||||
let link = row.tree().join("keys");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
let before = snapshot(&row.tree());
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
let key: PathBuf = link.join("p2p.key");
|
||||
let mut child = row.spawn(key.to_str().unwrap(), "0", &cwd);
|
||||
let (found, log) = read_until(&mut child, "p2p disabled");
|
||||
assert!(
|
||||
found,
|
||||
"port zero must bypass a hostile tree too\n--- stderr ---\n{log}"
|
||||
);
|
||||
drop(child);
|
||||
assert_eq!(
|
||||
snapshot(&row.tree()),
|
||||
before,
|
||||
"port zero must not touch a hostile key tree"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal `.` is a directory-valued spelling. Path drops it, so the load
|
||||
/// path would otherwise chmod the parent and publish a file named `keys`.
|
||||
#[test]
|
||||
fn terminal_dot_key_path_is_fatal_and_does_not_mutate() {
|
||||
// Absent leaf: `/data/keys/.` must not create `keys` as a file or chmod `/data`.
|
||||
{
|
||||
let row = Row::new();
|
||||
let data = row.tree().join("data");
|
||||
std::fs::create_dir(&data).unwrap();
|
||||
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let key = format!("{}/keys/.", data.display());
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_fatal_before_bind(&row, &key, &cwd);
|
||||
let mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(mode, 0o755, "rejection must not chmod the parent");
|
||||
assert!(
|
||||
!data.join("keys").exists(),
|
||||
"rejection must not create a file named keys"
|
||||
);
|
||||
}
|
||||
|
||||
// Existing directory at the would-be leaf: still boot-fatal, directory untouched.
|
||||
{
|
||||
let row = Row::new();
|
||||
let data = row.tree().join("data");
|
||||
let keys = data.join("keys");
|
||||
std::fs::create_dir_all(&keys).unwrap();
|
||||
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let key = format!("{}/.", keys.display());
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
assert_fatal_before_bind(&row, &key, &cwd);
|
||||
assert!(
|
||||
keys.is_dir(),
|
||||
"the existing keys directory must remain a directory"
|
||||
);
|
||||
let data_mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777;
|
||||
let keys_mode = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(data_mode, 0o755, "rejection must not chmod the parent");
|
||||
assert_eq!(
|
||||
keys_mode, 0o700,
|
||||
"rejection must not chmod the existing keys dir"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GITLAWB_KEY has the same Path-retarget hole as GITLAWB_P2P_KEY: a terminal
|
||||
/// `.` would publish a file named `keys` under `data`. Fatal before bind, and
|
||||
/// the parent tree is unchanged. p2p is off so it cannot chmod a shared dir.
|
||||
#[test]
|
||||
fn identity_terminal_dot_is_fatal_and_does_not_mutate() {
|
||||
let row = Row::new();
|
||||
let data = row.home.path().join("data");
|
||||
std::fs::create_dir(&data).unwrap();
|
||||
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let key = format!("{}/keys/.", data.display());
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
let mut child = row.spawn_env("p2p.key", "0", &cwd, &[("GITLAWB_KEY", key.as_str())]);
|
||||
let (status, err) = wait_for_exit(&mut child);
|
||||
assert!(
|
||||
!status.success(),
|
||||
"GITLAWB_KEY={key:?} must exit non-zero\n--- stderr ---\n{err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("invalid configuration"),
|
||||
"GITLAWB_KEY={key:?} must fail as invalid configuration\n--- stderr ---\n{err}"
|
||||
);
|
||||
assert!(
|
||||
!err.contains("binding HTTP listener"),
|
||||
"GITLAWB_KEY={key:?} must be refused BEFORE the listener binds\n--- stderr ---\n{err}"
|
||||
);
|
||||
let mode = std::fs::metadata(&data).unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(mode, 0o755, "rejection must not chmod the parent");
|
||||
assert!(
|
||||
!data.join("keys").exists(),
|
||||
"rejection must not create a file named keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// An existing 0755 identity parent is usable and must not be chmodded. p2p is
|
||||
/// off so a default `~/.gitlawb/p2p.key` cannot tighten the same directory.
|
||||
#[test]
|
||||
fn identity_existing_0755_parent_is_not_chmodded_on_boot() {
|
||||
let row = Row::new();
|
||||
let iddir = row.home.path().join("idparent");
|
||||
std::fs::create_dir(&iddir).unwrap();
|
||||
std::fs::set_permissions(&iddir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let key = iddir.join("identity.pem");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
let mut child = row.spawn_env(
|
||||
"p2p.key",
|
||||
"0",
|
||||
&cwd,
|
||||
&[("GITLAWB_KEY", key.to_str().unwrap())],
|
||||
);
|
||||
let (found, log) = read_until(&mut child, "degraded HTTP server ready");
|
||||
assert!(
|
||||
found,
|
||||
"a 0755 identity parent must still boot\n--- stderr ---\n{log}"
|
||||
);
|
||||
drop(child);
|
||||
let mode = std::fs::metadata(&iddir).unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(
|
||||
mode, 0o755,
|
||||
"boot must not chmod the existing identity parent"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&key).unwrap().permissions().mode() & 0o7777,
|
||||
0o600,
|
||||
"the identity key must be owner-only"
|
||||
);
|
||||
}
|
||||
|
||||
/// Creating into a world-writable identity parent is refused before bind, and
|
||||
/// the directory is left untouched.
|
||||
#[test]
|
||||
fn identity_writable_parent_is_fatal_and_unchanged() {
|
||||
let row = Row::new();
|
||||
let iddir = row.home.path().join("idparent");
|
||||
std::fs::create_dir(&iddir).unwrap();
|
||||
std::fs::set_permissions(&iddir, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let key = iddir.join("identity.pem");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
let mut child = row.spawn_env(
|
||||
"p2p.key",
|
||||
"0",
|
||||
&cwd,
|
||||
&[("GITLAWB_KEY", key.to_str().unwrap())],
|
||||
);
|
||||
let (status, err) = wait_for_exit(&mut child);
|
||||
assert!(
|
||||
!status.success(),
|
||||
"a writable identity parent must exit non-zero\n--- stderr ---\n{err}"
|
||||
);
|
||||
assert!(
|
||||
!err.contains("binding HTTP listener"),
|
||||
"a writable identity parent must be refused BEFORE the listener binds\n--- stderr ---\n{err}"
|
||||
);
|
||||
let mode = std::fs::metadata(&iddir).unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(mode, 0o777, "refusal must not chmod the parent");
|
||||
assert!(!key.exists(), "refusal must not publish the identity key");
|
||||
}
|
||||
|
||||
/// A 2700 (setgid, owner rwx) p2p key directory is repairable: boot, tighten
|
||||
/// to 0700, HTTP still comes up.
|
||||
#[test]
|
||||
fn p2p_setgid_key_directory_is_tightened_and_http_comes_up() {
|
||||
let row = Row::new();
|
||||
let keys = row.tree().join("keys");
|
||||
std::fs::create_dir(&keys).unwrap();
|
||||
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o2700)).unwrap();
|
||||
let key = keys.join("p2p.key");
|
||||
let cwd = row.home.path().to_path_buf();
|
||||
let mut child = row.spawn(key.to_str().unwrap(), "7546", &cwd);
|
||||
let (found, log) = read_until_all(
|
||||
&mut child,
|
||||
&["degraded HTTP server ready", "generated new p2p identity"],
|
||||
);
|
||||
assert!(
|
||||
found,
|
||||
"a 2700 key directory must boot and generate a key\n--- stderr ---\n{log}"
|
||||
);
|
||||
drop(child);
|
||||
let mode = std::fs::metadata(&keys).unwrap().permissions().mode() & 0o7777;
|
||||
assert_eq!(
|
||||
mode, 0o700,
|
||||
"2700 must be normalized to 0700, found {mode:04o}"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The Twigpine CLI — identity management, node control, MCP server"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -11,7 +11,7 @@ name = "gl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
icaptcha-client = { path = "../icaptcha-client" }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -42,4 +42,3 @@ alloy = { version = "1", default-features = false, features = [
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
+27
-26
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -16,11 +17,7 @@ pub struct AgentArgs {
|
||||
pub enum AgentCmd {
|
||||
/// List agents registered on a node
|
||||
List {
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Filter by capability (e.g. git:push)
|
||||
#[arg(long)]
|
||||
@@ -32,11 +29,7 @@ pub enum AgentCmd {
|
||||
Show {
|
||||
/// Agent DID or short key segment
|
||||
did: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
}
|
||||
@@ -64,16 +57,24 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
|
||||
.get(&path)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
// Keep the bespoke 404 hint (older node without the agents API) ahead of
|
||||
// read_json; every other status routes through it (capped + sanitized error).
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
anyhow::bail!(
|
||||
"this node does not yet support the agents API (v0.3+)\n\
|
||||
upgrade the node or check GITLAWB_NODE is pointing to the right server"
|
||||
);
|
||||
}
|
||||
|
||||
let body = crate::http::read_json(resp, "list agents").await?;
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list agents failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let agents = body["agents"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -116,15 +117,24 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
|
||||
.get(&format!("/api/v1/agents/{did}"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
// Keep the bespoke 404 hint ahead of read_json; other statuses route through it.
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
anyhow::bail!(
|
||||
"agent not found, or this node does not yet support the agents API (v0.3+)\n\
|
||||
upgrade the node or check GITLAWB_NODE is pointing to the right server"
|
||||
);
|
||||
}
|
||||
|
||||
let agent = crate::http::read_json(resp, "agent").await?;
|
||||
let agent: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = agent["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("agent not found ({status}): {msg}");
|
||||
}
|
||||
|
||||
let full_did = agent["did"].as_str().unwrap_or("?");
|
||||
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
|
||||
@@ -205,14 +215,11 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), None).await.unwrap_err();
|
||||
assert!(err.to_string().contains("agents API"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -223,14 +230,11 @@ mod tests {
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"internal error"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), None).await.unwrap_err();
|
||||
assert!(err.to_string().contains("list agents failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -257,7 +261,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"agent not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -265,8 +268,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("agents API") || err.to_string().contains("not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+58
-144
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -38,11 +39,7 @@ pub enum BountyCmd {
|
||||
/// Deadline in seconds (default 7 days)
|
||||
#[arg(long)]
|
||||
deadline: Option<i64>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -54,11 +51,7 @@ pub enum BountyCmd {
|
||||
/// Filter by status: open, claimed, submitted, completed
|
||||
#[arg(long)]
|
||||
status: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -67,11 +60,7 @@ pub enum BountyCmd {
|
||||
Show {
|
||||
/// Bounty ID
|
||||
id: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -83,11 +72,7 @@ pub enum BountyCmd {
|
||||
/// Your wallet address for payout
|
||||
#[arg(long)]
|
||||
wallet: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -99,11 +84,7 @@ pub enum BountyCmd {
|
||||
/// Pull request ID
|
||||
#[arg(long)]
|
||||
pr: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -115,11 +96,7 @@ pub enum BountyCmd {
|
||||
/// On-chain payout tx hash
|
||||
#[arg(long)]
|
||||
tx_hash: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -128,22 +105,14 @@ pub enum BountyCmd {
|
||||
Cancel {
|
||||
/// Bounty ID
|
||||
id: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show bounty stats and leaderboard
|
||||
Stats {
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
}
|
||||
@@ -220,7 +189,13 @@ async fn cmd_create(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let body = crate::http::read_json(resp, "create bounty").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create bounty failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let id = body["id"].as_str().unwrap_or("?");
|
||||
println!("Bounty created: {id}");
|
||||
@@ -256,14 +231,11 @@ async fn cmd_list(
|
||||
u
|
||||
};
|
||||
|
||||
let body = crate::http::read_json(
|
||||
client
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?,
|
||||
"bounties",
|
||||
)
|
||||
.await?;
|
||||
let resp = client
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
let bounties = body["bounties"].as_array();
|
||||
if let Some(arr) = bounties {
|
||||
@@ -287,7 +259,13 @@ async fn cmd_show(id: String, node: String, dir: Option<PathBuf>) -> Result<()>
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let resp = client.get_authed(&format!("/api/v1/bounties/{id}")).await?;
|
||||
|
||||
let body = crate::http::read_json(resp, "show bounty").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("not found");
|
||||
anyhow::bail!("bounty not found: {msg}");
|
||||
}
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&body)?);
|
||||
Ok(())
|
||||
@@ -311,7 +289,13 @@ async fn cmd_claim(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = crate::http::read_json(resp, "claim").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("claim failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
println!("Bounty {id} claimed. Deadline starts now.");
|
||||
Ok(())
|
||||
@@ -330,7 +314,13 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = crate::http::read_json(resp, "submit").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("submit failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
println!("Bounty {id} submitted with PR {pr}. Awaiting creator approval.");
|
||||
Ok(())
|
||||
@@ -354,7 +344,13 @@ async fn cmd_approve(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = crate::http::read_json(resp, "approve").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("approve failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
println!("Bounty {id} approved! Payout released to agent.");
|
||||
Ok(())
|
||||
@@ -373,7 +369,13 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = crate::http::read_json(resp, "cancel").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("cancel failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
println!("Bounty {id} cancelled. Tokens refunded.");
|
||||
Ok(())
|
||||
@@ -381,11 +383,8 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
async fn cmd_stats(node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
// Route through read_json so a node denial (403/404/5xx, incl. a non-JSON
|
||||
// body) surfaces as an error instead of a fake `Bounty Stats` with every
|
||||
// counter zeroed (#186, the denial-as-success class #123 fixes elsewhere).
|
||||
let body =
|
||||
crate::http::read_json(client.get("/api/v1/bounties/stats").await?, "bounty stats").await?;
|
||||
let resp = client.get("/api/v1/bounties/stats").await?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
let open = body["open"].as_i64().unwrap_or(0);
|
||||
let claimed = body["claimed"].as_i64().unwrap_or(0);
|
||||
@@ -522,7 +521,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -530,8 +528,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -548,86 +544,4 @@ mod tests {
|
||||
|
||||
cmd_stats(server.url()).await.unwrap();
|
||||
}
|
||||
|
||||
/// #186 (F1): a node denial on the stats endpoint must surface as an error,
|
||||
/// not print a fake `Bounty Stats` with every counter zeroed. RED before the
|
||||
/// fix (unwrap_or_default + no status check -> Ok with zeros), GREEN after
|
||||
/// (read_json bails on the non-2xx).
|
||||
#[tokio::test]
|
||||
async fn cmd_stats_surfaces_denial_not_fake_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/stats$".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_stats(server.url())
|
||||
.await
|
||||
.expect_err("a 403 must be an error, not a zeroed fake result");
|
||||
assert!(err.to_string().contains("403"), "err={err}");
|
||||
// Pin the mock hit explicitly. The status assertion above already fails
|
||||
// when the route does not match (mockito answers an unmatched route with
|
||||
// 501, so "403" is absent), but that proof is incidental to mockito's
|
||||
// fallback status; assert the hit directly so it survives a reworded
|
||||
// assertion.
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #186 (F1): the same denial-as-error behavior across the non-2xx response
|
||||
/// classes, not just 403 — a 404 and a 500 (incl. a non-JSON body) must each
|
||||
/// surface as an error with the status, never a zeroed fake result.
|
||||
#[tokio::test]
|
||||
async fn cmd_stats_surfaces_denial_across_status_classes() {
|
||||
for (status, body, json) in [
|
||||
(404, r#"{"message":"not found"}"#, true),
|
||||
(500, "internal boom", false),
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mut m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/stats$".to_string()))
|
||||
.with_status(status)
|
||||
.with_body(body);
|
||||
if json {
|
||||
m = m.with_header("content-type", "application/json");
|
||||
}
|
||||
let m = m.expect(1).create_async().await;
|
||||
|
||||
let err = cmd_stats(server.url()).await.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains(&status.to_string()),
|
||||
"status {status} must surface as an error, got: {err}"
|
||||
);
|
||||
m.assert_async().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_repo_scoped_surfaces_denial_not_empty() {
|
||||
// `gl bounty list --repo owner/name` hits the gated /repos/{o}/{n}/bounties;
|
||||
// a 404 must Err, not silently print nothing.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/repos/alice/secret/bounties".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list(Some("alice/secret".to_string()), None, server.url(), None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"bounty list --repo must Err on a gated 404"
|
||||
);
|
||||
// Prove the gated repo-scoped path was actually requested: without this,
|
||||
// an unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+50
-623
@@ -4,6 +4,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -25,11 +26,7 @@ pub enum CertCmd {
|
||||
List {
|
||||
/// Repository in <owner>/<repo> or <repo> format
|
||||
repo: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -40,11 +37,7 @@ pub enum CertCmd {
|
||||
repo: String,
|
||||
/// Certificate ID
|
||||
id: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -89,7 +82,12 @@ async fn resolve_repo(
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = signed_client(node, dir);
|
||||
let info = crate::http::read_json(client.get_authed("/").await?, "node info").await?;
|
||||
let info: Value = client
|
||||
.get_authed("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let did = info["did"].as_str().context("node info missing 'did'")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -102,7 +100,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs");
|
||||
let resp = crate::http::read_json(client.get_authed(&path).await?, "certificates").await?;
|
||||
let resp: Value = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list certificates")?;
|
||||
|
||||
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -136,10 +139,14 @@ async fn cmd_show(
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let id = resolve_cert_id(&client, &owner, &name, &id).await?;
|
||||
|
||||
// Fetch the certificate. read_json checks status first and surfaces the node's
|
||||
// capped+sanitized message on a non-2xx (a bounded error read, not the whole body).
|
||||
// Fetch the certificate
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs/{id}");
|
||||
let cert = crate::http::read_json(client.get_authed(&path).await?, "certificate").await?;
|
||||
let resp = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("certificate not found")?;
|
||||
let cert: Value = resp.json().await.context("certificate not found")?;
|
||||
|
||||
let cert_id = cert["id"].as_str().unwrap_or("?");
|
||||
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
|
||||
@@ -184,20 +191,27 @@ async fn cmd_show(
|
||||
|
||||
// Contextual only — the verdict above stands on its own, so a node-info
|
||||
// hiccup here must not turn a successfully displayed certificate into an
|
||||
// error exit. Route the lookup through read_json so a denial or a capped
|
||||
// error body yields a reportable reason instead of an opaque None, and keep
|
||||
// "carried no DID" distinct from "the lookup failed": an empty DID must not
|
||||
// reach the comparison and fabricate a mismatch warning.
|
||||
let current: std::result::Result<String, String> = match client.get("/").await {
|
||||
Ok(resp) => match crate::http::read_json(resp, "node info").await {
|
||||
Ok(info) => did_from_node_info(&info),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
// error exit.
|
||||
let current_node_did = match client.get("/").await {
|
||||
Ok(resp) => resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info["did"].as_str().map(str::to_string)),
|
||||
Err(_) => None,
|
||||
};
|
||||
let current_node_did = current.as_ref().ok().cloned();
|
||||
for line in did_check_report(¤t, node_did) {
|
||||
println!("{line}");
|
||||
match current_node_did.as_deref() {
|
||||
Some(current) if current == node_did => {
|
||||
println!(" Issuing node DID matches the node being queried.");
|
||||
}
|
||||
Some(current) => {
|
||||
println!(" WARNING: Certificate node DID ({node_did}) does not match");
|
||||
println!(" current node DID ({current}).");
|
||||
println!(" This certificate was issued by a different node.");
|
||||
}
|
||||
None => {
|
||||
println!(" NOTE: could not fetch current node info — skipping node-DID comparison.");
|
||||
}
|
||||
}
|
||||
|
||||
if require_valid {
|
||||
@@ -225,46 +239,6 @@ async fn cmd_show(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull the node's DID out of a `GET /` body for the comparison in `cmd_show`.
|
||||
///
|
||||
/// A missing OR empty `did` is a failure, not a value: letting `""` through
|
||||
/// would reach the comparison and print a mismatch WARNING against a DID the
|
||||
/// node never claimed, which reads as "issued by a different node" when the
|
||||
/// truth is that the lookup told us nothing.
|
||||
fn did_from_node_info(info: &serde_json::Value) -> std::result::Result<String, String> {
|
||||
match info["did"].as_str() {
|
||||
Some(did) if !did.is_empty() => Ok(did.to_string()),
|
||||
_ => Err("node info response carried no DID".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the report lines for the node-DID comparison in `cmd_show`.
|
||||
///
|
||||
/// `current` is the current node's DID, or the reason it could not be
|
||||
/// determined. A comparison verdict (match or WARNING) is only produced when a
|
||||
/// real DID was obtained; otherwise the report degrades to a could-not-compare
|
||||
/// hint naming the reason, plus the offline-verification guidance. The signature
|
||||
/// verdict printed above stands on its own either way — this block only answers
|
||||
/// *which* node issued the certificate.
|
||||
fn did_check_report(current: &std::result::Result<String, String>, node_did: &str) -> Vec<String> {
|
||||
match current {
|
||||
Ok(current) if current == node_did => {
|
||||
vec![" Issuing node DID matches the node being queried.".to_string()]
|
||||
}
|
||||
Ok(current) => vec![
|
||||
format!(" WARNING: Certificate node DID ({node_did}) does not match"),
|
||||
format!(" current node DID ({current})."),
|
||||
" This certificate was issued by a different node.".to_string(),
|
||||
],
|
||||
Err(reason) => vec![
|
||||
format!(" Could not fetch the current node's DID ({reason}), so the comparison"),
|
||||
" with the certificate's node DID is unavailable.".to_string(),
|
||||
" To verify offline, use the node's Ed25519 public key derived from:".to_string(),
|
||||
format!(" did:key → {node_did}"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the node's canonical signing payload (field order must match
|
||||
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
|
||||
/// certificate's Ed25519 signature against the key embedded in `node_did`.
|
||||
@@ -317,7 +291,14 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
|
||||
}
|
||||
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs?prefix={id}");
|
||||
let resp = crate::http::read_json(client.get_authed(&path).await?, "certificates").await?;
|
||||
let resp: Value = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("failed to list certificates")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list certificates")?;
|
||||
|
||||
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
|
||||
let matches: Vec<String> = certs
|
||||
@@ -416,558 +397,4 @@ mod tests {
|
||||
);
|
||||
assert!(garbage.is_err(), "malformed signature must not verify");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
// A gated 404 on the repo-scoped certs read must Err, not print "No certificates".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/certs$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
|
||||
assert!(result.is_err(), "cert list must Err on a gated 404");
|
||||
// Prove the gated certs path was actually requested: without this, an
|
||||
// unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_repo_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the GET / node-info
|
||||
// fetch. A gated 404 there must Err (surfacing the status), proving the
|
||||
// read_json conversion is load-bearing rather than silently ignored.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = resolve_repo("noslash", &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// The `GET /` node-info lookup after the cert loads is a fail-soft diagnostic:
|
||||
// a response-level failure degrades to a could-not-compare hint and the command
|
||||
// completes Ok, never a fabricated mismatch warning and never a fatal Err. The
|
||||
// cert fetch itself stays fail-closed. A >=36-char id skips resolve_cert_id so
|
||||
// only two mocks are needed.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_completes_with_degraded_hint_when_node_info_denied() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must complete despite a denied node-info lookup"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_degrades_on_malformed_node_info() {
|
||||
// A 2xx node-info body that fails to parse degrades the same way a denial
|
||||
// does: hint printed, command completes Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_body("not json")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must complete despite malformed node info"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_degrades_when_node_info_lacks_did() {
|
||||
// A 2xx node-info body with no `did` routes to the degraded hint, not a
|
||||
// fabricated empty-DID mismatch warning; the command completes Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("{}")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must complete when node info lacks a DID"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
// Must-not case: the certificate fetch itself stays fail-closed. A gated 404
|
||||
// aborts the command with the status surfaced, and the node-info lookup is
|
||||
// never reached (the expect(0) assert proves it never ran).
|
||||
#[tokio::test]
|
||||
async fn cmd_show_surfaces_denied_certificate() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"n"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_reports_matching_node_did() {
|
||||
// Pins the unchanged success path: node info fetched, DIDs compared, Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"n"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "cert show must report a matching node DID");
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_warns_on_mismatching_node_did() {
|
||||
// A real, differing node DID drives the WARNING branch end to end; the
|
||||
// command still completes Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:other"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must warn, not fail, on a mismatching node DID"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
// ── --verify issuer anchoring ────────────────────────────────────────────
|
||||
//
|
||||
// Every other cmd_show test passes require_valid=false, so the whole
|
||||
// `if require_valid` block went unexecuted. It is the security-bearing half of
|
||||
// the command: a valid signature only proves the certificate is internally
|
||||
// consistent (a hostile node can mint a keypair, name it in node_did, and
|
||||
// self-sign), so --verify must additionally anchor the issuer to a DID the
|
||||
// caller trusts. These drive all four outcomes plus the must-not case.
|
||||
//
|
||||
// The certificate must carry a REAL signature over the canonical payload:
|
||||
// with a bogus one, --verify bails on the signature and never reaches the
|
||||
// anchoring, which would make every assertion below vacuous.
|
||||
fn signed_cert(node_kp: &gitlawb_core::identity::Keypair) -> (String, String) {
|
||||
let node_did = node_kp.did().as_str().to_string();
|
||||
let id = "a".repeat(36); // >= 36 chars skips resolve_cert_id's prefix lookup
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "b".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
let body = serde_json::json!({
|
||||
"id": id,
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "b".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
})
|
||||
.to_string();
|
||||
(id, body)
|
||||
}
|
||||
|
||||
/// Mount the cert fetch, plus a `GET /` answering with `node_info` (a JSON
|
||||
/// body) or a denial when `None`.
|
||||
async fn cert_server(
|
||||
server: &mut mockito::Server,
|
||||
id: &str,
|
||||
cert_body: &str,
|
||||
node_info: Option<&str>,
|
||||
) -> (mockito::Mock, mockito::Mock) {
|
||||
let cert = server
|
||||
.mock("GET", format!("/api/v1/repos/alice/r/certs/{id}").as_str())
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let root = match node_info {
|
||||
Some(b) => {
|
||||
server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(b)
|
||||
.create_async()
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
server
|
||||
.mock("GET", "/")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.create_async()
|
||||
.await
|
||||
}
|
||||
};
|
||||
(cert, root)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_ok_when_issuing_node_is_the_queried_node() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let info = format!(r#"{{"did":"{}"}}"#, kp.did().as_str());
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let got = cmd_show("alice/r".to_string(), id, server.url(), None, true, None).await;
|
||||
assert!(got.is_ok(), "valid sig + matching issuer must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_bails_when_issuer_is_a_different_node() {
|
||||
// The self-signing-hostile-node case: the signature verifies against the
|
||||
// key the cert names, but that key is not the node we queried.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let other = gitlawb_core::identity::Keypair::generate();
|
||||
let info = format!(r#"{{"did":"{}"}}"#, other.did().as_str());
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("expected issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_bails_when_node_info_denied_and_no_expect_node() {
|
||||
// Fail closed: with no trusted DID to anchor against, --verify must NOT
|
||||
// fall through to a pass just because the signature checked out.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, None).await;
|
||||
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("cannot anchor the issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_ok_when_expect_node_anchors_a_denied_lookup() {
|
||||
// --expect-node supplies the trust anchor the denied lookup could not, so
|
||||
// the same denial that fails the case above now passes.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, None).await;
|
||||
|
||||
let got = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(kp.did().as_str().to_string()),
|
||||
)
|
||||
.await;
|
||||
assert!(got.is_ok(), "explicit anchor must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_bails_on_a_bad_signature_before_anchoring() {
|
||||
// The must-not: a forged certificate naming the queried node must fail on
|
||||
// the signature, even though its issuer would otherwise anchor cleanly.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let forged = body.replace(&"b".repeat(40), &"c".repeat(40));
|
||||
let info = format!(r#"{{"did":"{}"}}"#, kp.did().as_str());
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &forged, Some(&info)).await;
|
||||
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("signature did not verify"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
// The must-not case for the DID extraction: an empty or missing `did` has to
|
||||
// become a could-not-compare reason. If it leaks through as a value, the
|
||||
// comparison below fabricates a mismatch WARNING against a DID the node
|
||||
// never claimed.
|
||||
#[test]
|
||||
fn did_from_node_info_rejects_empty_and_missing() {
|
||||
assert!(did_from_node_info(&serde_json::json!({"did": "n"})).is_ok());
|
||||
for body in [
|
||||
serde_json::json!({"did": ""}),
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"did": 7}),
|
||||
] {
|
||||
let got = did_from_node_info(&body);
|
||||
assert!(
|
||||
got.is_err(),
|
||||
"must not yield a comparable DID: {body} -> {got:?}"
|
||||
);
|
||||
}
|
||||
// And the reason it produces must route to the degraded hint, never a verdict.
|
||||
let report =
|
||||
did_check_report(&did_from_node_info(&serde_json::json!({"did": ""})), "n").join("\n");
|
||||
assert!(
|
||||
!report.contains("WARNING"),
|
||||
"fabricated a mismatch: {report}"
|
||||
);
|
||||
assert!(report.contains("Could not fetch"), "got: {report}");
|
||||
}
|
||||
|
||||
// did_check_report is the three-way selector between the match text, the
|
||||
// mismatch WARNING, and the degraded could-not-compare hint. Substring
|
||||
// asserts (not full-line equality) so cosmetic wording edits don't break them.
|
||||
|
||||
#[test]
|
||||
fn did_check_report_match() {
|
||||
let report = did_check_report(&Ok("n".to_string()), "n").join("\n");
|
||||
assert!(
|
||||
report.contains("matches the node being queried"),
|
||||
"got: {report}"
|
||||
);
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
assert!(!report.contains("Could not fetch"), "got: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_check_report_mismatch() {
|
||||
let report = did_check_report(&Ok("did:key:other".to_string()), "n").join("\n");
|
||||
assert!(report.contains("WARNING"), "got: {report}");
|
||||
assert!(report.contains("does not match"), "got: {report}");
|
||||
assert!(report.contains("did:key:other"), "got: {report}");
|
||||
assert!(!report.contains("Could not fetch"), "got: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_check_report_missing_did_reason() {
|
||||
let report =
|
||||
did_check_report(&Err("node info response carried no DID".to_string()), "n").join("\n");
|
||||
assert!(report.contains("Could not fetch"), "got: {report}");
|
||||
assert!(
|
||||
report.contains("node info response carried no DID"),
|
||||
"got: {report}"
|
||||
);
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
// The must-not case: no real DID was obtained, so no comparison verdict
|
||||
// may be claimed in either direction.
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
assert!(
|
||||
!report.contains("matches the node being queried"),
|
||||
"got: {report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_check_report_lookup_error_reason() {
|
||||
let report =
|
||||
did_check_report(&Err("node info failed (403): denied".to_string()), "n").join("\n");
|
||||
assert!(report.contains("Could not fetch"), "got: {report}");
|
||||
assert!(report.contains("403"), "got: {report}");
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
}
|
||||
}
|
||||
+16
-38
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -14,11 +15,7 @@ pub struct ChangelogArgs {
|
||||
/// Maximum number of events to show
|
||||
#[arg(long, default_value = "20")]
|
||||
pub limit: usize,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
@@ -34,7 +31,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
};
|
||||
|
||||
if repo.is_empty() {
|
||||
anyhow::bail!("no repo specified — pass <repo> or run from inside a Twigpine repo");
|
||||
anyhow::bail!("no repo specified — pass <repo> or run from inside a gitlawb repo");
|
||||
}
|
||||
|
||||
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
|
||||
@@ -45,7 +42,12 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let did = info["did"].as_str().context("node missing DID")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -62,7 +64,13 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let body = crate::http::read_json(resp, "changelog").await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("changelog failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let events = body["events"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -204,7 +212,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -216,8 +223,6 @@ mod tests {
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(err.to_string().contains("changelog failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -265,31 +270,4 @@ mod tests {
|
||||
let err = rt.block_on(run(args)).unwrap_err();
|
||||
assert!(err.to_string().contains("no repo specified"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_via_run_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the inline GET /
|
||||
// node-info fetch during resolution. A gated 404 there must Err (surfacing
|
||||
// the status), proving the read_json conversion is load-bearing.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = run(ChangelogArgs {
|
||||
repo: Some("noslash".to_string()),
|
||||
limit: 20,
|
||||
node: server.url(),
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+11
-266
@@ -1,4 +1,4 @@
|
||||
//! `gl clone`: clean partial clone of a Twigpine repo with private subtrees.
|
||||
//! `gl clone`: clean partial clone of a gitlawb repo with private subtrees.
|
||||
//!
|
||||
//! A repo may withhold blob content under some path globs from the caller
|
||||
//! (Phase 3). The resulting pack is not closed under reachability, so a stock
|
||||
@@ -12,58 +12,9 @@ use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
/// Report why one candidate blob could not be recovered, in the register of the
|
||||
/// recovery loop's other warnings.
|
||||
///
|
||||
/// Both halves are gateway-supplied text on their way to a terminal: the oid comes
|
||||
/// out of a manifest fetched from a caller-chosen Arweave gateway, and a transport
|
||||
/// error carries the URL the same manifest chose. So the whole line is defanged and
|
||||
/// length-capped, like every other node/gateway string this crate prints.
|
||||
///
|
||||
/// Under `cfg(test)` the line is also mirrored into a per-thread buffer, so a test
|
||||
/// can assert the loop actually says what it skipped rather than only that it
|
||||
/// returned nothing.
|
||||
fn warn_skip(oid: &str, why: &str) {
|
||||
emit_warning(&format!(
|
||||
"warning: could not fetch encrypted blob {oid}: {why}; skipping"
|
||||
));
|
||||
}
|
||||
|
||||
/// The same report for a manifest rather than a blob. The manifest is one step
|
||||
/// earlier in the same recovery: losing it silently means every blob it would have
|
||||
/// named is missing with no reason given anywhere.
|
||||
fn warn_skip_manifest(id: &str, why: &str) {
|
||||
emit_warning(&format!(
|
||||
"warning: could not fetch blob manifest {id}: {why}; skipping"
|
||||
));
|
||||
}
|
||||
|
||||
/// Sanitize a warning and write it, once.
|
||||
///
|
||||
/// The write goes to [`warn_sink`], which is stderr in a normal build and the tests'
|
||||
/// per-thread mirror under `cfg(test)`. That indirection is the point: the mirror
|
||||
/// used to sit BESIDE an `eprintln!`, so deleting the user-visible write left every
|
||||
/// assertion on the mirror green and the shipped behaviour could be removed with the
|
||||
/// tests unchanged. There is one write now, and the tests observe it.
|
||||
fn emit_warning(line: &str) {
|
||||
use std::io::Write;
|
||||
let line = sanitize_node_msg(line);
|
||||
let _ = writeln!(warn_sink(), "{line}");
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn warn_sink() -> impl std::io::Write {
|
||||
std::io::stderr()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn warn_sink() -> impl std::io::Write {
|
||||
tests::WarnMirror
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CloneArgs {
|
||||
/// Repo to clone: gitlawb://<owner_did>/<name> or <owner>/<name>.
|
||||
@@ -76,11 +27,7 @@ pub struct CloneArgs {
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Arweave gateway for B3 manifest discovery/fetch when a node cannot supply
|
||||
@@ -365,25 +312,10 @@ async fn recover_encrypted_blobs(
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
// The node path has the same silent exit the gateway path had: a 403, a
|
||||
// 404, or a dead connection all reached the caller as "blob not
|
||||
// recoverable" with no reason attached. One unreachable blob still must
|
||||
// not end the recovery of the rest, so it warns and moves on.
|
||||
Ok(r) => {
|
||||
warn_skip(oid, &format!("node returned {}", r.status()));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn_skip(oid, &format!("node request failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let envelope = match env_resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn_skip(oid, &format!("reading the envelope failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
let Ok(envelope) = env_resp.bytes().await else {
|
||||
continue;
|
||||
};
|
||||
let plaintext = match open_blob(&envelope, keypair) {
|
||||
Ok(p) => p,
|
||||
@@ -578,7 +510,7 @@ fn merge_manifests(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Option B3 fallback recovery, with no dependency on a Twigpine node API. Query
|
||||
/// Option B3 fallback recovery, with no dependency on a gitlawb node API. Query
|
||||
/// the Arweave gateway for this repo's encrypted manifests, merge them, and for
|
||||
/// each blob still missing locally that the caller can decrypt, pull the envelope
|
||||
/// from a public IPFS gateway, decrypt, and install it as a loose object. Returns
|
||||
@@ -710,14 +642,7 @@ async fn recover_from_arweave(
|
||||
for r in refs {
|
||||
let m = match client.get(format!("{ag}/{}", r.id)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => resp,
|
||||
Ok(resp) => {
|
||||
warn_skip_manifest(&r.id, &format!("gateway returned {}", resp.status()));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn_skip_manifest(&r.id, &format!("gateway request failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if let Ok(parsed) = m.json::<Manifest>().await {
|
||||
manifests.push((parsed, r.height));
|
||||
@@ -759,31 +684,10 @@ async fn recover_from_arweave(
|
||||
}
|
||||
let env_resp = match client.get(format!("{ig}/ipfs/{cid}")).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
// Every other outcome used to leave through a bare `continue`, so a
|
||||
// gateway that answered 503, 404, or nothing at all was reported to the
|
||||
// caller as "blob not recoverable" with no reason attached. This is the
|
||||
// second in-repo client of GET /ipfs/{cid} and it has no resume ladder,
|
||||
// so saying what happened is all the recourse there is. It still skips to
|
||||
// the next candidate either way: one unreachable blob must not end the
|
||||
// recovery of the rest.
|
||||
Ok(r) => {
|
||||
warn_skip(&oid, &format!("gateway returned {}", r.status()));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn_skip(&oid, &format!("gateway request failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
// A body that dies part-way through is the same mid-read failure the capped
|
||||
// read exists to stop rendering as silence, and it sits INSIDE the loop whose
|
||||
// status arms were already fixed.
|
||||
let envelope = match env_resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn_skip(&oid, &format!("reading the envelope failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
let Ok(envelope) = env_resp.bytes().await else {
|
||||
continue;
|
||||
};
|
||||
// open_blob succeeds only if this caller is a recipient: this is the
|
||||
// authorization gate (no node, no DID check needed).
|
||||
@@ -897,42 +801,9 @@ pub async fn run(args: CloneArgs) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
thread_local! {
|
||||
/// Test-visible copy of the stderr warnings `warn_skip` emits.
|
||||
/// `#[tokio::test]` runs the future on the test's own thread, so a
|
||||
/// thread-local is enough.
|
||||
static WARNINGS: RefCell<String> = const { RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
/// The `cfg(test)` warning sink. `emit_warning` writes here instead of to
|
||||
/// stderr, so the assertions below observe the shipped write rather than a copy
|
||||
/// made beside it: delete that write and they go red.
|
||||
pub(super) struct WarnMirror;
|
||||
|
||||
impl std::io::Write for WarnMirror {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let text = String::from_utf8_lossy(buf).into_owned();
|
||||
WARNINGS.with(|w| w.borrow_mut().push_str(&text));
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_warnings() {
|
||||
WARNINGS.with(|w| w.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn warnings() -> String {
|
||||
WARNINGS.with(|w| w.borrow().clone())
|
||||
}
|
||||
|
||||
fn g(args: &[&str], dir: &Path) {
|
||||
assert!(Command::new("git")
|
||||
.args(args)
|
||||
@@ -1384,132 +1255,6 @@ mod tests {
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidTS"));
|
||||
}
|
||||
|
||||
/// A gateway that refuses one blob must SAY so, and must not take the rest of the
|
||||
/// recovery down with it.
|
||||
///
|
||||
/// The IPFS fetch used to leave through a bare `_ => continue` on every
|
||||
/// non-success, so a 503 (the truncation status a tuned node makes more likely),
|
||||
/// a 404, and a dead connection all reached the caller as the same silent "blob
|
||||
/// not recoverable". Two withheld blobs here: the gateway answers 503 for the
|
||||
/// first and serves the second. The recovered path proves the loop continued; the
|
||||
/// warning proves the skip was reported and names both the object and the status.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_reports_a_refusing_gateway_and_continues() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
reset_warnings();
|
||||
let (td, url) = bare_remote(&[
|
||||
("public/a.txt", b"pub\n"),
|
||||
("secret/b.txt", b"SECRET B\n"),
|
||||
("secret/c.txt", b"SECRET C\n"),
|
||||
]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
|
||||
let oid_of = |path: &str| {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
&format!("HEAD:{path}"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
let refused_oid = oid_of("secret/b.txt");
|
||||
let served_oid = oid_of("secret/c.txt");
|
||||
|
||||
// Origin death, so recovery has to go through the gateways.
|
||||
std::fs::remove_dir_all(bare).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET C\n", &[reader.verifying_key()]).unwrap();
|
||||
|
||||
let refused_cid = "cidrefused";
|
||||
let served_cid = "cidserved";
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _gql = server
|
||||
.mock("POST", "/graphql")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}}]}}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [
|
||||
{ "oid": refused_oid, "cid": refused_cid },
|
||||
{ "oid": served_oid, "cid": served_cid },
|
||||
],
|
||||
})
|
||||
.to_string();
|
||||
let _tx = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let refusal = server
|
||||
.mock("GET", format!("/ipfs/{refused_cid}").as_str())
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"search_incomplete","message":"scan truncated"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let ok = server
|
||||
.mock("GET", format!("/ipfs/{served_cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
refusal.assert_async().await;
|
||||
ok.assert_async().await;
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["secret/c.txt".to_string()],
|
||||
"a refused candidate must not stop the loop reaching the next one"
|
||||
);
|
||||
|
||||
let warnings = warnings();
|
||||
assert!(
|
||||
warnings.contains(&refused_oid),
|
||||
"the skip must name the object it could not fetch, got: {warnings}"
|
||||
);
|
||||
assert!(
|
||||
warnings.contains("503"),
|
||||
"the skip must name the gateway's status rather than failing silently, \
|
||||
got: {warnings}"
|
||||
);
|
||||
assert!(
|
||||
!warnings.contains(&served_oid),
|
||||
"the blob that was served must not be reported as skipped, got: {warnings}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-path end-to-end over a mocked Arweave + IPFS gateway: discover the
|
||||
/// manifest via GraphQL, fetch it, fetch the envelope, decrypt with the
|
||||
/// caller's key, and install the previously-withheld blob.
|
||||
|
||||
+14
-14
@@ -1,10 +1,10 @@
|
||||
//! `gl doctor` — check your Twigpine installation and connectivity.
|
||||
//! `gl doctor` — check your gitlawb installation and connectivity.
|
||||
//!
|
||||
//! Checks:
|
||||
//! 1. Identity — ~/.gitlawb/identity.pem exists, DID parseable
|
||||
//! 2. Registration — ~/.gitlawb/ucan.json exists (registered with a node)
|
||||
//! 3. GITLAWB_NODE — env var is set to a non-localhost URL
|
||||
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.twigpine.com)
|
||||
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.gitlawb.com)
|
||||
//! 5. git helper — git-remote-gitlawb is in PATH
|
||||
//! 6. git — git is installed
|
||||
//! 7. version — gl is up to date with latest GitHub release
|
||||
@@ -15,7 +15,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
|
||||
const PUBLIC_NODE: &str = "https://node.twigpine.com";
|
||||
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
|
||||
const GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -70,7 +70,7 @@ impl Check {
|
||||
}
|
||||
|
||||
pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
println!("gl doctor — checking your Twigpine setup");
|
||||
println!("gl doctor — checking your gitlawb setup");
|
||||
println!();
|
||||
|
||||
let dir = args.dir.unwrap_or_else(|| {
|
||||
@@ -160,7 +160,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
checks.push(Check::fail(
|
||||
"GITLAWB_NODE",
|
||||
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
|
||||
"export GITLAWB_NODE=https://node.twigpine.com",
|
||||
"export GITLAWB_NODE=https://node.gitlawb.com",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
"node is v{version} but gl is v{gl_ver} — your CLI may be missing \
|
||||
features (signing / iCaptcha) this node requires"
|
||||
),
|
||||
"upgrade gl: curl -sSf https://twigpine.com/install.sh | sh",
|
||||
"upgrade gl: curl -sSf https://gitlawb.com/install.sh | sh",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
checks.push(Check::fail(
|
||||
"git-remote-gitlawb",
|
||||
"not found in PATH — gitlawb:// clone/push will not work",
|
||||
"curl -sSf https://twigpine.com/install.sh | sh",
|
||||
"curl -sSf https://gitlawb.com/install.sh | sh",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ fn which_in_path(name: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from Twigpine/node (the actual release repo — see install.sh's
|
||||
/// Fetch the latest release tag from Gitlawb/node (the actual release repo — see install.sh's
|
||||
/// `GITLAWB_RELEASE_REPO` default) and compare to current version.
|
||||
async fn check_version(current: &'static str, github_api_base: &str) -> Check {
|
||||
let client = match reqwest::Client::builder()
|
||||
@@ -422,7 +422,7 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
|
||||
|
||||
let resp = match client
|
||||
.get(format!(
|
||||
"{github_api_base}/repos/Twigpine/node/releases/latest"
|
||||
"{github_api_base}/repos/Gitlawb/node/releases/latest"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
@@ -457,7 +457,7 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
|
||||
Check::warn(
|
||||
"version",
|
||||
format!("v{current} → v{tag} available"),
|
||||
"curl -sSf https://twigpine.com/install.sh | sh",
|
||||
"curl -sSf https://gitlawb.com/install.sh | sh",
|
||||
)
|
||||
} else {
|
||||
Check::pass("version", format!("v{current} (up to date)"))
|
||||
@@ -550,7 +550,7 @@ mod tests {
|
||||
assert!(is_loopback_url("http://localhost:7545"));
|
||||
assert!(is_loopback_url("http://[::1]:7545"));
|
||||
assert!(!is_loopback_url("https://localhost.example"));
|
||||
assert!(!is_loopback_url("https://node.twigpine.com/localhost"));
|
||||
assert!(!is_loopback_url("https://node.gitlawb.com/localhost"));
|
||||
assert!(!is_loopback_url("not a url"));
|
||||
}
|
||||
|
||||
@@ -603,7 +603,7 @@ mod tests {
|
||||
async fn test_check_version_queries_gitlawb_node_releases() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Twigpine/node/releases/latest")
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tag_name":"v9.9.9"}"#)
|
||||
@@ -619,7 +619,7 @@ mod tests {
|
||||
async fn test_check_version_up_to_date() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Twigpine/node/releases/latest")
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tag_name":"v0.1.0"}"#)
|
||||
@@ -635,7 +635,7 @@ mod tests {
|
||||
async fn test_check_version_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Twigpine/node/releases/latest")
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.with_status(403)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
+2
-951
@@ -1,4 +1,4 @@
|
||||
//! Signed HTTP client for Twigpine API calls (async).
|
||||
//! Signed HTTP client for gitlawb API calls (async).
|
||||
//!
|
||||
//! Writes are signed with RFC 9421 HTTP Signatures. When the node gates a write
|
||||
//! behind iCaptcha (HTTP 403 `icaptcha_proof_required`, advertised via the
|
||||
@@ -10,53 +10,11 @@ use anyhow::{Context, Result};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use icaptcha_client::IcaptchaCfg;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Max times we'll fetch a fresh proof and retry a 403-iCaptcha response
|
||||
/// (absorbs proof expiry / first-seen replay).
|
||||
const MAX_ICAPTCHA_RETRIES: usize = 2;
|
||||
|
||||
/// Total request timeout: from the start of connecting through the end of the
|
||||
/// response body, so it bounds a slow download and not just a slow handshake.
|
||||
const TOTAL_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Follow a redirect only when it stays on the origin that issued it AND re-issues the
|
||||
/// identical request-target, and only for as long as the chain bound allows.
|
||||
///
|
||||
/// The decision itself is [`gitlawb_core::redirect::may_follow`], shared with
|
||||
/// `git-remote-gitlawb` so the two signing clients cannot drift apart on it. Refusal
|
||||
/// is `stop`, not `error`: the 3xx comes back as an ordinary response and each caller
|
||||
/// reports it through the status path it already has.
|
||||
///
|
||||
/// `Policy::custom` replaces reqwest's built-in limit, so the chain bound is restated
|
||||
/// here. Same-origin redirects can cycle, and this is what stops a node answering 302
|
||||
/// to itself from being followed indefinitely. It is not what makes the request
|
||||
/// finite: `.timeout(...)` on the same builder is a TOTAL request timeout covering the
|
||||
/// whole chain, so without this bound the worst case is a 30 second spin, not an
|
||||
/// endless one. The bound is what keeps that spin from costing the node a request per
|
||||
/// round trip for the full 30 seconds.
|
||||
///
|
||||
/// `>` and not `>=`: reqwest pushes the redirecting URL onto `previous` before
|
||||
/// consulting the policy, so on the first redirect `previous.len()` is already 1, and
|
||||
/// `>=` would permit `MAX_REDIRECTS - 1` follows. `Policy::limited(max)` refuses at
|
||||
/// `previous.len() > max`, and matching it is the point of reusing its value.
|
||||
fn same_origin_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
|
||||
let Some(previous) = attempt.previous().last() else {
|
||||
// No previous URL to compare against. Unreachable through reqwest, which
|
||||
// pushes the redirecting URL before consulting the policy, but the safe
|
||||
// reading of "cannot prove same-origin" is to refuse.
|
||||
return attempt.stop();
|
||||
};
|
||||
if attempt.previous().len() > gitlawb_core::redirect::MAX_REDIRECTS {
|
||||
return attempt.stop();
|
||||
}
|
||||
if gitlawb_core::redirect::may_follow(previous, attempt.url()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NodeClient {
|
||||
inner: reqwest::Client,
|
||||
pub node_url: String,
|
||||
@@ -65,20 +23,8 @@ pub struct NodeClient {
|
||||
|
||||
impl NodeClient {
|
||||
pub fn new(node_url: impl Into<String>, keypair: Option<Keypair>) -> Self {
|
||||
Self::with_timeout(node_url, keypair, TOTAL_REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
/// `new` with the total request timeout as a parameter, so a test can drive the
|
||||
/// timeout's behaviour without waiting out the shipped value. `new` supplies the
|
||||
/// shipped one, which is what makes the scaled-down test cover the real client.
|
||||
fn with_timeout(
|
||||
node_url: impl Into<String>,
|
||||
keypair: Option<Keypair>,
|
||||
timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
let inner = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(format!("gl/{} gitlawb-cli", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
@@ -257,133 +203,6 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
.context("iCaptcha solver task panicked")?
|
||||
}
|
||||
|
||||
/// What a capped body read produced. `text` is the bytes that arrived; the two flags
|
||||
/// say why the read stopped where it did, which a caller that CLASSIFIES on the body
|
||||
/// cannot work out from the text alone.
|
||||
pub(crate) struct CappedBody {
|
||||
/// The bytes read, lossily decoded.
|
||||
pub(crate) text: String,
|
||||
/// The cap cut the body short.
|
||||
pub(crate) truncated: bool,
|
||||
/// A chunk read FAILED part-way through, so the body is not merely short, it is
|
||||
/// unfinished and the node may have had more to say.
|
||||
pub(crate) read_failed: bool,
|
||||
}
|
||||
|
||||
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
|
||||
/// hostile or broken node returning a huge error body — the display is capped
|
||||
/// separately, but the read itself must not be unbounded (INV-6, read half).
|
||||
///
|
||||
/// `truncated` reports whether the cap cut the body short. A caller that CLASSIFIES
|
||||
/// on the body needs it: a cut body fails JSON parse, and a parse failure is
|
||||
/// indistinguishable from a node that sent no code at all, so without this flag an
|
||||
/// oversized body silently picks a different arm.
|
||||
///
|
||||
/// `read_failed` reports the other way a body can end early. A mid-body read error
|
||||
/// used to leave through the same exit as a clean end of stream, so a 500 whose body
|
||||
/// died in transit surfaced as an empty message and the caller was told
|
||||
/// `node returned 500: ` with nothing after the colon. That is a report of what the
|
||||
/// node said, and the node never got to say it.
|
||||
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> CappedBody {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut read_failed = false;
|
||||
while buf.len() < cap {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let take = (cap - buf.len()).min(chunk.len());
|
||||
buf.extend_from_slice(&chunk[..take]);
|
||||
if take < chunk.len() {
|
||||
truncated = true;
|
||||
break; // hit the cap mid-chunk
|
||||
}
|
||||
}
|
||||
Ok(None) => break, // clean end of body
|
||||
Err(_) => {
|
||||
read_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A body that lands exactly on the cap may or may not have more behind it;
|
||||
// report it as cut, since the classification that follows cannot tell either.
|
||||
if buf.len() >= cap {
|
||||
truncated = true;
|
||||
}
|
||||
CappedBody {
|
||||
text: String::from_utf8_lossy(&buf).into_owned(),
|
||||
truncated,
|
||||
read_failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip terminal-dangerous characters from (and cap the length of) a
|
||||
/// node-supplied error string before surfacing it. The node a caller talks to
|
||||
/// could be hostile and embed escape sequences in its error body; those must not
|
||||
/// reach the terminal verbatim. We drop the C0/C1 control bytes (which
|
||||
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
pub(crate) fn sanitize_node_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a JSON response, surfacing a node denial/error instead of parsing it as
|
||||
/// the requested resource. On a non-2xx status it returns an `Err` carrying the
|
||||
/// node's sanitized `message` plus the status; on success it parses the
|
||||
/// body and propagates a parse error, so a truncated/garbage 2xx body is an error
|
||||
/// rather than a silently-empty success (the denial-as-success bug #123 fixes).
|
||||
/// `what` names the resource for the error text (e.g. "repo", "commits").
|
||||
///
|
||||
/// Callers must route gated reads through this rather than `resp.json().await?`:
|
||||
/// the bare parse renders a gated 404/5xx body back as the resource.
|
||||
/// Cap on the error body `read_json` reads before parsing a node's `message`.
|
||||
/// Matches the capped-read bound used on the sync error path; large enough for any
|
||||
/// legitimate error, small enough that a hostile node cannot exhaust memory.
|
||||
const ERROR_BODY_CAP: usize = 8 * 1024;
|
||||
|
||||
pub(crate) async fn read_json(resp: reqwest::Response, what: &str) -> Result<Value> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
// Bound the error read: `resp.json()` would buffer and parse the WHOLE
|
||||
// body, so a hostile node could stream an arbitrarily large valid JSON
|
||||
// error and exhaust this process's memory (#186). Read a small capped body
|
||||
// first, then best-effort extract `message`. The error body may also be
|
||||
// non-JSON (503 degraded, 413 body-limit from middleware), so a parse miss
|
||||
// falls back to the capped raw text; an empty body to the status alone.
|
||||
let raw = read_body_capped(resp, ERROR_BODY_CAP).await.text;
|
||||
// Preserve the prior contract: surface the JSON `message` when present in
|
||||
// the capped body, else the status alone ("request failed") — a non-JSON
|
||||
// or message-less body (503 degraded, 413 body-limit) never surfaces raw
|
||||
// node bytes. A body whose `message` sits past the cap parses as truncated
|
||||
// garbage here and correctly falls back rather than being buffered whole.
|
||||
//
|
||||
// Fall back to `error` before giving up: the node's shared AppError
|
||||
// envelope carries both keys, but the task API answers with `error` alone
|
||||
// (`{"error":"task not found"}` in gitlawb-node/src/api/tasks.rs), so
|
||||
// reading only `message` blanked every `gl task` denial to the generic
|
||||
// "request failed". `sync.rs`'s hand-rolled reader already reads both; this
|
||||
// matches it.
|
||||
let msg = serde_json::from_str::<Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("message")
|
||||
.or_else(|| b.get("error"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "request failed".to_owned());
|
||||
anyhow::bail!("{what} failed ({status}): {}", sanitize_node_msg(&msg));
|
||||
}
|
||||
// Success: a truncated/garbage 2xx body must be an `Err`, not `Ok(Null)` that
|
||||
// a caller then renders as an empty success.
|
||||
resp.json()
|
||||
.await
|
||||
.with_context(|| format!("invalid JSON in {what} response"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -768,772 +587,4 @@ mod tests {
|
||||
ic.challenge.assert();
|
||||
ic.answer.assert();
|
||||
}
|
||||
|
||||
// ── redirect policy ─────────────────────────────────────────────────
|
||||
|
||||
/// The signed headers must not survive a redirect off the node's origin.
|
||||
///
|
||||
/// reqwest strips only `Authorization`, `Cookie`, `Proxy-Authorization` and
|
||||
/// `WWW-Authenticate` across hosts, so `Signature` and `Signature-Input` used to
|
||||
/// ride a 302 straight to whatever origin the node named. The signature binds
|
||||
/// `@method`, `@path` and `content-digest` and nothing about the authority, so the
|
||||
/// receiving host holds a credential that reads path-scoped objects as the caller
|
||||
/// at any node until the clock-skew window closes.
|
||||
///
|
||||
/// Two mockito servers are two ports on one host, which is exactly the boundary
|
||||
/// this policy draws (and the one reqwest's own header stripping draws). The
|
||||
/// second server answers everything and expects nothing: a followed redirect
|
||||
/// fails the expectation whether or not the signature came with it. MUTATION
|
||||
/// (RED): drop the `.redirect(...)` line and the second server is hit.
|
||||
#[tokio::test]
|
||||
async fn cross_origin_redirect_is_not_followed() {
|
||||
let mut elsewhere = Server::new_async().await;
|
||||
let never = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body("bytes from the redirect target")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let signature_seen = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut node = Server::new_async().await;
|
||||
let bounce = node
|
||||
.mock("GET", "/api/v1/thing")
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/api/v1/thing", elsewhere.url()))
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client.get_signed("/api/v1/thing").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
302,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx \
|
||||
and reports it through the status path it already has"
|
||||
);
|
||||
let body = resp.text().await.unwrap();
|
||||
assert!(
|
||||
!body.contains("bytes from the redirect target"),
|
||||
"the redirect target's bytes must never reach the caller, got: {body}"
|
||||
);
|
||||
bounce.assert_async().await;
|
||||
never.assert_async().await;
|
||||
signature_seen.assert_async().await;
|
||||
}
|
||||
|
||||
/// A node redirecting to itself is same-origin, so the origin predicate follows it
|
||||
/// every time and only the chain bound ends the loop. Deleting the bound left the
|
||||
/// whole suite green, because nothing here had ever built a cycle.
|
||||
///
|
||||
/// It has a second job now. A redirect back to the identical path and query is the
|
||||
/// only same-origin hop the predicate still follows, so the eleven hits below are
|
||||
/// also this crate's executed proof that such a hop IS followed. The old positive
|
||||
/// fixture drove a trailing-slash rewrite, which the request-target rule refuses,
|
||||
/// and an http-to-https upgrade cannot be mocked over mockito's plain http.
|
||||
///
|
||||
/// The route answers 301 pointing back at itself. Bounded, the handler is hit
|
||||
/// once for the original request plus `MAX_REDIRECTS` follows and the call returns
|
||||
/// the 301 (a refused redirect stops rather than errors). Unbounded, it runs until
|
||||
/// the client's total request timeout cuts it off, which is a 30 second spin at the
|
||||
/// shipped value and a request per round trip for the node.
|
||||
///
|
||||
/// MUTATION (RED): delete the `previous().len()` check.
|
||||
#[tokio::test]
|
||||
async fn a_self_redirect_stops_at_the_chain_bound() {
|
||||
let mut node = Server::new_async().await;
|
||||
let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let h = hits.clone();
|
||||
let loop_route = node
|
||||
.mock("GET", "/api/v1/loop")
|
||||
.with_status(301)
|
||||
.with_header("location", "/api/v1/loop")
|
||||
.with_body_from_request(move |_req| {
|
||||
h.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Vec::new()
|
||||
})
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::with_timeout(
|
||||
node.url(),
|
||||
Some(test_keypair()),
|
||||
std::time::Duration::from_secs(5),
|
||||
);
|
||||
let resp = client
|
||||
.get_signed("/api/v1/loop")
|
||||
.await
|
||||
.expect("the bound must end the chain, not the timeout");
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"the chain ends by refusing the next hop, so the last 3xx is what comes back"
|
||||
);
|
||||
assert_eq!(
|
||||
hits.load(std::sync::atomic::Ordering::SeqCst),
|
||||
gitlawb_core::redirect::MAX_REDIRECTS + 1,
|
||||
"one original request plus MAX_REDIRECTS follows, matching what \
|
||||
Policy::limited(MAX_REDIRECTS) would have permitted"
|
||||
);
|
||||
loop_route.assert_async().await;
|
||||
}
|
||||
|
||||
/// A same-origin hop that REWRITES the request-target is refused, even though the
|
||||
/// origin never changes.
|
||||
///
|
||||
/// The signature binds `@path` as the literal path-and-query the client signed, and
|
||||
/// the node rebuilds it from the URI it received. A `/api/v1/thing` to
|
||||
/// `/api/v1/thing/` bounce therefore presents a signature over a target the node
|
||||
/// never saw, and every signed read behind such a proxy 401s. Refusing the hop
|
||||
/// hands the caller the 3xx that names what happened instead.
|
||||
///
|
||||
/// The target mock expects zero hits and is asserted: mockito only checks
|
||||
/// `.expect(N)` when `.assert()` runs, so an unbound or unasserted mock passes
|
||||
/// vacuously.
|
||||
#[tokio::test]
|
||||
async fn same_origin_path_changing_redirect_is_refused() {
|
||||
let mut node = Server::new_async().await;
|
||||
let bounce = node
|
||||
.mock("GET", "/api/v1/thing")
|
||||
.with_status(301)
|
||||
.with_header("location", "/api/v1/thing/")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let target = node
|
||||
.mock("GET", "/api/v1/thing/")
|
||||
.with_status(200)
|
||||
.with_body("bytes from the rewritten target")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client.get_signed("/api/v1/thing").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx \
|
||||
and reports it through the status path it already has"
|
||||
);
|
||||
let body = resp.text().await.unwrap();
|
||||
assert!(
|
||||
!body.contains("bytes from the rewritten target"),
|
||||
"the rewritten target's bytes must never reach the caller, got: {body}"
|
||||
);
|
||||
bounce.assert_async().await;
|
||||
target.assert_async().await;
|
||||
}
|
||||
|
||||
// ── the node's own verification, run over what the client actually sent ──
|
||||
|
||||
/// What the verifying mock made of a request that reached it.
|
||||
///
|
||||
/// The empty slot (`None`) is its own state and means the mock was never hit at
|
||||
/// all, which is what the refusal test asserts. It must stay distinguishable from
|
||||
/// [`Verdict::WrongIdentity`], because "nobody verified anything" and "something
|
||||
/// verified against the wrong key" are opposite findings.
|
||||
///
|
||||
/// The payloads are read through `Debug` in the assertion messages and nowhere
|
||||
/// else, which the dead-code pass does not count; they carry the detail that makes
|
||||
/// a failure legible, so they stay.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
enum Verdict {
|
||||
/// The chain accepted the signature AND the key it resolved is the test's DID.
|
||||
Accepted,
|
||||
/// The chain refused it. Carries the error so a failure reads as the actual
|
||||
/// rejection rather than a bare hit count.
|
||||
Rejected(String),
|
||||
/// The chain accepted a signature made by somebody else. A key resolved from
|
||||
/// the parsed `key_id` is read out of the artifact under verification, so an
|
||||
/// accept on it alone proves consistency, never authenticity.
|
||||
WrongIdentity { expected: String, got: String },
|
||||
}
|
||||
|
||||
/// The node's `require_signature` verification, over a request this crate did not
|
||||
/// build: parse the headers, recompute the content-digest from the body, rebuild
|
||||
/// the signing string over `@method`/`@path`/`content-digest`, Ed25519-verify.
|
||||
/// Returns the DID the signature resolved to, so a caller can pin the identity.
|
||||
///
|
||||
/// A hand-copy of its twin in `crates/git-remote-gitlawb/src/main.rs`, which gl
|
||||
/// cannot import (`git-remote-gitlawb` is a binary crate and this is its test
|
||||
/// module). Keep the two textually identical apart from the mockito seam around
|
||||
/// them, so an edit to one is visibly an edit to both.
|
||||
///
|
||||
/// The production verifier both copies mirror is `crate::auth::require_signature` in
|
||||
/// `crates/gitlawb-node/src/auth/mod.rs`. This is a re-implementation, not a call, so
|
||||
/// an edit to that middleware has to land here too: otherwise the copies drift and
|
||||
/// this test keeps passing against a rule the node has stopped applying.
|
||||
///
|
||||
/// It asserts internally, which is deliberate but constrains its callers: inside
|
||||
/// `with_body_from_request` those assertions fire on the server thread and reach
|
||||
/// the client as a transport error, not as a recorded verdict. So the identity
|
||||
/// check lives in the caller as a [`Verdict`] variant, never as an assert in here.
|
||||
fn node_verifies(
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
sig_input: &str,
|
||||
sig_header: &str,
|
||||
content_digest: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
|
||||
use gitlawb_core::identity::verify;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let sig = HttpSignature::parse(sig_input, sig_header)?;
|
||||
sig.check_created()?;
|
||||
assert!(
|
||||
sig.missing_components().is_empty(),
|
||||
"signature must cover all required components"
|
||||
);
|
||||
assert_eq!(sig.alg, "ed25519");
|
||||
assert_eq!(
|
||||
content_digest,
|
||||
compute_content_digest(body),
|
||||
"content-digest must match the body"
|
||||
);
|
||||
let vk = sig.key_id.to_verifying_key()?;
|
||||
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());
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
|
||||
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
|
||||
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
|
||||
verify(&vk, signing_string.as_bytes(), &sig_array)?;
|
||||
Ok(sig.key_id.to_string())
|
||||
}
|
||||
|
||||
/// Pull a header value off a received mockito request, or explain which one the
|
||||
/// client failed to send.
|
||||
fn received_header(req: &mockito::Request, name: &str) -> String {
|
||||
req.header(name)
|
||||
.first()
|
||||
.unwrap_or_else(|| panic!("the client sent no {name} header"))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Run [`node_verifies`] over a GET that arrived at the mock and record what the
|
||||
/// node would have made of it, pinned to `expected_did`.
|
||||
fn record_get_verdict(
|
||||
req: &mockito::Request,
|
||||
expected_did: &str,
|
||||
slot: &std::sync::Arc<std::sync::Mutex<Option<Verdict>>>,
|
||||
) {
|
||||
let verdict = match node_verifies(
|
||||
"GET",
|
||||
req.path_and_query(),
|
||||
b"",
|
||||
&received_header(req, "signature-input"),
|
||||
&received_header(req, "signature"),
|
||||
&received_header(req, "content-digest"),
|
||||
) {
|
||||
Ok(did) if did == expected_did => Verdict::Accepted,
|
||||
Ok(did) => Verdict::WrongIdentity {
|
||||
expected: expected_did.to_string(),
|
||||
got: did,
|
||||
},
|
||||
Err(e) => Verdict::Rejected(e.to_string()),
|
||||
};
|
||||
*slot.lock().unwrap() = Some(verdict);
|
||||
}
|
||||
|
||||
/// The finding's repro, now a guard: a rewritten same-origin target must never
|
||||
/// receive the signature, and the proof is the node's own verification, not a hit
|
||||
/// count.
|
||||
///
|
||||
/// The target mock runs the full `require_signature` chain over the request it
|
||||
/// receives. Post-fix the hop is refused, so the slot stays empty. Pre-fix the hop
|
||||
/// is followed and the slot records the Ed25519 rejection of a signature made over
|
||||
/// `/api/v1/thing` and presented at `/api/v1/thing/`, which is the 401 an operator
|
||||
/// behind such a proxy actually sees. The verdict is asserted first, so a failure
|
||||
/// speaks about verification rather than about reachability.
|
||||
///
|
||||
/// Its paired positive control is
|
||||
/// `a_direct_signed_get_verifies_under_the_node_verifier`: without it, an empty
|
||||
/// slot would be satisfied just as well by a harness that can never record
|
||||
/// anything.
|
||||
#[tokio::test]
|
||||
async fn a_rewritten_target_never_receives_the_signature() {
|
||||
let kp = test_keypair();
|
||||
let expected_did = kp.did().to_string();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = Server::new_async().await;
|
||||
let bounce = node
|
||||
.mock("GET", "/api/v1/thing")
|
||||
.with_status(301)
|
||||
.with_header("location", "/api/v1/thing/")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let target = node
|
||||
.mock("GET", "/api/v1/thing/")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(kp));
|
||||
let resp = client.get_signed("/api/v1/thing").await.unwrap();
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
verdict.is_none(),
|
||||
"the node's own verifier must never see this request: the signature covers \
|
||||
/api/v1/thing and the rewritten target is /api/v1/thing/, so what arrives \
|
||||
there is a stale request-target; recorded verdict: {verdict:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"the caller sees the 3xx, not the rewritten target's answer"
|
||||
);
|
||||
bounce.assert_async().await;
|
||||
target.assert_async().await;
|
||||
}
|
||||
|
||||
/// The positive control for the test above, and the proof that the client signs
|
||||
/// the query it sends.
|
||||
///
|
||||
/// A direct signed GET, no redirect anywhere, through the same verifying mock. The
|
||||
/// verdict must be `Accepted`, which is what makes the refusal test's empty slot
|
||||
/// attributable to the refusal rather than to a harness that cannot record. The
|
||||
/// path carries a query, so a client that signed the bare path would land here as
|
||||
/// `Rejected`.
|
||||
#[tokio::test]
|
||||
async fn a_direct_signed_get_verifies_under_the_node_verifier() {
|
||||
let kp = test_keypair();
|
||||
let expected_did = kp.did().to_string();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = Server::new_async().await;
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let route = node
|
||||
.mock("GET", "/api/v1/thing?x=1")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(kp));
|
||||
let resp = client.get_signed("/api/v1/thing?x=1").await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
matches!(verdict, Some(Verdict::Accepted)),
|
||||
"a direct signed GET must verify under the node's own chain and resolve to \
|
||||
{expected_did}, or the refusal test's empty slot proves nothing; recorded \
|
||||
verdict: {verdict:?}"
|
||||
);
|
||||
route.assert_async().await;
|
||||
}
|
||||
|
||||
// ── read_body_capped ────────────────────────────────────────────────
|
||||
|
||||
/// A body whose read FAILS mid-stream must be distinguishable from a body that
|
||||
/// ended. Both used to leave through the same `_ => break`, so a 500 whose body
|
||||
/// died in transit produced an empty message and the caller was told
|
||||
/// `node returned 500: ` with nothing after the colon.
|
||||
///
|
||||
/// The fixture is a raw listener that promises 64 bytes in `Content-Length`,
|
||||
/// writes 5, and closes. mockito cannot express that: it always completes the
|
||||
/// response it advertises. MUTATION (RED): restore the single `_ => break` arm
|
||||
/// (or hard-code `read_failed: false`) and the flag reads false.
|
||||
#[tokio::test]
|
||||
async fn read_body_capped_flags_a_mid_body_read_failure() {
|
||||
let addr = spawn_short_body_listener().await;
|
||||
let resp = reqwest::get(format!("http://{addr}/truncated"))
|
||||
.await
|
||||
.expect("headers arrive before the body is cut");
|
||||
let body = read_body_capped(resp, 8192).await;
|
||||
|
||||
assert!(
|
||||
body.read_failed,
|
||||
"a body cut off mid-stream must be reported as a failed read, not as a \
|
||||
body that ended: got {:?}",
|
||||
body.text
|
||||
);
|
||||
assert!(
|
||||
!body.truncated,
|
||||
"the cap did not cut this one; 5 bytes are nowhere near 8 KiB"
|
||||
);
|
||||
}
|
||||
|
||||
/// The must-not half: a body that ends cleanly must NOT be flagged, or the flag
|
||||
/// means nothing and every terminal starts claiming the node went quiet.
|
||||
#[tokio::test]
|
||||
async fn read_body_capped_does_not_flag_a_clean_body() {
|
||||
let mut server = Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/ok")
|
||||
.with_status(500)
|
||||
.with_body("node said this")
|
||||
.create_async()
|
||||
.await;
|
||||
let resp = reqwest::get(format!("{}/ok", server.url())).await.unwrap();
|
||||
let body = read_body_capped(resp, 8192).await;
|
||||
|
||||
assert_eq!(body.text, "node said this");
|
||||
assert!(!body.read_failed, "a complete body is not a failed read");
|
||||
assert!(!body.truncated, "a complete body is not a truncated one");
|
||||
}
|
||||
|
||||
/// Answer one request with headers promising more body than gets written, then
|
||||
/// close the connection. Returns the listener's address.
|
||||
async fn spawn_short_body_listener() -> std::net::SocketAddr {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut scratch = [0u8; 1024];
|
||||
let _ = sock.read(&mut scratch).await;
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 64\r\n\r\nshort")
|
||||
.await;
|
||||
let _ = sock.flush().await;
|
||||
// Drop closes the socket with 59 of the promised bytes never sent.
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
// ── total request timeout ───────────────────────────────────────────
|
||||
|
||||
/// The client's timeout is a TOTAL request timeout, so it bounds a download and
|
||||
/// not just the handshake. `gl ipfs get`'s documentation leans on exactly that:
|
||||
/// the wall-clock deadline covers the search and deliberately stops at the
|
||||
/// response headers, and this timeout is the only thing left bounding the body.
|
||||
///
|
||||
/// Driven at 250ms through `with_timeout`, the seam `new` itself calls with
|
||||
/// `TOTAL_REQUEST_TIMEOUT`, because a test at the shipped 30s has no place in this
|
||||
/// suite. What that costs is the value; what it proves is the SHAPE, which is the
|
||||
/// part in doubt: that the deadline keeps running once the headers have landed. A
|
||||
/// timeout that covered only the handshake would let this request hang until the
|
||||
/// listener gives up.
|
||||
#[tokio::test]
|
||||
async fn total_timeout_cuts_off_a_body_that_outruns_it() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut scratch = [0u8; 1024];
|
||||
let _ = sock.read(&mut scratch).await;
|
||||
// Headers land immediately, then the body stalls indefinitely.
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1024\r\n\r\nfirst")
|
||||
.await;
|
||||
let _ = sock.flush().await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
});
|
||||
|
||||
let client = NodeClient::with_timeout(
|
||||
format!("http://{addr}"),
|
||||
None,
|
||||
std::time::Duration::from_millis(250),
|
||||
);
|
||||
let started = std::time::Instant::now();
|
||||
let resp = client.get("/slow").await.expect("headers arrive promptly");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"the stall is in the body, not the status"
|
||||
);
|
||||
let err = resp
|
||||
.bytes()
|
||||
.await
|
||||
.expect_err("a body still arriving past the total timeout must be cut off");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
err.is_timeout(),
|
||||
"the body read must end in a timeout, got: {err}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(5),
|
||||
"the timeout must fire on its own schedule, not wait out the listener; \
|
||||
took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_client_uses_the_documented_total_timeout() {
|
||||
// The scaled-down test above proves the shape at 250ms. This pins the value
|
||||
// `new` actually ships, so the two together cover the documented behaviour.
|
||||
assert_eq!(TOTAL_REQUEST_TIMEOUT, std::time::Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_controls_bidi_and_caps_length() {
|
||||
// C0 (ESC/BEL) and the Cf bidi override (U+202E) are both removed; the
|
||||
// printable text survives. (Note: a stripped ESC leaves any following
|
||||
// "[31m" as inert literal text — that is the point, so the input here
|
||||
// avoids that residue to keep the expectation unambiguous.)
|
||||
let out = sanitize_node_msg("a\u{1b}\u{07}b\u{202e}c");
|
||||
assert!(
|
||||
!out.chars().any(|c| c.is_control()),
|
||||
"control char leaked: {out:?}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains('\u{202e}'),
|
||||
"RLO bidi override leaked: {out:?}"
|
||||
);
|
||||
assert_eq!(out, "abc");
|
||||
// Length is capped at 200 chars regardless of input size.
|
||||
let long = "x".repeat(250);
|
||||
assert_eq!(sanitize_node_msg(&long).chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_legitimate_and_rtl_text() {
|
||||
// Must not over-strip: a plain word, a genuine RTL SCRIPT letter (Arabic
|
||||
// U+0627, category Lo — NOT a format char), and ZWJ (U+200D, a legitimate
|
||||
// Cf char, e.g. emoji sequences) all survive. Guards the shared predicate
|
||||
// against being widened into a blanket Cf stripper.
|
||||
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
|
||||
assert_eq!(out, "ok \u{0627}\u{200D}b");
|
||||
}
|
||||
|
||||
// ── read_json (status-checked, bounded, sanitized read; #123) ────────
|
||||
|
||||
/// Drive a real `reqwest::Response` off a mockito mock so `read_json` sees an
|
||||
/// actual HTTP status + body, the same shape the gated read arms produce.
|
||||
async fn response_for(
|
||||
server: &mut Server,
|
||||
status: usize,
|
||||
body: &str,
|
||||
json: bool,
|
||||
) -> reqwest::Response {
|
||||
let mut m = server.mock("GET", "/x").with_status(status).with_body(body);
|
||||
if json {
|
||||
m = m.with_header("content-type", "application/json");
|
||||
}
|
||||
let _m = m.create_async().await;
|
||||
NodeClient::new(server.url(), None).get("/x").await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_returns_body_on_2xx() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
200,
|
||||
r#"{"name":"r","owner_did":"did:gitlawb:z"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let v = read_json(resp, "repo").await.unwrap();
|
||||
assert_eq!(v["name"], "r");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_404_surfacing_message_and_status() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
404,
|
||||
r#"{"message":"repository 'o/r' not found"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("404"), "err={err}");
|
||||
assert!(err.contains("not found"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_500_surfacing_message() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 500, r#"{"message":"internal boom"}"#, true).await;
|
||||
let err = read_json(resp, "commits").await.unwrap_err().to_string();
|
||||
assert!(err.contains("500"), "err={err}");
|
||||
assert!(err.contains("internal boom"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_non_json_error_body_with_fallback() {
|
||||
// 503 with a plain-text (middleware) body: no `message` field to surface.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 503, "service unavailable", false).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("503"), "err={err}");
|
||||
assert!(err.contains("request failed"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_sanitizes_control_and_bidi_in_message() {
|
||||
// A hostile node embeds ESC, BEL, and a right-to-left override in
|
||||
// the error message; none may reach the terminal verbatim.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
404,
|
||||
r#"{"message":"a\u001b[31mb\u0007c\u202ed"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(!err.contains('\u{1b}'), "ESC leaked: {err:?}");
|
||||
assert!(!err.contains('\u{7}'), "BEL leaked: {err:?}");
|
||||
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_garbage_2xx_body() {
|
||||
// The #123 correctness point: a 200 with a non-JSON/truncated body must be
|
||||
// an `Err`, NOT `Ok(Null)` that a caller renders as an empty success.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 200, "this is not json", false).await;
|
||||
assert!(read_json(resp, "repo").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_empty_2xx_body() {
|
||||
// A zero-byte 200 body must be an `Err`, not a silent `Ok(Null)`.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 200, "", false).await;
|
||||
assert!(read_json(resp, "repo").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_non_2xx_json_without_message_or_error() {
|
||||
// A non-2xx JSON body carrying NEITHER key is the real fallback case.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 403, r#"{"detail":"forbidden"}"#, true).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("403"), "err={err}");
|
||||
assert!(err.contains("request failed"), "err={err}");
|
||||
// The body's own text must not leak when no recognised key carries it.
|
||||
assert!(!err.contains("forbidden"), "raw body leaked: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_surfaces_error_key_when_message_absent() {
|
||||
// The task API answers with `error` alone — see
|
||||
// gitlawb-node/src/api/tasks.rs `{"error":"task not found"}`. Reading only
|
||||
// `message` blanked those denials to the generic "request failed", so the
|
||||
// user was told nothing about why the call failed.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 404, r#"{"error":"task not found"}"#, true).await;
|
||||
let err = read_json(resp, "task show").await.unwrap_err().to_string();
|
||||
assert!(err.contains("404"), "err={err}");
|
||||
assert!(err.contains("task not found"), "reason not surfaced: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_prefers_message_over_error_key() {
|
||||
// The shared AppError envelope carries both; `message` is the human text
|
||||
// and must win over the machine code in `error`.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
500,
|
||||
r#"{"error":"db_error","message":"database unavailable"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("database unavailable"), "err={err}");
|
||||
assert!(
|
||||
!err.contains("db_error"),
|
||||
"machine code won over message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_sanitizes_error_key_text() {
|
||||
// The `error` fallback goes through the same sanitizer as `message`; a
|
||||
// hostile node must not reach the terminal through the new path. The
|
||||
// controls are JSON \u escapes so the body stays VALID JSON — embedding
|
||||
// them raw would fail the parse and pass this test vacuously.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
404,
|
||||
r#"{"error":"a\u001b[31mb\u0007c\u202ed"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "task show").await.unwrap_err().to_string();
|
||||
// Proves the parse succeeded and the `error` key was read, not the
|
||||
// "request failed" fallback (which would make the asserts below vacuous).
|
||||
assert!(err.contains("task show failed (404"), "err={err}");
|
||||
assert!(
|
||||
!err.contains("request failed"),
|
||||
"fell back, sanitizer untested: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains('a') && err.contains('d'),
|
||||
"text dropped: {err}"
|
||||
);
|
||||
assert!(!err.contains('\u{1b}'), "ESC leaked: {err:?}");
|
||||
assert!(!err.contains('\u{7}'), "BEL leaked: {err:?}");
|
||||
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
|
||||
}
|
||||
|
||||
/// #186 (F2): the non-2xx error path must read a CAPPED body, not buffer and
|
||||
/// parse the whole thing — a hostile node can stream an arbitrarily large valid
|
||||
/// JSON error. Place the `message` field AFTER more than the cap of padding: the
|
||||
/// bounded read stops before it, so it must be ABSENT from the surfaced error.
|
||||
/// RED before the fix (`resp.json()` parses the full body → the far message
|
||||
/// appears), GREEN after (capped read truncates → fallback → far message gone).
|
||||
/// The status is still surfaced, so a legitimate small error is unaffected.
|
||||
#[tokio::test]
|
||||
async fn read_json_bounds_the_error_body_read() {
|
||||
let mut server = Server::new_async().await;
|
||||
// >8 KiB of padding, then the marker message at the very end of the body.
|
||||
let big = format!(
|
||||
r#"{{"padding":"{}","message":"FARMARKER_BEYOND_CAP"}}"#,
|
||||
"A".repeat(16 * 1024)
|
||||
);
|
||||
let resp = response_for(&mut server, 500, &big, true).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("500"), "bounded read still errors: {err}");
|
||||
assert!(
|
||||
!err.contains("FARMARKER_BEYOND_CAP"),
|
||||
"the error body read must be capped: a message beyond the cap must not be surfaced, err={err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #186 (F2): an EMPTY non-2xx body is its own input class — the capped read
|
||||
/// yields "", the JSON parse fails, and it falls back to the status alone
|
||||
/// ("request failed"), never a panic or a spurious success.
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_empty_non_2xx_body() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 502, "", false).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("502"), "err={err}");
|
||||
assert!(err.contains("request failed"), "err={err}");
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ async fn cmd_new_with_reader(
|
||||
println!(" DID: {did}");
|
||||
println!(" Key: {}", path.display());
|
||||
println!();
|
||||
println!(" Your DID is your identity on the Twigpine network.");
|
||||
println!(" Your DID is your identity on the gitlawb network.");
|
||||
println!(" Keep your key file safe — it cannot be recovered if lost.");
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `gl init` — zero-to-push in one command.
|
||||
//!
|
||||
//! Detects or initializes a git repo, ensures an identity exists,
|
||||
//! registers with the node, creates a remote repo, adds the Twigpine
|
||||
//! registers with the node, creates a remote repo, adds the gitlawb
|
||||
//! remote, and pushes.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -19,11 +19,7 @@ pub struct InitArgs {
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Node URL to register with
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
@@ -155,7 +151,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
println!(" Repository created.");
|
||||
}
|
||||
|
||||
// 5. Add Twigpine remote
|
||||
// 5. Add gitlawb remote
|
||||
let did_short = did.to_string();
|
||||
let did_short = did_short.split(':').next_back().unwrap_or(&did_short);
|
||||
let remote_url = format!("gitlawb://{did_short}/{repo_name}");
|
||||
|
||||
+25
-2122
File diff suppressed because it is too large.
Load diff
+55
-102
@@ -3,7 +3,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -32,11 +32,7 @@ pub enum IssueCmd {
|
||||
/// Issue body (optional)
|
||||
#[arg(long, short)]
|
||||
body: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -45,11 +41,7 @@ pub enum IssueCmd {
|
||||
List {
|
||||
/// Repository in <owner>/<repo> or <repo> format
|
||||
repo: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -60,11 +52,7 @@ pub enum IssueCmd {
|
||||
repo: String,
|
||||
/// Issue ID
|
||||
id: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -75,11 +63,7 @@ pub enum IssueCmd {
|
||||
repo: String,
|
||||
/// Issue ID
|
||||
id: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -90,11 +74,7 @@ pub enum IssueCmd {
|
||||
id: String,
|
||||
#[arg(long, short)]
|
||||
body: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -103,11 +83,7 @@ pub enum IssueCmd {
|
||||
Comments {
|
||||
repo: String,
|
||||
id: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -166,7 +142,12 @@ async fn resolve_repo(
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(node, None);
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let did = info["did"].as_str().context("node info missing 'did'")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -217,7 +198,13 @@ async fn cmd_create(
|
||||
.post(&path, &request_body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let result = crate::http::read_json(resp, "create issue").await?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create issue failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let id = result["id"].as_str().unwrap_or("?");
|
||||
println!("✓ Created issue #{id}");
|
||||
@@ -234,7 +221,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues");
|
||||
let resp = crate::http::read_json(client.get_authed(&path).await?, "issues").await?;
|
||||
let resp: Value = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list issues")?;
|
||||
|
||||
let issues = resp["issues"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -272,7 +264,13 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
|
||||
.get_authed(&path)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let issue = crate::http::read_json(resp, "show").await?;
|
||||
let status = resp.status();
|
||||
let issue: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = issue["message"].as_str().unwrap_or("issue not found");
|
||||
anyhow::bail!("show failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let title = issue["title"].as_str().unwrap_or("(no title)");
|
||||
let status = issue["status"].as_str().unwrap_or("?");
|
||||
@@ -303,7 +301,13 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
|
||||
.post(&format!("{path}/close"), &body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let _ = crate::http::read_json(resp, "close issue").await?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("close issue failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
println!("✗ Closed issue {id}");
|
||||
Ok(())
|
||||
@@ -328,7 +332,13 @@ async fn cmd_issue_comment(
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let _ = crate::http::read_json(resp, "comment").await?;
|
||||
let code = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !code.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("comment failed ({code}): {msg}");
|
||||
}
|
||||
|
||||
println!("· Comment posted on issue {id}");
|
||||
Ok(())
|
||||
@@ -343,15 +353,14 @@ async fn cmd_issue_comments(
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get_authed(&format!(
|
||||
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
|
||||
))
|
||||
.await?,
|
||||
"issue comments",
|
||||
)
|
||||
.await?;
|
||||
let resp: Value = client
|
||||
.get_authed(&format!(
|
||||
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
|
||||
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
|
||||
if comments.is_empty() {
|
||||
@@ -481,7 +490,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -496,8 +504,6 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("repo not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -545,7 +551,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"issue not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -559,8 +564,6 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("show failed"), "got: {err}");
|
||||
assert!(err.to_string().contains("issue not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -609,7 +612,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"issue not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -622,8 +624,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("close issue failed"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -670,7 +670,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"issue not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -684,8 +683,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("issue not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -743,48 +740,4 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
// A gated 404 on the issue-list read must Err, not print "No issues".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/issues$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
|
||||
assert!(result.is_err(), "issue list must Err on a gated 404");
|
||||
// Prove the gated issues path was actually requested: without this, an
|
||||
// unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_repo_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the GET / node-info
|
||||
// fetch. A gated 404 there must Err (surfacing the status), proving the
|
||||
// read_json conversion is load-bearing rather than silently ignored.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = resolve_repo("noslash", &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ mod whoami;
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "gl",
|
||||
about = "Twigpine CLI — identity, repos, MCP server",
|
||||
about = "gitlawb CLI — identity, repos, MCP server",
|
||||
version,
|
||||
propagate_version = true
|
||||
)]
|
||||
@@ -49,16 +49,16 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Manage your Twigpine identity (DID + keypair)
|
||||
/// Manage your gitlawb identity (DID + keypair)
|
||||
Identity {
|
||||
#[command(subcommand)]
|
||||
cmd: identity::IdentityCmd,
|
||||
},
|
||||
|
||||
/// Register this agent with a Twigpine node
|
||||
/// Register this agent with a gitlawb node
|
||||
Register(register::RegisterArgs),
|
||||
|
||||
/// Clone a Twigpine repo, handling private subtrees cleanly
|
||||
/// Clone a gitlawb repo, handling private subtrees cleanly
|
||||
Clone(clone::CloneArgs),
|
||||
|
||||
/// Manage repositories
|
||||
@@ -85,10 +85,10 @@ enum Commands {
|
||||
/// Manage webhooks for a repository
|
||||
Webhook(webhook::WebhookArgs),
|
||||
|
||||
/// Mirror a public GitHub/GitLab/any-git repo into Twigpine
|
||||
/// Mirror a public GitHub/GitLab/any-git repo into gitlawb
|
||||
Mirror(mirror::MirrorArgs),
|
||||
|
||||
/// MCP server — expose Twigpine tools to LLM agents
|
||||
/// MCP server — expose gitlawb tools to LLM agents
|
||||
Mcp(mcp::McpArgs),
|
||||
|
||||
/// Sync repos from peer nodes (HTTP fallback for p2p gossip)
|
||||
@@ -100,7 +100,7 @@ enum Commands {
|
||||
/// Register and resolve names on Base L2
|
||||
Name(name::NameArgs),
|
||||
|
||||
/// Check your Twigpine installation and connectivity
|
||||
/// Check your gitlawb installation and connectivity
|
||||
Doctor(doctor::DoctorArgs),
|
||||
|
||||
/// Zero-to-push in one command — init repo, identity, register, remote
|
||||
|
||||
+169
-927
File diff suppressed because it is too large.
Load diff
+10
-14
@@ -3,7 +3,7 @@
|
||||
//! Flow:
|
||||
//! 1. Parse source URL → derive repo name (or use --repo override)
|
||||
//! 2. `git clone --mirror <source>` into a temp dir
|
||||
//! 3. Create the repo on the Twigpine node (authenticated)
|
||||
//! 3. Create the repo on the gitlawb node (authenticated)
|
||||
//! 4. `git push --mirror gitlawb://<did>/<name>`
|
||||
//! 5. Clean up temp dir
|
||||
|
||||
@@ -22,19 +22,15 @@ pub struct MirrorArgs {
|
||||
/// Source repository URL (https://github.com/owner/repo, GitLab, or any public git URL)
|
||||
pub source: String,
|
||||
|
||||
/// Name to use on Twigpine (default: last path segment of source URL, .git suffix stripped)
|
||||
/// Name to use on gitlawb (default: last path segment of source URL, .git suffix stripped)
|
||||
#[arg(long)]
|
||||
pub repo: Option<String>,
|
||||
|
||||
/// Optional description for the Twigpine repo
|
||||
/// Optional description for the gitlawb repo
|
||||
#[arg(long, short)]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
#[arg(long)]
|
||||
@@ -66,7 +62,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
let owner_did = kp.did().to_string();
|
||||
|
||||
println!("Mirroring {source}");
|
||||
println!(" → Twigpine repo: {name}");
|
||||
println!(" → gitlawb repo: {name}");
|
||||
println!();
|
||||
|
||||
// ── 3. git clone --mirror into a temp dir ─────────────────────────────
|
||||
@@ -93,8 +89,8 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
bail!("git clone --mirror failed\nCheck that the source URL is accessible: {source}");
|
||||
}
|
||||
|
||||
// ── 4. Create the repo on Twigpine ─────────────────────────────────────
|
||||
println!("Creating repo on Twigpine node...");
|
||||
// ── 4. Create the repo on gitlawb ─────────────────────────────────────
|
||||
println!("Creating repo on gitlawb node...");
|
||||
let client = NodeClient::new(&args.node, Some(kp));
|
||||
|
||||
let body = serde_json::to_vec(&json!({
|
||||
@@ -117,7 +113,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
bail!("failed to create repo ({status}): {msg}");
|
||||
}
|
||||
|
||||
// ── 5. git push --mirror to Twigpine ───────────────────────────────────
|
||||
// ── 5. git push --mirror to gitlawb ───────────────────────────────────
|
||||
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
|
||||
println!("Pushing to {gitlawb_url}...");
|
||||
|
||||
@@ -129,7 +125,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
.context("failed to run git push")?;
|
||||
|
||||
if !push_status.success() {
|
||||
bail!("git push --mirror failed\nThe repo was created on Twigpine but the push did not complete.\nYou can retry with:\n cd {path} && git push --mirror {gitlawb_url}",
|
||||
bail!("git push --mirror failed\nThe repo was created on gitlawb but the push did not complete.\nYou can retry with:\n cd {path} && git push --mirror {gitlawb_url}",
|
||||
path = mirror_path.display());
|
||||
}
|
||||
|
||||
@@ -138,7 +134,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
println!();
|
||||
println!("✓ Mirror complete: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
println!(" View: https://gitlawb.com/{owner_short}/{name}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub enum NameCmd {
|
||||
/// Name registry contract address
|
||||
#[arg(long, env = "GITLAWB_CONTRACT_NAME_REGISTRY", default_value = DEFAULT_NAME_REGISTRY)]
|
||||
contract: String,
|
||||
/// Twigpine identity directory (reads your DID)
|
||||
/// gitlawb identity directory (reads your DID)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
+4
-16
@@ -20,11 +20,7 @@ pub struct NodeArgs {
|
||||
pub enum NodeCmd {
|
||||
/// Show a comprehensive status dashboard for the node
|
||||
Status {
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
@@ -33,21 +29,13 @@ pub enum NodeCmd {
|
||||
/// Check trust score for a DID
|
||||
Trust {
|
||||
did: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
/// Resolve a DID to node info
|
||||
Resolve {
|
||||
did: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
|
||||
@@ -324,7 +312,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
|
||||
// ── Render dashboard ──────────────────────────────────────────────────
|
||||
println!("╔══════════════════════════════════════════════╗");
|
||||
println!("║ Twigpine node status ║");
|
||||
println!("║ gitlawb node status ║");
|
||||
println!("╚══════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ pub async fn cmd_register(
|
||||
let token_addr: Address = token.parse().context("invalid token address")?;
|
||||
let contract_addr: Address = contract.parse().context("invalid contract address")?;
|
||||
|
||||
println!("Registering Twigpine node on Base L2...");
|
||||
println!("Registering gitlawb node on Base L2...");
|
||||
println!(" DID: {did}");
|
||||
println!(" Stake: {stake} $GITLAWB");
|
||||
println!(" HTTP URL: {http_url}");
|
||||
|
||||
+45
-720
@@ -7,7 +7,7 @@ use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{read_body_capped, sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -20,22 +20,14 @@ pub struct PeerArgs {
|
||||
pub enum PeerCmd {
|
||||
/// List known peers on the node
|
||||
List {
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
/// Announce yourself to a peer node (adds you to their peer list)
|
||||
Add {
|
||||
/// The URL of the peer node to announce to
|
||||
peer_url: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -44,22 +36,14 @@ pub enum PeerCmd {
|
||||
Ping {
|
||||
/// The DID of the peer to ping
|
||||
did: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
/// Resolve a DID to its node URL and p2p info (checks local cache then Kademlia DHT)
|
||||
Resolve {
|
||||
/// The DID to resolve (e.g. did:key:z6Mk...)
|
||||
did: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
}
|
||||
@@ -79,7 +63,12 @@ pub async fn run(args: PeerArgs) -> Result<()> {
|
||||
|
||||
async fn cmd_list(node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let resp = crate::http::read_json(client.get("/api/v1/peers").await?, "peers").await?;
|
||||
let resp: Value = client
|
||||
.get("/api/v1/peers")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list peers")?;
|
||||
|
||||
let peers = resp["peers"].as_array().cloned().unwrap_or_default();
|
||||
let count = resp["count"].as_u64().unwrap_or(peers.len() as u64);
|
||||
@@ -108,92 +97,22 @@ async fn cmd_list(node: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warning text for a local peer-add reply, or `None` when the node
|
||||
/// accepted it.
|
||||
///
|
||||
/// Split out so the refusal path is assertable. The node now answers 403 when
|
||||
/// an unproven caller tries to repoint an existing peer's `http_url`, so a
|
||||
/// reply that arrived and refused must not reach the success line.
|
||||
fn local_add_refusal(status: reqwest::StatusCode, body: &Value) -> Option<String> {
|
||||
if status.is_success() {
|
||||
return None;
|
||||
}
|
||||
// The message is the node's, not ours, so it is defanged before it reaches
|
||||
// the terminal. Only the message: the status and the prose are ours.
|
||||
let msg = sanitize_node_msg(body["message"].as_str().unwrap_or("unknown error"));
|
||||
Some(format!(
|
||||
"warning: local peer list not updated ({status}): {msg}"
|
||||
))
|
||||
}
|
||||
|
||||
/// The error text for a remote announce reply, or `None` when the peer accepted
|
||||
/// it.
|
||||
///
|
||||
/// Split out so the ordering is assertable: the status has to reach the caller
|
||||
/// even when the body does not parse. That case is reachable rather than
|
||||
/// theoretical, because the read above is capped, so a peer answering with a
|
||||
/// body past the cap leaves truncated JSON behind, and `peer_url` is whatever
|
||||
/// the caller passed, so a non-JSON error page is just as easy. Parsing first
|
||||
/// and failing on the parse would swallow the status the user needs.
|
||||
fn remote_announce_failure(status: reqwest::StatusCode, raw: &str) -> Option<String> {
|
||||
if status.is_success() {
|
||||
return None;
|
||||
}
|
||||
// Falls back to the body itself when there is no `message` field, which is
|
||||
// what a truncated or non-JSON reply leaves. Sanitized either way: it is the
|
||||
// peer's text and it is headed for the terminal.
|
||||
let parsed: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
|
||||
let msg = sanitize_node_msg(parsed["message"].as_str().unwrap_or(raw));
|
||||
Some(format!("announce failed ({status}): {msg}"))
|
||||
}
|
||||
|
||||
/// Which line the local peer-add prints, and whether it is a warning.
|
||||
///
|
||||
/// Split out because the SELECTION is what the fix is: `local_add_refusal` was
|
||||
/// already unit-tested six ways, and replacing this whole match with an
|
||||
/// unconditional "Added to local peer list." still left 312 tests green. The
|
||||
/// helper was pinned; the wiring was not, which is the shape
|
||||
/// `unit-test-on-helper-does-not-prove-handler-wiring` names. With the choice
|
||||
/// extracted, the call site is a two-line dispatch carrying no logic.
|
||||
fn local_add_report(status: reqwest::StatusCode, body: &Value) -> (bool, String) {
|
||||
match local_add_refusal(status, body) {
|
||||
Some(warning) => (true, warning),
|
||||
None => (false, " Added to local peer list.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block printed after a peer accepts our announce. Split out so the
|
||||
/// defanging is assertable: the DID and URL are the remote peer's own strings,
|
||||
/// and `peer_url` is caller-chosen, so this is the least trusted body the
|
||||
/// command handles. Only the display is sanitized; the values sent on to our
|
||||
/// local node stay verbatim, since that is data rather than terminal output and
|
||||
/// the node runs its own validation on them.
|
||||
fn announced_peer_summary(their_did: &str, their_url: &str, peer_count: u64) -> String {
|
||||
format!(
|
||||
"Announced to peer node:\n DID: {}\n URL: {}\n Their peers: {peer_count}\n",
|
||||
sanitize_node_msg(their_did),
|
||||
sanitize_node_msg(their_url),
|
||||
)
|
||||
}
|
||||
|
||||
async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let my_did = keypair.did().to_string();
|
||||
|
||||
// Fetch our node's public URL so we can announce it to the peer. This lookup
|
||||
// is a deliberate fail-soft diagnostic: it only improves the URL we advertise.
|
||||
// A response-level failure (denial, error, garbage body) falls back to the
|
||||
// --node value with a visible note rather than aborting; only a transport
|
||||
// failure on the GET itself is fatal. The announce below keeps the
|
||||
// fail-closed read_json check.
|
||||
// Fetch our node's public URL so we can announce it to the peer
|
||||
let local_client = NodeClient::new(&node, None);
|
||||
let my_url = match crate::http::read_json(local_client.get("/").await?, "node info").await {
|
||||
Ok(info) => info["public_url"].as_str().unwrap_or(&node).to_string(),
|
||||
Err(e) => {
|
||||
eprintln!("note: local node info unavailable ({e}); announcing {node}");
|
||||
node.clone()
|
||||
}
|
||||
};
|
||||
let node_info: Value = local_client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch local node info")?;
|
||||
let my_url = node_info["public_url"]
|
||||
.as_str()
|
||||
.unwrap_or(&node)
|
||||
.to_string();
|
||||
|
||||
// Announce our local node to the remote peer
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
@@ -208,26 +127,21 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
.await
|
||||
.context("failed to connect to peer")?;
|
||||
let status = resp.status();
|
||||
// `peer_url` is fully caller-chosen, so this body is the least trusted one
|
||||
// in the command: bound the read, and defang the message before it reaches
|
||||
// the terminal through the error return. An announce reply is a DID, a URL
|
||||
// and a count, so 8 KiB is well past what the shape needs.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await.text;
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if let Some(failure) = remote_announce_failure(status, &raw) {
|
||||
anyhow::bail!("{failure}");
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("announce failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let result: Value = serde_json::from_str(&raw).context("invalid JSON response")?;
|
||||
|
||||
let their_did = result["node_did"].as_str().unwrap_or("?");
|
||||
let their_url = result["node_url"].as_str().unwrap_or("?");
|
||||
let peer_count = result["peer_count"].as_u64().unwrap_or(0);
|
||||
|
||||
print!(
|
||||
"{}",
|
||||
announced_peer_summary(their_did, their_url, peer_count)
|
||||
);
|
||||
println!("Announced to peer node:");
|
||||
println!(" DID: {their_did}");
|
||||
println!(" URL: {their_url}");
|
||||
println!(" Their peers: {peer_count}");
|
||||
|
||||
// Also add their info to our local node's peer list
|
||||
// (the peer's /announce response includes their did + url)
|
||||
@@ -236,31 +150,9 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
"did": their_did,
|
||||
"http_url": their_url,
|
||||
}))?;
|
||||
// This requires the local node to be running, so a transport failure
|
||||
// stays a warning rather than failing the command. A reply that
|
||||
// ARRIVED and refused is different: the announce route answers 403
|
||||
// when an unproven caller tries to repoint an existing peer, and
|
||||
// printing the success line over that reports a completed add that
|
||||
// never happened. Same status handling as the remote announce above,
|
||||
// except best effort.
|
||||
match local_client.post("/api/v1/peers/announce", &add_body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// Same bound as the remote announce above: a compromised local
|
||||
// node (or a MITM on plain http) must not force an unbounded
|
||||
// read. A body that does not parse stays `Null`, which still
|
||||
// routes a non-success status to the warning.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await.text;
|
||||
let result: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
|
||||
let (is_warning, line) = local_add_report(status, &result);
|
||||
if is_warning {
|
||||
eprintln!("{line}");
|
||||
} else {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("warning: local peer list not updated: {e}"),
|
||||
}
|
||||
// This requires the local node to be running; ignore errors here
|
||||
let _ = local_client.post("/api/v1/peers/announce", &add_body).await;
|
||||
println!(" Added to local peer list.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -269,7 +161,12 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
async fn cmd_ping(did: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let path = format!("/api/v1/peers/{did}/ping");
|
||||
let resp = crate::http::read_json(client.get(&path).await?, "ping peer").await?;
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to ping peer")?;
|
||||
|
||||
let url = resp["http_url"].as_str().unwrap_or("?");
|
||||
let reachable = resp["reachable"].as_bool().unwrap_or(false);
|
||||
@@ -289,7 +186,12 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let encoded = urlencoding::encode(&did);
|
||||
let path = format!("/api/v1/resolve/{encoded}");
|
||||
let resp = crate::http::read_json(client.get(&path).await?, "resolve DID").await?;
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to resolve DID")?;
|
||||
|
||||
let source = resp["source"].as_str().unwrap_or("not found");
|
||||
let http_url = resp["http_url"].as_str().unwrap_or("(none)");
|
||||
@@ -308,580 +210,3 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
announced_peer_summary, cmd_add, cmd_list, cmd_ping, cmd_resolve, local_add_refusal,
|
||||
local_add_report, remote_announce_failure,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
/// The node's 403 for repointing an existing peer must surface, not print
|
||||
/// as a completed add. Without the status check the command prints "Added
|
||||
/// to local peer list." over a refusal.
|
||||
///
|
||||
/// The fixture body is the real wire shape, not an invented one: the
|
||||
/// message is the Display of `PeerWriteDenied::UnprovenRepoint`, declared
|
||||
/// in `crates/gitlawb-node/src/db/mod.rs`. The executable detector for a
|
||||
/// wording change is the node-side pin
|
||||
/// `announce_unsigned_repoint_is_a_403_denial` in
|
||||
/// `crates/gitlawb-node/src/api/peers.rs`, which asserts this same
|
||||
/// substring against a response driven through the real router, so
|
||||
/// rewording the Display turns that test red and the grep for the old
|
||||
/// phrase lands here.
|
||||
///
|
||||
/// The assertion bites rather than echoing its own input because the
|
||||
/// substring is reachable only through the helper's `body["message"]`
|
||||
/// extraction; the fixture's `error` field carries a different string, so
|
||||
/// reading `body["error"]` instead fails this test.
|
||||
#[test]
|
||||
fn a_refused_local_add_warns_with_the_node_reason() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({
|
||||
"error": "forbidden",
|
||||
"message": "unproven announce cannot change an existing peer's http_url: did:key:z6MkuMqUm4i228K9qXidJ57zqSWAcQLgrcbMxB8RKVLuqitj"
|
||||
}),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(warning.contains("403"), "must name the status: {warning}");
|
||||
assert!(
|
||||
warning.contains("unproven announce cannot change an existing peer"),
|
||||
"must carry the node's own reason: {warning}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refusal with no message body still warns rather than passing silently.
|
||||
#[test]
|
||||
fn a_refusal_without_a_message_still_warns() {
|
||||
let warning = local_add_refusal(StatusCode::BAD_REQUEST, &serde_json::Value::Null)
|
||||
.expect("a refusal must not render as success");
|
||||
assert!(warning.contains("400"), "must name the status: {warning}");
|
||||
}
|
||||
|
||||
/// The accepted case stays quiet, so the success line still prints.
|
||||
#[test]
|
||||
fn an_accepted_local_add_produces_no_warning() {
|
||||
assert!(local_add_refusal(StatusCode::OK, &json!({ "peer_count": 3 })).is_none());
|
||||
}
|
||||
|
||||
/// The warning is printed straight to a terminal, so a node-supplied message
|
||||
/// carrying an OSC title-rewrite plus a C0 escape must reach it defanged.
|
||||
#[test]
|
||||
fn a_refusal_message_cannot_carry_terminal_escapes() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "\u{1b}]2;pwned\u{7}legit-looking text" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
!warning.chars().any(|c| c.is_control()),
|
||||
"control char leaked to the terminal: {warning:?}"
|
||||
);
|
||||
assert!(
|
||||
warning.contains("legit-looking text"),
|
||||
"printable text dropped: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bidi format chars are not `char::is_control`, yet they reorder the
|
||||
/// displayed line, so a refusal must not carry them either.
|
||||
#[test]
|
||||
fn a_refusal_message_cannot_carry_bidi_overrides() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "peer \u{202e}refused\u{202c} here" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
!warning.chars().any(gitlawb_core::sanitize::is_bidi_format),
|
||||
"bidi format char leaked to the terminal: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hostile node must not be able to dump an arbitrary amount of text into
|
||||
/// the user's scrollback through the warning line.
|
||||
#[test]
|
||||
fn a_refusal_message_is_length_bounded() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "x".repeat(5000) }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
warning.chars().count() < 500,
|
||||
"warning not bounded: {} chars",
|
||||
warning.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
/// The status is the part the user can act on, so it must survive a body
|
||||
/// that does not parse. The read is capped, so a peer answering past the cap
|
||||
/// leaves exactly this: a valid status and truncated JSON.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_keeps_the_status_when_the_body_does_not_parse() {
|
||||
let truncated = r#"{"error":"service_unavailable","message":"backend is do"#;
|
||||
let failure = remote_announce_failure(StatusCode::SERVICE_UNAVAILABLE, truncated)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
failure.contains("503"),
|
||||
"the status must survive an unparseable body: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A parseable error body still reports the peer's own message.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_carries_the_peers_message() {
|
||||
let failure = remote_announce_failure(
|
||||
StatusCode::FORBIDDEN,
|
||||
r#"{"error":"forbidden","message":"unproven announce cannot change an existing peer"}"#,
|
||||
)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
failure.contains("403")
|
||||
&& failure.contains("unproven announce cannot change an existing peer"),
|
||||
"status and reason must both survive: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fallback path prints the raw body, so it is defanged like every other
|
||||
/// peer-supplied string headed for the terminal.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_defangs_an_unparseable_body() {
|
||||
let failure = remote_announce_failure(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"\u{1b}]2;pwned\u{7}<html>gateway error</html>",
|
||||
)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
!failure.chars().any(|c| c.is_control()),
|
||||
"control char leaked to the terminal: {failure:?}"
|
||||
);
|
||||
assert!(failure.contains("502"), "status dropped: {failure:?}");
|
||||
assert!(
|
||||
failure.contains("gateway error"),
|
||||
"the body is all the user gets when there is no message field: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unit tests above bind the helper. This one binds the CALL SITE: the
|
||||
/// failure check has to run before the parse, or a body past the read cap
|
||||
/// turns a 503 into "invalid JSON response" and the status the user needs is
|
||||
/// gone. Reordering the two statements in `cmd_add` compiles and passes every
|
||||
/// other test in this file, so only driving the command catches it.
|
||||
#[tokio::test]
|
||||
async fn a_peer_failure_past_the_read_cap_still_reports_its_status() {
|
||||
let mut peer = mockito::Server::new_async().await;
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _local_info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://me.example"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Valid JSON, but past the 8 KiB read cap, so what survives the read is
|
||||
// truncated and unparseable. This is the shape the cap itself creates.
|
||||
let oversized = format!(r#"{{"message":"{}"}}"#, "x".repeat(16 * 1024));
|
||||
let _peer_refusal = peer
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(oversized)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_add(peer.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.expect_err("a 503 from the peer must fail the command");
|
||||
|
||||
let rendered = format!("{err:#}");
|
||||
assert!(
|
||||
rendered.contains("503"),
|
||||
"the status must survive a body past the read cap: {rendered:?}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("invalid JSON response"),
|
||||
"the parse failure must not stand in for the status: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An accepted announce produces no error, so the caller proceeds to parse.
|
||||
#[test]
|
||||
fn an_accepted_remote_announce_produces_no_failure() {
|
||||
assert!(remote_announce_failure(StatusCode::OK, r#"{"peer_count":3}"#).is_none());
|
||||
}
|
||||
|
||||
/// The selection, not the helper. A refusal must never yield the success
|
||||
/// line, and the success line must never carry the warning prefix. This is
|
||||
/// what stayed green when the entire match was replaced with an
|
||||
/// unconditional "Added to local peer list."
|
||||
#[test]
|
||||
fn a_local_add_refusal_never_selects_the_success_line() {
|
||||
let (is_warning, line) = local_add_report(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "unproven announce cannot change an existing peer's http_url: did:key:zAbc" }),
|
||||
);
|
||||
|
||||
assert!(is_warning, "a 403 must select the warning stream: {line:?}");
|
||||
assert!(
|
||||
!line.contains("Added to local peer list"),
|
||||
"a refusal rendered as the success line: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
line.contains("unproven announce cannot change an existing peer"),
|
||||
"the node's reason must reach the user: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other direction, so the fix cannot be satisfied by always warning.
|
||||
#[test]
|
||||
fn an_accepted_local_add_selects_the_success_line() {
|
||||
let (is_warning, line) = local_add_report(StatusCode::OK, &json!({ "peer_count": 3 }));
|
||||
|
||||
assert!(!is_warning, "a 200 must not select the warning stream");
|
||||
assert!(
|
||||
line.contains("Added to local peer list"),
|
||||
"the success line must still print: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The accepted-announce block prints the remote peer's own DID and URL, so
|
||||
/// a hostile peer answering 200 must not get escapes onto the terminal that
|
||||
/// way either. The refusal path is not the only sink in this command.
|
||||
#[test]
|
||||
fn an_accepted_announce_summary_cannot_carry_terminal_escapes() {
|
||||
let summary = announced_peer_summary(
|
||||
"did:key:z6Mk\u{1b}]2;pwned\u{7}abc",
|
||||
"https://peer.example\u{202e}moc.live//:sptth",
|
||||
3,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!summary.chars().any(|c| c.is_control() && c != '\n'),
|
||||
"control char leaked to the terminal: {summary:?}"
|
||||
);
|
||||
assert!(
|
||||
!summary.chars().any(gitlawb_core::sanitize::is_bidi_format),
|
||||
"bidi format char leaked to the terminal: {summary:?}"
|
||||
);
|
||||
assert!(
|
||||
summary.contains("did:key:z6Mk") && summary.contains("Their peers: 3"),
|
||||
"printable content dropped: {summary:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The counterweight to the stripping tests: a legitimate message with RTL
|
||||
/// script letters and a ZWJ must survive intact, so the sanitizer is not
|
||||
/// proven by over-stripping.
|
||||
#[test]
|
||||
fn a_legitimate_refusal_message_survives_intact() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "peer refused \u{0627}\u{200D}b" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
warning.contains("peer refused \u{0627}\u{200D}b"),
|
||||
"legitimate text mangled: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
// A node error must Err, not print "No known peers" as if the list were empty.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/peers")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_list(server.url()).await.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_ping_surfaces_denial_not_unreachable() {
|
||||
// A node error must Err, not print a fabricated "unreachable" peer.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/peers/peer1/ping")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_ping("peer1".to_string(), server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_resolve_surfaces_denial_not_notfound() {
|
||||
// A node error must Err, not print "Source: not found" as if resolved-absent.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/resolve/".to_string()),
|
||||
)
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_resolve("peer1".to_string(), server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// cmd_add needs a local identity to build my_did before the GET /.
|
||||
fn identity_dir() -> tempfile::TempDir {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Mock a successful announce that requires `http_url` in the POSTed body.
|
||||
/// The `{}` response body matters: it leaves `node_url` absent, so cmd_add
|
||||
/// skips the follow-up local peer-list POST.
|
||||
async fn announce_ok_mock(server: &mut mockito::ServerGuard, http_url: &str) -> mockito::Mock {
|
||||
announce_mock_with_body(server, http_url, "{}").await
|
||||
}
|
||||
|
||||
/// Like [`announce_ok_mock`] but with a custom response body, for tests
|
||||
/// that need `node_url` populated so the follow-up local add runs.
|
||||
async fn announce_mock_with_body(
|
||||
server: &mut mockito::ServerGuard,
|
||||
http_url: &str,
|
||||
body: &str,
|
||||
) -> mockito::Mock {
|
||||
server
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
|
||||
"http_url": http_url,
|
||||
})))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await
|
||||
}
|
||||
|
||||
// The local `GET /` lookup in cmd_add is a fail-soft diagnostic: a denied
|
||||
// or error node-info response must fall back to announcing the --node URL
|
||||
// (with a stderr note), not abort the command before the announce POST.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_announces_fallback_url_when_node_info_denied() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"internal"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_ok_mock(&mut remote, &local.url()).await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// Same fallback for a 200 node-info response whose body is not JSON.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_falls_back_on_malformed_node_info() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_body("not json")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_ok_mock(&mut remote, &local.url()).await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// Must-not case: the announce POST itself stays fail-closed. A denied
|
||||
// announce surfaces as an Err naming the status, never a printed success.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_surfaces_denied_announce() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = remote
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let dir = identity_dir();
|
||||
let err = cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("403"), "got: {err}");
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// Success path unchanged: a usable node-info response announces its
|
||||
// public_url; the --node fallback must not leak in.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_uses_public_url_when_lookup_succeeds() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_ok_mock(&mut remote, "https://pub.example").await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// When the peer's announce response carries a node_url, cmd_add posts the
|
||||
// peer back to the local node's peer list; a successful local POST is the
|
||||
// one case that prints the added line.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_adds_peer_to_local_list_on_success() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _local_add = local
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"added"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_mock_with_body(
|
||||
&mut remote,
|
||||
"https://pub.example",
|
||||
r#"{"node_did":"their-did","node_url":"http://their.example"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
_local_add.assert_async().await;
|
||||
}
|
||||
|
||||
// The local add stays best-effort: a failing local POST must not fail the
|
||||
// command (the announce to the remote peer already succeeded), it only
|
||||
// changes the printed outcome.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_stays_ok_when_local_add_fails() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _local_add = local
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_mock_with_body(
|
||||
&mut remote,
|
||||
"https://pub.example",
|
||||
r#"{"node_did":"their-did","node_url":"http://their.example"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
_local_add.assert_async().await;
|
||||
}
|
||||
}
|
||||
+81
-361
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -34,11 +35,7 @@ pub enum PrCmd {
|
||||
/// Repo owner DID short key (defaults to your DID — use when opening a PR on another user's repo)
|
||||
#[arg(long)]
|
||||
owner: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -47,11 +44,7 @@ pub enum PrCmd {
|
||||
List {
|
||||
/// Repository name
|
||||
repo: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -62,11 +55,7 @@ pub enum PrCmd {
|
||||
repo: String,
|
||||
/// PR number
|
||||
number: u64,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -77,11 +66,7 @@ pub enum PrCmd {
|
||||
repo: String,
|
||||
/// PR number
|
||||
number: u64,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -92,11 +77,7 @@ pub enum PrCmd {
|
||||
repo: String,
|
||||
/// PR number
|
||||
number: u64,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -113,11 +94,7 @@ pub enum PrCmd {
|
||||
/// Review body
|
||||
#[arg(long, short)]
|
||||
body: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -131,11 +108,7 @@ pub enum PrCmd {
|
||||
/// Comment body
|
||||
#[arg(long, short)]
|
||||
body: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -146,11 +119,7 @@ pub enum PrCmd {
|
||||
repo: String,
|
||||
/// PR number
|
||||
number: u64,
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -264,7 +233,13 @@ async fn cmd_create(
|
||||
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let pr = crate::http::read_json(resp, "create PR").await?;
|
||||
let status = resp.status();
|
||||
let pr: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = pr["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create PR failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let number = pr["number"].as_i64().unwrap_or(0);
|
||||
println!("✓ Opened PR #{number}: {title}");
|
||||
@@ -279,13 +254,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
|
||||
.await?,
|
||||
"pull requests",
|
||||
)
|
||||
.await?;
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
|
||||
let prs = resp["pulls"].as_array().cloned().unwrap_or_default();
|
||||
if prs.is_empty() {
|
||||
@@ -324,13 +298,12 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let pr = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
|
||||
.await?,
|
||||
"pull request",
|
||||
)
|
||||
.await?;
|
||||
let pr: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
|
||||
let title = pr["title"].as_str().unwrap_or("?");
|
||||
let status = pr["status"].as_str().unwrap_or("?");
|
||||
@@ -348,25 +321,15 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
}
|
||||
|
||||
// Show reviews
|
||||
// A denied sub-fetch degrades this section rather than aborting: the header
|
||||
// above has already printed, so a bare `?` here leaves a half-rendered PR and
|
||||
// skips the comments below it. Mirrors `gl status`'s unavailable-section line.
|
||||
let reviews = match crate::http::read_json(
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
|
||||
))
|
||||
.await?,
|
||||
"reviews",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v["reviews"].as_array().cloned().unwrap_or_default(),
|
||||
Err(e) => {
|
||||
println!("\nReviews unavailable ({e})");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let reviews: Value = client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let reviews = reviews["reviews"].as_array().cloned().unwrap_or_default();
|
||||
if !reviews.is_empty() {
|
||||
println!("\nReviews ({}):", reviews.len());
|
||||
for r in &reviews {
|
||||
@@ -391,22 +354,15 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
}
|
||||
|
||||
// Show comments
|
||||
let comments = match crate::http::read_json(
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?,
|
||||
"comments",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v["comments"].as_array().cloned().unwrap_or_default(),
|
||||
Err(e) => {
|
||||
println!("\nComments unavailable ({e})");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let comments: Value = client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let comments = comments["comments"].as_array().cloned().unwrap_or_default();
|
||||
if !comments.is_empty() {
|
||||
println!("\nComments ({}):", comments.len());
|
||||
for c in &comments {
|
||||
@@ -430,13 +386,12 @@ async fn cmd_diff(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
|
||||
.await?,
|
||||
"diff",
|
||||
)
|
||||
.await?;
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
|
||||
let diff = resp["diff"].as_str().unwrap_or("");
|
||||
if diff.is_empty() {
|
||||
@@ -460,7 +415,13 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let result = crate::http::read_json(resp, "merge").await?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("merge failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let sha = result["merge_sha"].as_str().unwrap_or("?");
|
||||
println!("✓ Merged PR #{number}");
|
||||
@@ -492,7 +453,13 @@ async fn cmd_review(
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let _ = crate::http::read_json(resp, "review").await?;
|
||||
let code = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !code.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("review failed ({code}): {msg}");
|
||||
}
|
||||
|
||||
let icon = match status.as_str() {
|
||||
"approved" => "✓",
|
||||
@@ -522,7 +489,13 @@ async fn cmd_comment(
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let _ = crate::http::read_json(resp, "comment").await?;
|
||||
let code = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !code.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("comment failed ({code}): {msg}");
|
||||
}
|
||||
|
||||
println!("· Comment posted on PR #{number}");
|
||||
Ok(())
|
||||
@@ -533,15 +506,14 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?,
|
||||
"comments",
|
||||
)
|
||||
.await?;
|
||||
let resp: Value = client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
|
||||
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
|
||||
if comments.is_empty() {
|
||||
@@ -682,7 +654,6 @@ mod tests {
|
||||
.with_status(422)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"branch not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -700,8 +671,6 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("branch not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -799,7 +768,6 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"PR not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -813,8 +781,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("PR not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -899,250 +865,4 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Gated PR reads surface node denials, not empty/stub renders (#123) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls$".to_string()))
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"cmd_list must Err on 404, not print 'No pull requests'"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_view_surfaces_denial_not_stub() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// The PR fetch is first; a 404 there errors before the reviews/comments reads.
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"cmd_view must Err on 404, not print a stub PR"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_diff_surfaces_denial_not_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/diff$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_diff(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"cmd_diff must Err on 404, not print 'No diff'"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_comments_surfaces_denial_not_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_comments(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "cmd_comments must Err on a gated 404");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
/// A denied sub-fetch must not abort `gl pr view` after the header has
|
||||
/// already printed. The header is fatal; reviews and comments degrade.
|
||||
#[tokio::test]
|
||||
async fn cmd_view_continues_when_reviews_are_denied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _pr = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"number":1,"title":"T","status":"open","source_branch":"a","target_branch":"main","author_did":"did:key:z6MkTest"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _reviews = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/reviews$".to_string()),
|
||||
)
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
// The load-bearing assertion: this mock is only hit if rendering
|
||||
// continued past the denied reviews fetch.
|
||||
let _comments = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"comments":[]}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let r = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"cmd_view must finish rendering when reviews are denied"
|
||||
);
|
||||
_comments.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_view_continues_when_comments_are_denied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _pr = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"number":1,"title":"T","status":"open","source_branch":"a","target_branch":"main","author_did":"did:key:z6MkTest"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _reviews = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/reviews$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"reviews":[]}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _comments = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
|
||||
)
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let r = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"cmd_view must finish rendering when comments are denied"
|
||||
);
|
||||
_comments.assert_async().await;
|
||||
}
|
||||
|
||||
/// The must-not case: with no header there is nothing to render partially,
|
||||
/// so a denied header stays fatal. Softening it would resurrect
|
||||
/// denial-as-success for the whole command.
|
||||
#[tokio::test]
|
||||
async fn cmd_view_header_denial_stays_fatal() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _pr = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let r = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(r.is_err(), "a denied PR header must stay fatal");
|
||||
_pr.assert_async().await;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `gl profile` — manage your agent profile (display name, bio, avatar, socials).
|
||||
//!
|
||||
//! Profile metadata is stored on the Twigpine node and optionally pinned to IPFS
|
||||
//! Profile metadata is stored on the gitlawb node and optionally pinned to IPFS
|
||||
//! for decentralized resolution. All writes are signed with your DID key.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -59,11 +59,7 @@ pub enum ProfileCmd {
|
||||
pin: bool,
|
||||
|
||||
/// Node URL
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
|
||||
/// Identity directory
|
||||
@@ -74,11 +70,7 @@ pub enum ProfileCmd {
|
||||
/// Show your own profile
|
||||
Show {
|
||||
/// Node URL
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
|
||||
/// Identity directory
|
||||
@@ -92,11 +84,7 @@ pub enum ProfileCmd {
|
||||
did: String,
|
||||
|
||||
/// Node URL
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
},
|
||||
|
||||
@@ -110,11 +98,7 @@ pub enum ProfileCmd {
|
||||
pin: bool,
|
||||
|
||||
/// Node URL
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
|
||||
/// Identity directory
|
||||
@@ -125,11 +109,7 @@ pub enum ProfileCmd {
|
||||
/// Export your profile as JSON
|
||||
Export {
|
||||
/// Node URL
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
|
||||
/// Identity directory
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user