mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1424447257 | ||
|
|
99ef7b6035 | ||
|
|
c805e35f40 | ||
|
|
a4026e84ff | ||
|
|
38bab4191b | ||
|
|
d84c445f94 | ||
|
|
42cc893fb3 | ||
|
|
0189990ac9 | ||
|
|
61d8a4f8f1 | ||
|
|
b33562f1a9 | ||
|
|
d3845ced69 | ||
|
|
bac49f150a | ||
|
|
b4a3122500 | ||
|
|
0cdb4bcc7c | ||
|
|
0e5a1608e2 | ||
|
|
37947208c5 | ||
|
|
5ed3381a6b | ||
|
|
9b915b09a7 | ||
|
|
77ac1037cb | ||
|
|
db98b3957a | ||
|
|
8c9e4adc2d | ||
|
|
8eedb935c3 | ||
|
|
47c6e6ce22 | ||
|
|
317a1550c2 | ||
|
|
30637193a6 | ||
|
|
a23c81d75a | ||
|
|
5bb61cc97f | ||
|
|
2c341f71ac | ||
|
|
911be4d9fe | ||
|
|
df87b150fd | ||
|
|
0366f18458 | ||
|
|
c2e175d222 | ||
|
|
cd27756134 | ||
|
|
5479fd59b8 | ||
|
|
131bb95c68 | ||
|
|
0303222eae | ||
|
|
2450bbd9e9 | ||
|
|
c97695a259 | ||
|
|
6b2c53bbb8 | ||
|
|
fc87586411 | ||
|
|
288ad92fe8 | ||
|
|
86c544a577 | ||
|
|
206e0827ad | ||
|
|
421f03ac46 | ||
|
|
e2ae21795a | ||
|
|
ed76adfe29 | ||
|
|
19a7ce2bbd | ||
|
|
189aad96e5 | ||
|
|
6233bd9d85 | ||
|
|
76ca04f355 | ||
|
|
c301a68161 | ||
|
|
8a98cab243 | ||
|
|
623b600835 | ||
|
|
9bda2e103c | ||
|
|
151d688387 | ||
|
|
4d024e01bc | ||
|
|
6588590813 | ||
|
|
bfc44f926d | ||
|
|
65f5989a1d | ||
|
|
4725146db2 | ||
|
|
73fd7473af | ||
|
|
21c74c65d9 | ||
|
|
21f8cb6135 | ||
|
|
861cced8f8 | ||
|
|
39ce655904 | ||
|
|
d25cd0c6e6 | ||
|
|
76f630008f | ||
|
|
888fab6028 | ||
|
|
d0635d95b7 | ||
|
|
2656718916 | ||
|
|
366b599331 | ||
|
|
1bdefb18c2 | ||
|
|
42e6aabc10 | ||
|
|
69cfbd4b3c | ||
|
|
579eee07bc | ||
|
|
de4d932609 | ||
|
|
a7e8e35030 | ||
|
|
dc5daa14b4 | ||
|
|
1c86ee22d6 | ||
|
|
92bb55441d | ||
|
|
5c0d6f3252 | ||
|
|
1f0d9ff368 | ||
|
|
2f0174cc04 | ||
|
|
7de6f7cf5d | ||
|
|
586896d8b2 | ||
|
|
cdbda19c29 | ||
|
|
5a7a77b8dc | ||
|
|
c82a70ab21 | ||
|
|
ba44a018ec | ||
|
|
de3f230940 | ||
|
|
8c39cfdcb3 | ||
|
|
eac4d43c67 | ||
|
|
181f6178a0 | ||
|
|
9a8d190492 | ||
|
|
1d37cb1ce9 | ||
|
|
21e6873683 | ||
|
|
a60026bbfa | ||
|
|
75c0e282af | ||
|
|
f4f0f4bff0 | ||
|
|
e6cfb588c2 | ||
|
|
342dd48d24 | ||
|
|
0d7711c089 | ||
|
|
ae96ffa467 | ||
|
|
22f4db94b2 | ||
|
|
9babb5ece0 | ||
|
|
4fdfa1cedb | ||
|
|
22560b0872 | ||
|
|
650460acc0 | ||
|
|
4c665fbca8 | ||
|
|
bc202368e1 | ||
|
|
bd0bef1476 | ||
|
|
25cab14024 | ||
|
|
d88c0c726c | ||
|
|
4985e38af2 | ||
|
|
9758bc7303 | ||
|
|
5575da4b65 | ||
|
|
ed6355ed2c | ||
|
|
e4aaae8c52 | ||
|
|
1b44b12b46 | ||
|
|
9ac3fd3f6b | ||
|
|
9984e08212 | ||
|
|
bad82b3cd9 | ||
|
|
208fd808b6 | ||
|
|
ea2219b2cb | ||
|
|
4681f54327 | ||
|
|
f839fb9246 | ||
|
|
8f3a52175a | ||
|
|
9f74811269 | ||
|
|
a27a267871 | ||
|
|
b4007a5e00 | ||
|
|
a329174175 | ||
|
|
fd78cfa3ad | ||
|
|
3cc15cf724 | ||
|
|
16cc3b37e8 | ||
|
|
3f41df5868 | ||
|
|
b0e5cc0ee0 | ||
|
|
12bc759ea6 | ||
|
|
10e3492825 | ||
|
|
c10f2ca255 | ||
|
|
75b0df8f1c | ||
|
|
3add5356e3 | ||
|
|
fdd11030dc | ||
|
|
9bba8f9934 | ||
|
|
a0e0e0bf1b | ||
|
|
aeb6895141 | ||
|
|
d1fc9bc835 | ||
|
|
8557a12e59 | ||
|
|
979d3c45bb | ||
|
|
3ca35d216d | ||
|
|
e5c1da2bc6 | ||
|
|
219fbdbdba | ||
|
|
d85416127b | ||
|
|
ef9b7165cf | ||
|
|
752fd19b6d | ||
|
|
412d04e12d | ||
|
|
ff189ceab9 | ||
|
|
2961ba73ec | ||
|
|
a4f7d5dffb | ||
|
|
cc41d37bef | ||
|
|
9cef54ea18 | ||
|
|
15bdc5e64a | ||
|
|
c04c4a5da4 | ||
|
|
20c338eca5 | ||
|
|
86fc2763a3 | ||
|
|
6222775bb1 | ||
|
|
b881447b59 | ||
|
|
de2ad5a83d | ||
|
|
1e2f0d1dd6 | ||
|
|
107abf9145 | ||
|
|
bb3ecc8f30 | ||
|
|
dbadd243b4 | ||
|
|
332a5785ca | ||
|
|
b6bc69898c |
No files matched your search
+19
-15
@@ -8,24 +8,9 @@
|
||||
# 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
|
||||
@@ -35,4 +20,23 @@ 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)
|
||||
]
|
||||
+101
-16
@@ -1,15 +1,50 @@
|
||||
# gitlawb node — environment variables
|
||||
# Twigpine 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.
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
# Base URL for the web view of repos on this node (used by `gl` to print a
|
||||
# working View: link after repo creation). Omit for nodes with no web front-end.
|
||||
# Distinct from GITLAWB_PUBLIC_URL: that is API reachability for peers;
|
||||
# this is browser reachability for humans.
|
||||
# GITLAWB_WEB_URL=https://gitlawb.com
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
GITLAWB_HOST=0.0.0.0
|
||||
GITLAWB_PORT=7545
|
||||
@@ -24,11 +59,26 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
# ── Database pool & startup resilience ────────────────────────────────────
|
||||
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
|
||||
# connections open lazily. Size against the DB server's max_connections,
|
||||
# remembering admin tooling opens its own pool. Each concurrent write pins one
|
||||
# connection for its whole duration (the connection-affine advisory lock), so the
|
||||
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
|
||||
# headroom — keep this comfortably above that (default 48 for pushes 32).
|
||||
# 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
|
||||
# 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
|
||||
@@ -88,23 +138,32 @@ GITLAWB_P2P_PORT=7546
|
||||
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
|
||||
GITLAWB_BOOTSTRAP_PEERS=
|
||||
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
|
||||
# Require signatures on peer announce/sync write routes. Keep false during
|
||||
# rolling upgrades so existing live nodes can still communicate.
|
||||
# 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.
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
|
||||
GITLAWB_P2P_BOOTSTRAP=
|
||||
|
||||
# ── Access control ────────────────────────────────────────────────────────
|
||||
# Reserved for private-read mode. Public/private repo read enforcement is not
|
||||
# wired in the current live release; do not rely on this for private repositories.
|
||||
# Reserved and currently inert. Repo reads are gated per repository by
|
||||
# is_public and path-scoped visibility rules; this flag changes nothing.
|
||||
GITLAWB_PUBLIC_READ=true
|
||||
|
||||
# Maximum git smart-HTTP pack request size, in bytes.
|
||||
@@ -191,8 +250,9 @@ GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
|
||||
# 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 (owner enforcement defaults off, and
|
||||
# the push rate limiter caps arrival rate, not in-flight concurrency).
|
||||
# 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.
|
||||
|
||||
@@ -213,10 +273,26 @@ GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
|
||||
# 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. Default 64.
|
||||
# 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,
|
||||
@@ -237,6 +313,15 @@ GITLAWB_IPFS_MAX_REPO_VISITS=1024
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report a defect in a Gitlawb crate or node behavior
|
||||
description: Report a defect in a Twigpine 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/Gitlawb/node/blob/main/SECURITY.md
|
||||
url: https://github.com/Twigpine/node/blob/main/SECURITY.md
|
||||
about: Please report security issues privately per our security policy, not as a public issue.
|
||||
- name: Questions & discussion
|
||||
url: https://github.com/Gitlawb/node/discussions
|
||||
url: https://github.com/Twigpine/node/discussions
|
||||
about: For usage questions and open-ended discussion.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--
|
||||
Thanks for contributing to Gitlawb. Keep PRs focused on one change.
|
||||
Thanks for contributing to Twigpine. Keep PRs focused on one change.
|
||||
Protocol-level changes (identity, signatures, wire format) should start
|
||||
as an issue before code — see CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
@@ -68,6 +68,22 @@ jobs:
|
||||
- name: Test release tag resolver
|
||||
run: scripts/test-resolve-release-tag.sh
|
||||
|
||||
triage-detector:
|
||||
name: triage detector matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# pr-triage.yml runs on pull_request_target and never checks out PR
|
||||
# code, so its inline detector can only be exercised here, where the
|
||||
# proposed workflow body IS the checked-out one.
|
||||
- name: Test inline-test detector
|
||||
run: node scripts/test-pr-triage-detect.mjs
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.toolchain }})
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -76,9 +76,221 @@ jobs:
|
||||
// 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));
|
||||
//
|
||||
// The final path component may also carry `test` as an underscore-delimited
|
||||
// segment, so third-party harnesses land without enumerating them one by one
|
||||
// (#[test_case(1)], #[wasm_bindgen_test]). It is deliberately a segment and
|
||||
// not a substring: a substring would also match #[contest] / #[latest], and a
|
||||
// false positive here SUPPRESSES needs-tests rather than adding it. This
|
||||
// heuristic has to fail loud, because a wrongly labeled PR gets corrected by
|
||||
// the author while a wrongly cleared one is invisible. That trade costs the
|
||||
// no-underscore harness names (#[rstest]), which stay unmatched on purpose.
|
||||
//
|
||||
// The segment classes are [A-Za-z0-9]+ rather than \w+ on purpose, so that `_`
|
||||
// is only ever the literal delimiter. \w+ contains `_`, which makes the repeat
|
||||
// ambiguous and backtracks exponentially: on this workflow's pull_request_target
|
||||
// trigger f.patch is fork-controlled, so `#[test` followed by a long `_a` run is
|
||||
// a permissionless stall of the triage job.
|
||||
// Rust allows whitespace, line breaks, and comments between the
|
||||
// attribute path and ] or (. A flat regex cannot decide that
|
||||
// boundary: `//` runs to end of line, and block comments NEST, which
|
||||
// is beyond regular languages — every enumeration of comment
|
||||
// spellings so far has either dropped a legal separator (regressing
|
||||
// real tests into needs-tests) or let unrelated patch records
|
||||
// complete each other (clearing needs-tests from a testless PR, the
|
||||
// invisible direction). So the closer is found by a token-aware
|
||||
// scan instead: it consumes horizontal whitespace, `//`-to-newline,
|
||||
// and depth-counted `/* */` runs, and accepts only when the first
|
||||
// real token after the attribute path is `]` or `(`.
|
||||
//
|
||||
// Association is positional: the scan starts on the attribute's own
|
||||
// added line and may continue ONLY through the immediately following
|
||||
// added lines (a context line, hunk header, or removal breaks the
|
||||
// chain). Unrelated `]`/`(` lines elsewhere in the patch can never
|
||||
// complete a path they do not adjoin. The continuation is bounded;
|
||||
// past the bound the scan gives up and reports NO inline test, which
|
||||
// fails toward the loud label rather than the silent clearance.
|
||||
//
|
||||
// The detector body is fenced by the TRIAGE_DETECTOR markers so
|
||||
// scripts/test-pr-triage-detect.mjs can extract and run it against
|
||||
// the committed case matrix. Keep the fenced block self-contained.
|
||||
// TRIAGE_DETECTOR_BEGIN
|
||||
const TEST_ATTR_PATH =
|
||||
"(?:cfg[ \\t]*\\([ \\t]*test[ \\t]*\\)|(?:::[ \\t]*)?(?:[\\w-]+[ \\t]*::[ \\t]*)*(?:r#)?(?:[A-Za-z0-9]+_)*test(?:_[A-Za-z0-9]+)*)";
|
||||
// Anchored to the start of one added line; group 1 is whatever
|
||||
// follows the path on that line, handed to the separator scan.
|
||||
const TEST_ATTR_LINE = new RegExp(
|
||||
"^[ \\t]*#\\[[ \\t]*" + TEST_ATTR_PATH + "([\\s\\S]*)$"
|
||||
);
|
||||
// An attribute split across more added lines than this is not a
|
||||
// spelling anyone writes; refusing to scan further keeps the walk
|
||||
// linear in the patch and fails toward the loud label.
|
||||
const MAX_ATTR_CONTINUATION_LINES = 16;
|
||||
// segs[0] is the remainder of the attribute's own line; each later
|
||||
// entry is the text of one immediately following added line. True
|
||||
// only when the first non-separator token across them is ] or (.
|
||||
// Single forward pass, no backtracking: fork-controlled input.
|
||||
function attrCloserFollows(segs) {
|
||||
let depth = 0; // open block-comment nesting carried across lines
|
||||
for (const seg of segs) {
|
||||
let i = 0;
|
||||
while (i < seg.length) {
|
||||
if (depth > 0) {
|
||||
if (seg.startsWith("/*", i)) { depth += 1; i += 2; }
|
||||
else if (seg.startsWith("*/", i)) { depth -= 1; i += 2; }
|
||||
else { i += 1; }
|
||||
continue;
|
||||
}
|
||||
const c = seg[i];
|
||||
if (c === " " || c === "\t") { i += 1; continue; }
|
||||
if (seg.startsWith("//", i)) break; // comment to end of line
|
||||
if (seg.startsWith("/*", i)) { depth = 1; i += 2; continue; }
|
||||
return c === "]" || c === "(";
|
||||
}
|
||||
// Line exhausted inside separators; the newline is itself a
|
||||
// separator, so continue on the next added line.
|
||||
}
|
||||
return false; // out of adjoined lines (or bound hit): stay loud
|
||||
}
|
||||
// Before the candidate scan, a pre-pass walks added patch lines
|
||||
// tracking Rust lexical state: raw strings (r#*", br#*", cr#*"),
|
||||
// block comments (/* */ with nesting), regular strings ("...",
|
||||
// which can span newlines), and line comments (//). An added line
|
||||
// that starts inside any of these is skipped as a candidate,
|
||||
// because a #[test_case] appearing there is lexical non-code, not
|
||||
// a real test attribute. State is only tracked across consecutive
|
||||
// added lines and reset at any break (context line, removal,
|
||||
// hunk header): context lines carry a partial view of the file,
|
||||
// so a " or r#" or /* there might be inside a string that opened
|
||||
// before the hunk's visible window, and treating it as an opener
|
||||
// would mark every later added line as non-code (a false negative
|
||||
// on a real #[test]). The tradeoff is that a non-code region
|
||||
// opened by a context line is invisible: an added #[test] inside
|
||||
// an existing raw string or block comment is scanned as code and
|
||||
// may match (a false positive, silent direction). This is accepted
|
||||
// because (1) the old detector had the same behavior, (2) the
|
||||
// pattern is rare, and (3) needs-tests is advisory, not a merge
|
||||
// gate. Scanning context lines to close this gap would re-introduce
|
||||
// the false negative, which affects the far more common case of a
|
||||
// context line carrying a stray " from a string that opened before
|
||||
// the visible window. Single forward pass, O(n) in patch length.
|
||||
function patchAddsInlineTest(patch) {
|
||||
const lines = patch.split("\n");
|
||||
const nonCodeStart = new Set();
|
||||
let rawHashes = -1; // -1 = not in raw string; >=0 = # count
|
||||
let blockDepth = 0;
|
||||
let inString = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
const prefix = line[0];
|
||||
// Only scan added lines. Context and removal lines carry
|
||||
// a partial view of the file: a " or r#" or /* on a context
|
||||
// line might be inside a string that opened before the
|
||||
// hunk's visible window, and treating it as an opener would
|
||||
// mark every later added line as non-code (a false negative
|
||||
// on a real #[test]). So lexical state is only tracked across
|
||||
// consecutive added lines and reset at any break. A non-code
|
||||
// region opened by a context line is invisible; the candidate
|
||||
// is still scanned, which fails toward the loud label.
|
||||
if (prefix !== "+") {
|
||||
rawHashes = -1; blockDepth = 0; inString = false;
|
||||
continue;
|
||||
}
|
||||
const text = line.slice(1);
|
||||
if (rawHashes >= 0 || blockDepth > 0 || inString)
|
||||
nonCodeStart.add(i);
|
||||
let j = 0;
|
||||
while (j < text.length) {
|
||||
if (rawHashes >= 0) {
|
||||
if (text[j] === '"') {
|
||||
let h = 1;
|
||||
while (h <= rawHashes && text[j + h] === "#") h++;
|
||||
if (h > rawHashes) { rawHashes = -1; j += h; continue; }
|
||||
}
|
||||
j += 1; continue;
|
||||
}
|
||||
if (blockDepth > 0) {
|
||||
if (text.startsWith("/*", j)) { blockDepth += 1; j += 2; }
|
||||
else if (text.startsWith("*/", j)) { blockDepth -= 1; j += 2; }
|
||||
else { j += 1; }
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (text[j] === "\\") { j += 2; continue; }
|
||||
if (text[j] === '"') { inString = false; j += 1; continue; }
|
||||
j += 1; continue;
|
||||
}
|
||||
// Recognize Rust char and byte-char literals ('X', '\X',
|
||||
// '\u{...}', b'X') so their contents never open string or
|
||||
// comment state. A char literal has a closing ' within a
|
||||
// bounded distance; a lifetime or label ('a, 'static,
|
||||
// 'label:) does not, so it falls through as a regular char.
|
||||
// This is not a skip-to-next-' rule: the lookahead is 2-4
|
||||
// chars, or bounded to 20 for \u escapes (at most 6 hex
|
||||
// digits), never unbounded.
|
||||
if (text[j] === "'") {
|
||||
let k = j + 1;
|
||||
if (text[k] === "\\") {
|
||||
k += 1;
|
||||
if (text[k] === "u" && text[k + 1] === "{") {
|
||||
const uBound = k + 20;
|
||||
while (k < uBound && text[k] && text[k] !== "}") k++;
|
||||
if (text[k] === "}") k++;
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
if (text[k] === "'") { j = k + 1; continue; }
|
||||
// lifetime or label: just skip the apostrophe
|
||||
}
|
||||
if (text[j] === '"') { inString = true; j += 1; continue; }
|
||||
if (text.startsWith("//", j)) break;
|
||||
if (text.startsWith("/*", j)) { blockDepth = 1; j += 2; continue; }
|
||||
const prev = j > 0 ? text[j - 1] : "";
|
||||
const isIdent = /[A-Za-z0-9_]/.test(prev);
|
||||
if (!isIdent && text[j] === "r") {
|
||||
let k = j + 1, h = 0;
|
||||
while (text[k + h] === "#") h++;
|
||||
if (text[k + h] === '"') {
|
||||
rawHashes = h; j = k + h + 1; continue;
|
||||
}
|
||||
}
|
||||
if (!isIdent && (text[j] === "b" || text[j] === "c") && text[j + 1] === "r") {
|
||||
let k = j + 2, h = 0;
|
||||
while (text[k + h] === "#") h++;
|
||||
if (text[k + h] === '"') {
|
||||
rawHashes = h; j = k + h + 1; continue;
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i][0] !== "+") continue;
|
||||
if (nonCodeStart.has(i)) continue;
|
||||
const m = TEST_ATTR_LINE.exec(lines[i].slice(1));
|
||||
if (m === null) continue;
|
||||
const segs = [m[1]];
|
||||
for (
|
||||
let j = i + 1;
|
||||
j < lines.length &&
|
||||
lines[j][0] === "+" &&
|
||||
segs.length <= MAX_ATTR_CONTINUATION_LINES;
|
||||
j++
|
||||
) {
|
||||
segs.push(lines[j].slice(1));
|
||||
}
|
||||
if (attrCloserFollows(segs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// TRIAGE_DETECTOR_END
|
||||
const addsInlineTest = files.some(
|
||||
f => f.filename.endsWith(".rs") && !!f.patch && patchAddsInlineTest(f.patch)
|
||||
);
|
||||
const touchedTests =
|
||||
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
|
||||
if (changedRust && !touchedTests) want.add("needs-tests");
|
||||
@@ -124,7 +336,7 @@ jobs:
|
||||
"",
|
||||
lines,
|
||||
"",
|
||||
"See [CONTRIBUTING.md](https://github.com/Gitlawb/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
|
||||
"See [CONTRIBUTING.md](https://github.com/Twigpine/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 });
|
||||
+245
-48
@@ -20,7 +20,15 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Not keyed on the ref: two runs racing `imagetools create` on the same
|
||||
# moving tags could interleave, so runs of this workflow serialize outright.
|
||||
# GitHub keeps at most one running and one pending run per group; a newer
|
||||
# queued run replaces the pending one, and cancel-in-progress: false
|
||||
# protects only the run already executing. So a backfill dispatched while a
|
||||
# release is running, followed by a push to main, is replaced silently: the
|
||||
# remedy is to re-dispatch it once the running release completes, which the
|
||||
# push-only gate on the moving-tag step makes safe in any ordering.
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
# Least privilege by default; every job opts in to exactly what it needs.
|
||||
@@ -31,12 +39,13 @@ 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 == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
if: ${{ github.repository == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
environment: release
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
@@ -64,7 +73,7 @@ jobs:
|
||||
sync-release-lock:
|
||||
name: Sync Cargo.lock on the release PR
|
||||
needs: release-please
|
||||
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
if: ${{ !cancelled() && github.repository == 'Twigpine/node' && github.event_name != 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -78,6 +87,7 @@ jobs:
|
||||
# dispatch the release PR's head carries no check runs and the --locked
|
||||
# gate never validates it.
|
||||
actions: write
|
||||
environment: release
|
||||
steps:
|
||||
# Prefer the fresh `pr` output when this run just created/updated the
|
||||
# release PR; otherwise fall back to querying for an open release-please
|
||||
@@ -215,6 +225,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
environment: release
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -226,6 +237,7 @@ jobs:
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
@@ -237,7 +249,9 @@ jobs:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -312,6 +326,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
environment: release
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -323,9 +338,14 @@ jobs:
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing, so a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
@@ -345,24 +365,80 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
id: manifest
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
IMAGE: ${{ steps.rel.outputs.image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ghcr requires a lowercase repository path.
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
# The docker matrix pushes one digest per arch leg; anything else is
|
||||
# a broken set, not a smaller multi-arch image.
|
||||
count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)"
|
||||
if [ "$count" -ne 2 ]; then
|
||||
echo "::error::expected 2 arch digests in /tmp/digests, found $count"
|
||||
exit 1
|
||||
fi
|
||||
digests=""
|
||||
for f in /tmp/digests/*; do
|
||||
digests="$digests $IMAGE@sha256:$(basename "$f")"
|
||||
d="$(basename "$f")"
|
||||
case "$d" in
|
||||
*[!0-9a-f]*)
|
||||
echo "::error::digest filename is not lowercase sha256 hex: $d"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#d}" -ne 64 ]; then
|
||||
echo "::error::digest is not 64 hex chars: $d"
|
||||
exit 1
|
||||
fi
|
||||
digests="$digests $IMAGE@sha256:$d"
|
||||
done
|
||||
# The immutable tag always publishes. The moving tags are applied by
|
||||
# the gated step below, which never runs on workflow_dispatch.
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:$MAJOR_MINOR" \
|
||||
-t "$IMAGE:latest" \
|
||||
$digests
|
||||
docker buildx imagetools create -t "$IMAGE:$VERSION" $digests
|
||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
echo "digests=${digests# }" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Move floating tags
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
IMAGE: ${{ steps.rel.outputs.image }}
|
||||
DIGESTS: ${{ steps.manifest.outputs.digests }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
# :latest and :X.Y track the newest release. Only a push to main
|
||||
# carries a release-please-cut version, and release-please versions
|
||||
# are monotonic, so on this path the tags only ever advance. A
|
||||
# workflow_dispatch backfill republishes an existing tag and must
|
||||
# never move a pointer, so this step skips on dispatch. After a
|
||||
# backfill that is genuinely newest in its minor line, advance :X.Y
|
||||
# by hand: docker buildx imagetools create -t "$IMAGE:X.Y" <digests>.
|
||||
# The event gate alone does not bound a stale run: re-running an
|
||||
# older push run replays its stored needs outputs under the
|
||||
# original event and ref, and would move the pointers backward to
|
||||
# that run's version. Floor against live state instead: this run's
|
||||
# release already exists by now, so VERSION must be the newest
|
||||
# published release or the run is stale and the tags stay put. The
|
||||
# releases list is ordered by creation, not semver, so read every
|
||||
# page and take the semver maximum, not the first page's.
|
||||
tags="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
-q '.[] | select(.draft == false) | .tag_name')" || {
|
||||
echo "::error::could not list releases to bound the moving tags"
|
||||
exit 1
|
||||
}
|
||||
floor="$(printf '%s\n' "$tags" \
|
||||
| { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } \
|
||||
| sed 's/^v//' | sort -V | tail -n 1)"
|
||||
if [ -n "$floor" ] && [ "$floor" != "$VERSION" ]; then
|
||||
echo "::error::release v$floor is newer than this run's v$VERSION; refusing to move the moving tags backward (stale re-run?)"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
@@ -386,6 +462,7 @@ jobs:
|
||||
continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }}
|
||||
permissions:
|
||||
contents: write
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -405,10 +482,27 @@ jobs:
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash # matrix includes windows-latest, where run: defaults to pwsh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
@@ -454,7 +548,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.release-please.outputs.version }}"
|
||||
VERSION="${{ steps.rel.outputs.version }}"
|
||||
BIN_DIR="target/${{ matrix.target }}/release"
|
||||
EXE=""
|
||||
case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac
|
||||
@@ -474,7 +568,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ matrix.target }}"
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}"
|
||||
NAME="gitlawb-node-${{ steps.rel.outputs.version }}-${TARGET}"
|
||||
BIN_DIR="target/${TARGET}/release"
|
||||
EXE=""
|
||||
case "$TARGET" in *windows*) EXE=".exe" ;; esac
|
||||
@@ -515,7 +609,7 @@ jobs:
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: ${{ needs.release-please.outputs.tag_name }}
|
||||
tag_name: ${{ steps.rel.outputs.tag }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
@@ -528,7 +622,9 @@ jobs:
|
||||
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
|
||||
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
|
||||
# @gitlawb package must have this repo + workflow (release.yml) configured
|
||||
# as its trusted publisher on npmjs.com, or publish fails loudly here —
|
||||
# as its trusted publisher on npmjs.com, scoped to the release environment
|
||||
# so a dispatched copy of this file on another ref cannot mint a token, or
|
||||
# publish fails loudly here —
|
||||
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
|
||||
# never reached npm.
|
||||
if: >-
|
||||
@@ -540,6 +636,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm trusted publishing (OIDC) + provenance
|
||||
environment: release
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -551,6 +648,7 @@ jobs:
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
@@ -558,7 +656,9 @@ jobs:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
persist-credentials: false
|
||||
|
||||
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
|
||||
@@ -571,14 +671,23 @@ jobs:
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g npm@^11.5.1
|
||||
npm --version
|
||||
npm install -g npm@11.19.1
|
||||
# Assert the pin took, so an accidental unpin or a drift off 11.19.1
|
||||
# fails loudly here instead of silently running a different npm on
|
||||
# the OIDC path.
|
||||
installed="$(npm --version)"
|
||||
echo "npm installed: $installed"
|
||||
if [ "$installed" != "11.19.1" ]; then
|
||||
echo "::error::npm is not the pinned 11.19.1 (got: $installed)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lay in release binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
ASSETS: ${{ steps.rel.outputs.assets }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm platform package -> Rust target triple (unix only; Windows is not
|
||||
@@ -595,8 +704,18 @@ jobs:
|
||||
target="${entry#*:}"
|
||||
archive="gitlawb-node-${VERSION}-${target}.tar.gz"
|
||||
echo "==> $pkg <- $archive"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$archive" --dir _dl --clobber
|
||||
# Download by the asset id the resolver captured and uploader-
|
||||
# checked, not by name: release assets are mutable, and an id can
|
||||
# only ever point at the exact blob captured at resolve time.
|
||||
# The map keys are base64-encoded asset names.
|
||||
want="$(printf '%s' "$archive" | base64 -w0)"
|
||||
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::release $TAG has no asset $archive captured at resolve time"
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
|
||||
-H 'Accept: application/octet-stream' > "_dl/$archive"
|
||||
tar -xzf "_dl/$archive" -C _dl
|
||||
src="_dl/gitlawb-node-${VERSION}-${target}"
|
||||
cp "$src/gl" "npm/packages/$pkg/gl"
|
||||
@@ -635,14 +754,39 @@ jobs:
|
||||
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
|
||||
# Skip versions already on the registry so a rerun after a partial publish
|
||||
# is idempotent instead of erroring on the first existing package.
|
||||
# npm publish moves the latest dist-tag to whatever it publishes, so a
|
||||
# backfilled older version would hand :latest to stale code. Decide
|
||||
# the dist-tag per package from that package's own registry latest:
|
||||
# a partial publish can leave the platform packages ahead of the
|
||||
# wrapper. E404 means the package has never been published, so this
|
||||
# release gets latest; any other lookup failure must fail closed
|
||||
# rather than guess at the dist-tag and risk moving latest backward.
|
||||
# stderr goes to a file so a warning on a successful lookup cannot
|
||||
# leak into registry_latest; it is read only when the lookup fails.
|
||||
npm_err="$(mktemp)"
|
||||
trap 'rm -f -- "$npm_err"' EXIT
|
||||
for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do
|
||||
name="@gitlawb/$pkg"
|
||||
if npm view "$name@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "==> $name@$VERSION already published, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "==> npm publish $name@$VERSION"
|
||||
npm publish "npm/packages/$pkg" --provenance --access public
|
||||
registry_latest="$(npm view "$name" dist-tags.latest 2>"$npm_err")" || {
|
||||
if ! grep -q E404 "$npm_err"; then
|
||||
cat "$npm_err" >&2
|
||||
echo "::error::npm dist-tags lookup failed for $name; not guessing the dist-tag"
|
||||
exit 1
|
||||
fi
|
||||
registry_latest=""
|
||||
}
|
||||
dist_tag="latest"
|
||||
if [ -n "$registry_latest" ] && \
|
||||
[ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then
|
||||
dist_tag="backfill"
|
||||
echo "::notice::$VERSION is older than $name@$registry_latest; publishing under dist-tag backfill"
|
||||
fi
|
||||
echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)"
|
||||
npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag"
|
||||
done
|
||||
|
||||
homebrew-bump:
|
||||
@@ -652,6 +796,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT
|
||||
environment: release
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
@@ -665,11 +810,27 @@ jobs:
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out workflow scripts
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
id: rel
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Checkout tap repo
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Gitlawb/homebrew-tap
|
||||
repository: Twigpine/homebrew-tap
|
||||
token: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
path: tap
|
||||
persist-credentials: false
|
||||
@@ -678,15 +839,32 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
ASSETS: ${{ steps.rel.outputs.assets }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
mkdir -p _sums
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern '*.tar.gz.sha256' --dir _sums --clobber
|
||||
sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; }
|
||||
# Hash the tarballs themselves, fetched by the asset id the resolver
|
||||
# captured and uploader-checked. The *.sha256 sidecars are mutable
|
||||
# release assets too; trusting them would trust the same surface.
|
||||
sha() {
|
||||
archive="gitlawb-node-${VERSION}-$1.tar.gz"
|
||||
want="$(printf '%s' "$archive" | base64 -w0)"
|
||||
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::release $TAG has no asset $archive captured at resolve time" >&2
|
||||
exit 1
|
||||
fi
|
||||
# errexit is off inside the $(...) this runs under, so a failed
|
||||
# download must exit the function itself instead of hashing a
|
||||
# truncated file.
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
|
||||
-H 'Accept: application/octet-stream' > "_sums/$archive" \
|
||||
|| exit 1
|
||||
sha256sum "_sums/$archive" | awk '{print $1}'
|
||||
}
|
||||
SHA_MAC_ARM="$(sha aarch64-apple-darwin)"
|
||||
SHA_MAC_X64="$(sha x86_64-apple-darwin)"
|
||||
SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)"
|
||||
@@ -695,8 +873,8 @@ jobs:
|
||||
mkdir -p tap/Formula
|
||||
cat > tap/Formula/gl.rb <<EOF
|
||||
class Gl < Formula
|
||||
desc "Gitlawb CLI — decentralized git for AI agents and developers"
|
||||
homepage "https://gitlawb.com"
|
||||
desc "Twigpine CLI — decentralized git for AI agents and developers"
|
||||
homepage "https://twigpine.com"
|
||||
version "${VERSION}"
|
||||
license "MIT OR Apache-2.0"
|
||||
|
||||
@@ -747,7 +925,7 @@ jobs:
|
||||
working-directory: tap
|
||||
env:
|
||||
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -760,7 +938,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/Gitlawb/homebrew-tap.git" HEAD:main
|
||||
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Twigpine/homebrew-tap.git" HEAD:main
|
||||
|
||||
web-sync:
|
||||
name: Sync web (install scripts + version)
|
||||
@@ -769,6 +947,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT
|
||||
environment: release
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
@@ -782,11 +961,29 @@ jobs:
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out workflow scripts
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
id: rel
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Checkout node (release tag)
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
path: node
|
||||
persist-credentials: false
|
||||
|
||||
@@ -794,7 +991,7 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Gitlawb/web
|
||||
repository: Twigpine/web
|
||||
token: ${{ secrets.WEB_SYNC_PAT }}
|
||||
path: web
|
||||
persist-credentials: false
|
||||
@@ -806,8 +1003,8 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
|
||||
@@ -825,8 +1022,8 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Canonical install scripts (source of truth lives in the node repo).
|
||||
@@ -837,7 +1034,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/Gitlawb/node/releases#g' web/public/skill.md
|
||||
sed -i 's#github.com/gitlawb/releases#github.com/Twigpine/node/releases#g' web/public/skill.md
|
||||
fi
|
||||
|
||||
cd web
|
||||
@@ -851,16 +1048,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 gitlawb ${TAG} (install scripts + version)"
|
||||
git commit -m "chore: sync Twigpine ${TAG} (install scripts + version)"
|
||||
# Credentials are not persisted in .git/config; supply the token only for the push.
|
||||
git push --force "https://x-access-token:${GH_TOKEN}@github.com/Gitlawb/web.git" "HEAD:$branch"
|
||||
gh pr create --repo Gitlawb/web --head "$branch" \
|
||||
--title "Sync gitlawb ${TAG}" \
|
||||
--body "Automated sync from Gitlawb/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \
|
||||
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." \
|
||||
|| {
|
||||
# Only swallow the "PR already exists" case; surface auth/API failures
|
||||
# (a failed gh pr list must not be misread as "PR exists").
|
||||
pr_count="$(gh pr list --repo Gitlawb/web --head "$branch" --state open --json number --jq 'length')" || exit 1
|
||||
pr_count="$(gh pr list --repo Twigpine/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,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.1"
|
||||
".": "0.8.0"
|
||||
}
|
||||
+167
@@ -1,5 +1,172 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/Twigpine/node/compare/v0.7.1...v0.8.0) (2026-11-08)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **node:** the GraphQL `repos` field now returns an error instead of the full list when more than MAX_VISIBLE_REPO_PAGE_SIZE (200) repositories are visible; larger lists must use `reposPage` with `limit`/`after`. Repository names are additionally limited to 100 bytes at creation, fork, and mirror-registration time.
|
||||
* **node:** gl cert show <id> --verify now requires --expect-node <did>; the node's self-reported DID is no longer trusted as a cert anchor.
|
||||
* **node:** `git-receive-pack` now rejects a push whose authenticated DID is not the repo owner, returning 403 before any ref update is applied. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so an agent pushing under its own DID cannot push while this is on. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` during a rolling upgrade, or have automation push as the repo owner, until scoped collaborator / UCAN-delegated push rights land.
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** carry the resumed candidate's identity in the scan token ([2656718](https://github.com/Twigpine/node/commit/2656718916ab9c68dd7fccb8f6fba8bba6b43e82))
|
||||
* **node,git:** cap concurrent served git ops with a 503 load-shed ([#62](https://github.com/Twigpine/node/issues/62)) ([5981257](https://github.com/Twigpine/node/commit/5981257be582fe1d39980bb2b1b3ff5c98448e84))
|
||||
* **node:** persist upstream mirror configuration and transition state ([#538](https://github.com/Twigpine/node/issues/538)) ([0e5a160](https://github.com/Twigpine/node/commit/0e5a1608e21f4859b2919307287747263f9fa8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api,sync:** Delimit positional arguments in git clone mirror invocations ([#539](https://github.com/Twigpine/node/issues/539)) ([0cdb4bc](https://github.com/Twigpine/node/commit/0cdb4bcc7c4f7b0eb997fc5ea26981b1e0e26e3c))
|
||||
* **attest:** bound did:key method-id length before base58 decode ([#360](https://github.com/Twigpine/node/issues/360)) ([#535](https://github.com/Twigpine/node/issues/535)) ([9b915b0](https://github.com/Twigpine/node/commit/9b915b09a7c727af15c5c520107403b9dc93d35a))
|
||||
* **attest:** make verify_all lenient under RequireAll ([#359](https://github.com/Twigpine/node/issues/359)) ([#532](https://github.com/Twigpine/node/issues/532)) ([db98b39](https://github.com/Twigpine/node/commit/db98b3957a15eff9e9f286f8a2d084d8e731fcdb))
|
||||
* **attest:** use strict Ed25519 verification with regression test ([1fcbbbd](https://github.com/Twigpine/node/commit/1fcbbbd89c027ae7d1d903f705631d94f8dfbadc))
|
||||
* **ci:** add tested release tag resolver ([1b65f70](https://github.com/Twigpine/node/commit/1b65f70403ab47ced8c2828745c1c79c89a185e4))
|
||||
* **ci:** recognize namespaced test attributes in pr-triage's needs-tests check ([1989fcc](https://github.com/Twigpine/node/commit/1989fcca112d0efb56978efe09d9a836afa7cb59))
|
||||
* **ci:** reject unsafe release dispatch tags ([7b365b0](https://github.com/Twigpine/node/commit/7b365b0dc3ef3b5e3d14fd62bccc434e57200612))
|
||||
* **ci:** reject unsafe release dispatch tags ([0b990d6](https://github.com/Twigpine/node/commit/0b990d687f0eb379b62eb600ede2a6233017e419))
|
||||
* **core:** cap the blinded recipient scan in open_blob ([#419](https://github.com/Twigpine/node/issues/419)) ([#440](https://github.com/Twigpine/node/issues/440)) ([2450bbd](https://github.com/Twigpine/node/commit/2450bbd9e962f47b374b07932a1ff3fc969b9427))
|
||||
* **core:** compute created-skew in checked arithmetic ([#358](https://github.com/Twigpine/node/issues/358)) ([#545](https://github.com/Twigpine/node/issues/545)) ([61d8a4f](https://github.com/Twigpine/node/commit/61d8a4f8f1503ca0b088a039802a8999d305642d))
|
||||
* **core:** count distinct signer DIDs in certificate threshold check ([3993fd1](https://github.com/Twigpine/node/commit/3993fd1f5daacbebb8f8e2c62af8856c493ec87a))
|
||||
* **core:** Count distinct signer DIDs in certificate threshold check ([50d3cbb](https://github.com/Twigpine/node/commit/50d3cbbe97f77b3cab9fab220add9f3a0d0dbc2f))
|
||||
* **core:** enforce strict RFC 8032 Ed25519 signature verification ([425ebf4](https://github.com/Twigpine/node/commit/425ebf40e7737f44c5fe4e9dc8995c8aeb074ce1))
|
||||
* **core:** put the redirect predicate behind a feature so core stays lean ([c82a70a](https://github.com/Twigpine/node/commit/c82a70ab21a21192b2579693b7d7c13aeb8a4d20))
|
||||
* **core:** refuse a redirect that rewrites the signed request-target ([5c0d6f3](https://github.com/Twigpine/node/commit/5c0d6f32522adea8f167f8e445a26377b6fe3963))
|
||||
* **core:** reject small-order Ed25519 keys at DID resolution and before X25519 ([#314](https://github.com/Twigpine/node/issues/314)) ([86c544a](https://github.com/Twigpine/node/commit/86c544a577f28efb2341a53d4faa6b54d1c8b919))
|
||||
* **core:** reject zero threshold, canonicalize did:key, document replay ([#509](https://github.com/Twigpine/node/issues/509)) ([#540](https://github.com/Twigpine/node/issues/540)) ([b4a3122](https://github.com/Twigpine/node/commit/b4a3122500e87d0b84aa3b116171de070188a6bd))
|
||||
* **core:** share the origin-scoped redirect rule with the git helper ([ba44a01](https://github.com/Twigpine/node/commit/ba44a018ec8945b447977d89fc6a10d98fc1bb7b))
|
||||
* **core:** use strict Ed25519 signature verification ([a6c13be](https://github.com/Twigpine/node/commit/a6c13be15b1421f3791e49a8219ea739f1e3ec6e))
|
||||
* **deps:** bump aws-smithy-json to 0.62.7 (GHSA-8ffr-xgwf-xj56) ([#516](https://github.com/Twigpine/node/issues/516)) ([151d688](https://github.com/Twigpine/node/commit/151d68838743a96198879b3154cfbaedca04c3cd))
|
||||
* **deps:** bump follow-redirects and override ws in scripts/ ([#525](https://github.com/Twigpine/node/issues/525)) ([a23c81d](https://github.com/Twigpine/node/commit/a23c81d75ae0a7567905ca9ad093bc9ad7c5adcf))
|
||||
* **deps:** bump libp2p to pick up hickory 0.26 and serde_with 3.22 ([#518](https://github.com/Twigpine/node/issues/518)) ([623b600](https://github.com/Twigpine/node/commit/623b600835d717fcfdab36af116dd4b9a92a92cd)), closes [#76](https://github.com/Twigpine/node/issues/76)
|
||||
* **deps:** resolve cargo audit failures on main ([#514](https://github.com/Twigpine/node/issues/514)) ([4d024e0](https://github.com/Twigpine/node/commit/4d024e01bcf25c93a3b878ac50e6435575dcc5f4))
|
||||
* **git-remote:** abort fetch on EOF without POSTing; harden the real-git test harness ([#192](https://github.com/Twigpine/node/issues/192)) ([1cb930c](https://github.com/Twigpine/node/commit/1cb930cbd57ea5d232edddc8e50dc94fab4362a5))
|
||||
* **git-remote:** drive multi-round fetch as a v0 stateless-RPC client loop ([#117](https://github.com/Twigpine/node/issues/117)) ([5fdf155](https://github.com/Twigpine/node/commit/5fdf155c7fd7398f61c200c256cb7327729f378a))
|
||||
* **git-remote:** drive multi-round fetch as a v0 stateless-RPC client loop ([#117](https://github.com/Twigpine/node/issues/117)) ([a64b141](https://github.com/Twigpine/node/commit/a64b14150dfb7e369c16e5fab98375ba0ce38404))
|
||||
* **git-remote:** normalize the shim's accepted socket to blocking ([10e0840](https://github.com/Twigpine/node/commit/10e0840dd95204d2b8faa1c594808e2ad57bc9c4))
|
||||
* **git-remote:** normalize the shim's accepted socket to blocking ([#192](https://github.com/Twigpine/node/issues/192)) ([8d7f95f](https://github.com/Twigpine/node/commit/8d7f95fd63769ef30540f216190e1bb52ccd4f69))
|
||||
* **git-remote:** reject malformed pkt-line length headers instead of flushing ([8c04043](https://github.com/Twigpine/node/commit/8c04043fb56b5030a0aa6b328c91f20173352aa6))
|
||||
* **git-remote:** skip content-free flush rounds in the fetch loop ([#117](https://github.com/Twigpine/node/issues/117)) ([68ba492](https://github.com/Twigpine/node/commit/68ba49267c8d65169ab9adf0552761f87baba4c1))
|
||||
* **git-remote:** stop waiting on EOF for a delete-only push ([#442](https://github.com/Twigpine/node/issues/442)) ([c2e175d](https://github.com/Twigpine/node/commit/c2e175d2222975eccb4776447268e8678fd48ba4))
|
||||
* **gl:** accept the resume token the incomplete-scan message tells you to pass ([181f617](https://github.com/Twigpine/node/commit/181f6178a0e0f5b9506093fa7579ad6ef8dd2ff5))
|
||||
* **gl:** assert 404 verdict and harden transport error handling in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([a329174](https://github.com/Twigpine/node/commit/a3291741757038307e61d3952e6c20aa4901c8a3))
|
||||
* **gl:** assert whoami 404 verdict and preserve transport error cause ([#220](https://github.com/Twigpine/node/issues/220)) ([b4007a5](https://github.com/Twigpine/node/commit/b4007a5e005c63aa0c083b969cd8b5f65b918adb))
|
||||
* **gl:** close two completeness-gate escapes; surface the node's error key ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** derive View: URL from node instead of hardcoding gitlawb.com ([#370](https://github.com/Twigpine/node/issues/370)) ([#549](https://github.com/Twigpine/node/issues/549)) ([d84c445](https://github.com/Twigpine/node/commit/d84c445f94c05e43994c8199bb8e4cd432554fbc))
|
||||
* **gl:** follow scan continuations in gl ipfs get, bounded three ways ([9a8d190](https://github.com/Twigpine/node/commit/9a8d190492315cf767807dd1a98470debff4bd25))
|
||||
* **gl:** make doctor exit non-zero on Fail-class checks ([#357](https://github.com/Twigpine/node/issues/357)) ([#547](https://github.com/Twigpine/node/issues/547)) ([42cc893](https://github.com/Twigpine/node/commit/42cc893fb3af17468767cd8c6722894cd4ef7e44))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([21c74c6](https://github.com/Twigpine/node/commit/21c74c65d985200221489fa342d7419e8626362f))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([fd78cfa](https://github.com/Twigpine/node/commit/fd78cfa3ad0e7997b284371a55b7fb52753236d1))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3cc15cf](https://github.com/Twigpine/node/commit/3cc15cf7247396ab1efcd279d3dd51dd8deaf014))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([16cc3b3](https://github.com/Twigpine/node/commit/16cc3b37e8268d048f3145ab16978dae297df5d4))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3f41df5](https://github.com/Twigpine/node/commit/3f41df5868838544876473c2560c3d6be20e679c))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([b0e5cc0](https://github.com/Twigpine/node/commit/b0e5cc0ee0e91ebe35689bb1e073fa7e48ab37ee))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([12bc759](https://github.com/Twigpine/node/commit/12bc759ea6b20ab97c9dfeb4e00d7c6a6ca4fcfe))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([10e3492](https://github.com/Twigpine/node/commit/10e34928254205fafa8e96513a3df7899bfb10b1))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([c10f2ca](https://github.com/Twigpine/node/commit/c10f2ca255d63331f6acf3a29629e2a1c11ff355))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3add535](https://github.com/Twigpine/node/commit/3add5356e36f0c2f89a0617fcb8018209bf722ff))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([fdd1103](https://github.com/Twigpine/node/commit/fdd11030dc55a68142441ecf05f0bf33a8d38695))
|
||||
* **gl:** pass the mirror temp path to git as an OS string ([#434](https://github.com/Twigpine/node/issues/434)) ([df87b15](https://github.com/Twigpine/node/commit/df87b150fdb00e4b70e359996a41b2eebf7bb426))
|
||||
* **gl:** read whoami error bodies through CappedBody.text ([bfc44f9](https://github.com/Twigpine/node/commit/bfc44f926d08c0bf774e2c05dd76b245871294f1))
|
||||
* **gl:** read whoami error bodies through CappedBody.text ([4725146](https://github.com/Twigpine/node/commit/4725146db2c13cd3b8f998260d9df3a4b5f70c9e))
|
||||
* **gl:** report why the node-DID comparison was skipped in cert show ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** require non-empty error message and use ephemeral port in whoami tests ([#220](https://github.com/Twigpine/node/issues/220)) ([a27a267](https://github.com/Twigpine/node/commit/a27a2678712cc512b316f143d8c053dbdf981a4b))
|
||||
* **gl:** sanitize full transport error chain in whoami, remove raw error attachment ([#220](https://github.com/Twigpine/node/issues/220)) ([39ce655](https://github.com/Twigpine/node/commit/39ce655904f516094cbbef6f2ee585906ef77e05))
|
||||
* **gl:** status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result ([#123](https://github.com/Twigpine/node/issues/123)) ([#186](https://github.com/Twigpine/node/issues/186)) ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** stop carrying request signatures to a host the node redirected us to ([de3f230](https://github.com/Twigpine/node/commit/de3f230940f21d88da48a972bddeb877420d2165))
|
||||
* **icaptcha-client:** parse GITLAWB_ICAPTCHA_INSECURE as truthy only ([#227](https://github.com/Twigpine/node/issues/227)) ([#533](https://github.com/Twigpine/node/issues/533)) ([77ac103](https://github.com/Twigpine/node/commit/77ac1037cbf893bf0e2e861753cc4bc78cc9053a))
|
||||
* **icaptcha:** return unsolved instead of blocking or overflowing ([#444](https://github.com/Twigpine/node/issues/444)) ([5479fd5](https://github.com/Twigpine/node/commit/5479fd59b848f13e7c0f0f1054b0d41cf50cbb73))
|
||||
* **infra:** serialize the upgrade and write compose through a unique temp path ([888fab6](https://github.com/Twigpine/node/commit/888fab602828dc8b514cfa5fbb4d027a3a2db019))
|
||||
* **ipfs:** page the legacy CID scan instead of materializing every repo ([4985e38](https://github.com/Twigpine/node/commit/4985e38af2bd629fa81ec70aec63937ed9894b73))
|
||||
* **node,infra,macos:** make the owner-push opt-out reach every launch path ([1f0d9ff](https://github.com/Twigpine/node/commit/1f0d9ff368c0169048aeec219de4114c2630dff6))
|
||||
* **node:** ANS-104 codec and recovery policy types ([#26](https://github.com/Twigpine/node/issues/26) split 2/4) ([#524](https://github.com/Twigpine/node/issues/524)) ([2c341f7](https://github.com/Twigpine/node/commit/2c341f71ac66c0011a09ff5864e8734fd0348c09))
|
||||
* **node:** authenticate gossip ref-update events before writing them ([#325](https://github.com/Twigpine/node/issues/325)) ([9bda2e1](https://github.com/Twigpine/node/commit/9bda2e103c9ae25a5a798343164b4fa02219735b))
|
||||
* **node:** bind a peer row to its DID so only its keyholder can repoint it ([#273](https://github.com/Twigpine/node/issues/273)) ([8d38043](https://github.com/Twigpine/node/commit/8d38043a4ca7d0df3c9a4c4bdbb171d7adcdec4f))
|
||||
* **node:** bound federated repository aggregation ([#546](https://github.com/Twigpine/node/issues/546)) ([0189990](https://github.com/Twigpine/node/commit/0189990ac9e72bed3c42bc589347eacec04be740))
|
||||
* **node:** bound GraphQL query cost ([#552](https://github.com/Twigpine/node/issues/552)) ([c805e35](https://github.com/Twigpine/node/commit/c805e35f40873a120c81bf00d9ddb50da956fd97))
|
||||
* **node:** bound Identify-derived Kademlia addresses ([#544](https://github.com/Twigpine/node/issues/544)) ([b33562f](https://github.com/Twigpine/node/commit/b33562f1a913cbc1b294f00577f4ab1f7ad67984))
|
||||
* **node:** bound legacy readiness fallback ([5af14c2](https://github.com/Twigpine/node/commit/5af14c203d07fdd2d0b5689fcb12e189237dbbb2))
|
||||
* **node:** bound peer failure tracking ([74f7554](https://github.com/Twigpine/node/commit/74f75548941712573d344aa058d162f40189b4df))
|
||||
* **node:** bound replay of authenticated gossip ref-update events ([#334](https://github.com/Twigpine/node/issues/334)) ([206e082](https://github.com/Twigpine/node/commit/206e0827ad6beb051cbb0fb5ef459a63a102c454))
|
||||
* **node:** bound the fruitless reads one sweep run will spend ([e4aaae8](https://github.com/Twigpine/node/commit/e4aaae8c5229355010ca710abf22c181210201c3))
|
||||
* **node:** bound the legacy CID scan and let the caller carry its position ([22f4db9](https://github.com/Twigpine/node/commit/22f4db94b2fb3b3c7c43c5bd34608ea77ce5d197))
|
||||
* **node:** bound the legacy scan's query by the remaining row budget ([21e6873](https://github.com/Twigpine/node/commit/21e687368336fd219d82308ee20b3d6bb018a3dc))
|
||||
* **node:** bound the legacy-repair read by the pin batch deadline ([ea2219b](https://github.com/Twigpine/node/commit/ea2219b2cb635da61f120f65434db4c427494f6c))
|
||||
* **node:** bound the pin loops' DB work by the batch deadline, both lanes ([9984e08](https://github.com/Twigpine/node/commit/9984e082121f3e8d25750dd54c0d60c85cef0ac7))
|
||||
* **node:** bound the pre-walk CID resolve on its own short budget ([0d7711c](https://github.com/Twigpine/node/commit/0d7711c08917a70f982391249439485ddce382b0))
|
||||
* **node:** bound the request body the signature middleware buffers before it verifies ([#262](https://github.com/Twigpine/node/issues/262)) ([288ad92](https://github.com/Twigpine/node/commit/288ad92fe83526a717fa1a6ac37b1f3cf1f97961))
|
||||
* **node:** bring the Pinata pin path back into lockstep with the Kubo one ([4681f54](https://github.com/Twigpine/node/commit/4681f54327b58b6ebdd569a1cec17a3c2643ad13))
|
||||
* **node:** budget provenance and fallback walks separately ([ae96ffa](https://github.com/Twigpine/node/commit/ae96ffa46706a6d088a412aa02dbf2f715ceaa37))
|
||||
* **node:** canonicalize and 404-on-no-match in remove_label ([#344](https://github.com/Twigpine/node/issues/344)) ([#530](https://github.com/Twigpine/node/issues/530)) ([8c9e4ad](https://github.com/Twigpine/node/commit/8c9e4adc2d5ae3560c1bd3ba76c1d0ddb4de2af6))
|
||||
* **node:** carry discovery onto the per-(oid, repo) incompleteness marker ([bd0bef1](https://github.com/Twigpine/node/commit/bd0bef1476f90e99d9a697e2520c32b954f3ba6f))
|
||||
* **node:** charge discovery's probes against the per-run read budget ([bc20236](https://github.com/Twigpine/node/commit/bc202368e13202504fe77534273b508f7559a84a))
|
||||
* **node:** clamp every admission-held DB await in the CID resolver ([bad82b3](https://github.com/Twigpine/node/commit/bad82b3cd9fa970ebf67817395bfbd98591879b1))
|
||||
* **node:** close review findings on the owner-push default ([2f0174c](https://github.com/Twigpine/node/commit/2f0174cc041fb6e9eb3c904172b37b5b9a6c094b))
|
||||
* **node:** close three host-class gaps in the public-URL gate ([#346](https://github.com/Twigpine/node/issues/346)) ([76ca04f](https://github.com/Twigpine/node/commit/76ca04f3552aa8c17305fca73fa1c3469a29903c))
|
||||
* **node:** count a completed fetch once per fetch, not per stateless-RPC POST ([1c690c1](https://github.com/Twigpine/node/commit/1c690c125c16ad2ed38a5885d0e16b96789b715e))
|
||||
* **node:** count fetch completion from the response outcome, not the request ([5a5d4e1](https://github.com/Twigpine/node/commit/5a5d4e1b43a5b37fcc57731ec504b221a58931f1))
|
||||
* **node:** drop withheld-subtree trees from the replication pin set ([#382](https://github.com/Twigpine/node/issues/382)) ([19a7ce2](https://github.com/Twigpine/node/commit/19a7ce2bbdad9dd96bdc35ed6230d14f772a5969))
|
||||
* **node:** enforce owner-only push by default ([cdbda19](https://github.com/Twigpine/node/commit/cdbda19c29db576d2ea37ae74578c899b4eb3bb9))
|
||||
* **node:** Escape LIKE metacharacters in certificate prefix search ([eeafa1f](https://github.com/Twigpine/node/commit/eeafa1fa5350fb2d15ba30ac6cd6ad8247b4b7ec))
|
||||
* **node:** Escape LIKE wildcards in certificate prefix search ([96d8123](https://github.com/Twigpine/node/commit/96d8123e0f84e85a7b0234ad84f5e603a5a303aa))
|
||||
* **node:** fail pin_git_object when the add response carries no Hash ([#528](https://github.com/Twigpine/node/issues/528)) ([47c6e6c](https://github.com/Twigpine/node/commit/47c6e6ce2200dd196cb531bc6a6ed946cf587409))
|
||||
* **node:** fence a repo publish on the write attempt that owns it ([#285](https://github.com/Twigpine/node/issues/285)) ([ed76adf](https://github.com/Twigpine/node/commit/ed76adfe29d793229fbc63eeb86d24cac5d23d13))
|
||||
* **node:** gate /ipfs/pins and /arweave/anchors behind authentication ([#121](https://github.com/Twigpine/node/issues/121)) ([#543](https://github.com/Twigpine/node/issues/543)) ([d3845ce](https://github.com/Twigpine/node/commit/d3845ced69597b3e73e3c8cb5f452f6367897e5b))
|
||||
* **node:** gate bounty mutations on repo read before status checks ([#529](https://github.com/Twigpine/node/issues/529)) ([8eedb93](https://github.com/Twigpine/node/commit/8eedb935c3525a91ac311e273c3f22d3b4eb4615))
|
||||
* **node:** gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak ([#135](https://github.com/Twigpine/node/issues/135)) ([21f8cb6](https://github.com/Twigpine/node/commit/21f8cb61350b2355edd2b2ed527d9fb9c4305bd9))
|
||||
* **node:** gate replica register/unregister on repo read visibility ([#445](https://github.com/Twigpine/node/issues/445)) ([0303222](https://github.com/Twigpine/node/commit/0303222eae486c6d519be1441d4b3b11115a05ab))
|
||||
* **node:** gate two Unix-only tests so the crate compiles on Windows ([5a7a77b](https://github.com/Twigpine/node/commit/5a7a77b8dc1723cde44b0439327e517e23a048ab)), closes [#228](https://github.com/Twigpine/node/issues/228)
|
||||
* **node:** give each discovery row its own slice of the pass budget ([22560b0](https://github.com/Twigpine/node/commit/22560b0872bd16dd66100681b1050faa677fc568))
|
||||
* **node:** harden peer readiness state ([6bd8f41](https://github.com/Twigpine/node/commit/6bd8f4121235ec6c4b7062aadcf3a631f174510c))
|
||||
* **node:** hold fork to the same repo-name rules as every other creation route ([#412](https://github.com/Twigpine/node/issues/412)) ([fc87586](https://github.com/Twigpine/node/commit/fc8758641147ca5f38561fc71b13b1e41a78a5ed))
|
||||
* **node:** honor pre-assigned task assignee on claim ([#550](https://github.com/Twigpine/node/issues/550)) ([a4026e8](https://github.com/Twigpine/node/commit/a4026e84ffe82f48b41817c8ba687fbc1aac8f9b))
|
||||
* **node:** make metrics::init race-safe with Once ([c45ec96](https://github.com/Twigpine/node/commit/c45ec9605d55171f7b8f1b0b542478097ddf43a3))
|
||||
* **node:** make the pin-source incompleteness marker per (object, repo) ([5575da4](https://github.com/Twigpine/node/commit/5575da4b65ff19da78a6b218da07f854706a811c))
|
||||
* **node:** map ipfs/arweave DB outages to 503 via bare ? ([#251](https://github.com/Twigpine/node/issues/251)) ([0e2328b](https://github.com/Twigpine/node/commit/0e2328bd8044ce6e8923cc6bd42219068d3f6b9d))
|
||||
* **node:** map ipfs/arweave DB outages to 503 via bare ? ([#251](https://github.com/Twigpine/node/issues/251)) ([c29a91a](https://github.com/Twigpine/node/commit/c29a91af6b7eb444e202f4724e3f677d2ef25466))
|
||||
* **node:** mint a continuation when a ceiling stops the /ipfs scan mid-page ([69cfbd4](https://github.com/Twigpine/node/commit/69cfbd4b3ccde243ff669392820f0fce86381663))
|
||||
* **node:** never hand back a continuation that made no progress ([76f6300](https://github.com/Twigpine/node/commit/76f630008f0088c952755b643126affdf962d12b))
|
||||
* **node:** opaque AppError::Internal and AppError::Db HTTP bodies ([#226](https://github.com/Twigpine/node/issues/226)) ([241b366](https://github.com/Twigpine/node/commit/241b36633bca7e0ead33bf670b7c1531c3bd9ab5))
|
||||
* **node:** opaque AppError::Internal and AppError::Db HTTP bodies ([#226](https://github.com/Twigpine/node/issues/226)) ([d192028](https://github.com/Twigpine/node/commit/d192028ee1487a3638c545d6a39e58a4575df1f1))
|
||||
* **node:** opaque GraphQL DB error messages ([#250](https://github.com/Twigpine/node/issues/250)) ([c10ccf1](https://github.com/Twigpine/node/commit/c10ccf1f059eae18fcb0b7058a35bb134838b790))
|
||||
* **node:** order the CID candidate list so it cannot depend on heap order ([366b599](https://github.com/Twigpine/node/commit/366b59933112b345113501a65f0f30e76518ff61))
|
||||
* **node:** owner-gate create_task repo_id against hosted repos ([#541](https://github.com/Twigpine/node/issues/541)) ([bac49f1](https://github.com/Twigpine/node/commit/bac49f150a2b99d7b14fc9d53e231b7e743a753c))
|
||||
* **node:** persist the libp2p identity instead of deriving it from the node DID ([#324](https://github.com/Twigpine/node/issues/324)) ([c301a68](https://github.com/Twigpine/node/commit/c301a681619e890a081c385d7cbab5b8a8d4efbb))
|
||||
* **node:** port discovery's candidate load onto the paged repo query ([4c665fb](https://github.com/Twigpine/node/commit/4c665fbca8641f7dfe6770d1ee5384a0af3d215c))
|
||||
* **node:** preserve legacy peer compatibility ([547f9ae](https://github.com/Twigpine/node/commit/547f9aed10706bc617d0e1e2e5346cf52d19a901))
|
||||
* **node:** reconcile public-read messaging with per-repo enforcement ([#439](https://github.com/Twigpine/node/issues/439)) ([0366f18](https://github.com/Twigpine/node/commit/0366f18458dd996ea18057ce60abf88673e16ed4))
|
||||
* **node:** record a discovered source and its marker in one commit ([9babb5e](https://github.com/Twigpine/node/commit/9babb5ece053608c522f49b4528de23d5b61b749))
|
||||
* **node:** reject revision syntax in issue ids and drop service passthrough ([#521](https://github.com/Twigpine/node/issues/521)) ([421f03a](https://github.com/Twigpine/node/commit/421f03ac46592c57bc0df5a8fa79061c74d32fe6))
|
||||
* **node:** repair source-less legacy pins by bounded additive discovery ([d88c0c7](https://github.com/Twigpine/node/commit/d88c0c726c8630e104e0161366d1815d69e96428))
|
||||
* **node:** require Content-Digest on a signed request ([#306](https://github.com/Twigpine/node/issues/306)) ([189aad9](https://github.com/Twigpine/node/commit/189aad96e5c831f94cef654048d2d9797a5f574d))
|
||||
* **node:** resolve issue ids from git refnames, not request input ([#526](https://github.com/Twigpine/node/issues/526)) ([3063719](https://github.com/Twigpine/node/commit/30637193a626da800984363b6192fc50131b9741))
|
||||
* **node:** resume the /ipfs scan per candidate so a starved one is not skipped ([d0635d9](https://github.com/Twigpine/node/commit/d0635d95b7d601490474419c6bdb99b0ce402e71))
|
||||
* **node:** rewind the pin repair sweep cursor on every completed walk ([1b44b12](https://github.com/Twigpine/node/commit/1b44b12b468c265f946390a57cd815d67d205166))
|
||||
* **node:** rotate discovery candidates across traversals ([4fdfa1c](https://github.com/Twigpine/node/commit/4fdfa1cedb3dcb6d7d6cc4004e7277a9fa95de94))
|
||||
* **node:** scope bounty aggregates to anonymously-listable repos ([#477](https://github.com/Twigpine/node/issues/477)) ([#527](https://github.com/Twigpine/node/issues/527)) ([317a155](https://github.com/Twigpine/node/commit/317a1550c2810932118429b9987ea437a1465cf5))
|
||||
* **node:** scope the refs listing by the canonical repo slug ([#388](https://github.com/Twigpine/node/issues/388)) ([131bb95](https://github.com/Twigpine/node/commit/131bb95c686698d0f6a90dabd3976509df86b595))
|
||||
* **node:** size the ipfs work floor for provenance walks plus one full search ([dc5daa1](https://github.com/Twigpine/node/commit/dc5daa14b4d0816d8a14fabdf057259c1d177a46))
|
||||
* **node:** stop reporting a Kubo pin whose DB record never landed ([f839fb9](https://github.com/Twigpine/node/commit/f839fb924653f1b012c2b34d51260ea3faba7f62))
|
||||
* **node:** stop the /ipfs size probe reporting a fault as a missing object ([ed6355e](https://github.com/Twigpine/node/commit/ed6355ed2c42bed09dec35d46904805e97de806c))
|
||||
* **node:** use readiness for peer liveness ([225644a](https://github.com/Twigpine/node/commit/225644a3ea0302ae91959016928df1040ddd9cb5))
|
||||
* **node:** use readiness for peer liveness ([1a841a9](https://github.com/Twigpine/node/commit/1a841a99e2d40129a33f1898d3ce28d05eb04632))
|
||||
* **node:** validate git branch refs to close option injection ([#379](https://github.com/Twigpine/node/issues/379)) ([6233bd9](https://github.com/Twigpine/node/commit/6233bd9d850b60fdd2fb05afaa3d996d9f37f59d))
|
||||
* **node:** validate ref-update SHAs are hex, not just length 40 ([#433](https://github.com/Twigpine/node/issues/433)) ([911be4d](https://github.com/Twigpine/node/commit/911be4d9fef309e56117440d21e5dad7718be666))
|
||||
* **node:** version ref_certificates for forward compat ([#26](https://github.com/Twigpine/node/issues/26) split 3/4) ([#548](https://github.com/Twigpine/node/issues/548)) ([38bab41](https://github.com/Twigpine/node/commit/38bab4191baa5e8778f3412dc1f013d1f654bb15))
|
||||
* **release:** bump icaptcha-client with the rest of the workspace ([6668525](https://github.com/Twigpine/node/commit/6668525f41936ed2e1f8a1334b0dd17eaea866f0))
|
||||
* **repo_store:** use SHA-256 for stable advisory-lock key ([#210](https://github.com/Twigpine/node/issues/210)) ([c380f9c](https://github.com/Twigpine/node/commit/c380f9cee5139a2cab6fc52000d43844b86bb143))
|
||||
* **repo_store:** use SHA-256 for stable advisory-lock key ([#210](https://github.com/Twigpine/node/issues/210)) ([89e10d0](https://github.com/Twigpine/node/commit/89e10d0ff12dcccae3bd4490548d906f77d76636))
|
||||
* **review:** assert the read-timeout postcondition and drop the socket clone ([4d9bd5a](https://github.com/Twigpine/node/commit/4d9bd5a18a0692baa67f94aac8e8663d8e69bdac))
|
||||
* **review:** bound the probe's channel waits and stop overstating the handshake ([d4e5ecf](https://github.com/Twigpine/node/commit/d4e5ecf299afaabf05eee22db462b9314b5a6998))
|
||||
* **review:** bound token key, rule bytes, and the discovery load ([e6cfb58](https://github.com/Twigpine/node/commit/e6cfb588c2a1600d59f2af88471d248a99532f03))
|
||||
* **review:** keep the resume token on every terminal that still holds one ([8c39cfd](https://github.com/Twigpine/node/commit/8c39cfdcb347ca824cbec7d6b56720df9517a472))
|
||||
* **review:** make the coupled sites findable and pin two more properties ([a7e8e35](https://github.com/Twigpine/node/commit/a7e8e35030cae4e1126cda6c161bc516e3b8ffff))
|
||||
* **review:** make the LIKE escape independent of the SQL parser mode ([1d3987b](https://github.com/Twigpine/node/commit/1d3987bba6619a64a2cef15023224cbaa6cc8d1b)), closes [#319](https://github.com/Twigpine/node/issues/319)
|
||||
* **review:** mint a continuation at every ceiling and widen the token ([342dd48](https://github.com/Twigpine/node/commit/342dd48d2426a8870423cd537781b52d03e8f175))
|
||||
* **test:** match SHA-256 advisory-lock key in receive-pack deadline test ([c68e1b0](https://github.com/Twigpine/node/commit/c68e1b07f8db752cff786bdc9c226b49889a40b1))
|
||||
* **whoami:** update registered status handling on 404 response ([75b0df8](https://github.com/Twigpine/node/commit/75b0df8f1c7bda6a02444ce17e11088b839a51b6))
|
||||
|
||||
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
|
||||
|
||||
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
# Contributing to gitlawb
|
||||
# Contributing to Twigpine
|
||||
|
||||
Thanks for your interest in contributing. gitlawb is an open protocol — agents and humans welcome.
|
||||
Thanks for your interest in contributing. Twigpine is an open protocol — agents and humans welcome.
|
||||
|
||||
## Quick start for contributors
|
||||
|
||||
```sh
|
||||
# Clone and build
|
||||
git clone https://github.com/Gitlawb/node
|
||||
git clone https://github.com/Twigpine/node
|
||||
cd node
|
||||
|
||||
# Build everything
|
||||
@@ -38,7 +38,7 @@ docs/ Operator guides
|
||||
scripts/ Build helpers
|
||||
```
|
||||
|
||||
Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://github.com/Gitlawb/contracts).
|
||||
Smart contracts live in a separate repo: [github.com/Twigpine/contracts](https://github.com/Twigpine/contracts).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
@@ -81,7 +81,7 @@ time once updated.
|
||||
|
||||
**Requirements:**
|
||||
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
|
||||
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
|
||||
- PostgreSQL 16+ — required for the node (it uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later). Use the bundled `docker-compose.yml` for local dev.
|
||||
- Docker (optional, for full-stack local testing)
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
Generated
+939
-553
File diff suppressed because it is too large.
Load diff
+7
-2
@@ -20,8 +20,8 @@ members = [
|
||||
edition = "2021"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["gitlawb contributors"]
|
||||
repository = "https://github.com/gitlawb/node"
|
||||
authors = ["Twigpine contributors"]
|
||||
repository = "https://github.com/Twigpine/node"
|
||||
|
||||
[workspace.dependencies]
|
||||
# async
|
||||
@@ -53,6 +53,11 @@ 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; also used for the
|
||||
# absolute-browser-url validation on GITLAWB_WEB_URL in the node and CLI)
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ 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 gitlawb contributors
|
||||
Copyright 2026 Twigpine 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 gitlawb contributors
|
||||
Copyright (c) 2026 Twigpine 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 @@
|
||||
# Gitlawb Node
|
||||
# Twigpine Node
|
||||
|
||||
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
|
||||
|
||||
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 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 is not trying to be only "another git host." The long-term direction is:
|
||||
Twigpine is not trying to be only "another git host." The long-term direction is:
|
||||
|
||||
```txt
|
||||
Decentralized GitHub
|
||||
@@ -24,17 +24,17 @@ This is a Rust workspace with six 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 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. |
|
||||
| `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. |
|
||||
| `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 Gitlawb Nodes?
|
||||
## Why Twigpine Nodes?
|
||||
|
||||
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
|
||||
Most git hosting today depends on a small number of centralized platforms. Twigpine 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.
|
||||
@@ -47,12 +47,13 @@ Most git hosting today depends on a small number of centralized platforms. Gitla
|
||||
|
||||
## Current status
|
||||
|
||||
Gitlawb Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
|
||||
Twigpine 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:
|
||||
|
||||
- Local or Docker node startup.
|
||||
- Postgres-backed repo metadata.
|
||||
- Bounded GraphQL queries with [repository pagination](docs/graphql-pagination.md).
|
||||
- Bare git repository storage.
|
||||
- Git smart-HTTP clone/fetch/push.
|
||||
- RFC 9421-signed writes.
|
||||
@@ -66,12 +67,12 @@ 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.
|
||||
- Repository write authorization is secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` only for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint a `did:key` and sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- 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.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
|
||||
- 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.
|
||||
|
||||
See:
|
||||
@@ -87,7 +88,7 @@ See:
|
||||
The fastest path is Docker Compose. It starts a node and Postgres.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/node.git
|
||||
git clone https://github.com/Twigpine/node.git
|
||||
cd node
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
@@ -132,10 +133,10 @@ npm install -g @gitlawb/gl
|
||||
brew install gitlawb/tap/gl
|
||||
|
||||
# curl (macOS / Linux)
|
||||
curl -fsSL https://gitlawb.com/install.sh | sh
|
||||
curl -fsSL https://twigpine.com/install.sh | sh
|
||||
|
||||
# PowerShell (Windows)
|
||||
irm https://gitlawb.com/install.ps1 | iex
|
||||
irm https://twigpine.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
@@ -178,7 +179,7 @@ gl register --node http://localhost:7545
|
||||
Create a repo:
|
||||
|
||||
```bash
|
||||
gl repo create my-repo --description "My first Gitlawb repo" --node http://localhost:7545
|
||||
gl repo create my-repo --description "My first Twigpine repo" --node http://localhost:7545
|
||||
```
|
||||
|
||||
Use the git remote helper:
|
||||
@@ -192,7 +193,7 @@ 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.gitlawb.com`) require two things on writes:
|
||||
Public nodes (e.g. `node.twigpine.com`) require two things on writes:
|
||||
|
||||
1. **RFC 9421 HTTP Signatures**: every write is signed by your identity key. `gl`
|
||||
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
|
||||
@@ -205,8 +206,8 @@ Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
|
||||
|
||||
```bash
|
||||
gl identity new # create did:key identity
|
||||
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
|
||||
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
|
||||
gl register --node https://node.twigpine.com # signed + auto-solves iCaptcha
|
||||
gl repo create memlawb --node https://node.twigpine.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
|
||||
@@ -229,6 +230,53 @@ Notes:
|
||||
sends the bearer token **only** to your configured origin, never to a URL a
|
||||
node advertises, so a hostile node can't capture the key or redirect the solve.
|
||||
|
||||
### Fetching an object by CID
|
||||
|
||||
```bash
|
||||
gl ipfs list # CIDs this node has pinned
|
||||
gl ipfs get bafkrei... > object.bin # object bytes on stdout
|
||||
```
|
||||
|
||||
Objects that were pinned before the node started recording which repo they came
|
||||
from are found by scanning its repo inventory, and that scan stops at the
|
||||
per-request ceilings in the [Configuration](#configuration) table. A stopped scan
|
||||
answers 503 with a resume token instead of a false "not found", and `gl ipfs get`
|
||||
follows the token automatically: up to 8 resumes after the first request, so at
|
||||
most 9 calls to the node, waiting between attempts for as long as the node's
|
||||
`Retry-After` asks and never longer than 5 seconds.
|
||||
|
||||
The whole ladder runs under a 60 second wall-clock deadline. The deadline bounds
|
||||
the search, not the download: each attempt gets the time left on it to produce
|
||||
response headers, and once an object is found its bytes stream outside that
|
||||
deadline. They are not unbounded, though. The client's own 30 second HTTP timeout
|
||||
is a total request timeout, running from the start of a request until its body
|
||||
has finished, so a transfer still going 30 seconds after its request began is cut
|
||||
off. Waits between attempts never run past the deadline either, so a single run
|
||||
spends at most around 90 seconds on the network: the deadline plus the 30 second
|
||||
timeout covering the last attempt. Writing the object out sits outside both
|
||||
bounds, so piping into a reader that stops reading can hold the command open
|
||||
longer than that.
|
||||
|
||||
Two node-side brakes end a ladder early and are reported rather than retried
|
||||
around. A 429 is terminal, because the node's rate-limit window is an hour and
|
||||
that wait cannot be honored inside one invocation; a transient overload (a 503
|
||||
carrying no incomplete-scan code) is retried on the token already held, under the
|
||||
same cap, clamp and deadline. The per-IP fanout brake can also stop a ladder well
|
||||
short of the 9 calls, so automatic resumption is not a guarantee of reaching the
|
||||
object.
|
||||
|
||||
When a bound stops the ladder with a usable token in hand, the command prints the
|
||||
token and the invocation that continues from it before exiting nonzero:
|
||||
|
||||
```txt
|
||||
resume from where this stopped: gl ipfs get bafkrei... --scan <token>
|
||||
```
|
||||
|
||||
Run that to carry on from where the scan stopped. Re-running without `--scan`
|
||||
restarts at the first row, reproduces the same truncation and spends the node's
|
||||
per-IP budget again, so the token is the only thing that makes progress. Tokens
|
||||
are valid for an hour.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -264,6 +312,7 @@ metadata local disk / optional S3
|
||||
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
|
||||
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
|
||||
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
|
||||
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
|
||||
| Gossipsub | libp2p topic for ref-update events. |
|
||||
@@ -322,6 +371,12 @@ 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
|
||||
@@ -344,8 +399,10 @@ 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 signed peer announce/sync writes. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_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. |
|
||||
@@ -357,9 +414,12 @@ Important node settings:
|
||||
| `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_REPOS_WALKED` | Max expensive path-scope visibility walks per `/ipfs/{cid}` request; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. Default 64. |
|
||||
| `GITLAWB_IPFS_MAX_LEGACY_PROBES` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
|
||||
| `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` | Max repo rows one `/ipfs/{cid}` request's legacy scan may read from the database. The probe ceiling above only starts counting once a probe runs, and quarantined or private repos are denied before that, so this is what bounds a scan over an inventory that denies the caller everywhere. A truncated scan sheds a retryable 503 carrying an opaque `continuation` token; echoing it as `?scan=` resumes the scan where it stopped. Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one, so each request advances the ladder by at least the rows it read and a holder buried past a ceiling is reached in a bounded number of requests, `ceil(repos / ceiling) + 1` when this row ceiling is the one that binds. No ceiling ever produces a 404. Every page is charged to the caller's `/ipfs` work allowance, so raising this raises that allowance too. Lowering it sharpens an oracle: because a truncation emits a token and a completed wrap does not, laddering to the end reveals the node's total repo count (private and quarantined included) to within one ceiling. Default 2048. |
|
||||
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks a `/ipfs/{cid}` request may run per phase; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. It is charged per phase: the provenance lookup and the legacy-scan fallback get separate equal budgets, so one request can run up to twice the cap in total. Default 64. |
|
||||
| `GITLAWB_IPFS_MAX_REPO_VISITS` | Ceiling on repos one `/ipfs/{cid}` request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
|
||||
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
|
||||
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
@@ -367,11 +427,35 @@ Important node settings:
|
||||
|
||||
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
|
||||
|
||||
Gitlawb Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
|
||||
Twigpine 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:
|
||||
|
||||
@@ -441,8 +525,8 @@ Build:
|
||||
Output:
|
||||
|
||||
```txt
|
||||
dist/Gitlawb Node.app
|
||||
dist/Gitlawb Node.dmg
|
||||
dist/Twigpine Node.app
|
||||
dist/GitlawbNode-macOS.dmg
|
||||
```
|
||||
|
||||
Features:
|
||||
@@ -456,7 +540,7 @@ Features:
|
||||
Unsigned local build:
|
||||
|
||||
```bash
|
||||
xattr -cr "dist/Gitlawb Node.app"
|
||||
xattr -cr "dist/Twigpine Node.app"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -471,7 +555,7 @@ 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.
|
||||
5. Wire trusted UCAN delegation into repository write authorization.
|
||||
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
|
||||
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
|
||||
|
||||
|
||||
+7
-7
@@ -2,9 +2,9 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in gitlawb, please **do not open a public issue**.
|
||||
If you discover a security vulnerability in Twigpine, please **do not open a public issue**.
|
||||
|
||||
Report it privately by emailing **security@gitlawb.com** with:
|
||||
Report it privately by emailing **security@twigpine.com** with:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact assessment
|
||||
@@ -54,9 +54,9 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
These are documented limitations of the current live release. They 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.
|
||||
- `git-receive-pack` verifies HTTP Signatures and, with the default configuration, also requires the pusher DID to be the repository owner: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher *and* the pusher must be the repository owner. Authentication is not authorization on its own — anyone can mint a `did:key` and sign — so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, including private ones.
|
||||
- **Mitigation:** `GITLAWB_ENFORCE_OWNER_PUSH=false` is the explicit rolling-upgrade compatibility override. Disable it only for the duration of an upgrade whose pushers are not yet the repo owner, then turn it back on. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on.
|
||||
|
||||
### UCAN delegation and revocation
|
||||
- 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.
|
||||
@@ -118,13 +118,13 @@ These are documented limitations of the current live release. They should be pri
|
||||
|
||||
## Threat Model
|
||||
|
||||
gitlawb is designed to be secure against:
|
||||
Twigpine is designed to be secure against:
|
||||
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
- **Tampered git objects** — CIDv1 content addressing detects modification
|
||||
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
|
||||
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
|
||||
|
||||
gitlawb is **not yet** designed to defend against:
|
||||
Twigpine 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 Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-06-30",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"operator": "Gitlawb",
|
||||
"operator": "Twigpine",
|
||||
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
|
||||
"http_url": "https://node.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node2",
|
||||
"operator": "Gitlawb",
|
||||
"operator": "Twigpine",
|
||||
"did": null,
|
||||
"http_url": "https://node2.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node3",
|
||||
"operator": "Gitlawb",
|
||||
"operator": "Twigpine",
|
||||
"did": null,
|
||||
"http_url": "https://node3.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-manila",
|
||||
"operator": "Gitlawb",
|
||||
"operator": "Twigpine",
|
||||
"did": null,
|
||||
"http_url": "https://manila.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,7 +11,7 @@ name = "git-remote-gitlawb"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -199,9 +199,7 @@ fn handle_connect<R: Read>(
|
||||
other => bail!("unsupported git service: {other}"),
|
||||
}
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
let client = build_http_client()?;
|
||||
|
||||
// ── Phase 1: ref advertisement (GET /info/refs?service=<service>) ─────────
|
||||
//
|
||||
@@ -289,8 +287,10 @@ fn handle_connect<R: Read>(
|
||||
// this into one POST deadlocks a multi-round fetch (#117).
|
||||
//
|
||||
// git-receive-pack (push):
|
||||
// Git sends ref-update commands + the complete PACK blob, then closes its
|
||||
// write pipe. read_to_end is safe and correct here, a single POST.
|
||||
// Git sends the ref-update command list (pkt-lines ending in a flush),
|
||||
// then the complete PACK blob only when a command has a non-zero new-oid.
|
||||
// A delete-only push sends no pack, and git holds its write pipe open for
|
||||
// the report-status response, so EOF cannot delimit the request (#369).
|
||||
|
||||
let post_url = format!("{}/{}", repo_base, service);
|
||||
|
||||
@@ -298,10 +298,7 @@ fn handle_connect<R: Read>(
|
||||
return negotiate_upload_pack(&client, &post_url, service, signing_key, stdin, &mut stdout);
|
||||
}
|
||||
|
||||
let mut request_body = Vec::new();
|
||||
stdin
|
||||
.read_to_end(&mut request_body)
|
||||
.context("reading receive-pack request")?;
|
||||
let request_body = read_receive_pack_request(stdin)?;
|
||||
tracing::debug!("pack request: {} bytes from git", request_body.len());
|
||||
|
||||
if request_body.is_empty() {
|
||||
@@ -320,6 +317,58 @@ fn handle_connect<R: Read>(
|
||||
)
|
||||
}
|
||||
|
||||
// ── HTTP client ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Total request timeout. A pack transfer can be large and slow, so this is far
|
||||
/// wider than the CLI's.
|
||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
/// The one client both phases use, with the redirect policy the signing surfaces
|
||||
/// need.
|
||||
///
|
||||
/// Every request this client sends can carry RFC 9421 `Signature` and
|
||||
/// `Signature-Input` headers: a push signs from the first request, and a fetch of a
|
||||
/// private repo signs on the retry. reqwest strips only `Authorization`, `Cookie`,
|
||||
/// `Proxy-Authorization` and `WWW-Authenticate` across hosts, so under the default
|
||||
/// `Policy::limited(10)` those signature headers rode a 302 to whatever origin the
|
||||
/// node named, and on a 307/308 the pack body went with them. Scope the follow to the
|
||||
/// origin that issued the redirect AND to an identical request-target, which is the
|
||||
/// same predicate `gl` uses.
|
||||
fn build_http_client() -> Result<reqwest::blocking::Client> {
|
||||
Ok(reqwest::blocking::Client::builder()
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
|
||||
.build()?)
|
||||
}
|
||||
|
||||
/// Refuse any redirect that leaves the issuing origin or rewrites the request-target,
|
||||
/// and bound the chain.
|
||||
///
|
||||
/// Refusal is `stop`, not `error`: the 3xx comes back as an ordinary response and
|
||||
/// the caller reports it through the status path it already has.
|
||||
///
|
||||
/// `Policy::custom` replaces reqwest's built-in limit, so the chain bound is
|
||||
/// restated. It is not what makes the request finite (`HTTP_TIMEOUT` covers the whole
|
||||
/// chain); it is what keeps a node that redirects to itself from costing a request
|
||||
/// per round trip until that timeout. `>` and not `>=` because reqwest pushes the
|
||||
/// redirecting URL onto `previous` before consulting the policy, which is how
|
||||
/// `Policy::limited` reads the same counter.
|
||||
fn same_origin_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
|
||||
let Some(previous) = attempt.previous().last() else {
|
||||
// Unreachable through reqwest, which pushes the redirecting URL first, 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Smart-protocol request builders ───────────────────────────────────────────
|
||||
|
||||
const USER_AGENT: &str = "git/2.0 git-remote-gitlawb/0.1.0";
|
||||
@@ -485,6 +534,108 @@ fn read_upload_pack_round<R: Read>(stdin: &mut R) -> Result<(Vec<u8>, RoundEnd)>
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one git-receive-pack request: optional `shallow` lines, then EITHER a
|
||||
/// command list (pkt-lines terminated by a flush) or a push-cert block
|
||||
/// (pkt-lines terminated by a "push-cert-end" line), then the negotiated
|
||||
/// push-options block, then the pack ONLY when a command carries a non-zero
|
||||
/// new-oid.
|
||||
///
|
||||
/// A delete-only push sends no pack, and git holds its write pipe open waiting
|
||||
/// for the report-status response, so a plain read_to_end deadlocks the two
|
||||
/// (#369). A signed push is the other trap: when the client sends a push-cert
|
||||
/// it sends NO command list at all (the commands live inside the cert), so the
|
||||
/// request starts with a "push-cert" line and no flush ever arrives. The
|
||||
/// negotiated capability list is on the push-cert line, or on the first
|
||||
/// command line after its NUL when there is no cert. EOF at any point also
|
||||
/// ends the read, covering git versions that close the pipe early.
|
||||
fn read_receive_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
|
||||
/// Read one pkt-line into `body` (length prefix + data), returning its data
|
||||
/// or `None` on flush or EOF. Malformed lengths bail rather than collapse
|
||||
/// into a synthesized flush (#192 F5).
|
||||
fn read_pkt<R: Read>(stdin: &mut R, body: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
|
||||
let mut len_bytes = [0u8; 4];
|
||||
match stdin.read_exact(&mut len_bytes) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let Ok(len_hex) = std::str::from_utf8(&len_bytes) else {
|
||||
bail!("malformed pkt-line length prefix: non-UTF-8 bytes {len_bytes:02x?}");
|
||||
};
|
||||
let Ok(pkt_len) = usize::from_str_radix(len_hex, 16) else {
|
||||
bail!("malformed pkt-line length prefix: non-hex {len_hex:?}");
|
||||
};
|
||||
body.extend_from_slice(&len_bytes);
|
||||
if pkt_len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if pkt_len < 4 {
|
||||
bail!("invalid pkt-line length: {pkt_len}");
|
||||
}
|
||||
let mut data = vec![0u8; pkt_len - 4];
|
||||
stdin
|
||||
.read_exact(&mut data)
|
||||
.context("reading pkt-line data")?;
|
||||
body.extend_from_slice(&data);
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// "<40-hex old> <40-hex new> <refname>" — the command shape, whether it
|
||||
/// appears in a command list or inside a push-cert.
|
||||
fn is_command(data: &[u8]) -> bool {
|
||||
data.len() >= 82
|
||||
&& data[..40].iter().all(|b| b.is_ascii_hexdigit())
|
||||
&& data[40] == b' '
|
||||
&& data[41..81].iter().all(|b| b.is_ascii_hexdigit())
|
||||
&& data[81] == b' '
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
let mut caps = String::new();
|
||||
let mut pack_expected = false;
|
||||
let mut in_cert = false;
|
||||
loop {
|
||||
let Some(data) = read_pkt(stdin, &mut body)? else {
|
||||
// Flush ends a command list; EOF ends anything. A cert has no
|
||||
// legal flush inside it, so the same break covers malformed
|
||||
// input without hanging.
|
||||
break;
|
||||
};
|
||||
if !in_cert && data.starts_with(b"push-cert") {
|
||||
in_cert = true;
|
||||
if let Some(nul) = data.iter().position(|&b| b == 0) {
|
||||
caps = String::from_utf8_lossy(&data[nul + 1..]).into_owned();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_cert && data == b"push-cert-end\n" {
|
||||
break;
|
||||
}
|
||||
if is_command(&data) {
|
||||
if caps.is_empty() {
|
||||
if let Some(nul) = data.iter().position(|&b| b == 0) {
|
||||
caps = String::from_utf8_lossy(&data[nul + 1..]).into_owned();
|
||||
}
|
||||
}
|
||||
if data[41..81].iter().any(|&b| b != b'0') {
|
||||
pack_expected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// When both are negotiated the push-options block follows the cert; when
|
||||
// only options are negotiated it follows the command flush. Either way it
|
||||
// is pkt-lines to a flush.
|
||||
if caps.split(' ').any(|c| c.starts_with("push-options")) {
|
||||
while read_pkt(stdin, &mut body)?.is_some() {}
|
||||
}
|
||||
if pack_expected {
|
||||
stdin
|
||||
.read_to_end(&mut body)
|
||||
.context("reading receive-pack pack")?;
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// POST one self-contained stateless-RPC request body and stream the response to
|
||||
/// `stdout`. Signs the request when `signing_key` is present, so every round of a
|
||||
/// private-repo fetch carries the caller's signature (the node visibility-gates
|
||||
@@ -858,6 +1009,438 @@ mod tests {
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
/// The signed headers must not survive a redirect off the node's origin, on
|
||||
/// EITHER phase.
|
||||
///
|
||||
/// This is the binary git runs for `clone`, `fetch` and `push`. It built its
|
||||
/// client with a timeout and nothing else, so it ran reqwest's default
|
||||
/// `Policy::limited(10)`, and reqwest's cross-host header stripping covers
|
||||
/// `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` only.
|
||||
/// `Signature` and `Signature-Input` came straight through. A push signs from the
|
||||
/// first request, so a hostile node answering 302 was handed a working credential,
|
||||
/// and a 307/308 would have taken the pack body along.
|
||||
///
|
||||
/// Two mockito servers are two ports on one host, which is the boundary this
|
||||
/// policy draws. The second server answers everything and expects nothing, with a
|
||||
/// second mock matching on the `signature` header also at zero, so a followed
|
||||
/// redirect fails whether or not the signature came with it. Phase 1 is driven
|
||||
/// with a 302 and Phase 2 with a 308, the status that would carry the body.
|
||||
///
|
||||
/// MUTATION (RED): drop the `.redirect(...)` line from `build_http_client`.
|
||||
#[test]
|
||||
fn signed_requests_do_not_follow_a_redirect_off_the_node_origin() {
|
||||
let kp = Keypair::generate();
|
||||
let client = build_http_client().unwrap();
|
||||
|
||||
let mut elsewhere = mockito::Server::new();
|
||||
let never = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body("bytes from the redirect target")
|
||||
.expect(0)
|
||||
.create();
|
||||
let never_post = elsewhere
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create();
|
||||
let signature_seen_get = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create();
|
||||
let signature_seen_post = elsewhere
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let repo_base = format!("{}/zOwner/myrepo", node.url());
|
||||
let bounce_get = node
|
||||
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/info/refs", elsewhere.url()))
|
||||
.expect(1)
|
||||
.create();
|
||||
let bounce_post = node
|
||||
.mock(
|
||||
"POST",
|
||||
mockito::Matcher::Regex(r"/git-receive-pack$".to_string()),
|
||||
)
|
||||
.with_status(308)
|
||||
.with_header("location", &format!("{}/git-receive-pack", elsewhere.url()))
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let refs_url = format!("{repo_base}/info/refs?service=git-receive-pack");
|
||||
let advertisement = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
let body = b"0009done\n".to_vec();
|
||||
let post_url = format!("{repo_base}/git-receive-pack");
|
||||
let pack_post =
|
||||
build_pack_post_request(&client, &post_url, "git-receive-pack", &body, Some(&kp))
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
// The other origin first: it is the assertion that names the leak, and it must
|
||||
// be the one that speaks when a followed redirect makes every one of these
|
||||
// fail at once.
|
||||
never.assert();
|
||||
never_post.assert();
|
||||
signature_seen_get.assert();
|
||||
signature_seen_post.assert();
|
||||
bounce_get.assert();
|
||||
bounce_post.assert();
|
||||
|
||||
assert_eq!(
|
||||
advertisement.status(),
|
||||
302,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx"
|
||||
);
|
||||
assert!(
|
||||
!advertisement
|
||||
.text()
|
||||
.unwrap()
|
||||
.contains("bytes from the redirect target"),
|
||||
"the redirect target's bytes must never reach the caller"
|
||||
);
|
||||
assert_eq!(
|
||||
pack_post.status(),
|
||||
308,
|
||||
"the pack POST stops at the redirect too"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 helper signed, and
|
||||
/// the node rebuilds it from the URI it received. An `info/refs` to `info/refs/`
|
||||
/// bounce therefore presents a signature over a target the node never saw, so a
|
||||
/// clone or push 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.
|
||||
#[test]
|
||||
fn a_same_origin_path_changing_redirect_is_refused() {
|
||||
let kp = Keypair::generate();
|
||||
let client = build_http_client().unwrap();
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let bounce = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs")
|
||||
.with_status(301)
|
||||
.with_header("location", "/zOwner/myrepo/info/refs/")
|
||||
.expect(1)
|
||||
.create();
|
||||
let target = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs/")
|
||||
.with_status(200)
|
||||
.with_body("bytes from the rewritten target")
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let refs_url = format!("{}/zOwner/myrepo/info/refs", node.url());
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx"
|
||||
);
|
||||
assert!(
|
||||
!resp
|
||||
.text()
|
||||
.unwrap()
|
||||
.contains("bytes from the rewritten target"),
|
||||
"the rewritten target's bytes must never reach the caller"
|
||||
);
|
||||
bounce.assert();
|
||||
target.assert();
|
||||
}
|
||||
|
||||
/// The other direction, and the only same-origin hop still followed: a redirect
|
||||
/// that re-issues the IDENTICAL request-target.
|
||||
///
|
||||
/// Without this the policy could be tightened to `Policy::none()` and nothing in
|
||||
/// this crate would notice. It is also the first executed coverage of the chain
|
||||
/// bound in `build_http_client`'s policy closure: the route answers 301 pointing at
|
||||
/// itself, so bounded it is hit once for the original request plus `MAX_REDIRECTS`
|
||||
/// follows and the call returns the 301 (a refused hop stops rather than errors).
|
||||
/// Unbounded it would spin until `HTTP_TIMEOUT`, costing the node a request per
|
||||
/// round trip. Its twin is `a_self_redirect_stops_at_the_chain_bound` in
|
||||
/// `crates/gl/src/http.rs`.
|
||||
#[test]
|
||||
fn an_identical_target_redirect_is_still_followed_up_to_the_chain_bound() {
|
||||
let kp = Keypair::generate();
|
||||
let client = build_http_client().unwrap();
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let loop_route = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs")
|
||||
.with_status(301)
|
||||
.with_header("location", "/zOwner/myrepo/info/refs")
|
||||
.expect(gitlawb_core::redirect::MAX_REDIRECTS + 1)
|
||||
.create();
|
||||
|
||||
let refs_url = format!("{}/zOwner/myrepo/info/refs", node.url());
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.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"
|
||||
);
|
||||
loop_route.assert();
|
||||
}
|
||||
|
||||
// ── the node's own verification, run over what the helper 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
|
||||
/// necessarily 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.
|
||||
///
|
||||
/// Its twin is the hand-copy in `crates/gl/src/http.rs`, which cannot import this
|
||||
/// module (this is a binary crate's 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
|
||||
/// helper failed to send.
|
||||
fn received_header(req: &mockito::Request, name: &str) -> String {
|
||||
req.header(name)
|
||||
.first()
|
||||
.unwrap_or_else(|| panic!("the helper 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 advertisement's signature, and the proof is the node's own
|
||||
/// verification, not a hit count.
|
||||
///
|
||||
/// 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
|
||||
/// `.../info/refs?service=git-upload-pack` and presented at `.../info/refs/...`,
|
||||
/// 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_advertisement_verifies_under_the_node_verifier`: without it, an
|
||||
/// empty slot would be satisfied just as well by a harness that can never record
|
||||
/// anything.
|
||||
#[test]
|
||||
fn a_rewritten_target_never_receives_the_signature() {
|
||||
let kp = Keypair::generate();
|
||||
let expected_did = kp.did().to_string();
|
||||
let client = build_http_client().unwrap();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let bounce = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs?service=git-upload-pack")
|
||||
.with_status(301)
|
||||
.with_header(
|
||||
"location",
|
||||
"/zOwner/myrepo/info/refs/?service=git-upload-pack",
|
||||
)
|
||||
.expect(1)
|
||||
.create();
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let target = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs/?service=git-upload-pack")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let refs_url = format!(
|
||||
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
node.url()
|
||||
);
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
verdict.is_none(),
|
||||
"the node's own verifier must never see this request: the signature covers \
|
||||
/zOwner/myrepo/info/refs?service=git-upload-pack and the rewritten target \
|
||||
adds a trailing slash, 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();
|
||||
target.assert();
|
||||
}
|
||||
|
||||
/// The positive control for the test above, and the proof that the helper signs the
|
||||
/// query it sends.
|
||||
///
|
||||
/// A direct signed advertisement 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 advertisement URL carries `?service=`, so a helper that
|
||||
/// signed the bare path would land here as `Rejected`.
|
||||
#[test]
|
||||
fn a_direct_signed_advertisement_verifies_under_the_node_verifier() {
|
||||
let kp = Keypair::generate();
|
||||
let expected_did = kp.did().to_string();
|
||||
let client = build_http_client().unwrap();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let route = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs?service=git-upload-pack")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let refs_url = format!(
|
||||
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
node.url()
|
||||
);
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
matches!(verdict, Some(Verdict::Accepted)),
|
||||
"a direct signed advertisement 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();
|
||||
}
|
||||
|
||||
/// The regression that round-1 missed: the Phase-2 `git-upload-pack` POST was
|
||||
/// left unsigned, so an owner's fetch of a private repo cleared the (now signed)
|
||||
/// advertisement and then 404'd on the pack POST. Drive BOTH request builders
|
||||
@@ -1137,49 +1720,13 @@ mod tests {
|
||||
/// to end (sign here, verify with the node's verifier), not reasoned.
|
||||
#[test]
|
||||
fn client_signature_verifies_under_node_verification_for_both_services() {
|
||||
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
|
||||
use gitlawb_core::identity::verify;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let body = b"0009done\n".to_vec();
|
||||
|
||||
// Re-implements the node's require_signature verification (auth/mod.rs):
|
||||
// parse headers, recompute content-digest from the body, rebuild the signing
|
||||
// string over @method/@path/content-digest, Ed25519-verify. Ok iff the node
|
||||
// would accept it.
|
||||
let node_verifies = |method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
sig_input: &str,
|
||||
sig_header: &str,
|
||||
content_digest: &str|
|
||||
-> anyhow::Result<()> {
|
||||
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(())
|
||||
};
|
||||
// The verification chain is [`node_verifies`], the test-module helper the
|
||||
// redirect verdict tests share. Same primitives the node's require_signature
|
||||
// runs, over the request-target this crate transmits.
|
||||
// @path exactly as the node reconstructs it from the request it receives.
|
||||
let path_and_query = |req: &reqwest::blocking::Request| match req.url().query() {
|
||||
Some(q) => format!("{}?{}", req.url().path(), q),
|
||||
@@ -1571,6 +2118,184 @@ mod tests {
|
||||
assert!(help.ends_with('\n'));
|
||||
}
|
||||
|
||||
// ── #369 delete-only push deadlock ───────────────────────────────────────
|
||||
|
||||
/// A delete-only receive-pack request ends at the command flush; git then
|
||||
/// holds the write pipe open for the report and sends no pack. A reader that
|
||||
/// blocks after the command list reproduces the hang that a Cursor (EOF)
|
||||
/// hides. The read must return at the flush, inside the timeout.
|
||||
#[test]
|
||||
fn delete_only_receive_pack_request_returns_at_command_flush() {
|
||||
let old = "a".repeat(40);
|
||||
let zero = "0".repeat(40);
|
||||
let mut seed = Vec::new();
|
||||
seed.extend_from_slice(&pkt(&format!("{old} {zero} refs/heads/gone\n")));
|
||||
seed.extend_from_slice(b"0000"); // command-list flush; git then blocks
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
let mut reader = BlockAfterSeed {
|
||||
seed: io::Cursor::new(seed),
|
||||
gate: rx,
|
||||
};
|
||||
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let _ = done_tx.send(read_receive_pack_request(&mut reader).unwrap());
|
||||
});
|
||||
|
||||
let got = done_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
let _ = tx.send(()); // unblock the reader so the thread can exit
|
||||
let _ = handle.join();
|
||||
|
||||
let body = got.expect(
|
||||
"read_receive_pack_request blocked past the command flush waiting for \
|
||||
a pack a delete-only push never sends (#369)",
|
||||
);
|
||||
assert!(
|
||||
body.ends_with(b"0000"),
|
||||
"the request body keeps the command list and its flush terminator"
|
||||
);
|
||||
}
|
||||
|
||||
/// A push carrying objects still reads the pack through to EOF, and the
|
||||
/// command flush sits between the commands and the pack in the body.
|
||||
#[test]
|
||||
fn receive_pack_request_with_pack_reads_through_eof() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt(&format!(
|
||||
"{old} {new} refs/heads/main\0 report-status-v2\n"
|
||||
)));
|
||||
stream.extend_from_slice(b"0000");
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
let pos = body.windows(4).position(|w| w == b"0000").unwrap();
|
||||
assert_eq!(
|
||||
&body[pos + 4..],
|
||||
b"PACK\x00\x00\x00\x02fakepack",
|
||||
"a non-zero new-oid means the pack follows the flush"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signed delete-only push sends NO command list: the request starts
|
||||
/// with the "push-cert" line, the commands live inside the cert, and the
|
||||
/// cert ends with a "push-cert-end" pkt-line (not a flush). The reader
|
||||
/// must return after push-cert-end without waiting on the pipe.
|
||||
#[test]
|
||||
fn signed_delete_only_push_returns_after_push_cert_end() {
|
||||
let old = "a".repeat(40);
|
||||
let zero = "0".repeat(40);
|
||||
let mut seed = Vec::new();
|
||||
seed.extend_from_slice(&pkt("push-cert\0 report-status-v2 push-cert\n"));
|
||||
seed.extend_from_slice(&pkt("certificate version 0.1\n"));
|
||||
seed.extend_from_slice(&pkt("pusher did:key:z6Mk\n"));
|
||||
seed.extend_from_slice(&pkt("\n")); // empty line ends the headers
|
||||
seed.extend_from_slice(&pkt(&format!("{old} {zero} refs/heads/gone\n")));
|
||||
seed.extend_from_slice(&pkt("sig-line\n"));
|
||||
seed.extend_from_slice(&pkt("push-cert-end\n"));
|
||||
// git now blocks for the report; the pipe stays open.
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
let mut reader = BlockAfterSeed {
|
||||
seed: io::Cursor::new(seed),
|
||||
gate: rx,
|
||||
};
|
||||
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let _ = done_tx.send(read_receive_pack_request(&mut reader).unwrap());
|
||||
});
|
||||
let got = done_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
let _ = tx.send(());
|
||||
let _ = handle.join();
|
||||
|
||||
let body = got.expect(
|
||||
"read_receive_pack_request blocked past push-cert-end on a signed \
|
||||
delete-only push (#369)",
|
||||
);
|
||||
assert!(
|
||||
body.windows(6).any(|w| w == b"pusher"),
|
||||
"the cert block is part of the request body"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signed pack-bearing push: cert with the commands inside, then the
|
||||
/// pack. Everything must land in the body.
|
||||
#[test]
|
||||
fn signed_pack_push_keeps_cert_and_pack() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt("push-cert\0 report-status-v2 push-cert\n"));
|
||||
stream.extend_from_slice(&pkt("certificate version 0.1\n"));
|
||||
stream.extend_from_slice(&pkt("\n"));
|
||||
stream.extend_from_slice(&pkt(&format!("{old} {new} refs/heads/main\n")));
|
||||
stream.extend_from_slice(&pkt("push-cert-end\n"));
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
assert!(
|
||||
body.windows(5).any(|w| w == b"PACK\x00"),
|
||||
"the pack follows the cert into the body"
|
||||
);
|
||||
}
|
||||
|
||||
/// A negotiated push-options block is pkt-lines ending in its own flush.
|
||||
/// With no cert it follows the command flush; with a cert it follows
|
||||
/// push-cert-end.
|
||||
#[test]
|
||||
fn receive_pack_request_consumes_a_push_options_block() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt(&format!(
|
||||
"{old} {new} refs/heads/main\0 report-status-v2 push-options\n"
|
||||
)));
|
||||
stream.extend_from_slice(b"0000"); // command flush
|
||||
stream.extend_from_slice(&pkt("ci.skip\n"));
|
||||
stream.extend_from_slice(b"0000"); // options flush
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
assert!(body.windows(7).any(|w| w == b"ci.skip"), "options survived");
|
||||
assert!(
|
||||
body.ends_with(b"PACK\x00\x00\x00\x02fakepack"),
|
||||
"the pack lands after the options block"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cert and options together: the cert comes first and the options block
|
||||
/// sits between push-cert-end and the pack.
|
||||
#[test]
|
||||
fn signed_push_with_options_reads_cert_then_options_then_pack() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt(
|
||||
"push-cert\0 report-status-v2 push-cert push-options\n",
|
||||
));
|
||||
stream.extend_from_slice(&pkt("certificate version 0.1\n"));
|
||||
stream.extend_from_slice(&pkt("\n"));
|
||||
stream.extend_from_slice(&pkt(&format!("{old} {new} refs/heads/main\n")));
|
||||
stream.extend_from_slice(&pkt("push-cert-end\n"));
|
||||
stream.extend_from_slice(&pkt("ci.skip\n"));
|
||||
stream.extend_from_slice(b"0000"); // options flush
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
assert!(body.windows(7).any(|w| w == b"ci.skip"), "options survived");
|
||||
assert!(
|
||||
body.ends_with(b"PACK\x00\x00\x00\x02fakepack"),
|
||||
"the pack lands after the options block"
|
||||
);
|
||||
}
|
||||
|
||||
// ── #117 multi-round fetch negotiation ───────────────────────────────────
|
||||
|
||||
/// Encode a git pkt-line: 4-byte hex length (incl. the 4 bytes) + data.
|
||||
|
||||
@@ -95,6 +95,36 @@ fn upload_pack(repo: &Path, advertise: bool, input: &[u8]) -> Vec<u8> {
|
||||
out.stdout
|
||||
}
|
||||
|
||||
/// Run `git receive-pack --stateless-rpc [--advertise-refs] <repo>`, optionally
|
||||
/// feeding `input` on stdin. Returns raw stdout (the wire bytes). The receive
|
||||
/// half of `upload_pack`, used so the shim can serve a real push.
|
||||
fn receive_pack(repo: &Path, advertise: bool, input: &[u8]) -> Vec<u8> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("receive-pack").arg("--stateless-rpc");
|
||||
if advertise {
|
||||
cmd.arg("--advertise-refs");
|
||||
}
|
||||
cmd.arg(repo)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn git receive-pack");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let input = input.to_vec();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(&input);
|
||||
// drop closes stdin
|
||||
});
|
||||
let out = child.wait_with_output().expect("wait receive-pack");
|
||||
writer.join().ok();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git receive-pack failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
out.stdout
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ShimMode {
|
||||
/// Faithful v0 serving: pipe each POST to `git upload-pack --stateless-rpc`.
|
||||
@@ -107,6 +137,11 @@ enum ShimMode {
|
||||
struct Shim {
|
||||
base_url: String,
|
||||
posts: Arc<AtomicUsize>,
|
||||
/// Advertisement (GET /info/refs) count. One `git fetch` performs exactly
|
||||
/// one advertisement, so tests assert this is EXACTLY one: any fetch-level
|
||||
/// retry that comes back re-advertises and goes red on the spot, which the
|
||||
/// lower-bound POST assertions alone cannot see (#275 round 6).
|
||||
gets: Arc<AtomicUsize>,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
@@ -136,15 +171,17 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{addr}");
|
||||
let posts = Arc::new(AtomicUsize::new(0));
|
||||
let gets = Arc::new(AtomicUsize::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let posts_t = posts.clone();
|
||||
let gets_t = gets.clone();
|
||||
let stop_t = stop.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
while !stop_t.load(Ordering::SeqCst) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
handle_conn(stream, &repo, mode, &posts_t);
|
||||
handle_conn(stream, &repo, mode, &posts_t, &gets_t);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
@@ -157,6 +194,7 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
|
||||
Shim {
|
||||
base_url,
|
||||
posts,
|
||||
gets,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
}
|
||||
@@ -182,7 +220,13 @@ fn normalize_accepted_stream(stream: &TcpStream) {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
}
|
||||
|
||||
fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsize) {
|
||||
fn handle_conn(
|
||||
stream: TcpStream,
|
||||
repo: &Path,
|
||||
mode: ShimMode,
|
||||
posts: &AtomicUsize,
|
||||
gets: &AtomicUsize,
|
||||
) {
|
||||
normalize_accepted_stream(&stream);
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
@@ -218,7 +262,22 @@ fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsi
|
||||
reader.read_exact(&mut body).ok();
|
||||
}
|
||||
|
||||
let (content_type, payload) = if method == "GET" && target.contains("/info/refs") {
|
||||
let (content_type, payload) = if method == "GET" && target.contains("service=git-receive-pack")
|
||||
{
|
||||
// Receive-pack advertisement, same wrapper shape as upload-pack.
|
||||
let adv = receive_pack(repo, true, b"");
|
||||
let mut wrapped = pkt(b"# service=git-receive-pack\n");
|
||||
wrapped.extend_from_slice(b"0000");
|
||||
wrapped.extend_from_slice(&adv);
|
||||
("application/x-git-receive-pack-advertisement", wrapped)
|
||||
} else if method == "POST" && target.ends_with("/git-receive-pack") {
|
||||
posts.fetch_add(1, Ordering::SeqCst);
|
||||
(
|
||||
"application/x-git-receive-pack-result",
|
||||
receive_pack(repo, false, &body),
|
||||
)
|
||||
} else if method == "GET" && target.contains("/info/refs") {
|
||||
gets.fetch_add(1, Ordering::SeqCst);
|
||||
// v0 advertisement, wrapped exactly as the node's info_refs does.
|
||||
let adv = upload_pack(repo, true, b"");
|
||||
let mut wrapped = pkt(b"# service=git-upload-pack\n");
|
||||
@@ -293,6 +352,34 @@ fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Outpu
|
||||
run_bounded(cmd, Duration::from_secs(30))
|
||||
}
|
||||
|
||||
/// Run `git push` in `local` through the helper, same wiring and hard timeout as
|
||||
/// [`fetch_with_helper`]. `args` carries the push spec, e.g.
|
||||
/// `["--delete", "doomed"]`.
|
||||
fn push_with_helper(local: &Path, node_url: &str, args: &[&str]) -> (bool, std::process::Output) {
|
||||
let helper_bin = PathBuf::from(env!("CARGO_BIN_EXE_git-remote-gitlawb"));
|
||||
let helper_dir = helper_bin.parent().unwrap().to_path_buf();
|
||||
let path_env = match std::env::var_os("PATH") {
|
||||
Some(p) => {
|
||||
let mut dirs = vec![helper_dir.clone()];
|
||||
dirs.extend(std::env::split_paths(&p));
|
||||
std::env::join_paths(dirs).unwrap()
|
||||
}
|
||||
None => helper_dir.clone().into_os_string(),
|
||||
};
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(local)
|
||||
.arg("push")
|
||||
.arg("origin")
|
||||
.args(args)
|
||||
.env("PATH", path_env)
|
||||
.env("LC_ALL", "C")
|
||||
.env("GITLAWB_NODE", node_url)
|
||||
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-push");
|
||||
run_bounded(cmd, Duration::from_secs(30))
|
||||
}
|
||||
|
||||
/// A synthetic non-success `ExitStatus` used only on the unix timeout path, where
|
||||
/// `reap_group` has already consumed the child and the later `wait` returns ECHILD.
|
||||
/// The timeout branch already reports `completed == false`, so the exact status is
|
||||
@@ -935,6 +1022,7 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
let posts = AtomicUsize::new(0);
|
||||
let gets = AtomicUsize::new(0);
|
||||
let stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((s, _)) => break s,
|
||||
@@ -949,7 +1037,7 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
|
||||
// the assertions below run on every platform.
|
||||
stream.set_nonblocking(true).unwrap();
|
||||
accepted_tx.send(()).unwrap();
|
||||
handle_conn(stream, &repo, ShimMode::Normal, &posts);
|
||||
handle_conn(stream, &repo, ShimMode::Normal, &posts, &gets);
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).unwrap();
|
||||
@@ -1013,6 +1101,17 @@ fn real_git_multi_round_fetch_completes() {
|
||||
posts >= 2,
|
||||
"fixture did not force multi-round negotiation (observed {posts} POST(s)); the bridging path was not exercised"
|
||||
);
|
||||
// Exactly one advertisement: the POST bound above is a floor, so a
|
||||
// reintroduced fetch-level retry could satisfy it by running a second full
|
||||
// fetch instead of multi-round negotiation. Re-advertising is what a
|
||||
// second fetch cannot avoid, so this is the committed guard on the
|
||||
// retry's absence.
|
||||
let gets = shim.gets.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
gets, 1,
|
||||
"expected exactly one advertisement GET per fetch; {gets} means a \
|
||||
fetch-level retry is back"
|
||||
);
|
||||
|
||||
// The fetched tip is present and the clone's object graph is intact.
|
||||
let server_head = String::from_utf8(git(&server, &["rev-parse", "HEAD"])).unwrap();
|
||||
@@ -1105,6 +1204,17 @@ fn real_git_withheld_shaped_first_post() {
|
||||
"helper hung on a withheld-shaped response (should forward-and-terminate, not deadlock). stderr:\n{stderr}"
|
||||
);
|
||||
|
||||
// Exactly one advertisement, pass or fail: the POST assertions below are
|
||||
// floors, so a reintroduced fetch-level retry could mask the withheld
|
||||
// shape behind a second full fetch. A retry cannot avoid re-advertising,
|
||||
// so this is the committed guard on its absence.
|
||||
let gets = shim.gets.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
gets, 1,
|
||||
"expected exactly one advertisement GET per fetch; {gets} means a \
|
||||
fetch-level retry is back"
|
||||
);
|
||||
|
||||
if out.status.success() {
|
||||
// Real git accepted the mid-negotiation pack: the withheld multi-round
|
||||
// path works end to end with no extra handling.
|
||||
@@ -1242,3 +1352,77 @@ fn run_bounded_bounds_join_when_leader_exits_leaving_a_pipe_holder() {
|
||||
held pipes do not stall the joins)"
|
||||
);
|
||||
}
|
||||
|
||||
/// #369 end-to-end: a real `git push --delete` through the helper completes.
|
||||
/// The receive-pack request for a delete-only push is commands + flush with NO
|
||||
/// pack, and git holds its write pipe open for the report — so a helper that
|
||||
/// reads stdin to EOF deadlocks against it. The shim pipes the POST to a real
|
||||
/// `git receive-pack --stateless-rpc`, so the branch actually has to be deleted
|
||||
/// server-side for the assertions to pass.
|
||||
#[test]
|
||||
fn real_git_push_delete_completes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server = dir.path().join("server.git");
|
||||
let local = dir.path().join("local");
|
||||
|
||||
// A bare repo holding the doomed branch, plus a live one.
|
||||
git(
|
||||
dir.path(),
|
||||
&[
|
||||
"init",
|
||||
"--bare",
|
||||
server.to_str().unwrap(),
|
||||
"--initial-branch=main",
|
||||
],
|
||||
);
|
||||
let work = dir.path().join("work");
|
||||
git(
|
||||
dir.path(),
|
||||
&["init", work.to_str().unwrap(), "--initial-branch=main"],
|
||||
);
|
||||
git(&work, &["config", "user.email", "t@t"]);
|
||||
git(&work, &["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("f"), "x").unwrap();
|
||||
git(&work, &["add", "f"]);
|
||||
git(&work, &["commit", "-m", "c"]);
|
||||
git(&work, &["branch", "doomed"]);
|
||||
git(&work, &["push", server.to_str().unwrap(), "main", "doomed"]);
|
||||
|
||||
// The pusher: a local repo whose origin is the gitlawb remote pointing at
|
||||
// the shim. Nothing needs to be fetched first; git takes the old-oid from
|
||||
// the receive-pack advertisement.
|
||||
git(
|
||||
dir.path(),
|
||||
&["init", local.to_str().unwrap(), "--initial-branch=main"],
|
||||
);
|
||||
git(
|
||||
&local,
|
||||
&["remote", "add", "origin", "gitlawb://did:key:z6MkTest/repo"],
|
||||
);
|
||||
|
||||
let shim = start_shim(server.clone(), ShimMode::Normal);
|
||||
let (completed, out) = push_with_helper(&local, &shim.base_url, &["--delete", "doomed"]);
|
||||
let posts = shim.posts.load(Ordering::SeqCst);
|
||||
|
||||
assert!(
|
||||
completed,
|
||||
"git push --delete did not complete within the timeout (deadlock signature). stderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git push --delete failed. stderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(posts, 1, "a delete-only push is exactly one POST");
|
||||
let gone = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&server)
|
||||
.args(["rev-parse", "--verify", "--quiet", "refs/heads/doomed"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!gone.status.success(),
|
||||
"refs/heads/doomed must be deleted in the server repo"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
description = "External Attestation v1: pluggable provenance attachments for Twigpine ref-update certs"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -105,16 +105,30 @@ 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_strict(bytes, &sig)
|
||||
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
|
||||
|
||||
Ok(vk)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reparse `payload` as `P`. Errors if the type discriminator does not
|
||||
@@ -181,7 +195,7 @@ fn canonical_signing_bytes(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
|
||||
pub(crate) 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());
|
||||
@@ -202,6 +216,19 @@ 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 {
|
||||
@@ -217,8 +244,20 @@ 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");
|
||||
VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -465,6 +504,23 @@ mod tests {
|
||||
assert!(matches!(err, Error::Did(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_oversized_method_id_before_decoding() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
// A well-formed prefix on an absurdly long id must fail the length
|
||||
// bound, not the decoder: base58 decode cost is quadratic in input
|
||||
// length, so reaching it at all is the defect.
|
||||
att.signer = format!("did:key:z{}", "1".repeat(256));
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
// Match the bound's own message: any other Did error (a decode or
|
||||
// length mismatch downstream) means the oversized input was decoded.
|
||||
match err {
|
||||
Error::Did(m) => assert!(m.contains("too long"), "unexpected: {m}"),
|
||||
e => panic!("expected Error::Did, got {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_bad_signature_base64() {
|
||||
let sk = fresh();
|
||||
@@ -559,6 +615,111 @@ mod tests {
|
||||
/// 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];
|
||||
@@ -572,7 +733,13 @@ mod tests {
|
||||
let forged_sig = B64U.encode(forged);
|
||||
|
||||
// Build an attestation with the weak key as signer and the forged
|
||||
// signature. The weak-key check happens at verify time.
|
||||
// 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);
|
||||
@@ -584,9 +751,12 @@ mod tests {
|
||||
att.sig = forged_sig;
|
||||
|
||||
let err = att.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_)),
|
||||
"signature under a weak (small-order) public key must be rejected"
|
||||
);
|
||||
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 gitlawb ref-update certs.
|
||||
//! Pluggable provenance attachments for Twigpine 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,7 +121,12 @@ impl Registry {
|
||||
attestation: &Attestation,
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<VerifiedAttestation> {
|
||||
attestation.verify_signature(expected_cert_hash)?;
|
||||
// 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)?;
|
||||
|
||||
let fully = match self.by_type.get(attestation.type_.as_str()) {
|
||||
Some(v) => {
|
||||
@@ -138,30 +143,55 @@ impl Registry {
|
||||
|
||||
Ok(VerifiedAttestation {
|
||||
type_: attestation.type_.clone(),
|
||||
signer: attestation.signer.clone(),
|
||||
signer: crate::attestation::did_key_from_verifying_key(&verified_key),
|
||||
cert_hash: attestation.cert_hash.clone(),
|
||||
fully_verified: fully,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a batch, then enforce `RequireAll`.
|
||||
///
|
||||
/// Under [`Policy::RequireAll`] this never short-circuits: an attestation
|
||||
/// that fails to verify — bad signature, cert-hash mismatch, or malformed
|
||||
/// payload, the actual `Err` paths of [`Registry::verify`] — is dropped
|
||||
/// from the result rather than aborting the batch. A valid attestation of
|
||||
/// a type with NO registered verifier is not one of those: under
|
||||
/// `RequireAll` it verifies to `Ok` with `fully_verified: false` and stays
|
||||
/// in the result, preserving the distinction between *untrusted* and
|
||||
/// *invalid* (see `require_all_is_lenient_on_unknown_types_in_the_batch`).
|
||||
/// Any third party can attach an attestation, so a hard error here would
|
||||
/// let an unrelated `attacker/spam/v1` deny-of-service the whole cert (the
|
||||
/// DoS vector the module docstring warns about). The `required_types`
|
||||
/// check below is the real gate, and it only counts `fully_verified`
|
||||
/// entries — so a required type represented solely by a malformed
|
||||
/// attestation still fails with [`Error::RequiredMissing`].
|
||||
///
|
||||
/// Under `AcceptKnown` and `RejectUnknown` the batch stays strict: the
|
||||
/// first verification error is surfaced to the caller.
|
||||
pub fn verify_all(
|
||||
&self,
|
||||
attestations: &[Attestation],
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<Vec<VerifiedAttestation>> {
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
if self.policy != Policy::RequireAll {
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
}
|
||||
return Ok(verified);
|
||||
}
|
||||
if self.policy == Policy::RequireAll {
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
}
|
||||
|
||||
// RequireAll: drop entries that fail to verify instead of aborting.
|
||||
let verified: Vec<VerifiedAttestation> = attestations
|
||||
.iter()
|
||||
.filter_map(|a| self.verify(a, expected_cert_hash).ok())
|
||||
.collect();
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
}
|
||||
}
|
||||
Ok(verified)
|
||||
@@ -171,7 +201,7 @@ impl Registry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::attestation::{Attestation, AttestationPayload};
|
||||
use crate::attestation::{did_key_from_verifying_key, Attestation, AttestationPayload};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -319,6 +349,38 @@ mod tests {
|
||||
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
|
||||
}
|
||||
|
||||
/// The strict policies must surface a batch member's verification error
|
||||
/// through `verify_all`, not launder it into a partial result. This binds
|
||||
/// the POLICY BOUNDARY itself: only the `RequireAll` arm may drop failing
|
||||
/// entries, and an apparently harmless cleanup that applies its
|
||||
/// `filter_map(Result::ok)` shape to the strict arms would silently turn
|
||||
/// the fail-closed contracts into fail-open — with every per-attestation
|
||||
/// `verify` test still green, because none of them go through the batch
|
||||
/// path. The failing entry is a wrong-cert-hash attestation (signed over a
|
||||
/// different hash than the batch expects), a genuine `Err` under every
|
||||
/// policy.
|
||||
#[test]
|
||||
fn strict_policies_propagate_a_batch_members_error() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut wrong_hash = sample_hash();
|
||||
wrong_hash[0] ^= 0xff;
|
||||
|
||||
for policy in [Policy::AcceptKnown, Policy::RejectUnknown] {
|
||||
let mut reg = Registry::new().with_policy(policy);
|
||||
reg.register(DemoVerifier);
|
||||
let good = signed_demo(&sk, cert_hash, "ok");
|
||||
let bad = signed_demo(&sk, wrong_hash, "ok");
|
||||
let err = reg
|
||||
.verify_all(&[good, bad], cert_hash)
|
||||
.expect_err("a strict policy must fail the whole batch");
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_) | Error::CertHashMismatch),
|
||||
"expected the underlying verification error, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A required type that is present but unverified (no verifier registered
|
||||
/// even though the type appears in `required_types`) must fail
|
||||
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
|
||||
@@ -336,6 +398,65 @@ mod tests {
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
/// A cert-hash that does not match `sample_hash()`, so an attestation
|
||||
/// signed against it fails `verify_signature` when the batch is checked
|
||||
/// against `sample_hash()`.
|
||||
fn other_hash() -> [u8; 32] {
|
||||
let mut h = sample_hash();
|
||||
h[0] ^= 0xff;
|
||||
h
|
||||
}
|
||||
|
||||
/// Under `RequireAll`, a malformed attestation (here: signed against the
|
||||
/// wrong cert hash, so it fails signature verification) attached alongside a
|
||||
/// valid required one must not abort the batch. The junk is dropped and the
|
||||
/// cert still verifies. Before the fix, `verify_all` propagated the error
|
||||
/// via `?` and the whole batch failed — the DoS vector the docstring warns
|
||||
/// about.
|
||||
#[test]
|
||||
fn require_all_drops_a_malformed_extra_and_still_accepts() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
// Signed against `other_hash()`, so it will not verify under `cert_hash`.
|
||||
let malformed = signed_demo(&sk, other_hash(), "boom");
|
||||
let real = signed_demo(&sk, cert_hash, "ok");
|
||||
|
||||
let verified = reg
|
||||
.verify_all(&[malformed, real], cert_hash)
|
||||
.expect("a malformed extra must not abort the batch under RequireAll");
|
||||
assert_eq!(verified.len(), 1, "the malformed entry should be dropped");
|
||||
assert!(verified
|
||||
.iter()
|
||||
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
|
||||
}
|
||||
|
||||
/// Leniency must not open a hole: if the *only* attestation of a required
|
||||
/// type is malformed, dropping it leaves the required type absent and
|
||||
/// `verify_all` must still fail with `RequiredMissing`. Guards against a fix
|
||||
/// that skips errors so eagerly it lets an unverified required type pass.
|
||||
#[test]
|
||||
fn require_all_rejects_when_only_a_malformed_required_type_is_present() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
// The sole demo/v1 is bound to the wrong cert hash → fails verification.
|
||||
let malformed_required = signed_demo(&sk, other_hash(), "ok");
|
||||
|
||||
let err = reg
|
||||
.verify_all(&[malformed_required], cert_hash)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_check_failure_rejects() {
|
||||
let sk = fresh();
|
||||
@@ -374,4 +495,52 @@ 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,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -22,6 +22,7 @@ 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"
|
||||
@@ -30,3 +31,9 @@ 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"]
|
||||
@@ -5,6 +5,28 @@
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Replay
|
||||
//!
|
||||
//! A certificate proves *who* asserted a ref update and *what* they asserted.
|
||||
//! It does not prove the assertion is fresh. [`RefUpdateBody::seq`] and
|
||||
//! [`RefUpdateBody::nonce`] are inside the signed bytes (see
|
||||
//! [`RefUpdateBody::to_signing_bytes`]), so a verifier can trust the values,
|
||||
//! but deciding whether a nonce is being reused or a `seq` is going backwards
|
||||
//! needs state.
|
||||
//!
|
||||
//! This crate is deliberately stateless — it is embedded in both nodes and
|
||||
//! clients — so replay is **not** enforced here and cannot be. Ownership of
|
||||
//! the check sits with the verifier that accepts and persists certificates,
|
||||
//! which must:
|
||||
//!
|
||||
//! - reject a `nonce` it has already accepted for the same `(repo, ref_name)`;
|
||||
//! - reject a `seq` that does not advance past the last accepted `seq` for
|
||||
//! that `(repo, ref_name)`.
|
||||
//!
|
||||
//! Until such a verifier exists, replay of ref-update certificates is not
|
||||
//! enforced anywhere in this workspace. This contract exists so the first one
|
||||
//! inherits the requirement instead of re-deriving it.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -41,11 +63,19 @@ pub struct RefUpdateBody {
|
||||
pub from: String,
|
||||
/// The new commit hash (SHA-256 hex).
|
||||
pub to: String,
|
||||
/// Monotonically increasing sequence number. Prevents replay.
|
||||
/// Monotonically increasing sequence number, scoped to `(repo, ref_name)`.
|
||||
///
|
||||
/// Signed, but not enforced by this crate: core holds no state. The
|
||||
/// verifier that persists certificates must reject a `seq` that does not
|
||||
/// advance for the same repo and ref. See the module docs on replay.
|
||||
pub seq: u64,
|
||||
/// RFC 3339 timestamp of this update.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Random nonce for deduplication.
|
||||
/// Random nonce, unique per certificate.
|
||||
///
|
||||
/// Signed, but not enforced by this crate: core holds no state. The
|
||||
/// verifier that persists certificates must reject a nonce it has already
|
||||
/// accepted for the same repo and ref. See the module docs on replay.
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
@@ -140,7 +170,16 @@ impl RefUpdateCert {
|
||||
///
|
||||
/// Counts distinct signer DIDs, not signature entries: a repeated
|
||||
/// signature from the same maintainer counts once.
|
||||
///
|
||||
/// `threshold` must be at least 1. Zero is rejected instead of being
|
||||
/// treated as "no signatures required": a caller that derives the
|
||||
/// threshold from configuration and gets 0 would otherwise accept any
|
||||
/// certificate, whether its signatures verify or not.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
if threshold == 0 {
|
||||
return Err(Error::RefCert("threshold must be at least 1".to_string()));
|
||||
}
|
||||
|
||||
let valid = self.verify_all()?;
|
||||
let distinct_signers: HashSet<&Did> =
|
||||
valid.iter().filter(|d| maintainers.contains(d)).collect();
|
||||
@@ -378,7 +417,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_always_satisfied() {
|
||||
fn threshold_zero_is_rejected() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
@@ -389,6 +428,120 @@ mod tests {
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cert.satisfies_threshold(&[], 0).unwrap());
|
||||
|
||||
// Zero must never read as "no signatures required", even with an empty
|
||||
// maintainer set.
|
||||
let err = cert.satisfies_threshold(&[], 0).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("threshold must be at least 1"),
|
||||
"zero must be refused as an invalid threshold"
|
||||
);
|
||||
}
|
||||
|
||||
/// The threshold is checked before any signature work, so a zero is
|
||||
/// reported as a threshold error even when the certificate's signatures
|
||||
/// are also invalid. Otherwise a broken certificate would let a caller
|
||||
/// mistake the failure for a legitimate verification result.
|
||||
#[test]
|
||||
fn threshold_zero_is_rejected_before_signature_verification() {
|
||||
let kp = Keypair::generate();
|
||||
let other = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.signatures[0].sig = other.sign_b64(b"unrelated bytes");
|
||||
|
||||
let err = cert.satisfies_threshold(&[kp.did()], 0).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("threshold must be at least 1"),
|
||||
"the invalid threshold must win over the invalid signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// `seq` and `nonce` are the verifier's replay inputs, so they have to be
|
||||
/// inside the signed body; tapping either one must invalidate the
|
||||
/// certificate rather than leave it verifying with different values.
|
||||
#[test]
|
||||
fn replay_fields_are_covered_by_the_signature() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
7,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.verify_all().expect("baseline must verify");
|
||||
|
||||
let mut bumped = cert.clone();
|
||||
bumped.body.seq = 8;
|
||||
assert!(
|
||||
matches!(bumped.verify_all(), Err(Error::SignatureInvalid)),
|
||||
"changing seq must invalidate the signature"
|
||||
);
|
||||
|
||||
let mut renamed = cert.clone();
|
||||
renamed.body.nonce = "different-nonce".to_string();
|
||||
assert!(
|
||||
matches!(renamed.verify_all(), Err(Error::SignatureInvalid)),
|
||||
"changing nonce must invalidate the signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signature minted by a real key must not verify when the signer is
|
||||
/// spelled in another multibase alphabet. Otherwise the same identity
|
||||
/// would satisfy a maintainer allowlist under several DID strings.
|
||||
///
|
||||
/// Base64url is deliberate: its 47-character method-id fits under the
|
||||
/// pre-existing length bound, so this test reaches the canonical-prefix
|
||||
/// rule and fails on the pre-fix code instead of being refused for length.
|
||||
#[test]
|
||||
fn verify_all_rejects_a_non_canonical_did_spelling() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut prefixed = vec![0xed, 0x01];
|
||||
prefixed.extend_from_slice(&kp.verifying_key().to_bytes());
|
||||
cert.signatures[0].signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base64Url, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = cert
|
||||
.verify_all()
|
||||
.expect_err("a non-canonical did:key spelling must not resolve");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"rejection must come from the canonical-prefix rule, not the length guard"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
cert.satisfies_threshold(&[cert.signatures[0].signer.clone()], 1),
|
||||
Ok(true)
|
||||
),
|
||||
"the alternate spelling must not satisfy a threshold"
|
||||
);
|
||||
|
||||
// The canonical spelling of the same key is unaffected.
|
||||
cert.signatures[0].signer = kp.did();
|
||||
assert!(cert.satisfies_threshold(&[kp.did()], 1).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,19 @@ 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)
|
||||
@@ -177,6 +190,38 @@ 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 gitlawb network.
|
||||
//! Decentralized Identifier (DID) types for the Twigpine 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 gitlawb actor is `did:key` during bootstrap
|
||||
//! The canonical DID for a Twigpine actor is `did:key` during bootstrap
|
||||
//! and migrates to `did:gitlawb` once DHT anchoring is live.
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
@@ -20,6 +20,19 @@ use crate::{Error, Result};
|
||||
/// Value: 0xed01 encoded as a varint.
|
||||
const ED25519_MULTICODEC: &[u8] = &[0xed, 0x01];
|
||||
|
||||
/// The only multibase prefix a `did:key` method-specific id may carry.
|
||||
///
|
||||
/// The DID:key spec encodes Ed25519 keys as base58btc, which is multibase
|
||||
/// `z`. `multibase::decode` happily accepts every other alphabet, and each
|
||||
/// spelling of the same key bytes would produce a distinct DID string while
|
||||
/// gates, allowlists, and database rows compare DIDs as strings — one
|
||||
/// identity would read as several. Rejecting the alternatives here keeps the
|
||||
/// mapping from `did:key` to key single-valued for every caller of
|
||||
/// [`Did::to_verifying_key`] (HTTP signatures, UCAN, ref certs, peer
|
||||
/// announces). `gitlawb-attest` enforces the same rule for attestation
|
||||
/// signers.
|
||||
const DID_KEY_MULTIBASE_PREFIX: char = 'z';
|
||||
|
||||
/// A Decentralized Identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -71,7 +84,14 @@ impl Did {
|
||||
|
||||
/// Resolve the Ed25519 verifying key from a `did:key`.
|
||||
///
|
||||
/// Returns `Err` if the DID is not a `did:key` or the key bytes are invalid.
|
||||
/// Only the canonical base58btc (`z`) spelling is accepted. Other
|
||||
/// multibase encodings decode to the same key but spell a different DID
|
||||
/// string, so accepting them would let one identity appear under several
|
||||
/// identifiers to string-comparing gates and allowlists.
|
||||
///
|
||||
/// Returns `Err` if the DID is not a `did:key`, uses a non-canonical
|
||||
/// multibase prefix, the key bytes are invalid, or the key is a
|
||||
/// small-order (weak) point.
|
||||
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
|
||||
if !self.is_did_key() {
|
||||
return Err(Error::InvalidDid(format!(
|
||||
@@ -93,9 +113,27 @@ impl Did {
|
||||
));
|
||||
}
|
||||
|
||||
let (_, bytes) =
|
||||
// Cheap prefix gate before the decode, then the base check on what
|
||||
// multibase actually parsed. The first keeps a clearly non-canonical id
|
||||
// off the decoder. The second is belt-and-braces — multibase maps a
|
||||
// leading `z` only to `Base58Btc` — but it states the requirement
|
||||
// directly instead of relying on that mapping, and `gitlawb-attest`
|
||||
// makes the same pair of checks.
|
||||
if !self.method_id().starts_with(DID_KEY_MULTIBASE_PREFIX) {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key must use base58btc (z-prefix)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (base, bytes) =
|
||||
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
if base != Base::Base58Btc {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key must use base58btc (z-prefix)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !bytes.starts_with(ED25519_MULTICODEC) {
|
||||
return Err(Error::InvalidDid(
|
||||
"not an ed25519 multicodec key".to_string(),
|
||||
@@ -106,7 +144,25 @@ impl Did {
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
|
||||
|
||||
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.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)
|
||||
}
|
||||
|
||||
/// Return the full DID string as a `&str`.
|
||||
@@ -152,7 +208,7 @@ impl From<Did> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A DID Document for a gitlawb actor.
|
||||
/// A DID Document for a Twigpine actor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DidDocument {
|
||||
#[serde(rename = "@context")]
|
||||
@@ -369,4 +425,154 @@ mod tests {
|
||||
);
|
||||
did.to_verifying_key().expect("a real did:key must resolve");
|
||||
}
|
||||
|
||||
/// `multibase::decode` accepts every alphabet, so the same Ed25519 key can
|
||||
/// be spelled as several `did:key` strings. Only base58btc is canonical;
|
||||
/// the rest must be refused so one identity cannot appear as several to
|
||||
/// gates and allowlists that compare DID strings.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_non_base58btc_multibase() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[9u8; 32]);
|
||||
let mut prefixed = ED25519_MULTICODEC.to_vec();
|
||||
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let canonical = Did::from_verifying_key(&signing.verifying_key());
|
||||
assert_eq!(
|
||||
canonical.to_verifying_key().unwrap().to_bytes(),
|
||||
signing.verifying_key().to_bytes(),
|
||||
"base58btc is the accepted spelling"
|
||||
);
|
||||
|
||||
// These spellings fit inside the method-id bound, so they reach the
|
||||
// canonical-prefix check and must be refused there.
|
||||
for base in [Base::Base64Url, Base::Base32Lower] {
|
||||
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
|
||||
let did: Did = alternative.parse().expect("method is still `key`");
|
||||
assert_ne!(
|
||||
did.as_str(),
|
||||
canonical.as_str(),
|
||||
"the same key must not compare equal under another spelling"
|
||||
);
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("non-canonical multibase must be refused");
|
||||
assert!(
|
||||
matches!(err, Error::InvalidDid(_)),
|
||||
"must be an InvalidDid, got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// A base16 spelling of the same key is longer than the method-id
|
||||
// bound, so it is refused by the length guard before the prefix check.
|
||||
// Either way it must not resolve.
|
||||
for base in [Base::Base16Lower, Base::Base16Upper] {
|
||||
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
|
||||
let did: Did = alternative.parse().expect("method is still `key`");
|
||||
assert!(
|
||||
matches!(did.to_verifying_key(), Err(Error::InvalidDid(_))),
|
||||
"a base16 spelling of a real key must not resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// base58flickr (`Z`) is the non-bitcoin sibling alphabet. Its spelling of
|
||||
/// a real key fits under the length bound, so it must be refused for the
|
||||
/// prefix, not merely for its size.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_a_base58flickr_spelling() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[11u8; 32]);
|
||||
let mut prefixed = ED25519_MULTICODEC.to_vec();
|
||||
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let did: Did = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(Base::Base58Flickr, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("base58flickr must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty method-specific id must be refused by the prefix rule without
|
||||
/// panicking on a missing first character.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_an_empty_method_id() {
|
||||
let did: Did = "did:key:".parse().expect("method is still `key`");
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("an empty method id must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A canonical base58btc spelling is not enough on its own: the bytes must
|
||||
/// also carry the ed25519-pub multicodec. This pins the check that stops a
|
||||
/// key of another type from resolving as an Ed25519 key.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_base58btc_with_a_wrong_multicodec() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[12u8; 32]);
|
||||
let mut buf = vec![0x12, 0x00]; // sha2-256, not ed25519-pub
|
||||
buf.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let did: Did = format!("did:key:{}", multibase::encode(Base::Base58Btc, &buf))
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("a non-ed25519 multicodec must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("ed25519"),
|
||||
"the error must name the expected multicodec: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,6 +11,22 @@ 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())
|
||||
@@ -18,6 +34,26 @@ 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.
|
||||
@@ -49,6 +85,13 @@ 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)
|
||||
@@ -68,6 +111,14 @@ 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)
|
||||
@@ -126,9 +177,20 @@ 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..];
|
||||
|
||||
let my_x = XSecret::from(*x25519_secret_from_seed(&keypair.to_seed()));
|
||||
// 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);
|
||||
|
||||
// Identities are blinded: no entry says which recipient it belongs to, so
|
||||
// try each one. The ChaChaBox AEAD tag authenticates, so exactly the
|
||||
@@ -140,7 +202,12 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
|
||||
.ok()
|
||||
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
|
||||
{
|
||||
Some(b) => XPublic::from(b),
|
||||
// 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,
|
||||
None => continue,
|
||||
};
|
||||
// from_slice panics on a wrong length, and the envelope is attacker
|
||||
@@ -293,4 +360,436 @@ 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 gitlawb node must be signed by the actor's
|
||||
//! Every write request to a Twigpine 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;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The component identifiers covered by every gitlawb signature.
|
||||
/// The component identifiers covered by every Twigpine signature.
|
||||
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
|
||||
|
||||
/// The three headers produced by RFC 9421 signing.
|
||||
@@ -67,11 +67,20 @@ impl HttpSignature {
|
||||
let components_str = &rest[open + 1..close];
|
||||
let params_str = &rest[close + 1..]; // starts with ';'
|
||||
|
||||
// "\"@method\" \"@path\" \"content-digest\"" → ["@method", "@path", "content-digest"]
|
||||
let components: Vec<String> = components_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.trim_matches('"').to_string())
|
||||
.collect();
|
||||
// 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);
|
||||
}
|
||||
|
||||
let params = parse_params(params_str)?;
|
||||
|
||||
@@ -113,15 +122,48 @@ impl HttpSignature {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject if the `created` timestamp is more than 5 minutes from now.
|
||||
/// 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.
|
||||
pub fn check_created(&self) -> Result<()> {
|
||||
const MAX_AGE_SECS: i64 = 300;
|
||||
const MAX_FUTURE_SECS: i64 = 60;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let skew = (now - self.created).abs();
|
||||
if skew > 300 {
|
||||
|
||||
// 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 {
|
||||
return Err(Error::HttpSignature(format!(
|
||||
"clock skew too large: {skew}s (max 300s)"
|
||||
"clock skew too large: created {age}s in the past (max {MAX_AGE_SECS}s)"
|
||||
)));
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -203,6 +245,33 @@ 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();
|
||||
@@ -272,6 +341,91 @@ 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;
|
||||
@@ -335,6 +489,75 @@ 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();
|
||||
@@ -343,6 +566,26 @@ mod tests {
|
||||
assert!(sig.check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_created_timestamps_reject_without_wrapping() {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let parse = |created: i64| {
|
||||
let sig_input =
|
||||
format!(r#"sig1=("@method");keyid="{did}";alg="ed25519";created={created}"#);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
};
|
||||
// i64::MIN used to panic in debug (subtraction overflow) and wrap to a
|
||||
// passing skew in release; now - 2^63 wrapped to i64::MIN, whose abs()
|
||||
// wrapped back to itself and read as fresh. Both must reject.
|
||||
for created in [i64::MIN, i64::MAX, Utc::now().timestamp() - i64::MAX] {
|
||||
assert!(
|
||||
parse(created).check_created().is_err(),
|
||||
"created={created} must not pass the freshness gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_missing_sig1_prefix() {
|
||||
let err = HttpSignature::parse(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Keypair generation, storage, and signing.
|
||||
//!
|
||||
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
|
||||
//! A Twigpine 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 gitlawb actor.
|
||||
/// An Ed25519 keypair that is the root identity for a Twigpine actor.
|
||||
#[derive(Clone)]
|
||||
pub struct Keypair {
|
||||
signing_key: SigningKey,
|
||||
|
||||
@@ -5,7 +5,16 @@ 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;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! 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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//! 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. gitlawb uses UCANs for:
|
||||
//! with optional expiry and revocation. Twigpine 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 gitlawb capability strings.
|
||||
/// Well-known Twigpine capability strings.
|
||||
pub mod caps {
|
||||
pub const GIT_PUSH: &str = "git/push";
|
||||
pub const GIT_FETCH: &str = "git/fetch";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -23,6 +23,8 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
axum = { version = "0.8", features = ["http1", "http2", "json", "ws"] }
|
||||
async-graphql = { version = "7", features = ["chrono", "uuid", "tracing"] }
|
||||
async-graphql-axum = "7"
|
||||
@@ -33,6 +35,7 @@ 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 }
|
||||
@@ -44,14 +47,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.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"
|
||||
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"
|
||||
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"] }
|
||||
@@ -72,7 +75,7 @@ alloy = { version = "1", default-features = false, features = [
|
||||
"network",
|
||||
"rpc-types-eth",
|
||||
] }
|
||||
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
|
||||
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,13 @@
|
||||
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::{Extension, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::db::normalize_owner_key;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -21,17 +22,62 @@ fn default_limit() -> i64 {
|
||||
}
|
||||
|
||||
/// GET /api/v1/arweave/anchors
|
||||
///
|
||||
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
|
||||
/// request to the canonical read-authorization path used by every other
|
||||
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
|
||||
/// identity attached. Missing repositories, quarantined mirrors, and signed
|
||||
/// non-readers all collapse to the standard `404` (no existence oracle). The
|
||||
/// unscoped listing is auth-only — the #121 contract — and does not admit
|
||||
/// visibility filtering, which is the #136 stale-index class and explicitly
|
||||
/// out of scope for this auth slice.
|
||||
pub async fn list_anchors(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListAnchorsQuery>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> 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?;
|
||||
// The route's `optional_signature` layer is permissive (it admits unsigned
|
||||
// legacy callers) so this in-handler check is the actual gate. A signed
|
||||
// non-reader still passes this check and proceeds to the authz step below.
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
if caller.is_none() {
|
||||
return Err(AppError::Unauthorized(
|
||||
"authentication required for anchor listing".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp before the SQL query. A negative or non-numeric limit must not
|
||||
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
|
||||
// contract is the same default ceiling as the listing page itself.
|
||||
let limit = q.limit.clamp(0, 200);
|
||||
|
||||
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
|
||||
// Parse the user-supplied "owner/name" through the same slug validator
|
||||
// the sync path uses, so a malformed query is a 400, not a 500.
|
||||
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
|
||||
};
|
||||
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
|
||||
// missing repos, quarantined mirrors, and signed non-readers.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
|
||||
// Build the SQL filter from the canonical stored slug, NOT from the
|
||||
// user-supplied `?repo=` string. Anchor rows are written as
|
||||
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
|
||||
// request that passes authz with the full `did:key:…/name` form would
|
||||
// match zero rows and return a false empty page. The gate already
|
||||
// verified the repo is readable; `record.owner_did` is the canonical
|
||||
// identity and `record.name` is the stored name.
|
||||
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
|
||||
state
|
||||
.db
|
||||
.list_arweave_anchors(Some(&stored_slug), limit)
|
||||
.await?
|
||||
} else {
|
||||
// No scope → no repo-read decision. Auth-only.
|
||||
state.db.list_arweave_anchors(None, limit).await?
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
@@ -60,6 +106,7 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/arweave/anchors")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -285,11 +285,22 @@ pub async fn submit_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SubmitBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
if bounty.status != "claimed" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -325,11 +336,22 @@ pub async fn approve_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ApproveBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
if bounty.status != "submitted" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -372,11 +394,22 @@ pub async fn cancel_bounty(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
if bounty.status != "open" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -407,11 +440,22 @@ pub async fn dispute_bounty(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
if bounty.status != "claimed" && bounty.status != "submitted" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -456,21 +500,68 @@ pub async fn dispute_bounty(
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
|
||||
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
|
||||
/// all deduped repos, batch-load their visibility rules, keep only those that
|
||||
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
|
||||
/// per-repo authorization queries.
|
||||
///
|
||||
/// Both bounty stats handlers mount behind `optional_signature` (see
|
||||
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
|
||||
/// anonymous access. Every caller, signed or not, currently receives the
|
||||
/// anonymous-scoped aggregates (matching the #104 pattern); per-caller
|
||||
/// visibility can be threaded through here when that work lands.
|
||||
///
|
||||
/// A database error is returned to the caller, never collapsed to an empty
|
||||
/// set: an empty set would render as HTTP 200 with zero counts, which a client
|
||||
/// reads as confirmed absence of activity.
|
||||
async fn visible_repo_pairs(state: &AppState) -> Result<Vec<(String, String)>> {
|
||||
let rows = state.db.list_all_repos_deduped().await?;
|
||||
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
|
||||
let pairs = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
// mirror-rows-handled: a mirror with a canonical twin is deduped to the
|
||||
// canonical row and its rules; a mirror-only repo counts as public, the
|
||||
// same answer authorize_repo_read and /stats give. Pinned by
|
||||
// bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone.
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
|
||||
})
|
||||
.map(|r| {
|
||||
(
|
||||
crate::db::normalize_owner_key(&r.owner_did).to_string(),
|
||||
r.name.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// GET /api/v1/bounties/stats
|
||||
///
|
||||
/// Aggregates are restricted to anonymously-listable repos so private-repo
|
||||
/// bounty activity is not exposed (#477). The visible-repo set is resolved
|
||||
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
|
||||
/// passed into the filtered aggregate queries.
|
||||
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
|
||||
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
|
||||
let visible = visible_repo_pairs(&state).await?;
|
||||
|
||||
let open = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("open", &visible)
|
||||
.await?;
|
||||
let claimed = state
|
||||
.db
|
||||
.count_bounties_by_status("claimed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
.count_bounties_by_status_visible("claimed", &visible)
|
||||
.await?;
|
||||
let completed = state
|
||||
.db
|
||||
.count_bounties_by_status("completed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
.count_bounties_by_status_visible("completed", &visible)
|
||||
.await?;
|
||||
|
||||
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
|
||||
let leaders = state.db.bounty_leaderboard_visible(10, &visible).await?;
|
||||
let leaderboard = leaders
|
||||
.into_iter()
|
||||
.map(|(did, cnt, total)| AgentBountyEntry {
|
||||
@@ -489,11 +580,14 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
|
||||
}
|
||||
|
||||
/// GET /api/v1/agents/{did}/bounties
|
||||
///
|
||||
/// Per-agent earnings restricted to anonymously-listable repos (#477).
|
||||
pub async fn agent_bounty_stats(
|
||||
State(state): State<AppState>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
|
||||
let visible = visible_repo_pairs(&state).await?;
|
||||
let (count, total) = state.db.agent_bounty_stats_visible(&did, &visible).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"did": did,
|
||||
"completed_bounties": count,
|
||||
|
||||
@@ -52,6 +52,7 @@ pub async fn list_certs(
|
||||
"node_did": c.node_did,
|
||||
"signature": c.signature,
|
||||
"issued_at": c.issued_at,
|
||||
"version": c.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -92,5 +93,6 @@ pub async fn get_cert(
|
||||
"node_did": cert.node_did,
|
||||
"signature": cert.signature,
|
||||
"issued_at": cert.issued_at,
|
||||
"version": cert.version,
|
||||
})))
|
||||
}
|
||||
@@ -11,7 +11,8 @@ use crate::state::AppState;
|
||||
|
||||
/// Hard ceiling on rows any ref-update feed returns in one request. Shared by the
|
||||
/// shared collector's clamp and the per-handler request caps so they can't drift.
|
||||
const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
/// `pub(crate)` so the GraphQL complexity meter can price against the same bound.
|
||||
pub(crate) const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
|
||||
/// Collect up to `limit` ref-update rows visible to `caller`, newest first,
|
||||
/// paging past rows the feed gate drops. Filtering after a plain SQL `LIMIT`
|
||||
@@ -437,6 +438,7 @@ mod ref_updates_feed_tests {
|
||||
node_did: "did:key:z6MkNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: Utc::now().to_rfc3339(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7510
-618
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -14,14 +14,13 @@ pub struct LabelRequest {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/labels
|
||||
pub async fn add_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<LabelRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let label = req.label.trim().to_lowercase();
|
||||
/// Canonicalize and validate a label string. `add_label` and `remove_label`
|
||||
/// must agree on the form stored in the DB: `add_label` already trims and
|
||||
/// lowercases before storing, so `remove_label` has to do the same to find
|
||||
/// the row. Validation is shared so neither handler can drift on charset or
|
||||
/// length (#344).
|
||||
fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
|
||||
let label = raw.trim().to_lowercase();
|
||||
if label.is_empty() || label.len() > 50 {
|
||||
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
|
||||
}
|
||||
@@ -33,6 +32,17 @@ pub async fn add_label(
|
||||
"label must contain only alphanumeric characters, hyphens, and colons".into(),
|
||||
));
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/labels
|
||||
pub async fn add_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<LabelRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let label = canonicalize_label(&req.label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
@@ -59,6 +69,8 @@ pub async fn remove_label(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, label)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let label = canonicalize_label(&label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
@@ -66,7 +78,10 @@ pub async fn remove_label(
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
state.db.remove_label(&record.id, &label).await?;
|
||||
let removed = state.db.remove_label(&record.id, &label).await?;
|
||||
if !removed {
|
||||
return Err(AppError::NotFound(format!("label '{label}'")));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
|
||||
}
|
||||
|
||||
|
||||
@@ -80,15 +80,39 @@ pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
|
||||
/// renders it as `AppError::Forbidden`; a handler that emits its own
|
||||
/// `(StatusCode, Json)` envelope instead of `AppError` (e.g.
|
||||
/// `api::tasks::create_task`) must use this constant too, so two copies of one
|
||||
/// gate cannot drift in observable output.
|
||||
pub(crate) const REPO_OWNER_ONLY_MESSAGE: &str = "only the repo owner can perform this action";
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
/// and the author check (close policy) share one normalization.
|
||||
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
|
||||
if did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
"only the repo owner can perform this action".into(),
|
||||
))
|
||||
Err(AppError::Forbidden(REPO_OWNER_ONLY_MESSAGE.into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,17 +232,26 @@ mod authz_guard {
|
||||
(issues, "create_issue", "authorize_repo_read("),
|
||||
(bounties, "create_bounty", "authorize_repo_read("),
|
||||
(repos, "fork_repo", "authorize_repo_read("),
|
||||
// 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).
|
||||
// get_by_cid resolves each candidate (provenance path + legacy scan) through
|
||||
// the shared `gate_and_serve`; the gate markers themselves are asserted
|
||||
// below. This row proves the delegation is real, so the gate is actually
|
||||
// reached rather than dead code. The delegated gate still calls
|
||||
// `visibility_check` directly and never `authorize_repo_read`, so it keeps
|
||||
// the property the pre-merge marker enforced: no fuzzy re-resolve.
|
||||
(ipfs, "get_by_cid", "gate_and_serve("),
|
||||
// Sibling read surfaces: gate private-repo metadata on read visibility
|
||||
// (public repos stay anonymous; private repos 404).
|
||||
(replicas, "list_replicas", "authorize_repo_read("),
|
||||
(protect, "list_protected_branches", "authorize_repo_read("),
|
||||
(labels, "list_labels", "authorize_repo_read("),
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
// #496: create_task is signer-self AND owner-when-repo-scoped — a
|
||||
// repo_id naming a hosted repo admits tasks only from its owner.
|
||||
// Both halves are pinned: the did_matches row guards the signer
|
||||
// binding, this one the repo-ownership gate.
|
||||
(tasks, "create_task", "require_repo_owner("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(tasks, "complete_task", "did_matches("),
|
||||
(tasks, "fail_task", "did_matches("),
|
||||
@@ -250,6 +283,22 @@ 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!(
|
||||
@@ -259,6 +308,577 @@ 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(",
|
||||
"require_repo_owner(",
|
||||
],
|
||||
),
|
||||
(
|
||||
"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.
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct PeerResponse {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)";
|
||||
|
||||
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
|
||||
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
|
||||
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
|
||||
@@ -104,8 +106,8 @@ fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
|
||||
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
|
||||
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
|
||||
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
|
||||
/// and by the boot-time prune of already-poisoned rows.
|
||||
/// and `localhost` / `.localhost` / `.local` / `.internal` hostnames. Used at
|
||||
/// announce time and by the boot-time prune of already-poisoned rows.
|
||||
pub fn is_public_http_url(raw: &str) -> bool {
|
||||
let url = match reqwest::Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
@@ -118,13 +120,14 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
Some(h) => h.to_ascii_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
// Drop a single trailing dot (FQDN root): `localhost.` resolves the same as
|
||||
// Drop trailing dots (FQDN root): `localhost.` resolves the same as
|
||||
// `localhost`, so normalize before the suffix/equality checks below.
|
||||
if let Some(stripped) = host.strip_suffix('.') {
|
||||
host = stripped.to_string();
|
||||
}
|
||||
// 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 host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
{
|
||||
@@ -167,8 +170,13 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// fc00::/7 (unique-local) or fe80::/10 (link-local)
|
||||
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
|
||||
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
|
||||
// (site-local, deprecated by RFC 3879 and so never a legitimate
|
||||
// peer address, but still routable on networks that kept it).
|
||||
if (s[0] & 0xfe00) == 0xfc00
|
||||
|| (s[0] & 0xffc0) == 0xfe80
|
||||
|| (s[0] & 0xffc0) == 0xfec0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
|
||||
@@ -183,6 +191,13 @@ 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
|
||||
}
|
||||
@@ -248,9 +263,9 @@ pub async fn announce(
|
||||
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
|
||||
// the real peers under junk so node-origin repos stop replicating.
|
||||
if !is_public_http_url(&req.http_url) {
|
||||
return Err(AppError::BadRequest(
|
||||
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"http_url {PUBLIC_HTTP_URL_REQUIREMENT}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Reject self-announcements: a peer row whose http_url is our own public
|
||||
@@ -520,7 +535,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accepts_public_https_and_http() {
|
||||
assert!(is_public_http_url("https://node.gitlawb.com"));
|
||||
assert!(is_public_http_url("https://node.twigpine.com"));
|
||||
assert!(is_public_http_url("https://manila.gitlawb.com/"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545"));
|
||||
}
|
||||
@@ -529,6 +544,7 @@ mod tests {
|
||||
fn rejects_loopback_private_and_internal() {
|
||||
for bad in [
|
||||
"http://localhost:7545",
|
||||
"http://node.localhost:7545",
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://localhost:22/",
|
||||
"http://0.0.0.0:7545",
|
||||
@@ -544,7 +560,7 @@ mod tests {
|
||||
"http://[::1]:7545",
|
||||
"http://gitlawb-node.internal:7545",
|
||||
"http://my-node.local/",
|
||||
"ftp://node.gitlawb.com",
|
||||
"ftp://node.twigpine.com",
|
||||
"not-a-url",
|
||||
"",
|
||||
// Trailing-dot FQDN of an internal host.
|
||||
@@ -577,6 +593,49 @@ 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 now
|
||||
// exercises the `.localhost` suffix rejection together with the extra
|
||||
// trailing dot.
|
||||
for bad in [
|
||||
"http://localhost../",
|
||||
"http://localhost...:7545",
|
||||
"http://node.localhost../",
|
||||
] {
|
||||
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:
|
||||
@@ -1530,6 +1589,31 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn announce_rejects_localhost_subdomains_with_an_accurate_error(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(
|
||||
&did,
|
||||
&announce_body(&did, "https://node.localhost:7545"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, error, message) = status_and_error(resp).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error, "bad_request");
|
||||
assert_eq!(
|
||||
message,
|
||||
"http_url must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, &did).await.is_none(),
|
||||
"a rejected .localhost announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -50,6 +50,17 @@ 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();
|
||||
|
||||
@@ -213,7 +224,7 @@ pub async fn merge_pr(
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
let merger_did = auth.0;
|
||||
let merge_result = store::merge_branch(
|
||||
@@ -225,7 +236,10 @@ pub async fn merge_pr(
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
// Short-circuit on a refused publish before the PR is marked merged and
|
||||
// before the webhook fires. Both are irreversible announcements of a merge
|
||||
// commit that only exists on this node's disk.
|
||||
guard.release(merge_result.is_ok()).await.into_result()?;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
@@ -424,3 +438,105 @@ pub async fn list_comments(
|
||||
let comments = state.db.list_pr_comments(&pr.id).await?;
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
|
||||
/// pool must shed 503 + Retry-After.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
let now = Utc::now();
|
||||
crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/tmp/{name}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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),
|
||||
);
|
||||
|
||||
let repo = seed_repo(owner, "lp-merge");
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
state
|
||||
.db
|
||||
.create_pr(&PullRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo_id.clone(),
|
||||
number: 1,
|
||||
title: "lp".to_string(),
|
||||
body: None,
|
||||
author_did: owner.to_string(),
|
||||
source_branch: "feature".to_string(),
|
||||
target_branch: "main".to_string(),
|
||||
status: "open".to_string(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.expect("seed open PR");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-merge".to_string(), 1)),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"merge_pr: an exhausted write-lock pool must shed 503, not a 500 git error"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("1"),
|
||||
"merge_pr: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,11 @@ pub async fn register_replica(
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
validate_replica_url(&req.url)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// The mutation response carries repo metadata (name, replica count), so
|
||||
// registration applies the same read-visibility decision as the listing:
|
||||
// a caller who may not read the repo gets the same 404 as a missing one.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let replica_did = &auth.0;
|
||||
|
||||
@@ -93,11 +93,10 @@ pub async fn unregister_replica(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Same gate as register_replica: removal mutates repo-scoped metadata and
|
||||
// its response discloses the replica count.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let replica_did = &auth.0;
|
||||
state.db.unregister_replica(&record.id, replica_did).await?;
|
||||
|
||||
+3957
-225
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
//! DID resolution — look up a node by its gitlawb DID.
|
||||
//! DID resolution — look up a node by its Twigpine 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 gitlawb DID to an HTTP URL and p2p multiaddr.
|
||||
/// Resolves a Twigpine 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>,
|
||||
|
||||
@@ -30,6 +30,33 @@ fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
|
||||
)
|
||||
}
|
||||
|
||||
/// This module's error shape with an opaque body: the real error is logged
|
||||
/// server-side and never serialized (#250), matching the envelope
|
||||
/// `AppError::Db` renders. Also mirrors its status split: pool-class failures
|
||||
/// are a retryable 503 `db_unavailable`, everything else the opaque 500
|
||||
/// `db_error`, so a transient outage is not reported as permanent.
|
||||
fn db_error(e: anyhow::Error) -> (StatusCode, Json<Value>) {
|
||||
tracing::error!(error = %format!("{e:#}"), "task database error");
|
||||
if let Some(sql) = e.chain().find_map(|c| c.downcast_ref::<sqlx::Error>()) {
|
||||
if crate::error::db_unavailable(sql) {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": crate::error::DB_UNAVAILABLE_CODE,
|
||||
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "db_error",
|
||||
"message": crate::error::DB_ERROR_MESSAGE,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Request / response types ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -101,6 +128,37 @@ pub async fn create_task(
|
||||
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
|
||||
return Err(forbidden("delegator_did must be the authenticated signer"));
|
||||
}
|
||||
// #496: a caller-supplied repo_id must name a repo the caller owns. Without
|
||||
// this, any signed caller can plant a task — payload and UCAN included —
|
||||
// under a foreign repo id, where the named assignee can claim it. Resolve
|
||||
// the id against hosted repos: a hosted, non-quarantined repo admits tasks
|
||||
// only from its owner (403 otherwise). An id naming no hosted repo
|
||||
// (unknown, or quarantined — which is hidden as if it did not exist, so
|
||||
// 403ing it would confirm a real id) is stored verbatim as an opaque
|
||||
// label: this is a write gate and does not bind an unresolved id to a
|
||||
// repo. Task reads are caller-independent on this base (the list endpoints
|
||||
// are not repo-scoped), before and after this change. The 403-vs-201 split
|
||||
// between a hosted repo id and an unknown one is itself a one-bit oracle —
|
||||
// a stranger holding a private repo's id learns it is hosted here — but
|
||||
// denying foreign repos requires drawing exactly that line, and repo ids
|
||||
// already surface via the task list on this base. Repo-less tasks are
|
||||
// unaffected.
|
||||
if let Some(repo_id) = body.repo_id.as_deref() {
|
||||
let record = state.db.get_repo_by_id(repo_id).await.map_err(db_error)?;
|
||||
if let Some(record) = record {
|
||||
let quarantined = state
|
||||
.db
|
||||
.is_repo_quarantined(&record.id)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if !quarantined && crate::api::require_repo_owner(&record, &auth.0).is_err() {
|
||||
// Same denial text the GraphQL copy relays from
|
||||
// `require_repo_owner`, so the two copies of this gate do not
|
||||
// disagree in observable output.
|
||||
return Err(forbidden(crate::api::REPO_OWNER_ONLY_MESSAGE));
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -117,12 +175,7 @@ pub async fn create_task(
|
||||
updated_at: now,
|
||||
deadline: body.deadline,
|
||||
};
|
||||
state.db.create_task(&task).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let task = state.db.create_task(&task).await.map_err(db_error)?;
|
||||
Ok((StatusCode::CREATED, Json(task_to_json(&task))))
|
||||
}
|
||||
|
||||
@@ -175,16 +228,28 @@ pub async fn claim_task(
|
||||
return Err(forbidden("assignee_did must be the authenticated signer"));
|
||||
}
|
||||
let task = state.db.claim_task(&id, &auth.0).await.map_err(|e| {
|
||||
if e.downcast_ref::<crate::db::TaskReservedForOtherAssignee>()
|
||||
.is_some()
|
||||
{
|
||||
return forbidden("task not claimable: reserved for another assignee");
|
||||
}
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| auth.0.clone());
|
||||
let _ = state.task_event_tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: auth.0,
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(Json(task_to_json(&task)))
|
||||
@@ -219,14 +284,14 @@ pub async fn complete_task(
|
||||
})?;
|
||||
if !crate::api::did_matches(
|
||||
&auth.0,
|
||||
existing.assignee_did.as_deref().unwrap_or_default(),
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
return Err(forbidden("only the task assignee can complete it"));
|
||||
}
|
||||
let by_did = auth.0;
|
||||
let task = state
|
||||
.db
|
||||
.finish_task(&id, "completed", body.result.as_deref())
|
||||
.finish_task(&id, "completed", body.result.as_deref(), &by_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -234,6 +299,13 @@ pub async fn complete_task(
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or(by_did);
|
||||
let _ = state.task_event_tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -272,7 +344,7 @@ pub async fn fail_task(
|
||||
})?;
|
||||
if !crate::api::did_matches(
|
||||
&auth.0,
|
||||
existing.assignee_did.as_deref().unwrap_or_default(),
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
return Err(forbidden("only the task assignee can fail it"));
|
||||
}
|
||||
@@ -280,7 +352,7 @@ pub async fn fail_task(
|
||||
let reason = body.reason.unwrap_or_default();
|
||||
let task = state
|
||||
.db
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
.finish_task(&id, "failed", Some(&reason), &by_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -288,6 +360,13 @@ pub async fn fail_task(
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or(by_did);
|
||||
let _ = state.task_event_tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -297,3 +376,36 @@ pub async fn fail_task(
|
||||
});
|
||||
Ok(Json(task_to_json(&task)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gate's DB helper must mirror `AppError::Db`: a pool-level failure is
|
||||
/// the retryable 503 `db_unavailable`, a query fault the opaque 500.
|
||||
#[test]
|
||||
fn db_error_matches_app_error_status_split() {
|
||||
let unavailable = db_error(anyhow::Error::from(sqlx::Error::PoolTimedOut));
|
||||
assert_eq!(unavailable.0, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(unavailable.1 .0["error"], crate::error::DB_UNAVAILABLE_CODE);
|
||||
assert_eq!(
|
||||
unavailable.1 .0["message"],
|
||||
crate::error::DB_UNAVAILABLE_MESSAGE
|
||||
);
|
||||
|
||||
let query = db_error(anyhow::Error::from(sqlx::Error::Protocol(
|
||||
"column \"machine_id\" does not exist".into(),
|
||||
)));
|
||||
assert_eq!(query.0, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(query.1 .0["error"], "db_error");
|
||||
assert_eq!(query.1 .0["message"], crate::error::DB_ERROR_MESSAGE);
|
||||
assert!(!query.1 .0.to_string().contains("machine_id"));
|
||||
}
|
||||
|
||||
/// `.context()` layers must not hide the sqlx error from the status split.
|
||||
#[test]
|
||||
fn db_error_sees_sqlx_through_context() {
|
||||
let wrapped = anyhow::Error::from(sqlx::Error::PoolClosed).context("repo lookup failed");
|
||||
assert_eq!(db_error(wrapped).0, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,10 @@ pub async fn create_webhook(
|
||||
// endpoints (SSRF). Delivery runs on the shared no-redirect client
|
||||
// (main.rs), which closes the 3xx-to-internal bounce.
|
||||
if !crate::api::peers::is_public_http_url(&req.url) {
|
||||
return Err(AppError::BadRequest(
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"webhook URL {}",
|
||||
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
|
||||
)));
|
||||
}
|
||||
|
||||
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
|
||||
@@ -141,7 +142,19 @@ pub async fn delete_webhook(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::peers::is_public_http_url;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{create_webhook, CreateWebhookRequest};
|
||||
use crate::api::peers::{is_public_http_url, PUBLIC_HTTP_URL_REQUIREMENT};
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::RepoRecord;
|
||||
use crate::error::AppError;
|
||||
use crate::test_support::test_state;
|
||||
|
||||
// create_webhook gates req.url through is_public_http_url. Pin the exact
|
||||
// SSRF targets from issue #81 so the webhook path can never regress to the
|
||||
@@ -152,6 +165,7 @@ mod tests {
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost/",
|
||||
"http://node.localhost/",
|
||||
"http://10.0.0.5/",
|
||||
"http://[::1]/",
|
||||
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
|
||||
@@ -169,4 +183,53 @@ mod tests {
|
||||
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545/"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn webhook_localhost_rejection_uses_shared_public_url_contract(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate().did().to_string();
|
||||
let repo_id = Uuid::new_v4().to_string();
|
||||
state
|
||||
.db
|
||||
.create_repo(&RepoRecord {
|
||||
id: repo_id.clone(),
|
||||
name: "webhook-contract".to_string(),
|
||||
owner_did: owner.clone(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
disk_path: "/tmp/webhook-contract.git".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = create_webhook(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.clone())),
|
||||
Path((owner, "webhook-contract".to_string())),
|
||||
Json(CreateWebhookRequest {
|
||||
url: "https://node.localhost/hook".to_string(),
|
||||
secret: None,
|
||||
events: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(AppError::BadRequest(message)) => assert_eq!(
|
||||
message,
|
||||
format!("webhook URL {PUBLIC_HTTP_URL_REQUIREMENT}")
|
||||
),
|
||||
Err(error) => panic!("unexpected webhook rejection: {error}"),
|
||||
Ok(_) => panic!("a .localhost webhook must be rejected"),
|
||||
}
|
||||
assert!(
|
||||
state.db.list_webhooks(&repo_id).await.unwrap().is_empty(),
|
||||
"a rejected .localhost webhook must leave no row behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! #26 Split PR 2 — three-outcome recovery policy types.
|
||||
//!
|
||||
//! This module owns the recovery classification policy the reviewer
|
||||
//! demanded: only a trustworthy, protocol-defined absence authorizes
|
||||
//! a paid re-upload. Everything else stays non-terminal.
|
||||
//!
|
||||
//! Deliberately narrow: the ANS-104 codec lives in `ans104`, and the
|
||||
//! gateway probe, the `verify_anchor` path, and the public verify
|
||||
//! endpoint are deferred until the uploader, retrieval proof, and
|
||||
//! recovery consumer can be proven as one vertical slice (reviewer 2,
|
||||
//! round 5). A probe against a real provider contract — same-network
|
||||
//! upload/read pair, redirect-safe client, envelope-supplying read
|
||||
//! API, provider-documented absence evidence — belongs in that slice,
|
||||
//! not here. What ships here is the policy type plus the rule that
|
||||
//! only `DefinitivelyAbsent` permits spending another upload.
|
||||
|
||||
/// Outcome of a gateway probe for a persisted `item_id`.
|
||||
// Policy surface for the recovery slice: no construction on this
|
||||
// head (the probe arrives with the provider contract); the unit
|
||||
// test pins the rule meanwhile.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProbeOutcome {
|
||||
/// The persisted item was served back with a verifiable envelope
|
||||
/// bound to the requested id. No re-upload allowed.
|
||||
Present,
|
||||
/// Trustworthy, protocol-defined proof the item was never
|
||||
/// accepted. Authorizes re-upload. The exact evidence that
|
||||
/// establishes this is defined by the provider contract in the
|
||||
/// vertical slice that introduces the probe — never by a
|
||||
/// mock-only body shape.
|
||||
DefinitivelyAbsent,
|
||||
/// Anything else: transport failure, oversized body, bad
|
||||
/// signature, id mismatch, ambiguous gateway response. The
|
||||
/// outbox stays non-terminal.
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
impl ProbeOutcome {
|
||||
/// True iff the recovery code is allowed to spend another paid
|
||||
/// upload request. Only `DefinitivelyAbsent` qualifies.
|
||||
#[allow(dead_code)] // the recovery consumer lives in a later slice
|
||||
pub fn permits_reupload(self) -> bool {
|
||||
matches!(self, ProbeOutcome::DefinitivelyAbsent)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Only `DefinitivelyAbsent` authorizes a paid re-upload. This is
|
||||
/// the policy the reviewer named: collapsing any ambiguous outcome
|
||||
/// to absent spends a second immutable upload for the same
|
||||
/// transition.
|
||||
#[test]
|
||||
fn only_definitively_absent_authorizes_reupload() {
|
||||
assert!(!ProbeOutcome::Present.permits_reupload());
|
||||
assert!(ProbeOutcome::DefinitivelyAbsent.permits_reupload());
|
||||
assert!(!ProbeOutcome::Indeterminate.permits_reupload());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,38 @@ 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:
|
||||
@@ -48,20 +80,52 @@ use gitlawb_core::identity::verify;
|
||||
/// 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(request: Request, next: Next) -> Response {
|
||||
// Buffer the body so we can verify content-digest and pass it downstream
|
||||
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.
|
||||
let (parts, body) = request.into_parts();
|
||||
let body_bytes =
|
||||
match body.collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => return (
|
||||
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 (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "unreadable_body", "message": "could not read request body" }),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let sig_input = parts
|
||||
.headers
|
||||
@@ -120,7 +184,7 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
COVERED_COMPONENTS.join(", "),
|
||||
missing.join(", ")
|
||||
),
|
||||
"hint": "See https://gitlawb.com/agents#authentication",
|
||||
"hint": "See https://twigpine.com/agents#authentication",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -145,11 +209,14 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "unresolvable_did",
|
||||
"message": format!("cannot resolve DID '{}': {e}", sig.key_id),
|
||||
"hint": "only did:key is supported in alpha",
|
||||
"message": format!(
|
||||
"cannot resolve DID '{}': {e}",
|
||||
bounded_did_echo(&sig.key_id.to_string())
|
||||
),
|
||||
"hint": unresolvable_did_hint(&sig.key_id),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,17 +229,34 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
|
||||
let content_digest = parts
|
||||
// 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
|
||||
.headers
|
||||
.get("content-digest")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
{
|
||||
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()
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
request_values.insert("content-digest".to_string(), content_digest.clone());
|
||||
|
||||
// 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);
|
||||
@@ -217,23 +301,18 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
.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();
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
tracing::info!(did = %sig.key_id, "✓ authenticated request");
|
||||
@@ -248,11 +327,17 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
/// 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(request: Request, next: Next) -> Response {
|
||||
pub async fn optional_signature(
|
||||
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let has_signature_headers = request.headers().contains_key("signature-input")
|
||||
|| request.headers().contains_key("signature");
|
||||
if has_signature_headers {
|
||||
return require_signature(request, next).await;
|
||||
// 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;
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -380,7 +465,7 @@ fn human_detected(message: &str) -> impl IntoResponse {
|
||||
"error": "not_an_agent",
|
||||
"message": message,
|
||||
"hint": "gl identity new && gl register",
|
||||
"docs": "https://gitlawb.com/agents",
|
||||
"docs": "https://twigpine.com/agents",
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -423,6 +508,58 @@ 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();
|
||||
@@ -490,6 +627,7 @@ 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()
|
||||
@@ -516,9 +654,21 @@ 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)),
|
||||
federated_rate_limiter: RateLimiter::new_bounded(12, Duration::from_secs(60), 10_000),
|
||||
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)),
|
||||
@@ -534,7 +684,6 @@ mod tests {
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_ipfs_walk_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
git_bin: "git".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -626,4 +775,78 @@ 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 Gitlawb network nodes.
|
||||
//! Embedded seed list of public Twigpine 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`
|
||||
|
||||
+416
-10
@@ -11,6 +11,74 @@ use uuid::Uuid;
|
||||
use crate::db::RefCertificate;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Build the v1 signing payload — the EXACT bytes the node signs and
|
||||
/// gl's `verify_signature` reconstructs. This is the single source
|
||||
/// of truth shared by `issue_ref_certificate` and the frozen
|
||||
/// canonical-form tests below; drift here is what would break every
|
||||
/// existing cert.
|
||||
///
|
||||
/// Field order and key set are fixed: a default `serde_json::Value`
|
||||
/// serializes `Map<String, Value>` with sorted keys, so any change to
|
||||
/// the literal is observable as a different byte sequence on the
|
||||
/// wire.
|
||||
///
|
||||
/// v1 is the unversioned legacy shape: no `version` key in the signed
|
||||
/// JSON. From v2 onward the version belongs INSIDE the signed bytes
|
||||
/// (see `v2_signing_payload`): leaving it as an unsigned sibling
|
||||
/// would let a future v2 cert be downgraded to v1 and still verify
|
||||
/// cleanly, since the bytes would be identical.
|
||||
pub(crate) fn v1_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the v2 signing payload — the future shape for versioned
|
||||
/// certs. Identical to v1 except the wire `version` is part of the
|
||||
/// signed bytes, so stripping or flipping the `version` column
|
||||
/// invalidates the signature instead of verifying cleanly under the
|
||||
/// other version's path.
|
||||
///
|
||||
/// NOT yet issued: `issue_ref_certificate` still stamps v1 because
|
||||
/// the shipped `gl` verifier only supports v1. This builder exists
|
||||
/// to pin the downgrade-resistant shape now, before any v2 cert is
|
||||
/// ever signed.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn v2_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
"version": 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a signed ref-update certificate for a successful push.
|
||||
///
|
||||
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
|
||||
@@ -26,16 +94,12 @@ pub async fn issue_ref_certificate(
|
||||
let node_did = state.node_did.to_string();
|
||||
let issued_at = Utc::now().to_rfc3339();
|
||||
|
||||
// Build the canonical signing payload.
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
});
|
||||
// Build the canonical signing payload via the shared builder so
|
||||
// the frozen-vector test below and the live signer cannot drift
|
||||
// apart — a regression in either side fails both.
|
||||
let payload = v1_signing_payload(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher_did, &node_did, &issued_at,
|
||||
);
|
||||
let payload_bytes = serde_json::to_vec(&payload)?;
|
||||
|
||||
let signature = state.node_keypair.sign_b64(&payload_bytes);
|
||||
@@ -50,9 +114,351 @@ pub async fn issue_ref_certificate(
|
||||
node_did,
|
||||
signature,
|
||||
issued_at,
|
||||
// #26 Split PR 3: the wire-format version. New certs stamp
|
||||
// v1 — the pre-versioning 7-field payload with no `version`
|
||||
// key in the signed JSON — because the shipped `gl`
|
||||
// verifier only supports v1. Stamping v2 here would issue
|
||||
// certificates the shipped client refuses (round-3
|
||||
// finding: node pinned `version == 2` while gl pinned
|
||||
// "2 is refused", with nothing exercising the
|
||||
// composition). v2 is reserved for the future shape
|
||||
// defined by `v2_signing_payload`, which carries the
|
||||
// version INSIDE the signed bytes; land the v2 verify
|
||||
// path before stamping v2.
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// Persist and return the row as it exists in the database (on a
|
||||
// conflict the existing row survives when it is newer).
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod v1_payload_tests {
|
||||
//! #26 Split PR 3 — cert payload version compat.
|
||||
//!
|
||||
//! The v1 cert payload is the frozen canonical byte form the
|
||||
//! node signs. gl's `verify_signature` reconstructs the same
|
||||
//! payload and verifies. Any drift in field order, key set, or
|
||||
//! whitespace breaks every existing cert. The tests here pin
|
||||
//! the v1 payload shape, the round-trip, the cert id
|
||||
//! determinism per the v1 version, and the v2 read forward
|
||||
//! compat (an unknown version refuses to verify rather than
|
||||
//! guessing).
|
||||
//!
|
||||
//! Reviewer 1 finding: the live signer (`issue_ref_certificate`)
|
||||
//! and the frozen-vector test (`v1_payload_matches_frozen_canonical_form`)
|
||||
//! previously held two separate `serde_json::json!` literals,
|
||||
//! so a regression in the signer could not fail this test.
|
||||
//! Both now go through the shared `v1_signing_payload` builder.
|
||||
use super::{v1_signing_payload, v2_signing_payload};
|
||||
use crate::db::RefCertificate;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
/// v1 payload is the same shape the legacy code signed, byte
|
||||
/// for byte. Reverting this assertion to the v2 shape (which
|
||||
/// includes a `version` key) is what the versioned format
|
||||
/// forbids: an old client reading a v1 cert must not see a
|
||||
/// `version` key in the signed JSON.
|
||||
///
|
||||
/// The literal is built via the shared `v1_signing_payload`
|
||||
/// helper that `issue_ref_certificate` also uses, so the live
|
||||
/// signer and the frozen vector share one source of truth.
|
||||
#[test]
|
||||
fn v1_payload_matches_frozen_canonical_form() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let frozen = format!(
|
||||
r#"{{"new":"{}","node":"did:key:z6MkNode","old":"{}","pusher":"did:key:z6MkPusher","ref":"refs/heads/main","repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}}"#,
|
||||
"a".repeat(40),
|
||||
"0".repeat(40),
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
|
||||
}
|
||||
|
||||
/// Round-trip: sign the v1 payload with a keypair, build a
|
||||
/// RefCertificate with version: 1, and verify the structure.
|
||||
/// This is the construction the live code does on every push.
|
||||
///
|
||||
/// Uses the shared builder for both halves so a regression in
|
||||
/// the live signer (adding/removing/renaming a field) cannot
|
||||
/// pass this test by accident.
|
||||
#[test]
|
||||
fn v1_ref_certificate_structure_is_well_formed() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let payload_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let sig = kp.sign_b64(&payload_bytes);
|
||||
let cert = RefCertificate {
|
||||
id: "cert-id".into(),
|
||||
repo_id: "repo-1".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a".repeat(40),
|
||||
pusher_did: "did:key:z6MkPusher".into(),
|
||||
node_did: node_did.clone(),
|
||||
signature: sig.clone(),
|
||||
issued_at: "2026-07-22T00:00:00+00:00".into(),
|
||||
version: 1,
|
||||
};
|
||||
assert_eq!(cert.version, 1, "new certs carry version: 1");
|
||||
// The signed payload reconstructs identically: gl's
|
||||
// verify_signature would build the same JSON, hash the
|
||||
// same bytes, and verify the same signature.
|
||||
let reconstructed = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let reconstructed_bytes = serde_json::to_vec(&reconstructed).unwrap();
|
||||
assert_eq!(
|
||||
reconstructed_bytes, payload_bytes,
|
||||
"the round-trip serialization must be byte-identical; \
|
||||
this is what makes gl's verify_signature succeed"
|
||||
);
|
||||
// Round 3 (P3 reviewer): byte-equality alone never calls
|
||||
// `identity::verify`, so a bad signature helper would pass.
|
||||
// Verify the computed signature against the reconstructed
|
||||
// bytes — the same check gl performs.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &reconstructed_bytes, &sig_bytes)
|
||||
.expect("well-formed v1 cert signature must verify");
|
||||
}
|
||||
|
||||
/// Reviewer 1 finding: the live signer must share the
|
||||
/// canonical-form literal so a regression in either side
|
||||
/// fails both. This test pins the shared builder's output
|
||||
/// against the literal a future v2 byte stream would have
|
||||
/// to break — if someone adds a `version` key to
|
||||
/// `v1_signing_payload`, every existing cert breaks verify,
|
||||
/// and this test is one of the canaries that fires.
|
||||
#[test]
|
||||
fn v1_signing_payload_has_no_version_key() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let obj = payload.as_object().expect("payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"v1 signing payload must not carry a `version` key — adding \
|
||||
one changes the bytes and breaks every existing cert's \
|
||||
signature. v2 certs build a different payload, not this one"
|
||||
);
|
||||
// Pin the exact set so a future field addition is caught.
|
||||
let expected: std::collections::BTreeSet<&str> =
|
||||
["new", "node", "old", "pusher", "ref", "repo_id", "ts"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
|
||||
assert_eq!(actual, expected, "v1 payload key set is frozen");
|
||||
}
|
||||
|
||||
/// Round 3 (P2 reviewer): from v2 onward the version belongs
|
||||
/// INSIDE the signed bytes. v1 stays the unversioned legacy
|
||||
/// shape; v2 carries `"version": 2` in the payload so a
|
||||
/// version flip without re-signing invalidates the signature
|
||||
/// instead of verifying cleanly under the other path.
|
||||
#[test]
|
||||
fn v2_signing_payload_binds_version_and_resists_downgrade() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let ts = "2026-07-22T00:00:00+00:00";
|
||||
let v1 = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2 = v2_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2_obj = v2.as_object().expect("v2 payload is a JSON object");
|
||||
assert_eq!(
|
||||
v2_obj.get("version"),
|
||||
Some(&serde_json::json!(2)),
|
||||
"v2 payload must carry the version inside the signed bytes"
|
||||
);
|
||||
let v1_bytes = serde_json::to_vec(&v1).unwrap();
|
||||
let v2_bytes = serde_json::to_vec(&v2).unwrap();
|
||||
assert_ne!(
|
||||
v1_bytes, v2_bytes,
|
||||
"v1 and v2 payloads must differ, otherwise a version flip is cryptographically invisible"
|
||||
);
|
||||
|
||||
// A signature over v1 must NOT verify as v2 and vice versa.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_v1 = kp.sign_b64(&v1_bytes);
|
||||
let sig_v1_bytes: [u8; 64] = URL_SAFE_NO_PAD.decode(&sig_v1).unwrap().try_into().unwrap();
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v1_bytes, &sig_v1_bytes).is_ok(),
|
||||
"v1 signature must verify under v1 bytes"
|
||||
);
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v2_bytes, &sig_v1_bytes).is_err(),
|
||||
"a v1 signature presented as v2 (downgrade/upgrade flip) must not verify"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live issuer stamps the version the shipped client can
|
||||
/// verify. The gl client refuses to verify v2 certs explicitly;
|
||||
/// this test pins that the version the ISSUING PATH claims
|
||||
/// agrees with the payload shape it actually signs.
|
||||
///
|
||||
/// Round 2: the prior form built a `RefCertificate` literal with
|
||||
/// `version: 1` and asserted `version == 1` — true no matter what the
|
||||
/// signer does, so flipping the issuer's version (or its payload shape)
|
||||
/// left it green. This one drives the actual
|
||||
/// `issue_ref_certificate` so a regression in the issuer flips the test.
|
||||
/// The DB row is created against `test_support::test_state`, which
|
||||
/// gives us a real `AppState` with a real node keypair.
|
||||
///
|
||||
/// Round 3: the issuer stamps v1 because the shipped `gl`
|
||||
/// verifier only supports v1 (stamping v2 shipped certs the
|
||||
/// client refuses). The binding to the live issuer is kept —
|
||||
/// flipping the stamp still turns this test red — without
|
||||
/// changing what production issues.
|
||||
#[sqlx::test]
|
||||
#[allow(clippy::async_yields_async)]
|
||||
async fn issuer_stamps_v1_over_v1_payload(pool: sqlx::PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let repo_id = "repo-issuer-observe";
|
||||
let ref_name = "refs/heads/main";
|
||||
let old = "0".repeat(40);
|
||||
let new = "a".repeat(40);
|
||||
let pusher = "did:key:z6MkPusher";
|
||||
|
||||
// Drive the live issuer and read the persisted row back by
|
||||
// id, so the test covers the stored `version` column and
|
||||
// not just the in-process return value.
|
||||
let cert =
|
||||
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
|
||||
.await
|
||||
.expect("issue_ref_certificate must succeed");
|
||||
|
||||
// The cert returned by the issuer is the source of truth
|
||||
// for both the version claim and the signed bytes.
|
||||
assert_eq!(cert.repo_id, repo_id);
|
||||
assert_eq!(cert.ref_name, ref_name);
|
||||
|
||||
// Cross-check 1: the issuer's claimed version must match
|
||||
// what the live function stamps. This assertion is bound
|
||||
// to the live function, not a hand-built literal:
|
||||
// flipping the stamp in `issue_ref_certificate` breaks it
|
||||
// through the actual call path.
|
||||
assert_eq!(
|
||||
cert.version, 1,
|
||||
"the live issuer must stamp v1 until a v2 verifier ships; \
|
||||
flipping the stamp breaks this assertion through the actual call path"
|
||||
);
|
||||
|
||||
// Cross-check 1b: the persisted row carries the same stamp.
|
||||
let stored = state
|
||||
.db
|
||||
.get_ref_certificate(&cert.id)
|
||||
.await
|
||||
.expect("persisted cert must be readable")
|
||||
.expect("persisted cert must exist");
|
||||
assert_eq!(
|
||||
stored.version, cert.version,
|
||||
"the stored version column must match the issued claim"
|
||||
);
|
||||
|
||||
// Cross-check 2: the signature on the cert must verify
|
||||
// against the v1 payload builder's bytes, not against some
|
||||
// other shape the issuer might have introduced. This is
|
||||
// what gl's `verify_signature` does for a v1 cert: rebuild
|
||||
// the payload from the cert's own fields and verify the
|
||||
// signature. If the issuer ever signs a different shape
|
||||
// while still claiming v1, every shipped client breaks —
|
||||
// and so does this.
|
||||
let rebuilt = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let vk = cert
|
||||
.node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &serde_json::to_vec(&rebuilt).unwrap(), &sig_bytes)
|
||||
.expect("a v1 cert's signature must verify against the v1 payload shape");
|
||||
|
||||
// Cross-check 3: the v1 signed payload must NOT contain a
|
||||
// version key — v2 is the future shape that adds one inside
|
||||
// the signed bytes (see `v2_signing_payload`). The v1
|
||||
// payload (which the cert is signed over) is frozen at 7
|
||||
// fields. A change that adds `version` to the v1 payload
|
||||
// breaks every existing cert and this test.
|
||||
let obj = rebuilt
|
||||
.as_object()
|
||||
.expect("rebuilt v1 payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"the v1 signing payload must not carry a version key — \
|
||||
adding one changes the bytes and breaks every existing \
|
||||
cert's signature"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1517
-50
File diff suppressed because it is too large.
Load diff
+2703
-88
File diff suppressed because it is too large.
Load diff
@@ -69,6 +69,12 @@ 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
|
||||
@@ -90,15 +96,30 @@ 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()) {
|
||||
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),
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +152,21 @@ 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,6 +242,49 @@ 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()
|
||||
@@ -348,6 +427,43 @@ 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
|
||||
|
||||
@@ -50,6 +50,16 @@ pub enum AppError {
|
||||
#[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),
|
||||
|
||||
@@ -59,6 +69,15 @@ pub enum AppError {
|
||||
#[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),
|
||||
|
||||
@@ -82,7 +101,7 @@ 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 {
|
||||
pub(crate) fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
sqlx::Error::PoolTimedOut
|
||||
@@ -100,7 +119,31 @@ impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
match err.downcast::<sqlx::Error>() {
|
||||
Ok(sql) => AppError::Db(sql),
|
||||
Err(err) => AppError::Internal(err),
|
||||
// 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,10 +204,42 @@ impl IntoResponse for AppError {
|
||||
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,
|
||||
@@ -201,16 +276,36 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
let mut body = 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, body).into_response();
|
||||
// Overloaded advertises when to retry. It rides the shared tail above for
|
||||
// its body/status, so the header is attached here rather than in a bespoke
|
||||
// early return — keeping the variant handled in exactly one place.
|
||||
if matches!(self, AppError::Overloaded(_)) {
|
||||
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"),
|
||||
@@ -327,4 +422,31 @@ mod tests {
|
||||
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,6 +61,27 @@ 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/
|
||||
@@ -108,19 +129,22 @@ 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>> {
|
||||
// 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")
|
||||
.args(["cat-file", "-e", &exact_ref])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file -e")?;
|
||||
if check.status.success() {
|
||||
return Ok(Some(id_or_prefix.to_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);
|
||||
}
|
||||
|
||||
// Prefix search: list all refs that start with the given string.
|
||||
let prefix_glob = format!("refs/gitlawb/issues/{id_or_prefix}*");
|
||||
// List all refs that start with the given string; the glob also matches the exact
|
||||
// ref. The id handed back is always git's own refname, never the request input.
|
||||
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
|
||||
let prefix_glob = format!("{exact_ref}*");
|
||||
let list = Command::new("git")
|
||||
.args(["for-each-ref", "--format=%(refname)", &prefix_glob])
|
||||
.current_dir(repo_path)
|
||||
@@ -132,25 +156,44 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
|
||||
}
|
||||
|
||||
let output = String::from_utf8_lossy(&list.stdout);
|
||||
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let matches: Vec<&str> = output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => Ok(None),
|
||||
1 => {
|
||||
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
|
||||
let full_id = matches[0]
|
||||
.trim()
|
||||
.strip_prefix("refs/gitlawb/issues/")
|
||||
.unwrap_or(matches[0].trim())
|
||||
.to_string();
|
||||
Ok(Some(full_id))
|
||||
// An exact id wins over longer ids that share it as a prefix, but only if its
|
||||
// object exists: for-each-ref also lists a ref whose blob is missing, so a
|
||||
// dangling exact ref falls through to the prefix rules below.
|
||||
let exact = match matches.iter().find(|m| **m == exact_ref) {
|
||||
Some(m) => {
|
||||
let check = Command::new("git")
|
||||
.args(["cat-file", "-e", m])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file -e")?;
|
||||
check.status.success().then_some(*m)
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"ambiguous issue prefix '{}': matches {} issues",
|
||||
id_or_prefix,
|
||||
matches.len()
|
||||
),
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let found = match exact {
|
||||
Some(m) => m,
|
||||
None => match matches.as_slice() {
|
||||
[] => return Ok(None),
|
||||
[m] => *m,
|
||||
_ => anyhow::bail!(
|
||||
"ambiguous issue prefix '{}': matches {} issues",
|
||||
id_or_prefix,
|
||||
matches.len()
|
||||
),
|
||||
},
|
||||
};
|
||||
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
|
||||
let full_id = found
|
||||
.strip_prefix("refs/gitlawb/issues/")
|
||||
.unwrap_or(found)
|
||||
.to_string();
|
||||
Ok(Some(full_id))
|
||||
}
|
||||
|
||||
/// Close an issue by updating its status to "closed" in the git ref.
|
||||
@@ -234,6 +277,43 @@ mod tests {
|
||||
assert_eq!(resolved, Some(full_id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_exact_id_wins_over_longer_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
create_issue(dir.path(), "abc12345", r#"{"status":"open"}"#).unwrap();
|
||||
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open"}"#).unwrap();
|
||||
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
|
||||
assert_eq!(resolved, Some("abc12345".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_dangling_exact_id_does_not_win_over_longer_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
create_issue(dir.path(), "abc12345", r#"{"status":"open","n":1}"#).unwrap();
|
||||
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open","n":2}"#).unwrap();
|
||||
// Dangle the exact ref: the ref stays listed by for-each-ref, but its blob is gone.
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "refs/gitlawb/issues/abc12345"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8(out.stdout).unwrap();
|
||||
let hash = hash.trim();
|
||||
std::fs::remove_file(
|
||||
dir.path()
|
||||
.join(".git/objects")
|
||||
.join(&hash[..2])
|
||||
.join(&hash[2..]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_issue_id(dir.path(), "abc12345");
|
||||
assert!(result.unwrap_err().to_string().contains("ambiguous"));
|
||||
assert!(close_issue(dir.path(), "abc12345").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_prefix_matches_unique() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -249,6 +329,35 @@ 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();
|
||||
@@ -278,6 +387,27 @@ 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,4 +1,5 @@
|
||||
pub mod issues;
|
||||
pub mod publish;
|
||||
pub mod push_delta;
|
||||
pub mod repo_store;
|
||||
pub mod smart_http;
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! 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 filter (`visibility_pack::replicable_objects_fail_closed`)
|
||||
//! the fail-closed blob-and-tree 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
|
||||
@@ -211,8 +211,9 @@ 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) from commits/trees
|
||||
/// (structural, never withheld) without typing the candidate list itself.
|
||||
/// 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,
|
||||
@@ -244,24 +245,48 @@ 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).
|
||||
/// 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(list_all_objects_with_type(repo_path, git_bin, deadline)?
|
||||
.into_iter()
|
||||
.filter(|(_, ty)| ty == "blob")
|
||||
.map(|(oid, _)| oid)
|
||||
.collect())
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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 filter, because the reachable-only withheld set
|
||||
/// cannot classify dangling blobs (#99).
|
||||
/// apply the fail-closed blob-and-tree filter, because the reachable-only withheld set
|
||||
/// cannot classify dangling blobs or trees (#99, #172).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinCandidateSet {
|
||||
pub candidates: Vec<String>,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,13 +14,13 @@ use tokio::process::Command;
|
||||
/// the work they admitted, so admission is released only when that work is truly
|
||||
/// done — not the instant the handler future drops on a client disconnect.
|
||||
///
|
||||
/// A move-only wrapper: no methods beyond construction and `Drop`. The handler
|
||||
/// MOVEs its permits in and keeps no copy (a retained copy would drop early and
|
||||
/// release admission the moment the future is dropped, defeating the guard). It is
|
||||
/// threaded into `drive_git_child`, whose [`KillGroupOnDrop`] moves it into the
|
||||
/// detached reaper on disconnect, so both permits drop only after the process group
|
||||
/// is confirmed reaped (`kill(-pgid,0)==ESRCH`) rather than while the group is still
|
||||
/// alive holding PIDs past the concurrency cap (#174 P1-a, plain-spawn residual).
|
||||
/// A drop-only wrapper: nothing here inspects what it holds. The handler MOVEs its
|
||||
/// permits in and keeps no copy (a retained copy would drop early and release admission
|
||||
/// the moment the future is dropped, defeating the guard). It is threaded into
|
||||
/// `drive_git_child`, whose [`KillGroupOnDrop`] moves it into the detached reaper on
|
||||
/// disconnect, so both permits drop only after the process group is confirmed reaped
|
||||
/// (`kill(-pgid,0)==ESRCH`) rather than while the group is still alive holding PIDs past
|
||||
/// the concurrency cap (#174 P1-a, plain-spawn residual).
|
||||
///
|
||||
/// The `be0cdd6` path-scoped upload-pack walk already applies this discipline by
|
||||
/// moving its permits into the `spawn_blocking`; this generalizes it to the plain
|
||||
@@ -33,6 +33,8 @@ pub struct AdmissionGuard {
|
||||
// 'static` so the guard can move into the detached reaper task.
|
||||
_global: Option<Box<dyn Send + 'static>>,
|
||||
_caller: Option<Box<dyn Send + 'static>>,
|
||||
// Any further work-scoped hold that must outlive the process group; see `with_hold`.
|
||||
_hold: Option<Box<dyn Send + 'static>>,
|
||||
// Per-repo write lease (#174 U2/F3), `Some` ONLY on the receive-pack write path
|
||||
// (via [`with_lease`](Self::with_lease)); `None` on every read path and every
|
||||
// non-receive-pack write path. It rides this guard into `KillGroupOnDrop`'s detached
|
||||
@@ -54,10 +56,26 @@ impl AdmissionGuard {
|
||||
Self {
|
||||
_global: Some(Box::new(global)),
|
||||
_caller: caller.map(|c| Box::new(c) as Box<dyn Send + 'static>),
|
||||
_hold: None,
|
||||
_lease: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a further hold that must not be released until the process group is
|
||||
/// reaped, and ride it through the same seam as the permits.
|
||||
///
|
||||
/// The push handler uses this for the repo WRITE LOCK (#173 F2). Its
|
||||
/// `guard.release(..)` line is only reached if `receive_pack` returns, so on a client
|
||||
/// disconnect the lock used to be freed by the dropped future while the detached
|
||||
/// reaper was still giving the group its SIGTERM grace, admitting a second
|
||||
/// `receive-pack` on the same repo. Carrying the lock here holds it until the group
|
||||
/// is ESRCH-confirmed gone, which is the same invariant the timeout path already
|
||||
/// keeps ("a caller releasing a write lock can't race them", `reap_group_on_timeout`).
|
||||
pub fn with_hold(mut self, hold: impl Send + 'static) -> Self {
|
||||
self._hold = Some(Box::new(hold));
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the per-repo write lease (#174 U2/F3). Called ONLY on the receive-pack
|
||||
/// write path, so the lease rides the disconnect reaper; read paths never call this.
|
||||
pub fn with_lease(mut self, lease: crate::state::RepoWriteLease) -> Self {
|
||||
@@ -88,11 +106,11 @@ pub async fn info_refs(
|
||||
|
||||
let mut command = Command::new(git_bin);
|
||||
command
|
||||
.arg(service_to_command(service))
|
||||
.arg(service_to_command(service)?)
|
||||
.arg("--stateless-rpc")
|
||||
.arg("--advertise-refs")
|
||||
.arg(repo_path);
|
||||
// No request body — advertise-refs does not read stdin.
|
||||
// No request body: advertise-refs does not read stdin.
|
||||
let (stdout, admission) =
|
||||
drive_git_child(command, Bytes::new(), timeout, "advertise-refs", admission).await?;
|
||||
// Single-stage op: the advertisement's group is reaped by now; release admission
|
||||
@@ -408,7 +426,7 @@ async fn run_git_service(
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut command = Command::new(git_bin);
|
||||
command
|
||||
.arg(service_to_command(service))
|
||||
.arg(service_to_command(service)?)
|
||||
.arg("--stateless-rpc")
|
||||
.arg(repo_path);
|
||||
let (out, admission) = drive_git_child(command, input, timeout, service, admission).await?;
|
||||
@@ -560,11 +578,13 @@ async fn drive_git_child(
|
||||
Ok((out, admission))
|
||||
}
|
||||
|
||||
fn service_to_command(service: &str) -> &str {
|
||||
/// Map a validated service name to its git subcommand. Only the two fixed
|
||||
/// subcommands can come out, so an unrecognized service can never reach git's argv.
|
||||
fn service_to_command(service: &str) -> Result<&'static str> {
|
||||
match service {
|
||||
"git-upload-pack" => "upload-pack",
|
||||
"git-receive-pack" => "receive-pack",
|
||||
_ => service,
|
||||
"git-upload-pack" => Ok("upload-pack"),
|
||||
"git-receive-pack" => Ok("receive-pack"),
|
||||
other => bail!("unknown git service: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,14 +679,15 @@ pub async fn build_filtered_pack(
|
||||
command
|
||||
.args(["pack-objects", "--stdout"])
|
||||
.current_dir(repo_path);
|
||||
drive_git_child(
|
||||
let (out, admission) = drive_git_child(
|
||||
command,
|
||||
Bytes::from(data),
|
||||
deadline.saturating_duration_since(Instant::now()),
|
||||
"pack-objects",
|
||||
admission,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok((out, admission))
|
||||
}
|
||||
|
||||
/// Serve a clone/fetch with the withheld blobs removed from the response pack.
|
||||
@@ -847,6 +868,24 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn service_to_command_maps_allowlist_and_rejects_passthrough() {
|
||||
assert_eq!(
|
||||
service_to_command("git-upload-pack").unwrap(),
|
||||
"upload-pack"
|
||||
);
|
||||
assert_eq!(
|
||||
service_to_command("git-receive-pack").unwrap(),
|
||||
"receive-pack"
|
||||
);
|
||||
for bad in ["git-fetch-pack", "--version", "upload-pack", ""] {
|
||||
assert!(
|
||||
service_to_command(bad).is_err(),
|
||||
"must reject passthrough for {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// List OIDs in a pack by writing it to a temp dir and running verify-pack.
|
||||
pub(super) fn pack_object_ids(pack: &[u8]) -> std::collections::HashSet<String> {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1880,6 +1919,143 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// #174 U1 (R2, KTD3, RED-before/GREEN-after): the path-scoped filtered-pack serve
|
||||
// must hold read + per-caller admission until its pack-objects process group is
|
||||
// reaped on a client disconnect, exactly as the plain upload_pack path does. Before
|
||||
// the fix build_filtered_pack took no AdmissionGuard and the handler's `_hold`
|
||||
// permits dropped the instant the request future was dropped, so disconnect-spam on
|
||||
// a path-scoped repo could hold PIDs past the concurrency cap while the permits were
|
||||
// already free (#174 P1-a, on the filtered path the plain path had already closed).
|
||||
//
|
||||
// A real AdmissionGuard built from two owned semaphore permits rides
|
||||
// rev-list -> pack-objects. We drive the future until pack-objects has forked its
|
||||
// grandchild (the streaming pack-writer stand-in), assert the permits are still held
|
||||
// mid-serve, then DROP the future (client disconnect) and assert the permits are
|
||||
// released only AFTER the group is ESRCH-confirmed gone — never while it is alive.
|
||||
// Goes RED if the guard is not threaded into the pack-objects stage (it would then
|
||||
// drop after rev-list, freeing the permits mid-serve or on the bare future drop).
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn filtered_pack_holds_admission_until_group_reaped_on_disconnect() {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pidfile = tmp.path().join("pids");
|
||||
// rev-list returns one oid fast; pack-objects forks a grandchild (the streaming
|
||||
// writer stand-in), records leader+grandchild pids, then hangs so the future
|
||||
// parks mid-serve with the guard owned by the pack-objects KillGroupOnDrop. The
|
||||
// grandchild inherits (holds open) the stdout pipe, so drive_git_child's
|
||||
// read_to_end blocks and the future stays pending until we drop it.
|
||||
let body = format!(
|
||||
"#!/bin/sh\ncase \"$1\" in\n rev-list) echo deadbeefdeadbeefdeadbeefdeadbeefdeadbeef ;;\n pack-objects) sleep 300 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nwait ;;\n *) exit 1 ;;\nesac\n",
|
||||
pidfile.display()
|
||||
);
|
||||
let git_bin = write_fake_git(tmp.path(), &body);
|
||||
let withheld = HashSet::new();
|
||||
|
||||
// Retry the fake-git spawn race like the sibling disconnect test; each attempt
|
||||
// gets a FRESH semaphore so a dropped losing attempt can't skew the winning
|
||||
// attempt's permit accounting. Keep the winning attempt's future PENDING so the
|
||||
// drop below exercises the client-disconnect teardown.
|
||||
let (fut, sem, leader, grandchild) = {
|
||||
let mut attempt = 0u64;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let _ = std::fs::remove_file(&pidfile);
|
||||
// Semaphore(4): two owned permits model the handler's global-read +
|
||||
// per-caller admission, leaving 2 available while the op is in flight.
|
||||
let sem = Arc::new(Semaphore::new(4));
|
||||
let g = sem.clone().try_acquire_owned().unwrap();
|
||||
let c = sem.clone().try_acquire_owned().unwrap();
|
||||
let admission = AdmissionGuard::new(g, Some(c));
|
||||
let mut fut = Box::pin(build_filtered_pack(
|
||||
git_bin.to_str().unwrap(),
|
||||
tmp.path(),
|
||||
&withheld,
|
||||
Duration::from_secs(60),
|
||||
Some(admission),
|
||||
));
|
||||
// Advance the future a slice at a time until the fake records its pids
|
||||
// (i.e. pack-objects is running). `Ok(_)` means the future returned
|
||||
// before the pidfile appeared (spawn error / early exit); stop polling
|
||||
// then, since re-polling a completed future panics.
|
||||
let mut pids = None;
|
||||
for _ in 0..500 {
|
||||
let finished = tokio::time::timeout(Duration::from_millis(10), &mut fut)
|
||||
.await
|
||||
.is_ok();
|
||||
if let Some(p) = read_two_pids(&pidfile) {
|
||||
pids = Some(p);
|
||||
break;
|
||||
}
|
||||
if finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match pids {
|
||||
Some((l, gch)) => break (fut, sem, l, gch),
|
||||
None => {
|
||||
// Transient spawn miss: drop the still-armed future so its guard
|
||||
// reaps anything that spawned (and returns the permits), then
|
||||
// back off before retrying.
|
||||
drop(fut);
|
||||
assert!(
|
||||
attempt < FAKE_GIT_RETRY_ATTEMPTS,
|
||||
"fake git failed to reach the pack-objects stage after \
|
||||
{FAKE_GIT_RETRY_ATTEMPTS} attempts (persistent failure, \
|
||||
not a transient parallel-runner miss)"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
FAKE_GIT_BACKOFF_STEP_MS * attempt,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let _cleanup = ReapOnPanic(vec![leader, grandchild]);
|
||||
assert!(alive(grandchild), "grandchild must be running mid-serve");
|
||||
|
||||
// Mid-serve: the pack-objects stage owns the guard, so the two permits are still
|
||||
// held. A build that dropped the guard after rev-list (unthreaded pack-objects
|
||||
// stage) would have freed them here.
|
||||
assert_eq!(
|
||||
sem.available_permits(),
|
||||
2,
|
||||
"admission permits must be held while the filtered serve is in flight"
|
||||
);
|
||||
|
||||
// Client disconnect: drop the request future. The pack-objects KillGroupOnDrop
|
||||
// must tear the group down AND hold the permits until the group is
|
||||
// ESRCH-confirmed gone, releasing them only then.
|
||||
drop(fut);
|
||||
|
||||
let mut released_while_alive = false;
|
||||
let mut released_after_reap = false;
|
||||
for _ in 0..500 {
|
||||
let released = sem.available_permits() == 4;
|
||||
let group_alive = alive(grandchild);
|
||||
if released && group_alive {
|
||||
released_while_alive = true;
|
||||
}
|
||||
if released && !group_alive {
|
||||
released_after_reap = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(
|
||||
!released_while_alive,
|
||||
"admission permits were released while the git group was still alive — the \
|
||||
path-scoped concurrency-cap bypass (#174 P1-a) is open"
|
||||
);
|
||||
assert!(
|
||||
released_after_reap,
|
||||
"admission permits must be released once the group is reaped on disconnect"
|
||||
);
|
||||
}
|
||||
|
||||
// ── F1: filtered-serve admission threaded through both pack stages ──────
|
||||
//
|
||||
// The handler-level disconnect regression lives in api/repos.rs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Initialize a new bare git repository with SHA-1 object format (default).
|
||||
@@ -431,7 +431,9 @@ fn batch_check_probe(
|
||||
/// never on any English `fatal:` wording (KTD-4): a genuinely-absent object is the only
|
||||
/// `Ok(None)` (404) path. A probe that could not honestly examine the store is a
|
||||
/// [`ProbeError`], split by object-store readability into `Transient` (retryable 503)
|
||||
/// and `Deterministic` (terminal 500) so the serve path can shed the right status.
|
||||
/// and `Deterministic` (terminal 500) so the serve path can shed the right status. The
|
||||
/// deadline itself arrives as a `Transient` fault, so the handler marks the search
|
||||
/// truncated rather than reporting a false not-found (#173 round-10 R1/KTD2).
|
||||
pub fn object_type_bounded(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
@@ -599,6 +601,63 @@ pub(crate) fn object_store_readable_store_wide(repo_path: &Path) -> bool {
|
||||
object_store_readable(repo_path, "")
|
||||
}
|
||||
|
||||
/// Bounded `git cat-file -s` size read for the `GET /ipfs/{cid}` serve path (#173
|
||||
/// round-10, R1/KTD2): reads the object size WITHOUT its content (so an oversized object
|
||||
/// is rejected before it is buffered, #173 F6), under
|
||||
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so a wedged size
|
||||
/// read is reaped at `deadline` instead of pinning the held /ipfs walk admission.
|
||||
/// `Ok(n)` on success, and otherwise a [`ProbeError`] in the same vocabulary the type and
|
||||
/// content stages use: a reaped child is `Transient` (retryable), and every other failure
|
||||
/// goes through [`classify_store_fault`], so it is `Transient` when the object store is
|
||||
/// not readable and `Deterministic` when it is.
|
||||
///
|
||||
/// There is deliberately NO absence value (#173 round 12). This returned
|
||||
/// `Ok(None)` for every non-timeout failure, which `gate_and_serve` reads as a verified
|
||||
/// absence and does not taint the search for, so a corrupt object, an unreadable pack, or
|
||||
/// a failed spawn handed an authorized caller a definitive 404 instead of the retryable
|
||||
/// 503 tail. Absence is also not this stage's question: the caller has already had a
|
||||
/// `Present` verdict from [`object_type_bounded`], so an object that cannot be sized here
|
||||
/// is a fault, not a not-found, and the one honest exception (a concurrent gc between the
|
||||
/// two stages) is classified by store readability like any other. Making the absence
|
||||
/// unrepresentable is what keeps the next caller from reintroducing the swallow.
|
||||
///
|
||||
/// Takes the shared `deadline` rather than its own timeout so a caller that pairs this
|
||||
/// size check with a later read spends ONE budget across the pair, not one per stage.
|
||||
pub fn object_size_bounded(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> std::result::Result<u64, ProbeError> {
|
||||
match crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "-s", sha256_hex],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
) {
|
||||
Ok(out) => {
|
||||
let text = String::from_utf8_lossy(&out);
|
||||
text.trim().parse::<u64>().map_err(|e| {
|
||||
// git exited 0 but did not print a size: the store answered something
|
||||
// this code cannot read, which is a fault and never an absence.
|
||||
classify_store_fault(
|
||||
repo_path,
|
||||
sha256_hex,
|
||||
anyhow::anyhow!("unparseable `cat-file -s` output {:?}: {e}", text.trim()),
|
||||
)
|
||||
})
|
||||
}
|
||||
// The watchdog reaped the child at `deadline`; retryable whatever the store looks
|
||||
// like, so it is routed before readability gets a say (same rule as the content
|
||||
// stage in `read_object_bounded`).
|
||||
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => {
|
||||
Err(ProbeError::Transient(e))
|
||||
}
|
||||
Err(e) => Err(classify_store_fault(repo_path, sha256_hex, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded, reaped variant of [`read_object_content`] for the async `/ipfs` serve
|
||||
/// path (#174 F3). Same teardown guarantees as [`object_type_bounded`].
|
||||
pub fn read_object_content_bounded(
|
||||
@@ -687,13 +746,149 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
|
||||
Ok(Some((obj_type, content)))
|
||||
}
|
||||
|
||||
/// Outcome of validating a stored branch-field value.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GitRefValidationError {
|
||||
/// Caller supplied a name that is not a valid local branch name.
|
||||
Invalid(String),
|
||||
/// Git could not be executed to validate the name.
|
||||
GitUnavailable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GitRefValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Invalid(msg) | Self::GitUnavailable(msg) => f.write_str(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject revision shorthands and pseudoref names that `git check-ref-format
|
||||
/// --branch` accepts but that git later interprets as symbolic revisions rather
|
||||
/// than literal local branch names.
|
||||
fn reject_revision_shorthand(name: &str) -> Option<&'static str> {
|
||||
if name == "@" {
|
||||
return Some("branch ref must not be a revision shorthand");
|
||||
}
|
||||
if name.starts_with("@{") {
|
||||
return Some("branch ref must not be a reflog revision expression");
|
||||
}
|
||||
const PSEUDOREFS: &[&str] = &[
|
||||
"FETCH_HEAD",
|
||||
"ORIG_HEAD",
|
||||
"MERGE_HEAD",
|
||||
"CHERRY_PICK_HEAD",
|
||||
"RERERE_MERGE_HEAD",
|
||||
"REBASE_HEAD",
|
||||
"REVERT_HEAD",
|
||||
"BISECT_HEAD",
|
||||
"AUTO_MERGE",
|
||||
];
|
||||
if PSEUDOREFS.contains(&name) {
|
||||
return Some("branch ref must not be a git pseudoref name");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a git *branch name* before it is stored or passed to git subprocesses.
|
||||
/// Delegates to `git check-ref-format --branch`, then rejects fully qualified
|
||||
/// ref paths, revision-namespace shorthands (`heads/`, `tags/`, `remotes/`), and
|
||||
/// symbolic names that git would reinterpret at the sink.
|
||||
///
|
||||
/// At diff/merge sinks, validated short names are passed as `refs/heads/{name}` so
|
||||
/// grammar-valid revision shorthands cannot retarget another ref namespace. The
|
||||
/// merge worktree checks out the local branch name instead, so merge commits
|
||||
/// advance refs/heads/{target}. Symbolic revision names (`HEAD`), option-shaped
|
||||
/// trailing dots, and other invalid forms are rejected by check-ref-format itself.
|
||||
/// Revision shorthands that pass the grammar check (`@`, `@{-1}`, pseudorefs) are
|
||||
/// rejected explicitly because git treats them as symbolic revisions when bare.
|
||||
///
|
||||
/// Storage boundaries call this via [`validate_git_ref_with_git`] with the configured
|
||||
/// git binary. Sink functions call [`validate_git_ref`] (system `git`) then prefix
|
||||
/// before building argv, so the property holds for every caller and every row,
|
||||
/// including legacy rows and any writer that skipped the boundary check.
|
||||
pub fn validate_git_ref(name: &str) -> std::result::Result<(), GitRefValidationError> {
|
||||
validate_git_ref_with_git("git", name)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_git_ref_with_git(
|
||||
git_bin: &str,
|
||||
name: &str,
|
||||
) -> std::result::Result<(), GitRefValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(GitRefValidationError::Invalid(
|
||||
"branch ref must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if name.starts_with("refs/") {
|
||||
return Err(GitRefValidationError::Invalid(
|
||||
"branch ref must be a local branch name, not a fully qualified ref".into(),
|
||||
));
|
||||
}
|
||||
if name.starts_with("heads/") || name.starts_with("tags/") || name.starts_with("remotes/") {
|
||||
return Err(GitRefValidationError::Invalid(
|
||||
"branch ref must be a local branch name, not a revision shorthand".into(),
|
||||
));
|
||||
}
|
||||
if let Some(reason) = reject_revision_shorthand(name) {
|
||||
return Err(GitRefValidationError::Invalid(reason.into()));
|
||||
}
|
||||
// allow-unbounded-git: stateless check-ref-format on a user-supplied name; no repo acquire or concurrency permit
|
||||
let output = Command::new(git_bin)
|
||||
.args(["check-ref-format", "--branch", name])
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
// allow-unbounded-git: stateless check-ref-format on user-supplied name; no repo acquire
|
||||
GitRefValidationError::GitUnavailable(format!(
|
||||
"failed to run git check-ref-format: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(GitRefValidationError::Invalid(stderr.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject both refs at the sink, so an option-shaped ref can never reach a git
|
||||
/// argv element regardless of how it was stored.
|
||||
fn map_ref_validation_to_anyhow(which: &str, err: GitRefValidationError) -> anyhow::Error {
|
||||
match err {
|
||||
GitRefValidationError::Invalid(msg) => {
|
||||
anyhow::anyhow!("invalid {which} branch ref: {msg}")
|
||||
}
|
||||
GitRefValidationError::GitUnavailable(msg) => anyhow::anyhow!("{msg}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn guard_refs(target_branch: &str, source_branch: &str) -> Result<()> {
|
||||
validate_git_ref(target_branch).map_err(|e| map_ref_validation_to_anyhow("target", e))?;
|
||||
validate_git_ref(source_branch).map_err(|e| map_ref_validation_to_anyhow("source", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stored short branch names are passed to git as explicit local-branch refs so a
|
||||
/// syntax-valid name cannot be reinterpreted as a revision shorthand at the sink.
|
||||
fn local_branch_ref(short_name: &str) -> String {
|
||||
format!("refs/heads/{short_name}")
|
||||
}
|
||||
|
||||
/// Get the diff between two branches: changes on source_branch not in target_branch.
|
||||
pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -> Result<String> {
|
||||
guard_refs(target_branch, source_branch)?;
|
||||
let target = local_branch_ref(target_branch);
|
||||
let source = local_branch_ref(source_branch);
|
||||
let output = Command::new("git")
|
||||
.args(["diff", &format!("{target_branch}...{source_branch}")])
|
||||
.args(["diff", &format!("{target}...{source}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git diff")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git diff failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
@@ -706,13 +901,11 @@ pub fn branch_diff_names(
|
||||
target_branch: &str,
|
||||
source_branch: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
guard_refs(target_branch, source_branch)?;
|
||||
let target = local_branch_ref(target_branch);
|
||||
let source = local_branch_ref(source_branch);
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
&format!("{target_branch}...{source_branch}"),
|
||||
])
|
||||
.args(["diff", "--name-only", "-z", &format!("{target}...{source}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git diff --name-only")?;
|
||||
@@ -740,18 +933,78 @@ pub fn merge_branch(
|
||||
author_did: &str,
|
||||
pr_title: &str,
|
||||
) -> Result<String> {
|
||||
guard_refs(target_branch, source_branch)?;
|
||||
let source_ref = local_branch_ref(source_branch);
|
||||
let target_ref = local_branch_ref(target_branch);
|
||||
let worktree_path = repo_path.join("_merge_worktree");
|
||||
|
||||
// Clean up any leftover worktree
|
||||
if worktree_path.exists() {
|
||||
let remove_worktree = || {
|
||||
// Two --force flags, not one. A single --force refuses a LOCKED worktree
|
||||
// ("cannot remove a locked working tree") and `git worktree prune` skips
|
||||
// locked registrations too, so with one flag a locked leftover survives
|
||||
// the whole cleanup and wedges this endpoint for good. With both flags a
|
||||
// separate prune adds nothing: over every leftover state that can reach
|
||||
// here (registered/unregistered x directory present/gone/locked/corrupt)
|
||||
// `remove -f -f` plus the removal below already clears the registration.
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", "_merge_worktree"])
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
"--force",
|
||||
"_merge_worktree",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output();
|
||||
let _ = std::fs::remove_dir_all(&worktree_path);
|
||||
};
|
||||
|
||||
// Clean up any leftover worktree. This cannot be gated on the directory
|
||||
// existing: a registration whose directory is gone is the one state
|
||||
// `worktree add` refuses outright ("missing but already registered"), and
|
||||
// it is reachable from the cleanup above, so gating on .exists() would let
|
||||
// one failed removal wedge every later merge on this repo.
|
||||
remove_worktree();
|
||||
|
||||
// The merge must land on the LOCAL target branch, so the worktree has to end
|
||||
// up attached to exactly refs/heads/{target}. validate_git_ref proves
|
||||
// branch-name grammar only: it does not prove refs/heads/{target} exists,
|
||||
// and it cannot stop git's revision DWIM rules from resolving the same
|
||||
// short name in another namespace (refs/tags/{name}, refs/remotes/...). If
|
||||
// DWIM won, `worktree add` would check out a detached HEAD at the foreign
|
||||
// ref, the merge would succeed on that disposable HEAD, and cleanup would
|
||||
// discard the commit without ever advancing refs/heads/{target}.
|
||||
//
|
||||
// (1) Require the exact local ref before creating the worktree. show-ref
|
||||
// --verify matches the full refname literally — no DWIM, no abbreviation —
|
||||
// so a same-named tag with no local branch fails here instead of being
|
||||
// silently resolved.
|
||||
//
|
||||
// allow-unbounded-git: read-only `show-ref --verify` on one literal refname,
|
||||
// no network and no object walk. It also sits alongside the pre-existing bare
|
||||
// `git diff`, `git merge` and `git rev-parse` spawns in this same function,
|
||||
// all under the same acquire_write lock, so bounding only this one would not
|
||||
// change the function's exposure. Bounding merge_branch as a whole is a
|
||||
// separate change against already-reviewed code, tracked as follow-up.
|
||||
let target_exists = Command::new("git")
|
||||
.args(["show-ref", "--verify", "--quiet", &target_ref])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git show-ref")?;
|
||||
if !target_exists.status.success() {
|
||||
bail!("target branch {target_ref} does not exist as a local branch");
|
||||
}
|
||||
|
||||
// Create worktree on target branch
|
||||
// (2) Check out the local target branch in the worktree. The bare short
|
||||
// name is what makes git attach HEAD to the branch (passing
|
||||
// refs/heads/{name} would detach, so a successful merge would not advance
|
||||
// refs/heads/{target}); step (1) guarantees the branch exists, so an
|
||||
// attached checkout is the only acceptable outcome — verified right below.
|
||||
//
|
||||
// allow-unbounded-git: local `worktree add`, no network. Same reasoning as the
|
||||
// show-ref spawn above: the surrounding function's other git children are
|
||||
// already bare under this lock, so this one is not the thing that would need
|
||||
// bounding first.
|
||||
let wt = Command::new("git")
|
||||
.args(["worktree", "add", "_merge_worktree", target_branch])
|
||||
.current_dir(repo_path)
|
||||
@@ -764,12 +1017,31 @@ pub fn merge_branch(
|
||||
);
|
||||
}
|
||||
|
||||
// The worktree's HEAD must be a symbolic ref to exactly refs/heads/{target}.
|
||||
// A detached or differently-attached HEAD means git resolved the name as
|
||||
// something other than the local target branch; a merge committed on it
|
||||
// would be thrown away by cleanup, so refuse before merging rather than
|
||||
// relying on any denylist of symbolic shapes.
|
||||
//
|
||||
// allow-unbounded-git: read-only `symbolic-ref` resolving one local HEAD, no
|
||||
// network. Same reasoning as the two spawns above.
|
||||
let head_ref = Command::new("git")
|
||||
.args(["symbolic-ref", "--quiet", "HEAD"])
|
||||
.current_dir(&worktree_path)
|
||||
.output()
|
||||
.context("failed to run git symbolic-ref")?;
|
||||
let head_ref_name = String::from_utf8_lossy(&head_ref.stdout).trim().to_string();
|
||||
if !head_ref.status.success() || head_ref_name != target_ref {
|
||||
remove_worktree();
|
||||
bail!("merge worktree is not attached to {target_ref} (HEAD is {head_ref_name:?})");
|
||||
}
|
||||
|
||||
// Run merge in worktree
|
||||
let merge = Command::new("git")
|
||||
.args([
|
||||
"merge",
|
||||
"--no-ff",
|
||||
source_branch,
|
||||
&source_ref,
|
||||
"-m",
|
||||
&format!(
|
||||
"Merge branch '{}' into {} ({})",
|
||||
@@ -787,11 +1059,7 @@ pub fn merge_branch(
|
||||
let success = merge.status.success();
|
||||
|
||||
// Always remove worktree
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", "_merge_worktree"])
|
||||
.current_dir(repo_path)
|
||||
.output();
|
||||
let _ = std::fs::remove_dir_all(&worktree_path);
|
||||
remove_worktree();
|
||||
|
||||
if !success {
|
||||
bail!(
|
||||
@@ -800,18 +1068,42 @@ pub fn merge_branch(
|
||||
);
|
||||
}
|
||||
|
||||
// Get new HEAD of target branch
|
||||
// (3) The merge only counts if the exact target ref now points at a commit.
|
||||
// Plain `rev-parse` echoes an unresolvable token back on stdout with a
|
||||
// nonzero exit status, so an unchecked call here would hand the literal
|
||||
// string "refs/heads/{target}" upward as an apparently valid merge SHA and
|
||||
// the caller would mark the PR merged and fire the webhook. --verify plus
|
||||
// ^{commit} plus an explicit status check turn "target ref missing or not a
|
||||
// commit" into a hard failure before any of that happens.
|
||||
let head = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{target_branch}")])
|
||||
.args(["rev-parse", "--verify", &format!("{target_ref}^{{commit}}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to get merge commit")?;
|
||||
if !head.status.success() {
|
||||
bail!(
|
||||
"merge completed but {target_ref} does not resolve to a commit: {}",
|
||||
String::from_utf8_lossy(&head.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&head.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
|
||||
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
|
||||
///
|
||||
/// UNVALIDATED, and test-only for that reason. It performs the bare join with no
|
||||
/// allowlist, no containment check and no component walk, so it accepts an empty
|
||||
/// or traversal-bearing name. Production code must use
|
||||
/// `git::repo_store::validated_repo_disk_path`; the last production caller was
|
||||
/// `fork_repo`, which used this and turned a `{"name":""}` request into a repo
|
||||
/// row at `<repos_dir>/<owner_slug>/.git`. The `cfg(test)` is the guard: a new
|
||||
/// production caller fails to compile rather than silently skipping the barrier.
|
||||
///
|
||||
/// Tests keep it because a fixture that must escape `repos_dir` cannot be built
|
||||
/// with the validated form.
|
||||
#[cfg(test)]
|
||||
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> std::path::PathBuf {
|
||||
// Sanitize the DID for use as a directory name
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
repos_dir.join(owner_slug).join(format!("{repo_name}.git"))
|
||||
@@ -819,7 +1111,73 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::branch_diff_names;
|
||||
use super::{validate_git_ref, validate_git_ref_with_git, GitRefValidationError};
|
||||
|
||||
#[test]
|
||||
fn validate_git_ref_accepts_normal_branch_names() {
|
||||
for good in [
|
||||
"main",
|
||||
"feature/foo",
|
||||
"release-1.2",
|
||||
"v1.0.0",
|
||||
"user/fix-bug",
|
||||
"feature@",
|
||||
"user@host",
|
||||
] {
|
||||
assert!(
|
||||
validate_git_ref(good).is_ok(),
|
||||
"{good:?} should be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_git_ref_rejects_option_injection_and_malformed_refs() {
|
||||
for bad in [
|
||||
"",
|
||||
"HEAD",
|
||||
"@",
|
||||
"@{-1}",
|
||||
"FETCH_HEAD",
|
||||
"MERGE_HEAD",
|
||||
"feature.",
|
||||
"feature/x.",
|
||||
"refs/heads/main",
|
||||
"refs/tags/v1",
|
||||
"refs/heads/--output=/tmp/x",
|
||||
"heads/main",
|
||||
"tags/v1",
|
||||
"remotes/origin/main",
|
||||
"--output=/tmp/x",
|
||||
"-rf",
|
||||
"a b",
|
||||
"a..b",
|
||||
"a~b",
|
||||
"refs/heads/@{x}",
|
||||
"foo.lock",
|
||||
"/leading",
|
||||
"trailing/",
|
||||
"a//b",
|
||||
] {
|
||||
let err = validate_git_ref(bad).expect_err("{bad:?} should be rejected");
|
||||
assert!(
|
||||
matches!(err, GitRefValidationError::Invalid(_)),
|
||||
"{bad:?} should be an invalid-name rejection, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_git_ref_spawn_failure_is_git_unavailable_not_invalid() {
|
||||
let err = validate_git_ref_with_git("/nonexistent/gitlawb-git", "main")
|
||||
.expect_err("missing git binary should fail");
|
||||
assert!(
|
||||
matches!(err, GitRefValidationError::GitUnavailable(_)),
|
||||
"spawn failure must not be classified as invalid input, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
use super::{branch_diff_names, merge_branch};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -866,6 +1224,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 round-10 (KTD2): `object_type_bounded` reaps a wedged `cat-file` child at its
|
||||
/// deadline instead of blocking on it to natural exit, so a hung probe cannot pin the
|
||||
/// /ipfs walk admission the owning task holds. A fake `git` records its pid and sleeps
|
||||
/// far past the 1s deadline; the `run_bounded_git` watchdog (SIGTERM -> grace ->
|
||||
/// SIGKILL of the process group) must kill it well before that natural exit, and the
|
||||
/// call must surface `GitServiceTimeout`. REVERT PROOF (RED): swap the twin's
|
||||
/// `run_bounded_git` for the bare `Command::output()` and the wedged child stays alive
|
||||
/// past the deadline — the mid-flight liveness poll below reads it still running.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn object_type_bounded_reaps_wedged_child_at_deadline() {
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pidfile = tmp.path().join("catfile.pid");
|
||||
// `cat-file` records its own pid then sleeps 8s (>> the 1s deadline) so the probe
|
||||
// is genuinely wedged; the watchdog is what must end it.
|
||||
let body = format!(
|
||||
"#!/bin/sh\n\
|
||||
case \"$1\" in\n\
|
||||
cat-file) echo $$ > \"{}\"; sleep 8 ;;\n\
|
||||
*) : ;;\n\
|
||||
esac\n\
|
||||
exit 0\n",
|
||||
pidfile.display()
|
||||
);
|
||||
let git_path = tmp.path().join("fakegit");
|
||||
std::fs::write(&git_path, &body).unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perm = std::fs::metadata(&git_path).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&git_path, perm).unwrap();
|
||||
}
|
||||
let repo = tmp.path().to_path_buf();
|
||||
let git = git_path.to_str().unwrap().to_string();
|
||||
|
||||
let alive = |pid: i32| unsafe { libc::kill(pid, 0) == 0 };
|
||||
|
||||
// The bounded probe blocks until the watchdog tears the child down, so run it on
|
||||
// a worker thread and poll for the reap from here.
|
||||
let handle = std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(1);
|
||||
super::object_type_bounded(&git, &repo, "deadbeef", deadline)
|
||||
});
|
||||
|
||||
let mut pid = None;
|
||||
for _ in 0..500 {
|
||||
if let Some(p) = std::fs::read_to_string(&pidfile)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<i32>().ok())
|
||||
{
|
||||
pid = Some(p);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let pid = pid.expect("the fake cat-file must have spawned and recorded its pid");
|
||||
|
||||
// Past the 1s deadline + SIGTERM grace but well before the 8s natural exit: the
|
||||
// watchdog must already have reaped the wedged group. A bare, unbounded read would
|
||||
// leave it running here — the load-bearing RED.
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
let reaped = !alive(pid);
|
||||
// Defensive reap so a RED run leaks no orphan.
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
assert!(
|
||||
reaped,
|
||||
"object_type_bounded must reap the wedged cat-file child at the deadline, \
|
||||
not leave it running to its natural exit"
|
||||
);
|
||||
|
||||
let res = handle.join().expect("probe thread joins");
|
||||
let err = res.expect_err("a deadline overrun must be an error, not a value");
|
||||
// A reaped deadline is a retryable fault, not a verdict about the object, so it
|
||||
// must arrive as Transient (-> 503) carrying GitServiceTimeout.
|
||||
let super::ProbeError::Transient(inner) = &err else {
|
||||
panic!("a deadline overrun must be a Transient probe fault, got: {err:?}");
|
||||
};
|
||||
assert!(
|
||||
inner.is::<crate::git::smart_http::GitServiceTimeout>(),
|
||||
"a deadline overrun must surface GitServiceTimeout, got: {inner:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F5 (RED-before/GREEN-after): a packed object whose pack/idx is unreadable
|
||||
/// makes `git cat-file -t` emit "could not get object info" — byte-identical to a
|
||||
/// genuine miss. `object_type_bounded` must report absence ONLY when the object
|
||||
@@ -1371,6 +1815,134 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 round 12 (jatmn): the SIZE probe must use the same absence-versus-fault
|
||||
/// vocabulary the type probe does. It mapped every non-timeout failure to `Ok(None)`,
|
||||
/// which `gate_and_serve` reads as a verified absence and does not taint, so a
|
||||
/// corrupt object, an unreadable pack, or a failed spawn ended the search cleanly and
|
||||
/// handed an authorized caller a definitive 404 instead of the retryable 503 tail.
|
||||
///
|
||||
/// A corrupt loose object is the same fixture the type stage uses for this, and it is
|
||||
/// the honest case: the object EXISTS, the type probe says so, and only the size read
|
||||
/// fails. RED before the change (`Ok(None)`).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn object_size_bounded_corrupt_loose_object_is_fault_not_absence() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("sizecorrupt");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?}"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q", "--object-format=sha256", "."]);
|
||||
g(&["config", "user.email", "t@t"]);
|
||||
g(&["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("f.txt"), b"loose object content\n").unwrap();
|
||||
g(&["add", "f.txt"]);
|
||||
g(&["commit", "-qm", "c1"]);
|
||||
let blob = String::from_utf8(
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "HEAD:f.txt"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
// Healthy first: the probe reads the real size, so the fault case below is not
|
||||
// passing for the trivial reason that this fixture never worked.
|
||||
let healthy = super::object_size_bounded("git", &work, &blob, deadline);
|
||||
assert!(
|
||||
matches!(healthy, Ok(n) if n > 0),
|
||||
"a readable object reports its size; got {healthy:?}"
|
||||
);
|
||||
|
||||
let obj = work.join(".git/objects").join(&blob[0..2]).join(&blob[2..]);
|
||||
let mut perms = std::fs::metadata(&obj).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
std::fs::set_permissions(&obj, perms).unwrap();
|
||||
std::fs::write(&obj, b"garbage not a zlib stream").unwrap();
|
||||
|
||||
let res = super::object_size_bounded("git", &work, &blob, deadline);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"a corrupt object must surface as a probe fault, never as an absence the \
|
||||
resolver renders as a clean 404; got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 round 12 (jatmn), the transient arm: a store this process cannot read is the
|
||||
/// retryable case, so the size probe classifies it `Transient` exactly as the type
|
||||
/// probe does. Distinguishing the two arms is the whole point of routing through
|
||||
/// `classify_store_fault` rather than returning one undifferentiated error.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn object_size_bounded_unreadable_store_is_transient() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("sizeunreadable");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?}"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q", "--object-format=sha256", "."]);
|
||||
g(&["config", "user.email", "t@t"]);
|
||||
g(&["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("f.txt"), b"loose object content\n").unwrap();
|
||||
g(&["add", "f.txt"]);
|
||||
g(&["commit", "-qm", "c1"]);
|
||||
let blob = String::from_utf8(
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "HEAD:f.txt"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Make this oid's loose fan-out unreadable: git fails, and the store cannot
|
||||
// certify absence, so the fault is retryable rather than terminal.
|
||||
let fanout = work.join(".git/objects").join(&blob[0..2]);
|
||||
let mut perms = std::fs::metadata(&fanout).unwrap().permissions();
|
||||
perms.set_mode(0o000);
|
||||
std::fs::set_permissions(&fanout, perms).unwrap();
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let res = super::object_size_bounded("git", &work, &blob, deadline);
|
||||
|
||||
let mut restore = std::fs::metadata(&fanout).unwrap().permissions();
|
||||
restore.set_mode(0o755);
|
||||
std::fs::set_permissions(&fanout, restore).unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(res, Err(super::ProbeError::Transient(_))),
|
||||
"an unreadable object store is the retryable arm; got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F5/U4: a corrupt LOOSE object makes `git cat-file --batch-check` print
|
||||
/// `<oid> missing` on stdout (exit 0) yet emit `error:` diagnostics on stderr. The
|
||||
/// clean-`missing` absence path must NOT fire here — the `error:` line disqualifies
|
||||
@@ -1752,4 +2324,259 @@ mod tests {
|
||||
"a clean `missing` twice on a readable store is a genuine absence; got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// CodeRabbit #379: `merge_branch`'s leftover-worktree cleanup ran only when the
|
||||
/// `_merge_worktree` DIRECTORY still existed. A directory that is gone while
|
||||
/// `$GIT_DIR/worktrees/_merge_worktree` still holds its registration is precisely
|
||||
/// the state `git worktree add` refuses ("is a missing but already registered
|
||||
/// worktree", exit 128), and it is self-inflicted: `remove_worktree` deletes the
|
||||
/// directory even when the `git worktree remove` before it failed. Once a repo is
|
||||
/// in that state every later merge on it fails permanently. REVERT PROOF (RED):
|
||||
/// put the cleanup call back behind `if worktree_path.exists()` and this test
|
||||
/// fails at the merge below with "missing but already registered".
|
||||
#[test]
|
||||
fn merge_branch_recovers_from_a_stale_worktree_registration() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str], dir: &Path| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q"], &work);
|
||||
g(&["config", "user.email", "t@t"], &work);
|
||||
g(&["config", "user.name", "t"], &work);
|
||||
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
|
||||
g(&["add", "."], &work);
|
||||
g(&["commit", "-qm", "base"], &work);
|
||||
let main = {
|
||||
let o = Command::new("git")
|
||||
.args(["symbolic-ref", "--short", "HEAD"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
};
|
||||
g(&["checkout", "-q", "-b", "feature"], &work);
|
||||
std::fs::write(work.join("feat.txt"), b"feat\n").unwrap();
|
||||
g(&["add", "."], &work);
|
||||
g(&["commit", "-qm", "feat"], &work);
|
||||
g(&["checkout", "-q", &main], &work);
|
||||
|
||||
let bare = td.path().join("bare.git");
|
||||
g(
|
||||
&[
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap(),
|
||||
],
|
||||
td.path(),
|
||||
);
|
||||
|
||||
// Reproduce the stuck state exactly: register the worktree, then delete its
|
||||
// directory without unregistering it. This is what a failed `worktree remove`
|
||||
// followed by the unconditional `remove_dir_all` leaves behind.
|
||||
//
|
||||
// `gc.worktreePruneExpire=never` is set on purpose. Recovery must not depend on
|
||||
// git's prune expiry policy: a registration this fresh would sit inside any
|
||||
// sane expiry window, so a cleanup that only pruned on expiry would leave the
|
||||
// repo wedged. Both `worktree remove --force` and `worktree prune` unregister a
|
||||
// worktree whose gitdir target is gone regardless of this setting, and pinning
|
||||
// it here keeps that true if either call is ever changed.
|
||||
g(&["config", "gc.worktreePruneExpire", "never"], &bare);
|
||||
g(&["worktree", "add", "_merge_worktree", &main], &bare);
|
||||
std::fs::remove_dir_all(bare.join("_merge_worktree")).unwrap();
|
||||
assert!(
|
||||
bare.join("worktrees/_merge_worktree").exists(),
|
||||
"fixture must leave the registration behind"
|
||||
);
|
||||
|
||||
let before = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{main}")])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let before = String::from_utf8_lossy(&before.stdout).trim().to_string();
|
||||
|
||||
let merged = merge_branch(&bare, &main, "feature", "did:key:zTest", "t")
|
||||
.expect("a stale worktree registration must not wedge the merge endpoint");
|
||||
|
||||
let after = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{main}")])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let after = String::from_utf8_lossy(&after.stdout).trim().to_string();
|
||||
assert_ne!(before, after, "the merge must advance refs/heads/{main}");
|
||||
assert_eq!(after, merged, "the returned sha must be the new branch tip");
|
||||
}
|
||||
|
||||
/// Companion to the stale-registration case: the leftover worktree is also LOCKED.
|
||||
/// `git worktree remove --force` (ONE flag) refuses a locked worktree outright, and
|
||||
/// `git worktree prune` skips locked registrations too, so with a single --force the
|
||||
/// cleanup makes no progress and `worktree add` keeps failing. REVERT PROOF (RED):
|
||||
/// drop either --force from remove_worktree and this test fails.
|
||||
#[test]
|
||||
fn merge_branch_recovers_from_a_locked_stale_worktree() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str], dir: &Path| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q"], &work);
|
||||
g(&["config", "user.email", "t@t"], &work);
|
||||
g(&["config", "user.name", "t"], &work);
|
||||
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
|
||||
g(&["add", "."], &work);
|
||||
g(&["commit", "-qm", "base"], &work);
|
||||
let main = {
|
||||
let o = Command::new("git")
|
||||
.args(["symbolic-ref", "--short", "HEAD"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
};
|
||||
g(&["checkout", "-q", "-b", "feature"], &work);
|
||||
std::fs::write(work.join("feat.txt"), b"feat\n").unwrap();
|
||||
g(&["add", "."], &work);
|
||||
g(&["commit", "-qm", "feat"], &work);
|
||||
g(&["checkout", "-q", &main], &work);
|
||||
|
||||
let bare = td.path().join("bare.git");
|
||||
g(
|
||||
&[
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap(),
|
||||
],
|
||||
td.path(),
|
||||
);
|
||||
|
||||
g(&["worktree", "add", "_merge_worktree", &main], &bare);
|
||||
g(&["worktree", "lock", "_merge_worktree"], &bare);
|
||||
std::fs::remove_dir_all(bare.join("_merge_worktree")).unwrap();
|
||||
|
||||
let before = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{main}")])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let before = String::from_utf8_lossy(&before.stdout).trim().to_string();
|
||||
|
||||
let merged = merge_branch(&bare, &main, "feature", "did:key:zTest", "t")
|
||||
.expect("a locked leftover worktree must not wedge the merge endpoint");
|
||||
|
||||
let after = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{main}")])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let after = String::from_utf8_lossy(&after.stdout).trim().to_string();
|
||||
assert_ne!(before, after, "the merge must advance refs/heads/{main}");
|
||||
assert_eq!(after, merged, "the returned sha must be the new branch tip");
|
||||
}
|
||||
|
||||
/// The third leftover shape: a `_merge_worktree` DIRECTORY with no registration
|
||||
/// behind it, which is what a crash between `worktree add` creating the directory
|
||||
/// and git recording it leaves. `git worktree remove` refuses this one outright
|
||||
/// ("is not a working tree"), so the `remove_dir_all` in remove_worktree is the
|
||||
/// only thing that clears it and `worktree add` otherwise fails on the existing
|
||||
/// path. REVERT PROOF (RED): drop the remove_dir_all from remove_worktree and this
|
||||
/// test fails.
|
||||
#[test]
|
||||
fn merge_branch_recovers_from_an_unregistered_leftover_directory() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str], dir: &Path| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q"], &work);
|
||||
g(&["config", "user.email", "t@t"], &work);
|
||||
g(&["config", "user.name", "t"], &work);
|
||||
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
|
||||
g(&["add", "."], &work);
|
||||
g(&["commit", "-qm", "base"], &work);
|
||||
let main = {
|
||||
let o = Command::new("git")
|
||||
.args(["symbolic-ref", "--short", "HEAD"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
};
|
||||
g(&["checkout", "-q", "-b", "feature"], &work);
|
||||
std::fs::write(work.join("feat.txt"), b"feat\n").unwrap();
|
||||
g(&["add", "."], &work);
|
||||
g(&["commit", "-qm", "feat"], &work);
|
||||
g(&["checkout", "-q", &main], &work);
|
||||
|
||||
let bare = td.path().join("bare.git");
|
||||
g(
|
||||
&[
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap(),
|
||||
],
|
||||
td.path(),
|
||||
);
|
||||
|
||||
// A directory in the way, with nothing registered behind it.
|
||||
std::fs::create_dir_all(bare.join("_merge_worktree")).unwrap();
|
||||
std::fs::write(bare.join("_merge_worktree/leftover.txt"), b"junk\n").unwrap();
|
||||
assert!(
|
||||
!bare.join("worktrees/_merge_worktree").exists(),
|
||||
"fixture must leave no registration, only the directory"
|
||||
);
|
||||
|
||||
let before = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{main}")])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let before = String::from_utf8_lossy(&before.stdout).trim().to_string();
|
||||
|
||||
let merged = merge_branch(&bare, &main, "feature", "did:key:zTest", "t")
|
||||
.expect("an unregistered leftover directory must not wedge the merge endpoint");
|
||||
|
||||
let after = Command::new("git")
|
||||
.args(["rev-parse", &format!("refs/heads/{main}")])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let after = String::from_utf8_lossy(&after.stdout).trim().to_string();
|
||||
assert_ne!(before, after, "the merge must advance refs/heads/{main}");
|
||||
assert_eq!(after, merged, "the returned sha must be the new branch tip");
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,121 @@
|
||||
|
||||
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};
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -28,28 +132,48 @@ impl TigrisClient {
|
||||
Ok(Self {
|
||||
s3,
|
||||
bucket: bucket.to_string(),
|
||||
#[cfg(test)]
|
||||
compress_gate: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only constructor with an explicit S3 endpoint, region, and static
|
||||
/// credentials — no env-var reads, so parallel tests cannot race each other's
|
||||
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
|
||||
/// the client at a non-routable endpoint to exercise acquire-stall paths.
|
||||
/// 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(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(aws_config::Region::new("auto"))
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
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::new(&config),
|
||||
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")
|
||||
@@ -77,34 +201,325 @@ impl TigrisClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
|
||||
/// 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))
|
||||
}
|
||||
|
||||
// Create tar.zst in memory
|
||||
let archive_bytes = tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || compress_repo(&local_path)
|
||||
})
|
||||
/// 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>> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
.context("tar task panicked")?
|
||||
.context("compressing repo")?;
|
||||
}
|
||||
|
||||
/// 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 = {
|
||||
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"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
|
||||
|
||||
self.s3
|
||||
let mut req = 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")
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris PUT {key}"))?;
|
||||
.content_type("application/zstd");
|
||||
match &precondition {
|
||||
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
|
||||
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
|
||||
UploadPrecondition::Unconditional => {}
|
||||
}
|
||||
|
||||
info!(key = %key, "uploaded repo to tigris");
|
||||
Ok(())
|
||||
// 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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract to local disk.
|
||||
@@ -112,10 +527,32 @@ impl TigrisClient {
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
local_path: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
) -> 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 = %local_path.display(), "downloading repo from tigris");
|
||||
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
|
||||
|
||||
let resp = self
|
||||
.s3
|
||||
@@ -133,31 +570,59 @@ impl TigrisClient {
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || decompress_repo(&data, &local_path)
|
||||
// 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
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("extract task panicked")?
|
||||
.context("extracting repo")?;
|
||||
|
||||
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(())
|
||||
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
|
||||
Ok(extracted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +645,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.
|
||||
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
pub(crate) 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
|
||||
@@ -193,6 +658,35 @@ 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
|
||||
@@ -200,11 +694,16 @@ fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
/// 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: &Path) -> Result<()> {
|
||||
let parent = local_path.parent().context("repo path has no parent")?;
|
||||
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")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
|
||||
let file_name = local_path
|
||||
let file_name = live
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
@@ -229,17 +728,218 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 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")?;
|
||||
// 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)?;
|
||||
|
||||
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
@@ -3,7 +3,7 @@ pub mod query;
|
||||
pub mod subscription;
|
||||
pub mod types;
|
||||
|
||||
use async_graphql::Schema;
|
||||
use async_graphql::{Schema, SchemaBuilder};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
@@ -14,6 +14,22 @@ use subscription::SubscriptionRoot;
|
||||
|
||||
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||
|
||||
// Keep ordinary schema discovery and application queries usable while
|
||||
// bounding validation work and the number of resolver selections per request.
|
||||
const GRAPHQL_MAX_COMPLEXITY: usize = 400;
|
||||
// The current public schema is shallow; this leaves headroom for composed
|
||||
// clients and the canonical getIntrospectionQuery without allowing
|
||||
// recursively nested documents to grow unchecked.
|
||||
const GRAPHQL_MAX_DEPTH: usize = 14;
|
||||
|
||||
fn apply_query_limits<Query, Mutation, Subscription>(
|
||||
builder: SchemaBuilder<Query, Mutation, Subscription>,
|
||||
) -> SchemaBuilder<Query, Mutation, Subscription> {
|
||||
builder
|
||||
.limit_complexity(GRAPHQL_MAX_COMPLEXITY)
|
||||
.limit_depth(GRAPHQL_MAX_DEPTH)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -81,7 +97,7 @@ pub fn build_schema(
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
) -> GitlawbSchema {
|
||||
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
|
||||
.data(db)
|
||||
.data(ref_update_tx)
|
||||
.data(task_event_tx)
|
||||
@@ -91,6 +107,10 @@ pub fn build_schema(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
use async_graphql::{EmptyMutation, EmptySubscription, Object, Value};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_opaques_sqlx_chain() {
|
||||
@@ -160,6 +180,668 @@ mod tests {
|
||||
assert!(!err.message.contains("failed to open"));
|
||||
}
|
||||
|
||||
fn production_test_schema() -> GitlawbSchema {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
production_test_schema_with_db(Arc::new(Db::for_testing(pool)))
|
||||
}
|
||||
|
||||
fn production_test_schema_with_db(db: Arc<Db>) -> GitlawbSchema {
|
||||
build_schema(
|
||||
db,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_update_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("refUpdates { repo }").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_event_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("taskEvents { taskId }").await;
|
||||
}
|
||||
|
||||
async fn assert_subscription_alias_budget(field: &str) {
|
||||
use futures::StreamExt;
|
||||
use std::time::Duration;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(
|
||||
Arc::new(Db::for_testing(pool)),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
);
|
||||
for count in [7, 8] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let query = format!("subscription {{ {fields} }}");
|
||||
let mut responses = Box::pin(schema.execute_stream(query));
|
||||
if count == 8 {
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.expect("over-budget subscriptions must reject before waiting for events")
|
||||
.expect("the validation error must be returned");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
continue;
|
||||
}
|
||||
// Poll the accepted operation far enough to register its receivers.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(30), responses.next())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 7);
|
||||
let _ = ref_tx.send(RefUpdateBroadcast {
|
||||
repo: "owner/repo".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "1".repeat(40),
|
||||
pusher_did: "did:key:test".into(),
|
||||
node_did: "did:key:node".into(),
|
||||
timestamp: "2026-01-01T00:00:00Z".into(),
|
||||
owner_did: "did:key:test".into(),
|
||||
});
|
||||
let _ = task_tx.send(TaskEventBroadcast {
|
||||
task_id: "task".into(),
|
||||
old_status: "pending".into(),
|
||||
new_status: "claimed".into(),
|
||||
by_did: "did:key:test".into(),
|
||||
at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
drop(responses);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_mutation_budget_accepts_seven_and_rejects_eight(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let caller = "did:key:reviewer";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for index in 0..8 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-{index}"),
|
||||
repo_id: None,
|
||||
kind: "test".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: caller.into(),
|
||||
assignee_did: None,
|
||||
capability: "test".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = production_test_schema_with_db(db.clone());
|
||||
for count in [8, 7] {
|
||||
let fields = (0..count)
|
||||
.map(|n| {
|
||||
format!("r{n}: claimTask(id: \"task-{n}\", assigneeDid: \"{caller}\") {{ id }}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema
|
||||
.execute(
|
||||
async_graphql::Request::new(format!("mutation {{ {fields} }}"))
|
||||
.data(crate::auth::AuthenticatedDid(caller.into())),
|
||||
)
|
||||
.await;
|
||||
if count == 8 {
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(
|
||||
db.list_tasks(Some("pending"), None, 8).await.unwrap().len(),
|
||||
8
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_tasks(Some("claimed"), Some(caller), 8)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_task("task-7").await.unwrap().unwrap().status,
|
||||
"pending"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_list_cost_scales_with_the_same_alias_count(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
// Bounds are per-root named constants so the cases move with the
|
||||
// documented maximums instead of going stale as literals.
|
||||
for (field, selection, max) in [
|
||||
("refUpdates", "repo", MAX_VISIBLE_REF_UPDATES),
|
||||
("tasks", "id", MAX_VISIBLE_TASK_PAGE_SIZE),
|
||||
(
|
||||
"reposPage",
|
||||
"nodes { name }",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE as i64,
|
||||
),
|
||||
] {
|
||||
// Two aliases at the maximum always exceed the budget.
|
||||
let fields = (0..2)
|
||||
.map(|n| format!("r{n}: {field}(limit: {max}) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
|
||||
// At limit 1 two aliases fit for the row-priced roots; reposPage
|
||||
// prices the full corpus scan, so a single alias is the most that
|
||||
// fits under the budget.
|
||||
let aliases = if field == "reposPage" { 1 } else { 2 };
|
||||
let fields = (0..aliases)
|
||||
.map(|n| format!("r{n}: {field}(limit: 1) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{field}: {:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
aliases
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_limits_reject_mutation_aliases_and_large_lists() {
|
||||
let schema = production_test_schema();
|
||||
// Limits are derived from the MAX_* constants so the boundaries move
|
||||
// with the documented maximums instead of going stale as literals.
|
||||
for (prefix, count, field) in [
|
||||
("mutation", 8, "claimTask(id: \"missing\", assigneeDid: \"did:key:test\") { id }".to_owned()),
|
||||
("mutation", 8, "createTask(delegatorDid: \"did:key:test\", input: { kind: \"test\", capability: \"test\" }) { id }".to_owned()),
|
||||
("mutation", 8, "completeTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("mutation", 8, "failTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("query", 8, "task(id: \"missing\") { id }".to_owned()),
|
||||
("query", 2, format!("refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }}")),
|
||||
("query", 2, format!("tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }}")),
|
||||
("query", 2, format!("reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ nodes {{ name }} }}")),
|
||||
("query", 8, "reposPage(limit: 1) { nodes { name } }".to_owned()),
|
||||
] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{prefix} {{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_repos_page_serves_the_documented_maximum(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
for selection in [
|
||||
"nodes { name }",
|
||||
"nodes { name ownerDid } hasNextPage endCursor",
|
||||
] {
|
||||
let response = schema
|
||||
.execute(format!(
|
||||
"{{ reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ {selection} }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response.data.into_json().unwrap()["reposPage"]["nodes"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_root_aliases_are_accepted() {
|
||||
struct Root(Arc<AtomicUsize>);
|
||||
#[Object]
|
||||
impl Root {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn repos(&self) -> Vec<Nested> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
vec![Nested]
|
||||
}
|
||||
}
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
Root(calls.clone()),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: repos {{ value }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expensive_root_aliases_are_rejected_before_database_access() {
|
||||
// Use the production builder with a lazy pool: rejection must precede DB access.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..8)
|
||||
.map(|n| format!("r{n}: repos {{ name }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
|
||||
assert_eq!(response.data, Value::Null);
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_schema_introspection_remains_available() {
|
||||
let schema = production_test_schema();
|
||||
let canonical_introspection = r#"
|
||||
query IntrospectionQuery {
|
||||
__schema {
|
||||
queryType { name }
|
||||
mutationType { name }
|
||||
subscriptionType { name }
|
||||
types {
|
||||
...FullType
|
||||
}
|
||||
directives {
|
||||
name
|
||||
description
|
||||
locations
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment FullType on __Type {
|
||||
kind
|
||||
name
|
||||
description
|
||||
fields(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
type {
|
||||
...TypeRef
|
||||
}
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
inputFields {
|
||||
...InputValue
|
||||
}
|
||||
interfaces {
|
||||
...TypeRef
|
||||
}
|
||||
enumValues(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
possibleTypes {
|
||||
...TypeRef
|
||||
}
|
||||
}
|
||||
|
||||
fragment InputValue on __InputValue {
|
||||
name
|
||||
description
|
||||
type { ...TypeRef }
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fragment TypeRef on __Type {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let response = schema.execute(canonical_introspection).await;
|
||||
assert!(
|
||||
response.errors.is_empty(),
|
||||
"canonical introspection query failed against production schema: {:?}",
|
||||
response.errors
|
||||
);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_and_tasks_single_alias_max_limit_complexity_contract() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
pool.close().await;
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(Arc::new(Db::for_testing(pool)), ref_tx, task_tx);
|
||||
|
||||
// refUpdates(limit: MAX_VISIBLE_REF_UPDATES):
|
||||
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
|
||||
// It passes validation and reaches the resolver (which yields db error on lazy pool).
|
||||
let single_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
assert!(
|
||||
single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2 -> cost 100 + 200 + 2 = 302 <= 400.
|
||||
// The documented max-200 request stays reachable with multiple fields
|
||||
// (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo refName }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
assert!(
|
||||
multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
|
||||
// tasks(limit: MAX_VISIBLE_TASK_PAGE_SIZE):
|
||||
// Single-field selection has child_complexity 1, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 1 <= 400.
|
||||
// It passes validation and reaches the resolver.
|
||||
let single_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
assert!(
|
||||
single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 400.
|
||||
// A request at the documented maximum stays reachable with multiple
|
||||
// fields (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id status }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
assert!(
|
||||
multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_repos_aliases_exceed_the_budget() {
|
||||
// `repos` is priced as the full page it scans
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child), so two `repos { name }`
|
||||
// aliases cost 2 * 251 = 502 > 400 and the document is rejected before
|
||||
// any resolver runs. If the price reverts to `50 + child_complexity`
|
||||
// the aliases cost 2 * 51 = 102, validation passes, and the failure
|
||||
// mode changes (resolver DB errors on the lazy pool): this test goes
|
||||
// red on revert.
|
||||
let schema = production_test_schema();
|
||||
let response = schema
|
||||
.execute("{ r0: repos { name } r1: repos { name } }")
|
||||
.await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_repos_page_aliases_exceed_the_budget() {
|
||||
// `reposPage` is priced as the full corpus scan
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity), so seven
|
||||
// `reposPage(limit: 1) { nodes { name } }` aliases cost
|
||||
// 7 * (50 + 200 + 2) = 1764 > 400 and the document is rejected
|
||||
// before any resolver runs. Under the previous row-priced formula
|
||||
// the same aliases cost 7 * (50 + 1 + 2) = 371, validation passes,
|
||||
// and the failure mode changes (resolver DB errors on the lazy
|
||||
// pool): this test goes red on revert. Eight aliases are rejected
|
||||
// under both formulas, so seven is the load-bearing boundary.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: reposPage(limit: 1) {{ nodes {{ name }} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_fixed_base_is_charged() {
|
||||
// The 100 base in the refUpdates complexity formula charges for the
|
||||
// collector's fixed work (loading the deduped repo set, the
|
||||
// quarantined set, and the visibility rules, then walking up to 16
|
||||
// keyset pages) even at limit: 1. Four aliases at limit: 1 cost
|
||||
// 4 * (100 + 1 + 1) = 408 > 400 and must be rejected. If the base
|
||||
// disappears, the cost drops to 4 * (1 + 1) = 8 and this test fails.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..4)
|
||||
.map(|n| format!("r{n}: refUpdates(limit: 1) {{ repo }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Nested;
|
||||
|
||||
#[Object]
|
||||
impl Nested {
|
||||
async fn child(&self) -> Nested {
|
||||
Nested
|
||||
}
|
||||
|
||||
async fn value(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingQuery(Arc<AtomicUsize>);
|
||||
|
||||
#[Object]
|
||||
impl CountingQuery {
|
||||
async fn nested(&self) -> Nested {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
Nested
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_depth_limit_accepts_fourteen_and_rejects_fifteen() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
CountingQuery(Arc::clone(&calls)),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let query_at_depth = |depth: usize| {
|
||||
let selection = (2..depth).fold("value".to_string(), |selection, _| {
|
||||
format!("child {{ {selection} }}")
|
||||
});
|
||||
format!("{{ nested {{ {selection} }} }}")
|
||||
};
|
||||
|
||||
let accepted = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH)).await;
|
||||
|
||||
assert!(
|
||||
accepted.errors.is_empty(),
|
||||
"depth {GRAPHQL_MAX_DEPTH} should be accepted: {:?}",
|
||||
accepted.errors
|
||||
);
|
||||
assert_eq!(calls.swap(0, Ordering::Relaxed), 1);
|
||||
|
||||
let rejected = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH + 1)).await;
|
||||
|
||||
assert_eq!(rejected.data, Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_schema_enforces_depth_limit() {
|
||||
let schema = production_test_schema();
|
||||
// Depth 14 is accepted through the production build_schema path.
|
||||
let of_types_14 = (0..9).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query_14 =
|
||||
format!("{{ __schema {{ types {{ fields {{ type {{ {of_types_14} }} }} }} }} }}");
|
||||
let accepted = schema.execute(&query_14).await;
|
||||
assert!(accepted.errors.is_empty(), "{:?}", accepted.errors);
|
||||
|
||||
// Construct an introspection query of depth 15 using __schema.
|
||||
// 1: __schema
|
||||
// 2: types
|
||||
// 3: fields
|
||||
// 4: type
|
||||
// 5..14: ofType (10 times)
|
||||
// 15: name
|
||||
// Total depth = 15 > GRAPHQL_MAX_DEPTH (14).
|
||||
let of_types = (0..10).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query = format!("{{ __schema {{ types {{ fields {{ type {{ {of_types} }} }} }} }} }}");
|
||||
|
||||
let rejected = schema.execute(&query).await;
|
||||
assert_eq!(rejected.data, async_graphql::Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
impl MutationRoot {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn create_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -36,6 +37,33 @@ impl MutationRoot {
|
||||
}
|
||||
let delegator_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// #496: same write gate as the REST handler — a repo_id naming a
|
||||
// hosted, non-quarantined repo admits tasks only from its owner, so a
|
||||
// stranger cannot file under a foreign repo id. An id naming no hosted
|
||||
// repo (unknown, or quarantined — hidden as if it did not exist) is
|
||||
// stored verbatim as an opaque label; the gate does not bind an
|
||||
// unresolved id to a repo. Task reads are caller-independent on this
|
||||
// base (the tasks resolver is not repo-scoped), before and after this
|
||||
// change. As in tasks.rs, the success-vs-denial split between a hosted
|
||||
// repo id and an unknown one is itself a one-bit oracle — a stranger
|
||||
// holding a private repo's id learns it is hosted here — but denying
|
||||
// foreign repos requires drawing exactly that line.
|
||||
if let Some(repo_id) = input.repo_id.as_deref() {
|
||||
let record = db
|
||||
.get_repo_by_id(repo_id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if let Some(record) = record {
|
||||
let quarantined = db
|
||||
.is_repo_quarantined(&record.id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if !quarantined {
|
||||
crate::api::require_repo_owner(&record, caller)
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -52,12 +80,17 @@ impl MutationRoot {
|
||||
updated_at: now,
|
||||
deadline: input.deadline,
|
||||
};
|
||||
db.create_task(&task)
|
||||
// Return the stored row, not the pre-insert input: create_task
|
||||
// normalizes the assignee (whitespace-only becomes NULL/open), and a
|
||||
// response that shows the raw input lies about the reservation.
|
||||
let stored = db
|
||||
.create_task(&task)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(AgentTaskType::from(task))
|
||||
Ok(AgentTaskType::from(stored))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn claim_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -77,16 +110,25 @@ impl MutationRoot {
|
||||
.claim_task(&id, &assignee_did)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
// Report the stored assignee form like REST, so subscribers see the
|
||||
// same identity whichever API surface ran the transition.
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(assignee_did.as_str())
|
||||
.to_string();
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: assignee_did,
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn complete_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -110,15 +152,24 @@ impl MutationRoot {
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
if !crate::api::did_matches(
|
||||
caller,
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can complete it",
|
||||
));
|
||||
}
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref())
|
||||
.finish_task(&id, "completed", input.result.as_deref(), caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(by_did.as_str())
|
||||
.to_string();
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -129,6 +180,7 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn fail_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -151,16 +203,25 @@ impl MutationRoot {
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
if !crate::api::did_matches(
|
||||
caller,
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can fail it",
|
||||
));
|
||||
}
|
||||
let reason = input.reason.unwrap_or_default();
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
.finish_task(&id, "failed", Some(&reason), caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(by_did.as_str())
|
||||
.to_string();
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -186,57 +247,94 @@ mod tests {
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// N2: GraphQL mutations require a verified signer and bind the acting DID to
|
||||
/// it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// The `id` scalar returned by a successful `createTask` mutation.
|
||||
fn created_id(resp: &Response) -> String {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(task) = obj.get("createTask").expect("createTask key")
|
||||
else {
|
||||
panic!("createTask not an object");
|
||||
};
|
||||
let async_graphql::Value::String(id) = task.get("id").expect("id key") else {
|
||||
panic!("id not a string");
|
||||
};
|
||||
id.clone()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// rejected; the matching signer passes the auth gate.
|
||||
#[sqlx::test]
|
||||
async fn mutation_requires_and_binds_signer(pool: PgPool) {
|
||||
async fn each_mutation_requires_and_binds_signer(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let assignee = "did:key:zASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let q = format!(
|
||||
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{assignee}") {{ id }} }}"#
|
||||
);
|
||||
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 }} }}"#
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// 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)
|
||||
);
|
||||
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)
|
||||
);
|
||||
|
||||
// 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)
|
||||
);
|
||||
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)
|
||||
);
|
||||
|
||||
// 3. Signed as the claimed assignee → passes the auth gate. The missing
|
||||
// task is a business error from claim_task, not a sqlx fault, so the
|
||||
// actionable message must survive (not the opaque DB string) (#250).
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
assert!(
|
||||
errs.contains("task not claimable"),
|
||||
"claim race / missing task must keep its business message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"business error must not be rewritten as opaque DB error: {errs}"
|
||||
);
|
||||
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.
|
||||
@@ -332,4 +430,339 @@ mod tests {
|
||||
errors(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): createTask applies the same repo-ownership gate as the
|
||||
/// REST handler — a stranger naming a hosted repo is rejected without
|
||||
/// persisting anything, while the owner files. The quarantined and
|
||||
/// unknown-id arms are pinned too, including that each accepted write
|
||||
/// stores the supplied id verbatim, so this copy of the gate cannot regress
|
||||
/// silently.
|
||||
#[sqlx::test]
|
||||
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let owner = "did:key:zGQLTASKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zGQLTASKSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves (so the signer binding passes) but
|
||||
// names the victim's repo → rejected by the ownership gate, leaking
|
||||
// nothing about the repo.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
|
||||
"a non-owner must not file under a foreign repo id (shared denial text): {errs}"
|
||||
);
|
||||
|
||||
// The denial must not persist: no task row may carry the victim's
|
||||
// repo id afterwards.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks(None, None, 50)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
stored
|
||||
.iter()
|
||||
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
|
||||
"a denied write must not leave a task under the foreign repo id"
|
||||
);
|
||||
|
||||
// The owner files under their own repo id.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(owner)).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"the owner should file against their repo: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// Unknown repo ids stay accepted as opaque labels.
|
||||
let q_unknown = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{stranger}", input: {{ kind: "build", capability: "repo:write", repoId: "no-such-repo-id" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q_unknown).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"an id naming no hosted repo stays an opaque label: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the unresolved id must be stored verbatim, not
|
||||
// dropped or rewritten to a repo it never named.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some("no-such-repo-id"),
|
||||
"an unresolved id must be stored verbatim as a label"
|
||||
);
|
||||
|
||||
// A quarantined repo id is treated as unknown (accepted, never a 403
|
||||
// that would confirm the id names a real repo). This pins the
|
||||
// `!quarantined` arm on this copy of the gate.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&repo.id, true)
|
||||
.await
|
||||
.expect("quarantine");
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"a quarantined repo id must not 403 (existence oracle): {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the quarantined id is stored verbatim as a label
|
||||
// too, so the accepted write is not silently dropped.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some(repo.id.as_str()),
|
||||
"a quarantined id must be stored verbatim as a label"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): a repo-lookup fault in createTask's gate must stay
|
||||
/// opaque. Dropping a column `get_repo_by_id` selects faults that lookup;
|
||||
/// the resolver must return the fixed DB message and leak no schema text
|
||||
/// (#250), as `create_task_db_error_message_is_opaque` pins for the
|
||||
/// agent_tasks fault.
|
||||
#[sqlx::test]
|
||||
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner = "did:key:zGQLTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-fault-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-fault-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop machine_id");
|
||||
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{owner}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"gate sqlx fault must be opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("machine_id") && !errs.contains("column"),
|
||||
"schema text leaked through the gate: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-assigned GraphQL claimTask must reject a stranger (no UCAN leak)
|
||||
/// and admit the reserved assignee.
|
||||
#[sqlx::test]
|
||||
async fn claim_task_honors_preassigned_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let reserved = "did:key:zGQLRESERVEDAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let thief = "did:key:zGQLTHIEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-gql-reserved".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: Some(reserved.into()),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: Some("gql-ucan-secret".into()),
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ claimTask(id: "task-gql-reserved", assigneeDid: "{actor}") {{ id status ucanToken }} }}"#
|
||||
)
|
||||
};
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q(thief)).data(AuthenticatedDid(thief.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("reserved"),
|
||||
"stranger claim must surface reserved deny distinctly: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("gql-ucan-secret"),
|
||||
"UCAN must not leak in GraphQL errors: {errs}"
|
||||
);
|
||||
if let Ok(data) = resp.data.into_json() {
|
||||
let s = data.to_string();
|
||||
assert!(
|
||||
!s.contains("gql-ucan-secret"),
|
||||
"UCAN must not leak in GraphQL data on failed claim: {s}"
|
||||
);
|
||||
}
|
||||
// Parity with REST: failed steal must leave the row untouched.
|
||||
let still = state
|
||||
.db
|
||||
.get_task("task-gql-reserved")
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("task exists");
|
||||
assert_eq!(
|
||||
still.status, "pending",
|
||||
"status must stay pending after steal"
|
||||
);
|
||||
assert_eq!(
|
||||
still.assignee_did.as_deref(),
|
||||
Some(reserved),
|
||||
"assignee_did must stay reserved after steal"
|
||||
);
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q(reserved)).data(AuthenticatedDid(reserved.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"reserved assignee must claim: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert_eq!(data["claimTask"]["status"], "claimed");
|
||||
assert_eq!(data["claimTask"]["ucanToken"], "gql-ucan-secret");
|
||||
}
|
||||
|
||||
/// create_task normalizes a whitespace-only assignee to NULL (open task),
|
||||
/// so the create response must show the stored row, not echo the input: a
|
||||
/// client trusting the echoed "reservation" would believe a claimable task
|
||||
/// is reserved. Before the fix the response was built from the pre-insert
|
||||
/// struct and returned the padded input verbatim.
|
||||
#[sqlx::test]
|
||||
async fn create_task_reports_stored_assignee_not_input(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let caller = "did:key:zGQLCREATORAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{caller}", input: {{ kind: "build", capability: "repo:write", assigneeDid: " " }}) {{ id assigneeDid }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q).data(AuthenticatedDid(caller.into())))
|
||||
.await;
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert!(
|
||||
data["createTask"]["assigneeDid"].is_null(),
|
||||
"whitespace-only assigneeDid must come back null (stored shape): {data}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A task seeded with a whitespace-padded assignee must still be
|
||||
/// completable by that assignee over GraphQL: REST and Db::finish_task
|
||||
/// trim the stored value before comparing, and the GraphQL gate must do
|
||||
/// the same or padded reservations finish only through REST.
|
||||
#[sqlx::test]
|
||||
async fn graphql_finish_accepts_padded_stored_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let assignee = "did:key:zGQLPADDEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-gql-padded".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORDDDDDDDDDDDDDDDDDDDDDDDDDD".into(),
|
||||
assignee_did: Some(format!("\t{assignee}\n")),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let claim = format!(
|
||||
r#"mutation {{ claimTask(id: "task-gql-padded", assigneeDid: "{assignee}") {{ id status }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(claim).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"reserved assignee must claim a padded reservation: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
let finish = format!(
|
||||
r#"mutation {{ completeTask(id: "task-gql-padded", byDid: "{assignee}", input: {{}}) {{ id status }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(finish).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.is_empty(),
|
||||
"assignee must complete a padded reservation: {errs}"
|
||||
);
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert_eq!(data["completeTask"]["status"], "completed");
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,154 @@
|
||||
use async_graphql::{Context, Object, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{Db, RepoRecord, MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoType};
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoPageType, RepoType};
|
||||
|
||||
fn repo_type(repo: RepoRecord) -> RepoType {
|
||||
RepoType {
|
||||
name: repo.name,
|
||||
owner_did: repo.owner_did,
|
||||
description: repo.description,
|
||||
default_branch: repo.default_branch,
|
||||
created_at: repo.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_cursor(repo: &RepoRecord) -> String {
|
||||
URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&(&repo.owner_did, &repo.name))
|
||||
.expect("a pair of strings is JSON serializable"),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_repo_cursor(cursor: &str) -> Result<(String, String)> {
|
||||
// A position contains only public response fields, not a database id or
|
||||
// authority. Every page re-evaluates visibility for the current caller.
|
||||
if cursor.len() > 4096 {
|
||||
return Err(async_graphql::Error::new("invalid repository cursor"));
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| async_graphql::Error::new("invalid repository cursor"))?;
|
||||
let position: (String, String) = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| async_graphql::Error::new("invalid repository cursor"))?;
|
||||
// PostgreSQL text cannot contain NUL. Reject it as malformed input before
|
||||
// binding either cursor component, rather than reporting a database error.
|
||||
if position.0.contains('\0') || position.1.contains('\0') {
|
||||
return Err(async_graphql::Error::new("invalid repository cursor"));
|
||||
}
|
||||
Ok(position)
|
||||
}
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
// DB-backed roots carry a base cost so aliases consume the request budget
|
||||
// even when each alias selects only one inexpensive response field.
|
||||
// `repos` performs the same full corpus scan as a max-size `reposPage`
|
||||
// (list_visible_repos_page with MAX_VISIBLE_REPO_PAGE_SIZE + 1), so it
|
||||
// is priced as the full page it is, not as a cheap alias.
|
||||
#[graphql(complexity = "50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity")]
|
||||
/// Complete visible repository list, up to the documented maximum number
|
||||
/// of entries. Larger lists must use reposPage; this field returns an
|
||||
/// error instead of truncating silently.
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
|
||||
// this surface does not enumerate private repos (#97). The caller DID is
|
||||
// threaded onto the context by optional_signature; absent = anonymous.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = db
|
||||
.list_visibility_rules_for_repos(&ids)
|
||||
let mut repos = db
|
||||
.list_visible_repos_page(caller, None, MAX_VISIBLE_REPO_PAGE_SIZE + 1)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
Ok(repos
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
|
||||
})
|
||||
.map(|r| RepoType {
|
||||
name: r.name,
|
||||
owner_did: r.owner_did,
|
||||
description: r.description,
|
||||
default_branch: r.default_branch,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
if repos.len() > MAX_VISIBLE_REPO_PAGE_SIZE {
|
||||
return Err(async_graphql::Error::new(format!(
|
||||
"repository list exceeds {} entries; use reposPage with limit and after",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
)));
|
||||
}
|
||||
// Preserve the legacy activity ordering for complete, small lists.
|
||||
repos.sort_by_key(|repo| std::cmp::Reverse(repo.updated_at));
|
||||
Ok(repos.into_iter().map(repo_type).collect())
|
||||
}
|
||||
|
||||
/// Bounded visible repositories ordered by owner and name. Continue with
|
||||
/// endCursor while hasNextPage is true. Pages are not a database snapshot.
|
||||
// Every page sorts the full deduplicated corpus under COLLATE "C"
|
||||
// (list_visible_repos_page documents the accepted O(total repos) bound),
|
||||
// so the price charges the scan, not the emitted rows: a limit:1 page
|
||||
// costs the same as a max-size page, the way `repos` is priced.
|
||||
#[graphql(complexity = "50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity")]
|
||||
async fn repos_page(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Page size from 1 to the documented maximum; other values are rejected."
|
||||
)]
|
||||
limit: i64,
|
||||
after: Option<String>,
|
||||
) -> Result<RepoPageType> {
|
||||
if !(1..=MAX_VISIBLE_REPO_PAGE_SIZE as i64).contains(&limit) {
|
||||
return Err(async_graphql::Error::new(format!(
|
||||
"limit must be between 1 and {}",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
)));
|
||||
}
|
||||
let after = after.as_deref().map(parse_repo_cursor).transpose()?;
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let mut repos = db
|
||||
.list_visible_repos_page(
|
||||
caller,
|
||||
after
|
||||
.as_ref()
|
||||
.map(|(owner, name)| (owner.as_str(), name.as_str())),
|
||||
limit as usize + 1,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let has_next_page = repos.len() > limit as usize;
|
||||
repos.truncate(limit as usize);
|
||||
let end_cursor = repos.last().map(repo_cursor);
|
||||
Ok(RepoPageType {
|
||||
nodes: repos.into_iter().map(repo_type).collect(),
|
||||
has_next_page,
|
||||
end_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
// Complexity is additive with a base reflecting the collector's fixed scan:
|
||||
// every request loads the full deduped repo set, the quarantined set, and
|
||||
// the visibility rules, then walks up to max(limit, 2048) event rows past
|
||||
// withheld ones, regardless of `limit`. A multiplicative price both
|
||||
// undercharged limit:1 (51 for that fixed work) and made a request at the
|
||||
// documented maximum unreachable (two fields at max limit cost 450 > 400).
|
||||
//
|
||||
// Decision on the collector floor (review #465, 3rd round): the up-to-2048
|
||||
// walk is bounded constant overhead that does not scale with `limit`, so
|
||||
// it is priced in the fixed base, not the limit term; the limit term
|
||||
// prices only the emitted rows. Pricing the walk at face value in the base
|
||||
// would put every refUpdates query over the 400 budget and make the field
|
||||
// unusable, so the base prices that fixed scan as a lump sum instead.
|
||||
#[graphql(
|
||||
complexity = "100 + (limit.clamp(0, MAX_VISIBLE_REF_UPDATES) as usize) + child_complexity"
|
||||
)]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
repo: Option<String>,
|
||||
#[graphql(
|
||||
default = 20,
|
||||
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
|
||||
desc = "Requests above the documented maximum return the newest rows up to that maximum (no continuation cursor)."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<RefUpdateType>> {
|
||||
@@ -105,6 +202,14 @@ impl QueryRoot {
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
// Complexity is additive: list_tasks is a single LIMIT-bounded query whose
|
||||
// cost does not scale with the selected fields, so the price is the base
|
||||
// plus the clamped limit plus the child fields. A multiplicative price
|
||||
// made a request at the documented maximum unreachable (two fields at max
|
||||
// limit cost 450 > 400).
|
||||
#[graphql(
|
||||
complexity = "50 + (limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE) as usize) + child_complexity"
|
||||
)]
|
||||
async fn tasks(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -112,7 +217,7 @@ impl QueryRoot {
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
|
||||
desc = "Requests above the documented maximum are clamped to it (no error). Negative values clamp to 0."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
@@ -120,7 +225,7 @@ impl QueryRoot {
|
||||
// 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 limit = limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
@@ -128,6 +233,7 @@ impl QueryRoot {
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let t = db
|
||||
@@ -141,12 +247,642 @@ impl QueryRoot {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OWNER: &str = "did:key:z6MkOwner";
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_legacy_rejects_overflow_and_pages_reach_every_visible_repo(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 5;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
db.create_repo(&repo(&name, OWNER, &name, true))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The SQL helper itself must bound materialization, even for a caller
|
||||
// accidentally requesting an unlimited page.
|
||||
assert_eq!(
|
||||
db.list_visible_repos_page(None, None, usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 1
|
||||
);
|
||||
let schema = schema(db);
|
||||
let legacy = anon(&schema, "{ repos { name } }").await;
|
||||
assert_eq!(legacy.errors.len(), 1);
|
||||
assert!(legacy.errors[0].message.contains("use reposPage"));
|
||||
assert_eq!(legacy.data, async_graphql::Value::Null);
|
||||
|
||||
let mut cursor = None;
|
||||
let mut names = Vec::new();
|
||||
loop {
|
||||
let response = schema.execute(
|
||||
async_graphql::Request::new(
|
||||
"query($after: String) { reposPage(limit: 50, after: $after) { nodes { name } hasNextPage endCursor } }",
|
||||
).variables(async_graphql::Variables::from_json(serde_json::json!({"after": cursor}))),
|
||||
).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let json = response.data.into_json().unwrap();
|
||||
let page = &json["reposPage"];
|
||||
let rows = page["nodes"].as_array().unwrap();
|
||||
assert!(rows.len() <= 50);
|
||||
names.extend(
|
||||
rows.iter()
|
||||
.map(|row| row["name"].as_str().unwrap().to_owned()),
|
||||
);
|
||||
if !page["hasNextPage"].as_bool().unwrap() {
|
||||
break;
|
||||
}
|
||||
let next = page["endCursor"].as_str().unwrap().to_owned();
|
||||
assert_ne!(cursor.as_ref(), Some(&next));
|
||||
cursor = Some(next);
|
||||
assert!(names.len() <= crate::db::MAX_VISIBLE_REPO_PAGE_SIZE);
|
||||
}
|
||||
assert_eq!(
|
||||
names,
|
||||
(0..total)
|
||||
.map(|index| format!("repo-{index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_legacy_accepts_exactly_the_visible_bound(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let base_time = Utc::now();
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
let mut r = repo(&name, OWNER, &name, true);
|
||||
// Anti-correlate updated_at with name, creation order, and index
|
||||
// so only ordering by updated_at DESC can satisfy the expectation.
|
||||
let offset = (index * 37) % total;
|
||||
r.updated_at = base_time + chrono::Duration::seconds(offset as i64);
|
||||
db.create_repo(&r).await.unwrap();
|
||||
}
|
||||
db.create_repo(&repo("hidden", OWNER, "hidden", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let response = anon(&schema(db), "{ repos { name } }").await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let repos = response.data.into_json().unwrap()["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(repos.len(), total);
|
||||
let names: Vec<_> = repos.iter().filter_map(|r| r["name"].as_str()).collect();
|
||||
assert!(
|
||||
!names.contains(&"hidden"),
|
||||
"hidden repo must be excluded from legacy repos response"
|
||||
);
|
||||
let mut expected_indices = (0..total).collect::<Vec<_>>();
|
||||
expected_indices.sort_by_key(|&idx| std::cmp::Reverse((idx * 37) % total));
|
||||
let expected_names: Vec<String> = expected_indices
|
||||
.into_iter()
|
||||
.map(|index| format!("repo-{index:03}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
expected_names
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
"legacy repos must retain activity ordering (most recently updated first)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_pagination_crosses_owner_boundary(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let owner1 = "did:key:z6MkaOwner1";
|
||||
let owner2 = "did:key:z6MkbOwner2";
|
||||
let earlier_owner = "did:web:example.com";
|
||||
// This owner sorts first only after did:key normalization. Its name
|
||||
// sorts past the first cursor, so an unqualified OR name predicate
|
||||
// would incorrectly serve it again on the next page.
|
||||
//
|
||||
// Rows are inserted out of order and not in reverse asserted order.
|
||||
// Assigned IDs do not track name order, and owner2 contains both
|
||||
// "B-repo" and "a-repo" whose byte order (B < a) differs from their
|
||||
// case-folded order (a < b), ensuring lower(d.name) cannot satisfy
|
||||
// the keyset query. Sized to 6 entries so the last page is full-sized
|
||||
// (limit 2), proving hasNextPage distinguishes a full terminal page.
|
||||
db.create_repo(&repo("r5", owner2, "c-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r9", earlier_owner, "zz-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r3", owner2, "B-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r8", owner1, "z-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r2", owner1, "b-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r1", owner2, "a-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let page1_resp = anon(
|
||||
&schema,
|
||||
"{ reposPage(limit: 2) { nodes { name ownerDid } hasNextPage endCursor } }",
|
||||
)
|
||||
.await;
|
||||
assert!(page1_resp.errors.is_empty(), "{:?}", page1_resp.errors);
|
||||
let p1 = page1_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p1["hasNextPage"], true);
|
||||
assert_eq!(
|
||||
p1["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "zz-repo", "ownerDid": earlier_owner},
|
||||
{"name": "b-repo", "ownerDid": owner1},
|
||||
])
|
||||
);
|
||||
let cursor = p1["endCursor"].as_str().unwrap();
|
||||
|
||||
let page2_query = format!(
|
||||
"{{ reposPage(limit: 2, after: \"{cursor}\") {{ nodes {{ name ownerDid }} hasNextPage endCursor }} }}"
|
||||
);
|
||||
let page2_resp = anon(&schema, &page2_query).await;
|
||||
assert!(page2_resp.errors.is_empty(), "{:?}", page2_resp.errors);
|
||||
let p2 = page2_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p2["hasNextPage"], true);
|
||||
assert_eq!(
|
||||
p2["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "z-repo", "ownerDid": owner1},
|
||||
{"name": "B-repo", "ownerDid": owner2},
|
||||
])
|
||||
);
|
||||
let cursor = p2["endCursor"].as_str().unwrap();
|
||||
let page3_query = format!(
|
||||
"{{ reposPage(limit: 2, after: \"{cursor}\") {{ nodes {{ name ownerDid }} hasNextPage endCursor }} }}"
|
||||
);
|
||||
let page3_resp = anon(&schema, &page3_query).await;
|
||||
assert!(page3_resp.errors.is_empty(), "{:?}", page3_resp.errors);
|
||||
let p3 = page3_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p3["hasNextPage"], false);
|
||||
assert_eq!(
|
||||
p3["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "a-repo", "ownerDid": owner2},
|
||||
{"name": "c-repo", "ownerDid": owner2},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_at_documented_maximum_distinguishes_terminal_page(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
db.create_repo(&repo(&name, OWNER, &name, true))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db.clone());
|
||||
let query =
|
||||
format!("{{ reposPage(limit: {total}) {{ nodes {{ name }} hasNextPage endCursor }} }}");
|
||||
let resp200 = anon(&schema, &query).await;
|
||||
assert!(resp200.errors.is_empty(), "{:?}", resp200.errors);
|
||||
let p200 = resp200.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p200["hasNextPage"], false);
|
||||
let nodes200 = p200["nodes"].as_array().unwrap().clone();
|
||||
assert_eq!(nodes200.len(), total);
|
||||
|
||||
// Adding row 201 keeps page 1 content identical while flipping hasNextPage to true.
|
||||
let name201 = format!("repo-{total:03}");
|
||||
db.create_repo(&repo(&name201, OWNER, &name201, true))
|
||||
.await
|
||||
.unwrap();
|
||||
let resp201 = anon(&schema, &query).await;
|
||||
assert!(resp201.errors.is_empty(), "{:?}", resp201.errors);
|
||||
let p201 = resp201.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p201["hasNextPage"], true);
|
||||
assert_eq!(p201["nodes"], p200["nodes"]);
|
||||
assert_eq!(p201["endCursor"], p200["endCursor"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_visibility_matches_the_shared_gate(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
let db = db(pool).await;
|
||||
let reader = "did:key:zReader";
|
||||
for (id, public) in [
|
||||
("open", true),
|
||||
("private", false),
|
||||
("root-deny", true),
|
||||
("root-reader", false),
|
||||
("subtree", true),
|
||||
("root-tie", true),
|
||||
("odd-star", true),
|
||||
("malformed-readers", true),
|
||||
("non-array-readers", true),
|
||||
("non-string-reader", true),
|
||||
("quarantined", true),
|
||||
] {
|
||||
db.create_repo(&repo(id, OWNER, id, public)).await.unwrap();
|
||||
}
|
||||
// Canonical and mirror copies must still collapse before pagination.
|
||||
db.create_repo(&repo("z6MkOwner/open", "z6MkOwner", "open", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo(
|
||||
"other-method",
|
||||
"did:web:z6MkOwner",
|
||||
"other-method",
|
||||
false,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_repo_quarantine("quarantined", true).await.unwrap();
|
||||
for (id, glob, readers) in [
|
||||
("root-deny", "/", vec![]),
|
||||
("root-reader", "/**", vec![reader.to_owned()]),
|
||||
("subtree", "/secret/**", vec![]),
|
||||
("root-tie", "/", vec![reader.to_owned()]),
|
||||
("root-tie", "/**", vec![]),
|
||||
("odd-star", "/*", vec![]),
|
||||
("malformed-readers", "/", vec![]),
|
||||
("non-array-readers", "/", vec![]),
|
||||
("non-string-reader", "/", vec![]),
|
||||
] {
|
||||
db.set_visibility_rule(id, glob, VisibilityMode::B, &readers, OWNER)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The typed setter cannot create malformed JSON, but existing TEXT rows
|
||||
// can contain it. Such a rule must deny this repo, not abort the page.
|
||||
// All three deny arms of the reader_dids predicate are pinned here:
|
||||
// (a) invalid JSON, (b) valid JSON that is not an array, (c) an array
|
||||
// containing a non-string member. The Rust gate parses reader_dids as
|
||||
// Vec<String> with unwrap_or_default(), so (b) and (c) also fail to
|
||||
// parse and deny; the SQL CASE must agree on every shape.
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("not JSON")
|
||||
.bind("malformed-readers")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("\"just-a-string\"")
|
||||
.bind("non-array-readers")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
// The named reader is listed, but the non-string member poisons the
|
||||
// whole list: both sides must still deny them.
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("[\"did:key:zReader\", 42]")
|
||||
.bind("non-string-reader")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
let all = db.list_all_repos_deduped().await.unwrap();
|
||||
for caller in [
|
||||
None,
|
||||
Some(OWNER),
|
||||
Some("z6MkOwner"),
|
||||
Some(reader),
|
||||
Some("zReader"),
|
||||
Some("did:web:z6MkOwner"),
|
||||
] {
|
||||
let mut expected = Vec::new();
|
||||
for record in &all {
|
||||
let rules = db.list_visibility_rules(&record.id).await.unwrap();
|
||||
if crate::visibility::listable_at_root(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
caller,
|
||||
) {
|
||||
expected.push(record.id.clone());
|
||||
}
|
||||
}
|
||||
expected.sort();
|
||||
let mut actual = db
|
||||
.list_visible_repos_page(caller, None, usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|record| record.id)
|
||||
.collect::<Vec<_>>();
|
||||
actual.sort();
|
||||
assert_eq!(actual, expected, "caller {caller:?}");
|
||||
}
|
||||
let schema = schema(db);
|
||||
for caller in [None, Some("did:key:zUnauthorized")] {
|
||||
let query = "{ repos { name } }";
|
||||
let response = match caller {
|
||||
None => anon(&schema, query).await,
|
||||
Some(caller) => authed(&schema, query, caller).await,
|
||||
};
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let mut names: Vec<String> = response.data.into_json().unwrap()["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|repo| repo["name"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["odd-star", "open", "subtree"]);
|
||||
}
|
||||
let query = "{ reposPage(limit: 1) { nodes { name ownerDid } hasNextPage endCursor } }";
|
||||
let response = anon(&schema, query).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let json = response.data.into_json().unwrap();
|
||||
let page = &json["reposPage"];
|
||||
assert_eq!(
|
||||
page["nodes"],
|
||||
serde_json::json!([{"name": "odd-star", "ownerDid": OWNER}])
|
||||
);
|
||||
assert_eq!(page["hasNextPage"], true);
|
||||
let cursor = super::parse_repo_cursor(page["endCursor"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(cursor, (OWNER.to_owned(), "odd-star".to_owned()));
|
||||
assert!(!json.to_string().contains("private"));
|
||||
assert!(!json.to_string().contains("quarantined"));
|
||||
|
||||
// Route-level check with unauthorized authenticated caller:
|
||||
// Excludes private, quarantined, and root-deny repos, matching anonymous behavior.
|
||||
let all_query = "{ reposPage(limit: 50) { nodes { name } hasNextPage } }";
|
||||
let unauth_response = authed(&schema, all_query, "did:key:zUnauthorized").await;
|
||||
assert!(
|
||||
unauth_response.errors.is_empty(),
|
||||
"{:?}",
|
||||
unauth_response.errors
|
||||
);
|
||||
let unauth_json = unauth_response.data.into_json().unwrap();
|
||||
let unauth_names: Vec<&str> = unauth_json["reposPage"]["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| n["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(unauth_names, vec!["odd-star", "open", "subtree"]);
|
||||
let unauth_str = unauth_json.to_string();
|
||||
assert!(!unauth_str.contains("private"));
|
||||
assert!(!unauth_str.contains("quarantined"));
|
||||
assert!(!unauth_str.contains("root-deny"));
|
||||
assert!(!unauth_str.contains("root-reader"));
|
||||
assert!(!unauth_str.contains("root-tie"));
|
||||
|
||||
// Reader caller gets root-reader and root-tie, but still excludes private, quarantined, root-deny.
|
||||
let reader_response = authed(&schema, all_query, reader).await;
|
||||
assert!(
|
||||
reader_response.errors.is_empty(),
|
||||
"{:?}",
|
||||
reader_response.errors
|
||||
);
|
||||
let reader_json = reader_response.data.into_json().unwrap();
|
||||
let reader_names: Vec<&str> = reader_json["reposPage"]["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| n["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reader_names,
|
||||
vec!["odd-star", "open", "root-reader", "subtree"]
|
||||
);
|
||||
let reader_str = reader_json.to_string();
|
||||
assert!(!reader_str.contains("private"));
|
||||
assert!(!reader_str.contains("quarantined"));
|
||||
assert!(!reader_str.contains("root-deny"));
|
||||
assert!(!reader_str.contains("root-tie"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_rechecks_cursor_authority_and_exact_boundary(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let visible = repo("visible", OWNER, "a-visible", true);
|
||||
db.create_repo(&visible).await.unwrap();
|
||||
db.create_repo(&repo("hidden", OWNER, "z-private", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let first = anon(
|
||||
&schema,
|
||||
"{ reposPage(limit: 1) { nodes { name } hasNextPage endCursor } }",
|
||||
)
|
||||
.await;
|
||||
assert!(first.errors.is_empty());
|
||||
let first = first.data.into_json().unwrap();
|
||||
assert_eq!(first["reposPage"]["hasNextPage"], false);
|
||||
let query = format!(
|
||||
"{{ reposPage(limit: 1, after: \"{}\") {{ nodes {{ name }} hasNextPage endCursor }} }}",
|
||||
super::repo_cursor(&visible)
|
||||
);
|
||||
let owner = authed(&schema, &query, OWNER).await;
|
||||
assert!(owner.errors.is_empty());
|
||||
assert_eq!(
|
||||
owner.data.into_json().unwrap()["reposPage"]["nodes"][0]["name"],
|
||||
"z-private"
|
||||
);
|
||||
let anon = anon(&schema, &query).await;
|
||||
assert!(anon.errors.is_empty());
|
||||
assert_eq!(
|
||||
anon.data.into_json().unwrap()["reposPage"],
|
||||
serde_json::json!({
|
||||
"nodes": [], "hasNextPage": false, "endCursor": null
|
||||
})
|
||||
);
|
||||
let unauth = authed(&schema, &query, "did:key:zUnauthorized").await;
|
||||
assert!(unauth.errors.is_empty());
|
||||
assert_eq!(
|
||||
unauth.data.into_json().unwrap()["reposPage"],
|
||||
serde_json::json!({
|
||||
"nodes": [], "hasNextPage": false, "endCursor": null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repos_page_rejects_nul_cursors_before_database_access() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
for position in [("did:key:zOwner\0", "repo"), ("did:key:zOwner", "repo\0")] {
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&position).unwrap());
|
||||
let query = format!("{{ reposPage(after: \"{cursor}\") {{ hasNextPage }} }}");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.expect("invalid cursors must be rejected without accessing the database");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "invalid repository cursor");
|
||||
assert_eq!(response.data, async_graphql::Value::Null);
|
||||
}
|
||||
let position = ("did:key:zOwner", "repo");
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&position).unwrap());
|
||||
assert_eq!(
|
||||
super::parse_repo_cursor(&cursor).unwrap(),
|
||||
(position.0.to_owned(), position.1.to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repos_page_rejects_invalid_inputs_before_database_access() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
let over_max = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 1;
|
||||
let expected_limit_message = format!(
|
||||
"limit must be between 1 and {}",
|
||||
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
);
|
||||
// Each input pins its own rejection message: the loop must not accept
|
||||
// either message for every query, or a misattributed message (e.g. the
|
||||
// limit arm returning "invalid repository cursor") stays invisible.
|
||||
for (query, expected_message) in [
|
||||
(
|
||||
"{ reposPage(limit: 0) { hasNextPage } }".to_owned(),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
"{ reposPage(limit: -1) { hasNextPage } }".to_owned(),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
format!("{{ reposPage(limit: {over_max}) {{ hasNextPage }} }}"),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
"{ reposPage(after: \"invalid!\") { hasNextPage } }".to_owned(),
|
||||
"invalid repository cursor".to_owned(),
|
||||
),
|
||||
] {
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(
|
||||
response.errors[0].message, expected_message,
|
||||
"wrong rejection message for query: {query}"
|
||||
);
|
||||
}
|
||||
// Valid base64 and valid cursor JSON: only the length guard rejects it.
|
||||
let oversized = super::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&("did:key:reader", "a".repeat(3100))).unwrap());
|
||||
assert!(oversized.len() > 4096);
|
||||
assert!(serde_json::from_slice::<(String, String)>(
|
||||
&super::URL_SAFE_NO_PAD.decode(&oversized).unwrap()
|
||||
)
|
||||
.is_ok());
|
||||
assert!(super::parse_repo_cursor(&oversized).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_repo_cursor_rejects_wrong_shape_json() {
|
||||
// Valid base64 and valid JSON, but not a (String, String) pair: the
|
||||
// wrong-shape arm must report "invalid repository cursor", exactly,
|
||||
// before any database access.
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
for payload in [
|
||||
serde_json::json!(["only-one"]),
|
||||
serde_json::json!(["a", "b", "c"]),
|
||||
serde_json::json!({"owner": "x", "name": "y"}),
|
||||
serde_json::json!(42),
|
||||
serde_json::json!(["a", 1]),
|
||||
] {
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
|
||||
let err = super::parse_repo_cursor(&cursor).unwrap_err();
|
||||
assert_eq!(err.message, "invalid repository cursor");
|
||||
let query = format!("{{ reposPage(after: \"{cursor}\") {{ hasNextPage }} }}");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.expect("wrong-shape cursors must be rejected without accessing the database");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "invalid repository cursor");
|
||||
assert_eq!(response.data, async_graphql::Value::Null);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn longest_repo_name_cursor_round_trips() {
|
||||
// #465 4th round: a cursor the server emits must always parse back.
|
||||
// Repo names are bounded at the create/fork/mirror write paths, so the
|
||||
// longest admissible name must produce a cursor under the 4096-byte
|
||||
// parse limit.
|
||||
let name = "a".repeat(crate::db::MAX_REPO_NAME_LEN);
|
||||
let record = repo("long", OWNER, &name, true);
|
||||
let cursor = super::repo_cursor(&record);
|
||||
assert!(
|
||||
cursor.len() <= 4096,
|
||||
"emitted cursor must stay under the parse limit: {}",
|
||||
cursor.len()
|
||||
);
|
||||
assert_eq!(
|
||||
super::parse_repo_cursor(&cursor).unwrap(),
|
||||
(OWNER.to_owned(), name)
|
||||
);
|
||||
}
|
||||
|
||||
/// A negative refUpdates(limit) must be accepted and clamped to zero rows,
|
||||
/// like tasks(limit: -1): the complexity formula's `limit.clamp(0, ...)`
|
||||
/// prices it at the floor and the shared collector returns before any
|
||||
/// database access. Mirrors tasks_negative_limit_clamped.
|
||||
#[tokio::test]
|
||||
async fn ref_updates_negative_limit_clamped() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
let resp = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
anon(&schema, "{ refUpdates(limit: -1) { repo } }"),
|
||||
)
|
||||
.await
|
||||
.expect("negative limit must clamp without accessing the database");
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(count(&resp), 0);
|
||||
}
|
||||
|
||||
/// The mirror write path binds peer-supplied names verbatim, so it must
|
||||
/// enforce the same bound as the local write paths: an overlong peer name
|
||||
/// would otherwise make reposPage emit an unparseable cursor. The
|
||||
/// rejection happens before any database access.
|
||||
#[tokio::test]
|
||||
async fn mirror_repo_rejects_overlong_name() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let db = Db::for_testing(pool);
|
||||
let name = "b".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
|
||||
let err = db
|
||||
.upsert_mirror_repo("z6MkLong", &name, "/tmp/long", None, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exceeds"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn db(pool: PgPool) -> Arc<Db> {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
@@ -475,12 +1211,13 @@ mod tests {
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
}
|
||||
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises the
|
||||
/// documented maximum).
|
||||
#[sqlx::test]
|
||||
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
|
||||
async fn tasks_limit_ceiling_clamped_to_max(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for i in 0..201 {
|
||||
for i in 0..crate::db::MAX_VISIBLE_TASK_PAGE_SIZE + 1 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-ceil-{i}"),
|
||||
repo_id: None,
|
||||
@@ -501,7 +1238,49 @@ mod tests {
|
||||
}
|
||||
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");
|
||||
assert_eq!(
|
||||
count_tasks(&resp),
|
||||
crate::db::MAX_VISIBLE_TASK_PAGE_SIZE as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_TASK_PAGE_SIZE"
|
||||
);
|
||||
}
|
||||
|
||||
/// A limit above MAX_VISIBLE_REF_UPDATES must reach the resolver clamped,
|
||||
/// not be rejected: the complexity formula clamps the priced limit (so the
|
||||
/// document passes validation) and the shared collector clamps the rows
|
||||
/// (so at most MAX_VISIBLE_REF_UPDATES come back). Removing the formula
|
||||
/// clamp prices the raw limit and rejects the document; removing the
|
||||
/// collector clamp returns unbounded rows. Mirrors
|
||||
/// tasks_limit_ceiling_clamped_to_max.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_limit_ceiling_clamped_to_max(pool: PgPool) {
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let total = MAX_VISIBLE_REF_UPDATES as usize + 1;
|
||||
for i in 0..total {
|
||||
let mut row = ref_row(&format!("ceil{i}"), "z6MkOwner/widget");
|
||||
row.timestamp = format!("2026-07-01T10:{:02}:{:02}+00:00", i / 60, i % 60);
|
||||
db.insert_ref_update(&row).await.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
let query = format!(
|
||||
"{{ refUpdates(limit: {}) {{ repo }} }}",
|
||||
MAX_VISIBLE_REF_UPDATES + 4800
|
||||
);
|
||||
let resp = anon(&schema, &query).await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"limit above the maximum must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(
|
||||
count(&resp),
|
||||
MAX_VISIBLE_REF_UPDATES as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_REF_UPDATES"
|
||||
);
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
|
||||
@@ -20,6 +20,7 @@ impl SubscriptionRoot {
|
||||
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
|
||||
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
|
||||
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -46,6 +47,7 @@ impl SubscriptionRoot {
|
||||
})
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task_events(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -11,6 +11,14 @@ pub struct RepoType {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject)]
|
||||
pub struct RepoPageType {
|
||||
pub nodes: Vec<RepoType>,
|
||||
pub has_next_page: bool,
|
||||
/// Position after the last returned visible repository; null for an empty page.
|
||||
pub end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct AgentTaskType {
|
||||
pub id: String,
|
||||
|
||||
+2580
-37
File diff suppressed because it is too large.
Load diff
+2176
-72
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
//! Prometheus metrics for the gitlawb node.
|
||||
//! Prometheus metrics for the Twigpine node.
|
||||
//!
|
||||
//! Exposes a small, deliberately conservative set of counters and histograms
|
||||
//! that cover the questions an operator actually asks of a live node:
|
||||
@@ -11,6 +11,11 @@
|
||||
//! `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
|
||||
@@ -33,8 +38,8 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use prometheus::{
|
||||
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
|
||||
TextEncoder,
|
||||
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
|
||||
Registry, TextEncoder,
|
||||
};
|
||||
|
||||
/// The single, process-wide metrics registry. Initialized by [`init`].
|
||||
@@ -49,8 +54,15 @@ 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
|
||||
@@ -168,6 +180,33 @@ 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",
|
||||
@@ -202,6 +241,18 @@ 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)");
|
||||
@@ -263,13 +314,75 @@ pub fn record_sync_processed(status: &str) {
|
||||
}
|
||||
|
||||
/// Record one webhook delivery attempt outcome. `result` ∈
|
||||
/// {ok, http_error, network_error, skipped}.
|
||||
/// {ok, http_error, network_error, skipped, invalid_url}.
|
||||
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
|
||||
/// check at delivery time and the POST was never sent.
|
||||
pub fn record_webhook_delivery(result: &str) {
|
||||
if let Some(c) = WEBHOOK_DELIVERIES.get() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -284,6 +397,13 @@ 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> {
|
||||
@@ -365,6 +485,12 @@ 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);
|
||||
}
|
||||
|
||||
+11080
-64
File diff suppressed because it is too large.
Load diff
@@ -70,48 +70,94 @@ pub async fn pin_object(
|
||||
/// Pin any of the given candidate git objects that haven't yet been sent to
|
||||
/// Pinata.
|
||||
///
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin (blobs and
|
||||
/// withheld-subtree trees already dropped): the caller
|
||||
/// applies `visibility_pack::replicable_objects` on the delta path or the
|
||||
/// `..._fail_closed` filter on the full-scan path before calling. `repo_path` is
|
||||
/// still needed to read each object's bytes, and `git_bin` names the binary those
|
||||
/// reads run: the production caller passes the literal `"git"`, and a test passes a
|
||||
/// fake so the loop's own bound can be driven with a git that never answers.
|
||||
/// Objects already recorded with a `pinata_cid` are skipped. Returns
|
||||
/// `(sha_hex, cid)` pairs for each newly pinned object.
|
||||
/// `git_timeout` is the per-object read bound, the same value and the same role it has
|
||||
/// in the twin: it bounds both the pin read and the skip branch's opportunistic repair.
|
||||
/// Objects already recorded with a `pinata_cid` are skipped, and `repo_id` records the
|
||||
/// pin's provenance (#173). Returns `(sha_hex, provider_cid)` pairs for each newly
|
||||
/// pinned object: the provider CID is the Pinata gateway CID (used for branch->CID
|
||||
/// recording and ref-update gossip), NOT the raw resolver-key CID stored in
|
||||
/// `pinned_cids.cid`.
|
||||
///
|
||||
/// # What `batch_budget` does and does not bound
|
||||
///
|
||||
/// The loop runs under a `pin_semaphore` permit and that pool defers rather than
|
||||
/// sheds, so the hold has to be bounded by something other than the pusher's object
|
||||
/// count. Two things here are:
|
||||
/// count. Three things here are:
|
||||
///
|
||||
/// - this loop's own wall-clock: the deadline is taken once at loop start and
|
||||
/// checked at the top of every iteration, so no object's work begins with less
|
||||
/// than the read floor left. It is a gate, not a hard ceiling, since a started
|
||||
/// iteration still runs to completion;
|
||||
/// - the git read: `store::read_object_bounded` runs under `spawn_blocking` against the
|
||||
/// ABSOLUTE batch deadline (not the loop-top remainder, which the `has_pinata_cid`
|
||||
/// round-trip sitting between the two would push past it), with SIGTERM-then-SIGKILL
|
||||
/// process-group teardown, so a hung `git cat-file` costs this batch its remaining
|
||||
/// budget plus one watchdog teardown instead of holding the permit for the child's
|
||||
/// whole lifetime and blocking a runtime worker while it does.
|
||||
/// earlier of the ABSOLUTE batch deadline (not the loop-top remainder, which the
|
||||
/// `has_pinata_cid` round-trip sitting between the two would push past it) and this
|
||||
/// object's own `git_timeout`, with SIGTERM-then-SIGKILL process-group teardown, so a
|
||||
/// hung `git cat-file` costs this batch one `git_timeout` plus one watchdog teardown
|
||||
/// instead of holding the permit for the child's whole lifetime and blocking a runtime
|
||||
/// worker while it does;
|
||||
/// - the DB round-trips: every DB operation reachable from inside the region is
|
||||
/// bounded by the same absolute deadline through `crate::ipfs_pin::db_bounded`,
|
||||
/// including the two inside `repair_legacy_provider_cid`, which this loop body's own
|
||||
/// call sites do not show. `retry_db_record` is wrapped as a whole so its ladder
|
||||
/// cannot multiply one remainder, and the durability writes (the post-upload
|
||||
/// `record_pinata_cid` and source record, the skip branch's source record, and both
|
||||
/// incomplete markers) take the floored remainder `max(remaining, DB_RECORD_GRACE)`
|
||||
/// so a spent budget delays the permit release rather than dropping a write. That
|
||||
/// floor matters more here than on the twin: `pinned.push` is unconditional, so a
|
||||
/// dropped record would leave `api::repos` advertising a CID the resolver 404s. A
|
||||
/// bound is not a rollback, and what an elapsed bound MEANS is a property of the
|
||||
/// operation, so each site maps that arm from the shape of the call it wrapped. Both
|
||||
/// source-record sites wrap `record_pin_source`, an explicit transaction whose
|
||||
/// `tx.commit()` a cancelled future never reaches, so a timeout there definitely did
|
||||
/// not land and both write the incomplete marker exactly as the definite-error arm
|
||||
/// does. `record_pinata_cid` is a single autocommit upsert, so ITS timeout is a
|
||||
/// genuine unknown outcome and is never treated as a failed write. See
|
||||
/// `crate::ipfs_pin::BoundedDbError::Elapsed`.
|
||||
///
|
||||
/// So the LOOP's hold is bounded by roughly `batch_budget`, plus one watchdog
|
||||
/// teardown and one upload (the shared client's whole-request timeout bounds the
|
||||
/// upload; `pin_object` takes no per-request override). The PERMIT's hold is NOT
|
||||
/// bounded by any of this: `api::repos` acquires the permit and then re-derives the
|
||||
/// object list with `pinata_object_list_for_refs` BEFORE this function is entered,
|
||||
/// and that walk carries no aggregate deadline. The DB round-trips
|
||||
/// (`has_pinata_cid`, `record_pinata_cid`) are untimed inside the budgeted region
|
||||
/// too.
|
||||
/// teardown, one upload (the shared client's whole-request timeout bounds the
|
||||
/// upload; `pin_object` takes no per-request override), and the record graces one
|
||||
/// iteration can chain. `db_record_deadline` re-floors from `Instant::now()` at EVERY
|
||||
/// call, so the graces inside a single iteration add up rather than sharing one floor:
|
||||
/// the worst case here is the add path at `deadline + 6s` (`record_pinata_cid`, then
|
||||
/// `record_pin_source`, then its incomplete marker), against the skip branch's
|
||||
/// `deadline + 4s`. It does NOT stack per object, because the next iteration's first
|
||||
/// statement is `batch_budget_gate`, which breaks the batch, so the overrun is one
|
||||
/// iteration's worth however many objects the push carried. Against the 120s
|
||||
/// `PIN_BATCH_BUDGET` that is roughly a 5% overrun for the batch, not an unbounded
|
||||
/// hold. The PERMIT's hold is NOT bounded by any of this, and that stays out of
|
||||
/// scope: `api::repos` acquires the permit (repos.rs ~2688) and only then re-derives
|
||||
/// the object list with `pinata_object_list_for_refs` (~2697), BEFORE this function is
|
||||
/// entered, and that walk carries no aggregate deadline. What is bounded is this
|
||||
/// loop's own hold, not the permit's total hold and not the semaphore's worst-case
|
||||
/// queue.
|
||||
///
|
||||
/// The twin in `ipfs_pin.rs` shares the budget gate and the bounded read with this
|
||||
/// loop, so both are at parity again. Change them in lockstep: the skip-if-pinned
|
||||
/// check, the fault arms, the returned pairs, and the budget handling.
|
||||
// Eight arguments, one over clippy's threshold: the two the budget adds (`git_bin`,
|
||||
// `batch_budget`) are what put the read under test injection and under a deadline, and
|
||||
// grouping them into a struct would only move the same values behind a name the twin in
|
||||
// `ipfs_pin.rs` does not use. Same allow as the sibling call sites in `api::repos`.
|
||||
/// The twin in `ipfs_pin.rs` is at parity with this loop on everything that bounds or
|
||||
/// repairs an object: the shared budget gate, the read bounded by the earlier of the
|
||||
/// batch deadline and `git_timeout`, the skip branch's opportunistic legacy
|
||||
/// provider-CID repair, and the DB bound above, which is the SAME helper and the same
|
||||
/// floor on both sides rather than a copy. Change them in lockstep: the
|
||||
/// skip-if-pinned check, the provenance and source recording, the fault arms, and the
|
||||
/// budget handling.
|
||||
///
|
||||
/// The RETURNED PAIRS are the one deliberate divergence, and it is not drift. This side
|
||||
/// pushes a pin whose DB record exhausted its retries, because this return is a real
|
||||
/// input: `api::repos` builds the sha-to-cid `cid_map` from it, which drives
|
||||
/// `upsert_branch_cid` and the p2p `publish_ref_update` gossip CID. The twin's return is
|
||||
/// log-only, so it omits a record-failed pin rather than logging a pin the resolver
|
||||
/// cannot serve. Moving this side to match would need that consumer moved first.
|
||||
// Ten arguments, over clippy's threshold: the three the budget and the git seam add
|
||||
// (`git_bin`, `git_timeout`, `batch_budget`) plus #173's `repo_id` are what put the read
|
||||
// under test injection and under a deadline, and grouping them into a struct would only
|
||||
// move the same values behind a name the twin in `ipfs_pin.rs` does not use. Same allow
|
||||
// as the sibling call sites in `api::repos`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn pin_new_objects(
|
||||
client: &reqwest::Client,
|
||||
@@ -119,8 +165,10 @@ pub async fn pin_new_objects(
|
||||
jwt: &str,
|
||||
repo_path: &std::path::Path,
|
||||
git_bin: &str,
|
||||
git_timeout: Duration,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
batch_budget: Duration,
|
||||
) -> Vec<(String, String)> {
|
||||
if jwt.is_empty() {
|
||||
@@ -144,8 +192,124 @@ pub async fn pin_new_objects(
|
||||
break;
|
||||
}
|
||||
|
||||
match db.has_pinata_cid(&sha).await {
|
||||
Ok(true) => continue,
|
||||
// Every DB call from here to the end of the iteration is bounded by the
|
||||
// ABSOLUTE batch deadline (F3, #173), through the same `db_bounded` helper the
|
||||
// ipfs_pin twin routes through: this loop runs under the same global pin permit
|
||||
// and a bare await parked it for the whole stall. The elapsed arm is mapped per
|
||||
// site below, never as a blanket "existing error arm": a timeout cancels the
|
||||
// client future but not the statement Postgres is running, so it reports an
|
||||
// UNKNOWN outcome, not a failed write.
|
||||
match crate::ipfs_pin::db_bounded(deadline, db.has_pinata_cid(&sha)).await {
|
||||
Ok(true) => {
|
||||
// Backfill NULL first-pinner provenance from a known source, in lockstep
|
||||
// with the ipfs_pin skip branch: a pinata-only node otherwise leaves
|
||||
// pre-provenance rows' `pinned_cids.repo_id` NULL forever (grok P2-D). The
|
||||
// resolver still finds the object via the pin_repo_sources union below, so
|
||||
// this is a consistency backfill, not a correctness fix.
|
||||
//
|
||||
// Elapsed here is free to skip: the read costs nothing when it lands late,
|
||||
// and the backfill's own `AND repo_id IS NULL` guard makes a late-landing
|
||||
// write idempotent.
|
||||
match crate::ipfs_pin::db_bounded(deadline, db.provenance_for_oid(&sha)).await {
|
||||
Ok(None) => {
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
deadline,
|
||||
db.backfill_pin_provenance(&sha, repo_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to backfill pin provenance");
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error reading pin provenance");
|
||||
}
|
||||
}
|
||||
// F1 (#173 round 8): record this repo as an additional source for the
|
||||
// already-pinned object (mirrors the ipfs_pin skip-branch insert) so the
|
||||
// resolver can serve a shared object from any pin-path source. U3 (#173):
|
||||
// retried through the SHARED helper (this was a bare call, so a single
|
||||
// transient error dropped the source outright) and, on exhaustion, marked
|
||||
// durably so the resolver keeps the bounded scan fallback for the object.
|
||||
// The retry ladder is bounded AS A WHOLE, not per attempt: three stalls
|
||||
// plus their backoff otherwise multiply one remainder by three. Floored at
|
||||
// DB_RECORD_GRACE because this is a durability write.
|
||||
match crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
// Elapsed here is a DEFINITE non-write, in lockstep with the twin,
|
||||
// and for a reason that comes from the operation rather than the
|
||||
// timeout: `record_pin_source` is an explicit transaction whose
|
||||
// `tx.commit()` a cancelled future never reaches, so no COMMIT is
|
||||
// sent and the row cannot have landed. Mark the set incomplete
|
||||
// exactly as the definite-error arm does; an incomplete-and-unmarked
|
||||
// set is read as COMPLETE and 404s a copy this repo would serve. The
|
||||
// marker's cost is bounded (the fallback scan is capped at
|
||||
// `ipfs_max_legacy_probes` and charges the per-IP work rate limiter
|
||||
// per probe). The arm stays separate only so the warn tells an
|
||||
// operator a stalled batch from a scattered per-object failure.
|
||||
Err(e @ crate::ipfs_pin::BoundedDbError::Elapsed) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
err = %e,
|
||||
"pin source record did not complete inside the batch deadline; \
|
||||
a cancelled multi-statement transaction never commits, so the \
|
||||
source is definitely missing and the set is marked incomplete"
|
||||
);
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
// The retries are spent on REAL errors, so this repo is definitely NOT
|
||||
// in the source set and the set is known incomplete. Floored for the
|
||||
// same reason the record above is: a spent budget must not drop the
|
||||
// compensation. The marker write is a single autocommit statement, so
|
||||
// ITS own elapsed arm is a genuine unknown outcome; nothing branches
|
||||
// on it, which is why warn-only is right there.
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
}
|
||||
// R8 (#173 round 10), in lockstep with the ipfs_pin skip branch:
|
||||
// opportunistically repair a legacy provider-CID row (Kubo dag-pb /
|
||||
// Pinata) to the raw-content resolver key on this re-push. Cost-gated on
|
||||
// the stored key's codec, so a non-legacy row reads no bytes. Warn-only:
|
||||
// a failure leaves the row as-is for a later re-push or the deferred
|
||||
// one-shot sweep.
|
||||
// Clamped to the batch deadline, in lockstep with the ipfs_pin twin: this
|
||||
// runs with the pin permit held, so an unclamped `git_timeout` would let
|
||||
// one wedged read hold a global pin slot for 600s against a 120s budget.
|
||||
if let Err(e) = crate::ipfs_pin::repair_legacy_provider_cid(
|
||||
repo_path,
|
||||
git_bin,
|
||||
std::cmp::min(deadline, std::time::Instant::now() + git_timeout),
|
||||
&sha,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to repair legacy provider CID");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking pinata_cid");
|
||||
@@ -163,7 +327,16 @@ pub async fn pin_new_objects(
|
||||
// between the two, so `Instant::now() + budget_left` would land past `deadline` by
|
||||
// however long the DB took, and under a saturated pool that is the dominant term.
|
||||
// A slow DB check must not push the read's own bound out.
|
||||
let read_deadline = deadline;
|
||||
//
|
||||
// Bounded by the EARLIER of the batch deadline (#174) and this object's own
|
||||
// `git_timeout` (#173), the same pair the ipfs_pin twin uses. Both bounds are
|
||||
// load-bearing and neither implies the other: the batch deadline alone would let
|
||||
// ONE wedged `cat-file` hold the pin permit for the whole budget, while
|
||||
// `git_timeout` alone would let a batch of merely-slow reads run past the budget.
|
||||
// As on the twin, at SHIPPED DEFAULTS the batch deadline is the arm that binds
|
||||
// (600s git timeout against a 120s budget); the `git_timeout` arm is for an
|
||||
// operator who tightens that knob below the remaining budget.
|
||||
let read_deadline = std::cmp::min(deadline, std::time::Instant::now() + git_timeout);
|
||||
let read_path = repo_path.to_path_buf();
|
||||
let read_sha = sha.clone();
|
||||
let read_git = git_bin.to_string();
|
||||
@@ -224,9 +397,86 @@ pub async fn pin_new_objects(
|
||||
|
||||
match pin_object(client, upload_url, jwt, &sha, &data).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
if let Err(e) = db.record_pinata_cid(&sha, &cid).await {
|
||||
// The resolver key (`pinned_cids.cid`) must be the locally-computed
|
||||
// raw-content CID, never the provider CID: Pinata wraps the bytes in
|
||||
// dag-pb/UnixFS, so its returned CID does not hash the raw content and
|
||||
// must not become an alias `/ipfs/{cid}` serves raw git bytes for (#173).
|
||||
let raw_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&data).to_string();
|
||||
// U3 (#173): both records go through the shared retry helper, at parity
|
||||
// with the ipfs_pin twin. These were bare calls, so one transient DB error
|
||||
// permanently dropped a pin source.
|
||||
//
|
||||
// Both bounds here are FLOORED at DB_RECORD_GRACE (F3, #173). The upload
|
||||
// runs under the shared client's own ceiling, so a successful one can
|
||||
// return with ~0 of the batch budget left, and an unfloored bound would
|
||||
// fail a write that today completes in milliseconds. That costs more on
|
||||
// this side than on the twin: `pinned.push` below is UNCONDITIONAL, so
|
||||
// `api/repos.rs` builds its `cid_map` from the pair either way and drives
|
||||
// `upsert_branch_cid` plus the p2p `publish_ref_update` gossip from it. A
|
||||
// dropped record therefore makes the node ADVERTISE a CID whose `/ipfs`
|
||||
// read 404s. If the floored bound still fires, THIS site's outcome really
|
||||
// is unknown, and unlike the source record below that is a property of
|
||||
// the operation: `record_pinata_cid` is a single autocommit upsert, so
|
||||
// the statement Postgres already started can still land after the client
|
||||
// future is cancelled. The warn names the arm through the error's own
|
||||
// Display and the site keeps its existing behavior: the pair is still
|
||||
// returned, and the row may or may not exist.
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
crate::ipfs_pin::retry_db_record(|| {
|
||||
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id))
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB");
|
||||
}
|
||||
// F1 (#173 round 8): also record the first pinner in pin_repo_sources.
|
||||
// U3: an exhausted retry marks the set incomplete so the resolver keeps
|
||||
// the scan fallback rather than 404ing a copy it could serve.
|
||||
match crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
// Same rule as the skip branch above, and the same reason: this
|
||||
// wraps `record_pin_source`, an explicit transaction, so a timed-out
|
||||
// call definitely never committed and the source is definitely
|
||||
// missing. Mark the set incomplete rather than leaving it incomplete
|
||||
// and unmarked. Note the contrast with `record_pinata_cid` a few
|
||||
// lines up: that one is a single autocommit statement, so its
|
||||
// timeout genuinely is an unknown outcome and it is warn-only.
|
||||
Err(e @ crate::ipfs_pin::BoundedDbError::Elapsed) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
err = %e,
|
||||
"pin source record did not complete inside the batch deadline; \
|
||||
a cancelled multi-statement transaction never commits, so the \
|
||||
source is definitely missing and the set is marked incomplete"
|
||||
);
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
}
|
||||
pinned.push((sha, cid));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -433,8 +683,10 @@ mod tests {
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(60),
|
||||
oids,
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_millis(5500),
|
||||
),
|
||||
)
|
||||
@@ -522,8 +774,10 @@ mod tests {
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
fake.to_str().unwrap(),
|
||||
Duration::from_secs(60),
|
||||
oids,
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
)
|
||||
@@ -574,6 +828,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// U3 scenario 5 (#173): the read is bounded by the EARLIER of the batch deadline and
|
||||
/// this object's own `git_timeout`, the same pair the ipfs_pin twin uses. The batch
|
||||
/// budget here is generous (60s) so the budget gate cannot be what ends the call: only
|
||||
/// the 1s `git_timeout` can. A wedged `git cat-file` that traps SIGTERM and sleeps 30s
|
||||
/// must therefore be reaped in the `git_timeout` order and the call must return, rather
|
||||
/// than holding the pin permit for the whole budget.
|
||||
///
|
||||
/// RED with `let read_deadline = deadline;` (the pre-U3 bare batch deadline): the read
|
||||
/// waits out the wedged child, the call runs ~30s, and the outer 20s timeout fires.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_bounds_the_read_by_git_timeout_not_the_batch_budget(
|
||||
pool: sqlx::PgPool,
|
||||
) {
|
||||
let db = crate::db::Db::for_testing(pool);
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("git-timeout.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
let fake = tmp.path().join("hanging-git");
|
||||
write_script(&fake, "#!/bin/sh\ntrap '' TERM\necho $$ > pid\nsleep 30\n");
|
||||
|
||||
let (_logs, _guard) = capture_logs();
|
||||
let client = reqwest::Client::new();
|
||||
let started = std::time::Instant::now();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
"http://127.0.0.1:9",
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
fake.to_str().unwrap(),
|
||||
// The bound under test.
|
||||
Duration::from_secs(1),
|
||||
oids,
|
||||
&db,
|
||||
"repo-git-timeout",
|
||||
// Generous, so a call that ends on time ended on `git_timeout`.
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"the read must be bounded by git_timeout, not by the batch budget: a wedged git \
|
||||
cannot hold the pin permit for the whole 60s budget",
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(15),
|
||||
"elapsed {elapsed:?} must stay in the git_timeout order (1s plus one watchdog \
|
||||
teardown), not the 60s batch budget"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `git_bin` wrapper that records every invocation's arguments and then execs the
|
||||
/// real git, so a test can tell which objects the loop actually attempted. The returned
|
||||
/// pin list cannot: it is empty both when the loop broke after one object and when it
|
||||
@@ -598,6 +912,7 @@ mod tests {
|
||||
/// probe carries its oid on stdin rather than in argv, so an oid appears in the log only
|
||||
/// once an object has already got past its probe, and a healthy object costs two
|
||||
/// invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
@@ -650,8 +965,10 @@ mod tests {
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
&git_bin,
|
||||
Duration::from_secs(60),
|
||||
oids,
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
@@ -722,8 +1039,10 @@ mod tests {
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
&git_bin,
|
||||
Duration::from_secs(60),
|
||||
oids,
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
@@ -771,8 +1090,10 @@ mod tests {
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(60),
|
||||
oids.clone(),
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
@@ -798,8 +1119,10 @@ mod tests {
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(60),
|
||||
oids,
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
@@ -849,8 +1172,10 @@ mod tests {
|
||||
"",
|
||||
&repo_path,
|
||||
fake.to_str().unwrap(),
|
||||
Duration::from_secs(60),
|
||||
oids,
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
@@ -865,6 +1190,406 @@ mod tests {
|
||||
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
// ── Stalled-DB bound (F3, #173) ───────────────────────────────────────
|
||||
//
|
||||
// `api/repos.rs` acquires the GLOBAL `pin_semaphore` for the Pinata
|
||||
// replication task and holds it across this whole function, the same permit
|
||||
// the IPFS lane takes. `batch_budget_gate` only gates BETWEEN objects and the
|
||||
// git read is already clamped, so a bare DB await in the region parked that
|
||||
// permit for as long as the query was stuck. The tests below drive the stall
|
||||
// with a `LOCK TABLE .. IN ACCESS EXCLUSIVE MODE` held on a dedicated pooled
|
||||
// connection, the same technique the ipfs_pin twin's stall tests use, and copy
|
||||
// their tolerances (a 1.5s budget, an `elapsed < 3s` assertion, a 10s outer
|
||||
// wrap). The budget is above `PIN_READ_FLOOR` on purpose: below it
|
||||
// `batch_budget_gate` breaks the batch as the loop body's FIRST statement, so a
|
||||
// ~1s budget would never reach a DB call and the test would pass with the bound
|
||||
// deleted.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Take an `ACCESS EXCLUSIVE` lock on `table` on a dedicated pooled connection.
|
||||
/// Every SELECT needs `ACCESS SHARE`, which conflicts, so the next statement
|
||||
/// touching the table blocks at lock acquisition regardless of row count.
|
||||
/// Copied from `ipfs_pin.rs`'s test mod rather than shared, since test mods are
|
||||
/// private, the same way `seed_loose_blobs` and `capture_logs` are.
|
||||
async fn lock_table(
|
||||
pool: &sqlx::PgPool,
|
||||
table: &str,
|
||||
) -> sqlx::pool::PoolConnection<sqlx::Postgres> {
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
sqlx::raw_sql(&format!(
|
||||
"BEGIN; LOCK TABLE {table} IN ACCESS EXCLUSIVE MODE;"
|
||||
))
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
async fn rollback(conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>) {
|
||||
sqlx::raw_sql("ROLLBACK")
|
||||
.execute(&mut **conn)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Scenario 3: the FIRST DB call in this lane's budgeted region
|
||||
/// (`has_pinata_cid`) stalls. With the batch deadline bounding it the loop
|
||||
/// abandons the object, the budget gate then breaks the batch, and the call
|
||||
/// returns at ~budget having uploaded nothing. Pre-fix the bare await blocks for
|
||||
/// the lock's whole lifetime, holding the caller's global pin permit with it.
|
||||
///
|
||||
/// The upload mock is at `.expect(0)`: a stalled pinned-status check must never
|
||||
/// fall through to an upload, since that would re-send bytes Pinata may already
|
||||
/// hold and, worse, return a CID this node then advertises.
|
||||
#[sqlx::test]
|
||||
async fn pinata_pin_new_objects_stalled_db_returns_by_budget(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("pinata_stalled.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let upload = server
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body(r#"{"data":{"cid":"QmShouldNotHappen"}}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Install a log capture even though nothing here asserts on it: `tracing`
|
||||
// caches a callsite's interest globally the first time it is hit, and a hit
|
||||
// from a thread with no subscriber caches it as never-interested for the whole
|
||||
// binary, which silently blinds the sibling tests that DO assert on the batch
|
||||
// deadline warn.
|
||||
let (_logs, _log_guard) = capture_logs();
|
||||
|
||||
let mut lock = lock_table(&pool, "pinned_cids").await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let started = std::time::Instant::now();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&server.url(),
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids,
|
||||
&db,
|
||||
"repo-pinata-stalled",
|
||||
Duration::from_millis(1500),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"a stalled DB must cost the batch its budget, not the lock's lifetime: the \
|
||||
bare await hangs past this wrap",
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
"a stalled pinata-status check cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"the batch deadline must end the call at ~budget (1.5s); got {elapsed:?}"
|
||||
);
|
||||
upload.assert_async().await;
|
||||
|
||||
rollback(&mut lock).await;
|
||||
}
|
||||
|
||||
/// The Pinata half of the timed-out source record, driven rather than argued: the
|
||||
/// twin's `pin_new_objects_skip_branch_stalled_record_returns_by_budget` covers the
|
||||
/// Kubo lane and this covers the site that has to change in lockstep with it.
|
||||
///
|
||||
/// The object already has a `pinata_cid`, so the loop takes the skip branch and
|
||||
/// tries to record this repo as an additional source; `pin_repo_sources` is locked
|
||||
/// for the whole run, so that insert stalls inside `retry_db_record` and the whole
|
||||
/// ladder elapses against one floored remainder. Two properties:
|
||||
///
|
||||
/// - the call still returns promptly, at the record floor rather than the lock's
|
||||
/// lifetime;
|
||||
/// - on the TIMEOUT arm the incomplete marker IS written. `record_pin_source` is an
|
||||
/// explicit transaction, so the cancelled future never reaches `tx.commit()`, no
|
||||
/// COMMIT is ever sent, and the source definitely did not land. Withholding the
|
||||
/// marker there would leave the set incomplete AND unmarked, which the resolver
|
||||
/// reads as complete and 404s a copy this repo would serve.
|
||||
///
|
||||
/// `pinned_cids` is deliberately NOT locked, so the marker write itself is free to
|
||||
/// land and the assertion below is about the branch, not about lock contention.
|
||||
#[sqlx::test]
|
||||
async fn pinata_skip_branch_stalled_record_marks_incomplete(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("pinata_skip_stalled.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
let sha = oids[0].clone();
|
||||
// Seed the object as already Pinata-pinned, by a DIFFERENT repo, so the skip
|
||||
// branch is taken and the source record below is a genuine additional-source
|
||||
// insert rather than a no-op on the conflict. The resolver key is a canonical
|
||||
// raw CIDv1 so the opportunistic legacy repair takes its cost gate and reads no
|
||||
// bytes.
|
||||
let raw_cid =
|
||||
gitlawb_core::cid::Cid::from_git_object_bytes(b"pinata skip seed").to_string();
|
||||
db.record_pinata_cid(&sha, &raw_cid, "QmSeedProviderCid", Some("repo-seed"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.record_pin_source(&sha, "repo-seed").await.unwrap();
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint = delaying_pinata_endpoint(
|
||||
vec![Duration::from_millis(0)],
|
||||
std::sync::Arc::clone(&requests),
|
||||
)
|
||||
.await;
|
||||
// Install a log capture even though nothing here asserts on it: `tracing`
|
||||
// caches a callsite's interest globally the first time it is hit, and a hit
|
||||
// from a thread with no subscriber caches it as never-interested for the whole
|
||||
// binary, which silently blinds the sibling tests that DO assert on the batch
|
||||
// deadline warn.
|
||||
let (_logs, _log_guard) = capture_logs();
|
||||
|
||||
let mut lock = lock_table(&pool, "pin_repo_sources").await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let started = std::time::Instant::now();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids,
|
||||
&db,
|
||||
"repo-pinata-skip-stalled",
|
||||
Duration::from_millis(1500),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"the wrapped retry ladder must fit inside one floored remainder: the bare \
|
||||
retry_db_record hangs past this wrap",
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
"an already-pinned object is skipped, never re-uploaded: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"the skip branch must not reach the upload at all"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"the record's floored remainder must end the call promptly; got {elapsed:?}"
|
||||
);
|
||||
|
||||
rollback(&mut lock).await;
|
||||
drop(lock);
|
||||
|
||||
assert!(
|
||||
db.pin_sources_incomplete(&sha).await.unwrap(),
|
||||
"a TIMED-OUT `record_pin_source` definitively did not land: it is an explicit \
|
||||
multi-statement transaction, and the cancelled future never reaches \
|
||||
`tx.commit()`, so no COMMIT is ever sent and the row cannot exist. The set is \
|
||||
therefore incomplete, and leaving it UNMARKED is the exact state the marker \
|
||||
exists to prevent: the resolver reads a non-empty below-cap set as complete \
|
||||
and 404s a copy this repo would serve"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 8, the Pinata half of the durability floor. `batch_budget_gate` only
|
||||
/// guarantees `PIN_READ_FLOOR` before an object STARTS and the upload runs under
|
||||
/// the shared client's own ceiling, so a successful upload can return with ~0 of
|
||||
/// the batch budget left. Without the floor the post-upload `record_pinata_cid`
|
||||
/// would then be failed by a spent deadline, and this lane's `pinned.push` is
|
||||
/// UNCONDITIONAL: `api/repos.rs` builds `cid_map` from the return and drives
|
||||
/// `upsert_branch_cid` plus the p2p `publish_ref_update` gossip from it, so a
|
||||
/// dropped record makes the node advertise a CID whose `/ipfs` read 404s.
|
||||
///
|
||||
/// Fixture: a 2s budget, a 1.7s upload, and `pinned_cids` locked from 500ms (well
|
||||
/// after `has_pinata_cid` has read it, and still well before the upload returns)
|
||||
/// until 2.4s. The record therefore starts at ~1.72s with ~280ms of budget left
|
||||
/// and needs ~680ms of lock wait to land, which only the `DB_RECORD_GRACE` floor
|
||||
/// buys it.
|
||||
///
|
||||
/// The lock time is a MARGIN, not a boundary: taking it at 100ms left
|
||||
/// `has_pinata_cid` racing it on a loaded box, and losing that race makes the read
|
||||
/// block, time out, and break the batch, which fails on `pinned.len() == 1` for a
|
||||
/// reason that has nothing to do with the floor. Any time between the
|
||||
/// `has_pinata_cid` round trip and the upload's 1.7s return proves the same thing.
|
||||
#[sqlx::test]
|
||||
async fn pinata_pin_add_with_spent_budget_still_records_cid(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("pinata_spent_budget.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
let sha = oids[0].clone();
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint = delaying_pinata_endpoint(
|
||||
vec![Duration::from_millis(1700)],
|
||||
std::sync::Arc::clone(&requests),
|
||||
)
|
||||
.await;
|
||||
// Install a log capture even though nothing here asserts on it: `tracing`
|
||||
// caches a callsite's interest globally the first time it is hit, and a hit
|
||||
// from a thread with no subscriber caches it as never-interested for the whole
|
||||
// binary, which silently blinds the sibling tests that DO assert on the batch
|
||||
// deadline warn.
|
||||
let (_logs, _log_guard) = capture_logs();
|
||||
|
||||
let lock_pool = pool.clone();
|
||||
let locker = async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let mut conn = lock_table(&lock_pool, "pinned_cids").await;
|
||||
tokio::time::sleep(Duration::from_millis(1900)).await;
|
||||
rollback(&mut conn).await;
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let pin = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids.clone(),
|
||||
&db,
|
||||
"repo-pinata-spent-budget",
|
||||
Duration::from_millis(2000),
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, locker);
|
||||
let pinned = pinned.expect("the floored record must land well inside this wrap");
|
||||
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"the fixture only proves anything if the upload actually ran"
|
||||
);
|
||||
assert!(
|
||||
db.has_pinata_cid(&sha).await.unwrap(),
|
||||
"a successful upload whose batch deadline is spent must still land its \
|
||||
pinned_cids row: this lane pushes the pair unconditionally, so a dropped \
|
||||
record makes api/repos.rs advertise a CID the resolver cannot serve"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
1,
|
||||
"the uploaded pin must still be returned: {pinned:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The POST-UPLOAD source record's timeout arm, the one site of the three that
|
||||
/// nothing else executes. The skip-branch twin above and the ipfs_pin lane cover
|
||||
/// the other two; this arm sits after a SUCCESSFUL `pin_object`, so no skip-branch
|
||||
/// fixture can reach it.
|
||||
///
|
||||
/// Why it has to write the marker at all: `record_pin_source` is an explicit
|
||||
/// transaction ending in `tx.commit()`, and a cancelled future never gets there, so
|
||||
/// no COMMIT is sent and the row definitely does not exist. The set is therefore
|
||||
/// incomplete, and leaving it unmarked is the state the marker exists to prevent.
|
||||
///
|
||||
/// Fixture: the object is NOT seeded as Pinata-pinned, so `has_pinata_cid` is false
|
||||
/// and the run takes the upload path. The mock answers the upload at once, then
|
||||
/// `pin_repo_sources` is held under `ACCESS EXCLUSIVE` for the whole run, so the
|
||||
/// post-upload `record_pin_source` blocks and elapses against its floored bound at
|
||||
/// ~2s. `pinned_cids` is deliberately left UNLOCKED, so both `record_pinata_cid`
|
||||
/// and the marker write itself are free to land and the assertion is about the arm
|
||||
/// rather than about my own lock.
|
||||
///
|
||||
/// The upload assertion is what keeps this from being vacuous: without it the test
|
||||
/// would pass just as well if the run never reached the post-upload path at all.
|
||||
#[sqlx::test]
|
||||
async fn pinata_post_upload_stalled_record_marks_incomplete(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("pinata_post_upload.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
let sha = oids[0].clone();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let upload = server
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body(r#"{"data":{"cid":"QmPostUploadProviderCid"}}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Install a log capture even though nothing here asserts on it: `tracing`
|
||||
// caches a callsite's interest globally the first time it is hit, and a hit
|
||||
// from a thread with no subscriber caches it as never-interested for the whole
|
||||
// binary, which silently blinds the sibling tests that DO assert on the batch
|
||||
// deadline warn.
|
||||
let (_logs, _log_guard) = capture_logs();
|
||||
|
||||
let mut lock = lock_table(&pool, "pin_repo_sources").await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let started = std::time::Instant::now();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&server.url(),
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids,
|
||||
&db,
|
||||
"repo-pinata-post-upload",
|
||||
Duration::from_millis(1500),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"the wrapped retry ladder must fit inside one floored remainder: the bare \
|
||||
retry_db_record hangs past this wrap",
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
rollback(&mut lock).await;
|
||||
drop(lock);
|
||||
|
||||
upload.assert_async().await;
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
1,
|
||||
"the upload succeeded, so this lane still returns the pair: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(8),
|
||||
"the record's floored remainder must end the call promptly, never at the \
|
||||
lock's lifetime; got {elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
db.pin_sources_incomplete(&sha).await.unwrap(),
|
||||
"the POST-UPLOAD arm must mark the source set incomplete when its record \
|
||||
times out: `record_pin_source` is an explicit transaction whose cancelled \
|
||||
future never reaches `tx.commit()`, so the row definitely did not land, and \
|
||||
an incomplete-and-unmarked set is read as complete and 404s a copy this \
|
||||
repo would serve"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pin_skipped_when_jwt_empty() {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
@@ -121,11 +121,43 @@ impl RateLimiter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Number of keys currently tracked. Tests use it to observe what a sweep
|
||||
/// reclaimed; there is no production reader.
|
||||
/// 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 async fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().await.len()
|
||||
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) {
|
||||
@@ -137,6 +169,14 @@ 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
|
||||
|
||||
@@ -42,17 +42,37 @@ 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).
|
||||
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
|
||||
///
|
||||
/// `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> {
|
||||
router
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
auth::require_ucan_chain,
|
||||
))
|
||||
.layer(middleware::from_fn(auth::require_signature))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(body_limit),
|
||||
auth::require_signature,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
@@ -63,7 +83,10 @@ 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(auth::optional_signature))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
@@ -74,6 +97,7 @@ 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) ──────────────────────────────────────────
|
||||
@@ -107,6 +131,7 @@ 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));
|
||||
@@ -181,6 +206,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
axum::routing::delete(agents::deregister_agent),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
|
||||
@@ -205,33 +231,68 @@ 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));
|
||||
|
||||
// ── IPFS content-addressed retrieval and pin listing ──────────────────
|
||||
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
|
||||
// identity and can apply per-repo visibility (#110); anonymous callers stay
|
||||
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
|
||||
// stays unsigned — gating the pin index is tracked separately (#121).
|
||||
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
|
||||
// request can drive a full-history git walk, so the per-IP rate limiter is the
|
||||
// outermost layer (rejects a flood before the walk-admission work), mirroring the
|
||||
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
|
||||
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
|
||||
// Two independent sub-routers, then merged. They share a URL prefix family
|
||||
// but have separate rate-limit policies and must not share a bucket.
|
||||
//
|
||||
// `/ipfs/{cid}` (CID resolver): carries `optional_signature` so `get_by_cid`
|
||||
// sees the caller identity and can apply per-repo visibility (#110); anon
|
||||
// callers stay anonymous and still read genuinely public content. The
|
||||
// per-IP flood brake is layered on because the resolver is anon-reachable
|
||||
// and each request can drive a full-history git walk — the brake is the
|
||||
// outermost layer (rejects a flood before the walk-admission work), mirroring
|
||||
// the push/create routers. The `IpRateLimiter` extension MUST be attached
|
||||
// or `rate_limit_by_ip` is a silent no-op.
|
||||
//
|
||||
// `/api/v1/ipfs/pins` (pin listing): now carries `optional_signature` only
|
||||
// (#121). The handler rejects requests without a verified `AuthenticatedDid`
|
||||
// with 401. It does NOT carry the CID flood brake — `list_pins` is a single
|
||||
// `list_pinned_cids()` call, no walk, and routing pins through the resolver's
|
||||
// bucket would let `/ipfs/{cid}` traffic exhaust the bucket and 429 the
|
||||
// pins endpoint, or let signed pin polling exhaust the bucket for legitimate
|
||||
// CID reads. The two surfaces have separate availability contracts.
|
||||
//
|
||||
// Both sub-routers are built first with their own layer sets, then merged.
|
||||
// This is the structure the prior routing guidance called for and the
|
||||
// earlier 429 test for `/ipfs/{cid}` (test_support.rs) assumes.
|
||||
let ipfs_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.ipfs_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let ipfs_routes = Router::new()
|
||||
let ipfs_cid_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.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))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
.layer(axum::Extension(ipfs_limiter));
|
||||
let ipfs_pins_routes = Router::new()
|
||||
.route("/api/v1/ipfs/pins", get(ipfs::list_pins))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
let ipfs_routes = ipfs_cid_routes.merge(ipfs_pins_routes);
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
|
||||
// `list_anchors` rejects callers without a verified `AuthenticatedDid`, so
|
||||
// unsigned enumeration is denied. The same `optional_signature` layer used
|
||||
// on the other read surfaces is applied here, closing the anonymous
|
||||
// anchor-listing gap alongside `/api/v1/ipfs/pins`. The verify endpoint
|
||||
// and its probe are deferred to the vertical slice with the uploader and
|
||||
// a real provider contract.
|
||||
let arweave_routes = Router::new()
|
||||
.route("/api/v1/arweave/anchors", get(arweave::list_anchors))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
|
||||
// ── Bounty routes (write — require HTTP Signature) ─────────────────
|
||||
let bounty_write_routes = add_auth_layers(
|
||||
@@ -258,6 +319,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(bounties::dispute_bounty),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Bounty routes (read — open) ──────────────────────────────────────
|
||||
@@ -273,12 +335,16 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/agents/{did}/bounties",
|
||||
get(bounties::agent_bounty_stats),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
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) ─────
|
||||
@@ -293,6 +359,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(issues::create_issue_comment),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Peer discovery routes ─────────────────────────────────────────────
|
||||
@@ -311,6 +378,7 @@ 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 {
|
||||
@@ -327,9 +395,12 @@ 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())
|
||||
add_auth_layers(peer_write_routes, state.clone(), SIGNED_BODY_LIMIT)
|
||||
} else {
|
||||
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
|
||||
peer_write_routes.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
};
|
||||
let peer_write_routes = peer_write_routes
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
@@ -341,7 +412,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// ── Read routes — open for public repos ───────────────────────────────
|
||||
let read_routes = Router::new()
|
||||
.route("/api/v1/repos", get(repos::list_repos))
|
||||
.route("/api/v1/repos/federated", get(repos::list_federated_repos))
|
||||
.route(
|
||||
"/api/v1/repos/federated",
|
||||
get(repos::list_federated_repos)
|
||||
.route_layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.route_layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.federated_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
})),
|
||||
)
|
||||
.route("/api/v1/repos/{owner}/{repo}", get(repos::get_repo))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/commits",
|
||||
@@ -428,7 +507,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
get(replicas::list_replicas),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
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.
|
||||
@@ -460,7 +542,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit))
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
// `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,
|
||||
));
|
||||
|
||||
// ── Meta ──────────────────────────────────────────────────────────────
|
||||
let meta_routes = Router::new()
|
||||
@@ -531,7 +619,7 @@ async fn ready(State(state): State<AppState>) -> axum::response::Response {
|
||||
|
||||
async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let p2p_peer_id = state.p2p.as_ref().map(|h| h.local_peer_id.to_string());
|
||||
Json(json!({
|
||||
let mut body = json!({
|
||||
"name": "gitlawb-node",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"did": state.node_did.to_string(),
|
||||
@@ -540,7 +628,11 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
"auth": "http-signature-rfc9421",
|
||||
"identity": "ed25519",
|
||||
"p2p_peer_id": p2p_peer_id,
|
||||
}))
|
||||
});
|
||||
if let Some(web_url) = &state.config.web_url {
|
||||
body["web_url"] = json!(web_url);
|
||||
}
|
||||
Json(body)
|
||||
}
|
||||
|
||||
pub(crate) async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
@@ -619,3 +711,667 @@ async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
None => Json(json!({ "enabled": false })),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::db::RepoRecord;
|
||||
use crate::test_support::test_state;
|
||||
|
||||
/// Regression: anonymous callers must not see the pin/anchor index (#121, #134).
|
||||
#[sqlx::test]
|
||||
async fn unsigned_get_pins_and_anchors_is_401_through_build_router(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
|
||||
// Seed identifiable rows so the denial oracle proves the 401 body omits
|
||||
// them: a status-only check would stay green if a later change served
|
||||
// the listing before the auth layer rejected.
|
||||
state
|
||||
.db
|
||||
.record_pinned_cid("seeded-pin-oid", "bafyPIN-SECRET-DO-NOT-LEAK", None)
|
||||
.await
|
||||
.expect("seed pin");
|
||||
let owner_did = "did:key:zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo = seed_repo_inline(owner_did, "seeded-repo");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: "zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/seeded-repo",
|
||||
owner_did,
|
||||
ref_name: "refs/heads/secret",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
|
||||
arweave_url: "https://arweave.net/secret-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let router = build_router(state);
|
||||
|
||||
let pins = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/ipfs/pins?limit=50")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let pins_resp = router.clone().oneshot(pins).await.unwrap();
|
||||
assert_eq!(
|
||||
pins_resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"anonymous pin listing must be rejected"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(pins_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("bafyPIN-SECRET-DO-NOT-LEAK"),
|
||||
"the 401 body must not leak the seeded pin cid; body was: {body}"
|
||||
);
|
||||
|
||||
let anchors = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/arweave/anchors?limit=50")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let anchors_resp = router.oneshot(anchors).await.unwrap();
|
||||
assert_eq!(
|
||||
anchors_resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"anonymous anchors listing must be rejected"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(anchors_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
|
||||
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a real RFC-9421 signature produced exactly as `gl` does — built
|
||||
/// with `gitlawb_core::http_sig::sign_request` over a GET, headers attached,
|
||||
/// and sent through the actual `build_router` — is verified by the
|
||||
/// `optional_signature` layer that wraps the pin/anchor routes, and the
|
||||
/// handler returns 200. Pairs with the anonymous-denial test above; one
|
||||
/// proves headers are required, the other proves a valid header is honored.
|
||||
/// Without this test the unsigned-denial test would stay green even if the
|
||||
/// `optional_signature` layer were never wired onto these routes, because
|
||||
/// signed and unsigned requests would fail identically (#134 review).
|
||||
#[sqlx::test]
|
||||
async fn signed_get_pins_and_anchors_succeeds_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let router = build_router(state);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
|
||||
let pins_path = "/api/v1/ipfs/pins";
|
||||
let signed = sign_request(&kp, "GET", pins_path, b"");
|
||||
let pins = Request::builder()
|
||||
.method("GET")
|
||||
.uri(pins_path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let pins_resp = router.clone().oneshot(pins).await.unwrap();
|
||||
assert_eq!(
|
||||
pins_resp.status(),
|
||||
StatusCode::OK,
|
||||
"a valid signature on /api/v1/ipfs/pins must be honored through build_router"
|
||||
);
|
||||
|
||||
let anchors_path = "/api/v1/arweave/anchors";
|
||||
let signed = sign_request(&kp, "GET", anchors_path, b"");
|
||||
let anchors = Request::builder()
|
||||
.method("GET")
|
||||
.uri(anchors_path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let anchors_resp = router.oneshot(anchors).await.unwrap();
|
||||
assert_eq!(
|
||||
anchors_resp.status(),
|
||||
StatusCode::OK,
|
||||
"a valid signature on /api/v1/arweave/anchors must be honored through build_router"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion to the two regressions above: a request that *carries* signature
|
||||
/// headers but whose signature does not verify (here, garbled) must be denied
|
||||
/// with 401, not silently treated as anonymous and re-checked by the handler.
|
||||
/// This pins the failure mode of the `optional_signature` layer: when the
|
||||
/// caller claims to be signed, the layer must commit to verifying — there is
|
||||
/// no fall-through path that lets a bad signature bypass auth.
|
||||
#[sqlx::test]
|
||||
async fn malformed_signature_on_pins_is_401_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let router = build_router(state);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let path = "/api/v1/ipfs/pins";
|
||||
let mut signed = sign_request(&kp, "GET", path, b"");
|
||||
// Flip a character well inside the signature value (base64) so the header
|
||||
// still parses but does not verify against the key.
|
||||
let mut tampered = signed.signature.clone();
|
||||
let mid = tampered.len() / 2;
|
||||
let flipped = if tampered.as_bytes()[mid] == b'A' {
|
||||
'B'
|
||||
} else {
|
||||
'A'
|
||||
};
|
||||
tampered.replace_range(mid..mid + 1, &flipped.to_string());
|
||||
signed.signature = tampered;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a malformed signature must be rejected by the auth layer, not silently accepted"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same malformed-signature rejection must hold on the anchors route,
|
||||
/// which carries its own `optional_signature` layer beside pins: a later
|
||||
/// edit that dropped the layer here (but not on pins) would otherwise stay
|
||||
/// green.
|
||||
#[sqlx::test]
|
||||
async fn malformed_signature_on_anchors_is_401_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let router = build_router(state);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let path = "/api/v1/arweave/anchors";
|
||||
let mut signed = sign_request(&kp, "GET", path, b"");
|
||||
let mut tampered = signed.signature.clone();
|
||||
let mid = tampered.len() / 2;
|
||||
let flipped = if tampered.as_bytes()[mid] == b'A' {
|
||||
'B'
|
||||
} else {
|
||||
'A'
|
||||
};
|
||||
tampered.replace_range(mid..mid + 1, &flipped.to_string());
|
||||
signed.signature = tampered;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a malformed signature must be rejected by the auth layer, not silently accepted"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scoped anchor contract (P1 follow-up: ?repo= must authorize_repo_read) ──
|
||||
//
|
||||
// The follow-up review (after the auth-layer wiring landed) found that a
|
||||
// signed but unauthorized caller could still obtain scoped anchor metadata
|
||||
// because the `?repo=` branch handed the user-supplied string straight to
|
||||
// the SQL filter. These tests exercise the full production contract end to
|
||||
// end: real RFC-9421 signature → `optional_signature` layer →
|
||||
// `authorize_repo_read` → SQL. They fail closed if the authz call is moved
|
||||
// after the query or removed entirely.
|
||||
|
||||
/// Inline repo seed for the scoped-anchor tests. Mirrors the shape in
|
||||
/// `test_support::tests::seed_repo` without taking a cross-module private
|
||||
/// helper as a dependency.
|
||||
fn seed_repo_inline(owner_did: &str, name: &str) -> RepoRecord {
|
||||
let now = chrono::Utc::now();
|
||||
RepoRecord {
|
||||
id: uuid::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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned scoped anchor request → 401, identical to the global anchor
|
||||
/// 401 test. Proves the auth layer fires before any scope decision (no
|
||||
/// existence oracle via the `?repo=` path either).
|
||||
#[sqlx::test]
|
||||
async fn unsigned_scoped_anchors_is_401_through_build_router(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
|
||||
// Seed an anchor under a repo the request claims to scope to, so the
|
||||
// denial oracle proves the 401 body omits it rather than merely
|
||||
// returning the right status over an empty table.
|
||||
let owner_did = "did:key:zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo = seed_repo_inline(owner_did, "priv");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: "zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/priv",
|
||||
owner_did,
|
||||
ref_name: "refs/heads/secret",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-scoped-secret-DO-NOT-LEAK",
|
||||
arweave_url: "https://arweave.net/scoped-secret",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let router = build_router(state);
|
||||
|
||||
let resp = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/arweave/anchors?repo=did:key:zSCOPED%2Fpriv")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unsigned scoped anchor request must be 401, before any scope lookup"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("irys-scoped-secret-DO-NOT-LEAK"),
|
||||
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Signed owner on a private repo → 200, anchor metadata returned. Mirrors
|
||||
/// the existing `list_webhooks_accepts_a_real_gl_signature_e2e` shape: real
|
||||
/// signature, real middleware, real `authorize_repo_read`.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_owner_succeeds_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo_inline(&owner_did, "scoped-priv");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!("{short}/scoped-priv"),
|
||||
owner_did: &owner_did,
|
||||
ref_name: "refs/heads/main",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-owner-tx",
|
||||
arweave_url: "https://arweave.net/owner-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let path = format!("/api/v1/arweave/anchors?repo={short}/scoped-priv");
|
||||
let signed = sign_request(&kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the owner of a private repo must see their scoped anchors"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
body.contains("irys-owner-tx"),
|
||||
"owner must see the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
// Owner must see the ref name + new SHA so this is not just a count oracle.
|
||||
assert!(
|
||||
body.contains("refs/heads/main"),
|
||||
"owner must see the ref name; body was: {body}"
|
||||
);
|
||||
|
||||
// Sanity: a second test would need a different repo name to avoid
|
||||
// colliding on `did:key:zSCOPED/scoped-priv` in the anchors table.
|
||||
// Distinct repo names per test keep the rows queryable in isolation.
|
||||
let _ = repo;
|
||||
}
|
||||
|
||||
/// #121 R3: the SQL filter must use the canonical stored slug, not the
|
||||
/// user-supplied `?repo=` string. Anchor rows are stored as
|
||||
/// `{normalize_owner_key(owner_did)}/{name}` (the short form). Authz
|
||||
/// passes for the full-DID form, but a literal `WHERE repo=$1` against
|
||||
/// the full DID would match zero rows and return a false empty page,
|
||||
/// breaking callers that legitimately use the full `did:key:…/name`
|
||||
/// form in `?repo=`. RED before the fix: handler built the filter from
|
||||
/// the raw `?repo=` string, so this query returned `anchors: []`.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router(
|
||||
pool: PgPool,
|
||||
) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo_inline(&owner_did, "scoped-did-form");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
// Use the same slug-construction the writer uses
|
||||
// (`db::normalize_owner_key`) so the test reader and the production
|
||||
// writer can't drift if a future change moves the slug off the
|
||||
// short-form. P3 (reviewer-3): the previous `split(':').next_back()`
|
||||
// hand-rolled a third convention that agreed with the writer for
|
||||
// `did:key` owners and diverged for every other method.
|
||||
let short = crate::db::normalize_owner_key(&owner_did).to_string();
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!("{short}/scoped-did-form"),
|
||||
owner_did: &owner_did,
|
||||
ref_name: "refs/heads/main",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-did-form-tx",
|
||||
arweave_url: "https://arweave.net/did-form-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
// P2 (reviewer-3): the previous suite only proved the authorized
|
||||
// row is present. A correct filter must ALSO prove that anchors
|
||||
// for OTHER repos do not leak into the response — the failure
|
||||
// mode of the original bug was a cross-repo leak, not a
|
||||
// fail-safe empty page. Seed a second repo (also under the
|
||||
// same owner, so the authz test stays scoped) with its own
|
||||
// irys_tx_id and assert it is absent from the body.
|
||||
let other_owner = {
|
||||
let kp2 = Keypair::generate();
|
||||
kp2.did().to_string()
|
||||
};
|
||||
let mut other_repo = seed_repo_inline(&other_owner, "other-repo");
|
||||
other_repo.is_public = true;
|
||||
state
|
||||
.db
|
||||
.create_repo(&other_repo)
|
||||
.await
|
||||
.expect("seed other repo");
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!(
|
||||
"{}/other-repo",
|
||||
crate::db::normalize_owner_key(&other_owner)
|
||||
),
|
||||
owner_did: &other_owner,
|
||||
ref_name: "refs/heads/main",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "2".repeat(64).as_str(),
|
||||
cid: Some("bafyother"),
|
||||
irys_tx_id: "irys-other-repo-tx",
|
||||
arweave_url: "https://arweave.net/other-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed other anchor");
|
||||
|
||||
// The caller passes the FULL DID form in ?repo=. validate_repo_slug
|
||||
// accepts it, authorize_repo_read resolves it, and the handler MUST
|
||||
// then translate to the canonical stored slug (short form) before
|
||||
// running the SQL — otherwise this returns 200 with anchors: [].
|
||||
let path = format!("/api/v1/arweave/anchors?repo={owner_did}/scoped-did-form");
|
||||
let signed = sign_request(&kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"authz passed via the full DID form, so the response must be 200"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
body.contains("irys-did-form-tx"),
|
||||
"the SQL filter must use the stored short slug, not the full DID — \
|
||||
a false-empty page would break callers that pass ?repo=did:key:…/name. \
|
||||
body was: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("irys-other-repo-tx"),
|
||||
"the scoped anchor filter must exclude other repos' anchors — \
|
||||
a cross-repo leak would expose metadata of repos the caller \
|
||||
did not authorize. body was: {body}"
|
||||
);
|
||||
|
||||
let _ = repo;
|
||||
}
|
||||
|
||||
/// Signed non-reader on a private repo → 404, anchor metadata MUST NOT
|
||||
/// leak. This is the test that catches the "auth-but-no-authz" bug if
|
||||
/// the gate is moved after the SQL query or removed entirely. The 404
|
||||
/// is the standard `repo_not_found` shape — indistinguishable from the
|
||||
/// missing-repo case below.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_non_reader_is_404_no_leak_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let owner_kp = Keypair::generate();
|
||||
let stranger_kp = Keypair::generate();
|
||||
let owner_did = owner_kp.did().to_string();
|
||||
let stranger_did = stranger_kp.did().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo_inline(&owner_did, "scoped-priv-nr");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let short_owner = owner_did.split(':').next_back().unwrap().to_string();
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!("{short_owner}/scoped-priv-nr"),
|
||||
owner_did: &owner_did,
|
||||
ref_name: "refs/heads/secret",
|
||||
old_sha: "2".repeat(64).as_str(),
|
||||
new_sha: "3".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
|
||||
arweave_url: "https://arweave.net/secret-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let _ = stranger_did; // signature carries the DID; this is just documentation
|
||||
let path = format!("/api/v1/arweave/anchors?repo={short_owner}/scoped-priv-nr");
|
||||
let signed = sign_request(&stranger_kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a signed non-reader must be 404 on a private repo's anchors"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
|
||||
"the 404 body must not leak the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("refs/heads/secret"),
|
||||
"the 404 body must not leak the ref name; body was: {body}"
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v["error"], "repo_not_found",
|
||||
"non-reader denial must carry the same error code as a missing repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Signed caller on a missing repo → 404, same shape as the non-reader
|
||||
/// case. This is the indistinguishability half: a probe cannot tell
|
||||
/// "private" from "absent" from the response.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_missing_repo_is_404_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let short = kp
|
||||
.did()
|
||||
.to_string()
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let path = format!("/api/v1/arweave/anchors?repo={short}/does-not-exist");
|
||||
let signed = sign_request(&kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a missing repo must 404 indistinguishably from a non-readable private repo"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v["error"], "repo_not_found",
|
||||
"the 404 body must carry the standard repo_not_found error code"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?limit=-1` must not crash as `LIMIT -1` (Postgres 500). It clamps to
|
||||
/// zero and returns 200 with an empty list — the same shape as a valid
|
||||
/// listing that happens to have no rows in the configured range.
|
||||
#[sqlx::test]
|
||||
async fn signed_anchors_negative_limit_clamps_to_zero_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let path = "/api/v1/arweave/anchors?limit=-1";
|
||||
let signed = sign_request(&kp, "GET", path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"?limit=-1 must clamp, not 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(v["count"], 0, "clamped limit yields an empty list");
|
||||
assert_eq!(v["anchors"].as_array().map(|a| a.len()), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ use crate::git::repo_store::RepoStore;
|
||||
use crate::p2p::P2pHandle;
|
||||
use crate::rate_limit::RateLimiter;
|
||||
|
||||
/// HKDF salt for [`AppState::derive_scan_token_key`]. A constant, not a secret: HKDF's
|
||||
/// salt is a domain qualifier, and the confidentiality of the derived key rests entirely
|
||||
/// on the node's private seed being the input keying material.
|
||||
const SCAN_TOKEN_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/ipfs-scan-token";
|
||||
|
||||
/// HKDF `info` for [`AppState::derive_scan_token_key`]: the domain separation AND the
|
||||
/// rotation handle in one string. Nothing else in the node derives from this label, so
|
||||
/// the token key is unrelated to the signing key it shares an input with; bumping the
|
||||
/// trailing version rotates every node's token key on its next boot, which invalidates
|
||||
/// outstanding continuations (they simply fail to open and the caller restarts at the
|
||||
/// front) and needs no migration, no config, and no change to this function.
|
||||
const SCAN_TOKEN_KEY_INFO: &[u8] = b"gitlawb/ipfs-scan-token/v1";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefUpdateBroadcast {
|
||||
pub repo: String,
|
||||
@@ -66,6 +79,102 @@ pub struct AppState {
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
pub push_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for `close_issue`'s pre-lock snapshot path.
|
||||
/// Distinct from `push_rate_limiter` so a flood of close attempts cannot
|
||||
/// drain the receive-pack budget for the same source IP.
|
||||
pub close_issue_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP ROUTE brake for `GET /ipfs/{cid}`: charged ONCE per request by the
|
||||
/// `rate_limit_by_ip` middleware (server.rs), never inside the handler. It bounds
|
||||
/// request RATE (the "requests per hour" contract of `GITLAWB_IPFS_RATE_LIMIT`) on
|
||||
/// the non-farmable source IP, so an anonymous flood of the public route is capped.
|
||||
/// The per-probe/per-walk WORK accounting the resolver does WITHIN a request draws
|
||||
/// from the SEPARATE `ipfs_work_rate_limiter` below — the two cannot share one bucket
|
||||
/// or a single request that spends a route token and then its own probe token off the
|
||||
/// same bucket is admitted at the route and falsely shed mid-request (#173 round-10,
|
||||
/// R6). Keyed by `push_limiter_trust`.
|
||||
pub ipfs_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP WORK-budget limiter for the `GET /ipfs/{cid}` resolver's internal
|
||||
/// fan-out: charged per legacy (NULL-provenance) PROBE (`acquire` + `cat-file`) and
|
||||
/// per provenance-path WALK, and peeked non-consuming before the O(repos) legacy
|
||||
/// preload. A legacy CID from the public pins index otherwise lets one request drive
|
||||
/// O(repos) subprocess spawns and cold Tigris fetches, and repeat requests amplify
|
||||
/// that across requests with zero limiter contact (INV-10, F3). Charging the work to
|
||||
/// the non-farmable source IP bounds it. A bucket DISTINCT from the route brake above:
|
||||
/// one request legitimately spends many work tokens (a full legacy scan is up to
|
||||
/// `ipfs_max_legacy_probes` probes), so it must not double as the once-per-request
|
||||
/// route bucket. Capacity is DERIVED from the route limit (`AppState::ipfs_work_budget`,
|
||||
/// no separate operator knob), floored at the legacy-probe budget so a single default-
|
||||
/// config deep search never self-throttles mid-scan. Keyed by `push_limiter_trust`.
|
||||
pub ipfs_work_rate_limiter: RateLimiter,
|
||||
/// Per-request ceiling on full-history reachability walks the CID resolver
|
||||
/// may spawn (default `api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST`). A field,
|
||||
/// not a bare const, so tests can shrink it to exercise the cap cheaply;
|
||||
/// production keeps the const default.
|
||||
pub ipfs_max_history_walks: u32,
|
||||
/// Per-request ceiling on legacy (NULL-provenance) repo probes in the CID
|
||||
/// resolver's scan fallback (default `api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST`).
|
||||
/// Bounds the anonymous `acquire` + `cat-file` fan-out across the node (#173,
|
||||
/// INV-10); a field for the same test-seam reason as `ipfs_max_history_walks`.
|
||||
pub ipfs_max_legacy_probes: u32,
|
||||
/// How many repo rows the CID resolver's legacy scan pulls per keyset page
|
||||
/// (default `api::ipfs::LEGACY_SCAN_PAGE_ROWS`). Bounds the DATABASE-facing half
|
||||
/// of the same fan-out `ipfs_max_legacy_probes` bounds on the probe side: without
|
||||
/// it the scan materialized every repo row and every matching visibility rule
|
||||
/// before spending a single probe (#173, INV-10). A field for the same test-seam
|
||||
/// reason as the sibling caps.
|
||||
pub ipfs_legacy_scan_page_rows: usize,
|
||||
/// Per-request ceiling on how many repo ROWS the CID resolver's legacy scan may
|
||||
/// fetch (default `api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST`, operator-tunable via
|
||||
/// `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS`). `ipfs_max_legacy_probes` bounds the PROBE
|
||||
/// fan-out but only starts counting once a probe runs, and quarantine plus a
|
||||
/// root-scope visibility deny both return before a probe or a visit is spent, so an
|
||||
/// all-denying inventory paged the whole repo table at zero probes (#173 round 13, F2).
|
||||
/// Truncating here sheds a retryable 503 carrying a sealed continuation token.
|
||||
pub ipfs_max_legacy_scan_rows: usize,
|
||||
/// Per-request ceiling on the BYTES of visibility rules the CID resolver's legacy
|
||||
/// scan may retain (default `api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST`). The
|
||||
/// row ceiling above bounds the row count but not the memory each row drags in: the
|
||||
/// pager keeps every fetched page's rules for the whole request, and neither the
|
||||
/// number of rules per repo nor the length of a rule's reader list is capped, so a
|
||||
/// rule COUNT would be the wrong unit. Enforced by the rules QUERY
|
||||
/// (`Db::list_visibility_rules_for_repos_bounded`) rather than by a sum taken once the
|
||||
/// page has landed, so the oversized page is never materialized at all. Deliberately
|
||||
/// NOT an operator knob (it is a
|
||||
/// memory guard, not a reach tradeoff); a field only for the same test-seam reason as
|
||||
/// the sibling caps.
|
||||
pub ipfs_max_legacy_scan_rule_bytes: usize,
|
||||
/// Key sealing the legacy scan's continuation tokens (INV-13), derived from the
|
||||
/// node's persistent identity by [`AppState::derive_scan_token_key`].
|
||||
///
|
||||
/// The token is minted from a FETCHED row on a scan that served nothing, so by
|
||||
/// construction that row is a private or quarantined repo the caller may not read:
|
||||
/// its `created_at` and its `id` (which carries the owner's DID) are withheld
|
||||
/// fields. The token is therefore AEAD-SEALED, never signed plaintext and never
|
||||
/// base64-of-plaintext, since integrity is not confidentiality.
|
||||
///
|
||||
/// DERIVED, not random per boot. This reverses an earlier revision of this design,
|
||||
/// which argued that derivation "would make old tokens valid across restarts for no
|
||||
/// benefit and tie a throwaway transport secret to a long-lived signing key." Both
|
||||
/// halves were wrong. Surviving a restart IS the benefit: the token is the ONLY way
|
||||
/// a caller resumes a ladder, an unopenable one is treated as absent, and a caller
|
||||
/// treated as absent silently restarts at the front of the scan. A node whose
|
||||
/// inventory needs several ladder steps and which deploys more often than a caller
|
||||
/// can climb therefore keeps that caller from ever reaching a holder, which
|
||||
/// contradicts the reach bound the README states. And the tie to the signing key is
|
||||
/// what the HKDF domain separation removes: the derived key is a one-way function of
|
||||
/// the seed under an `info` string nothing else uses, so it is not the signing key
|
||||
/// and cannot be worked back into one.
|
||||
///
|
||||
/// Persisting a random key instead would need a schema change for a value the node
|
||||
/// already has on disk, so derivation is also the smaller mechanism. Rotation is a
|
||||
/// bump of the version in [`SCAN_TOKEN_KEY_INFO`].
|
||||
pub ipfs_scan_token_key: Arc<[u8; 32]>,
|
||||
/// Hard ceiling on the byte size of an object `GET /ipfs/{cid}` will buffer and
|
||||
/// serve (default `api::ipfs::MAX_SERVED_OBJECT_BYTES`). The serve reads via a
|
||||
/// blocking `git cat-file` and buffers the whole object; without a bound a large
|
||||
/// public blob could exhaust memory or block a runtime worker (#173, F6, INV-10).
|
||||
/// A field for the same test-seam reason as the sibling caps.
|
||||
pub ipfs_max_served_object_bytes: u64,
|
||||
/// Which forwarded header (if any) the edge is trusted to set, for
|
||||
/// resolving the push limiter's client-IP key. See `GITLAWB_TRUSTED_PROXY`.
|
||||
/// Node-wide; also keys the two peer-sync limiters below.
|
||||
@@ -81,6 +190,9 @@ pub struct AppState {
|
||||
/// sink as trigger and accepts unsigned requests from known peers, so it is
|
||||
/// braked too; each peer's distinct IP gets its own bucket.
|
||||
pub peer_write_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP federation budget: 12 requests per minute, with at most
|
||||
/// 10,000 tracked source keys. Shared by router instances and swept below.
|
||||
pub federated_rate_limiter: RateLimiter,
|
||||
/// Process-wide graceful-shutdown signal. Sending `true` causes every
|
||||
/// task that holds a `watch::Receiver` to exit at its next await point.
|
||||
/// Used by:
|
||||
@@ -214,13 +326,6 @@ pub struct AppState {
|
||||
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
|
||||
/// it (INV-15).
|
||||
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// Per-client-IP rate limiter for `GET /ipfs/{cid}`. The route is publicly
|
||||
/// reachable and each request can drive a full-history git walk, so it carries a
|
||||
/// per-IP flood brake in addition to the concurrency cap above — a rate limit
|
||||
/// bounds request *rate*, the semaphore bounds concurrent slow holds (different
|
||||
/// axes). Keyed on the resolved client IP via `push_limiter_trust`. Layered on the
|
||||
/// `/ipfs` route via `rate_limit_by_ip`.
|
||||
pub ipfs_rate_limiter: RateLimiter,
|
||||
/// The `git` executable the served-git withheld-blob walk spawns. Production is
|
||||
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
|
||||
/// process-group teardown in handler tests without mutating the process-global
|
||||
@@ -235,6 +340,23 @@ impl AppState {
|
||||
self.shutdown_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Sweep expired entries from every per-IP/DID rate limiter. Driven by the
|
||||
/// periodic cleanup task so a bounded limiter's key map sheds stale entries
|
||||
/// instead of sitting near its cap until an inline capacity sweep reclaims
|
||||
/// them. Every limiter on the state is swept here; adding a new limiter means
|
||||
/// adding it to this list.
|
||||
pub(crate) async fn sweep_rate_limiters(&self) {
|
||||
self.rate_limiter.cleanup().await;
|
||||
self.create_ip_rate_limiter.cleanup().await;
|
||||
self.push_rate_limiter.cleanup().await;
|
||||
self.close_issue_rate_limiter.cleanup().await;
|
||||
self.ipfs_rate_limiter.cleanup().await;
|
||||
self.ipfs_work_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
self.peer_write_rate_limiter.cleanup().await;
|
||||
self.federated_rate_limiter.cleanup().await;
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown. Idempotent — calling more than once
|
||||
/// has no effect. Returns `true` if this call was the one that
|
||||
/// flipped the signal.
|
||||
@@ -255,6 +377,130 @@ impl AppState {
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
*self.shutdown_tx.borrow()
|
||||
}
|
||||
|
||||
/// Legacy-probe budget wired from the `GITLAWB_IPFS_MAX_LEGACY_PROBES` operator
|
||||
/// knob. The knob seeds `ipfs_max_legacy_probes` at construction so it controls the
|
||||
/// per-request legacy (NULL-provenance) probe fan-out it advertises. It deliberately
|
||||
/// does NOT feed the history-walk ceiling: that is governed by
|
||||
/// `ipfs_max_repos_walked` under a `MAX_PIN_SOURCES + 1` floor, because a value
|
||||
/// below the floor truncates a provenanced request with a full source set into a
|
||||
/// false 503. The knob is `usize`, the field `u32`; the range cap (1_048_576) keeps
|
||||
/// the cast lossless.
|
||||
pub(crate) fn ipfs_legacy_probe_budget(config: &crate::config::Config) -> u32 {
|
||||
config.ipfs_max_legacy_probes as u32
|
||||
}
|
||||
|
||||
/// Legacy-scan ROW budget wired from the `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` knob,
|
||||
/// the same helper shape as the probe budget above so the knob cannot become a
|
||||
/// silent no-op if a struct literal drifts back to the bare constant.
|
||||
pub(crate) fn ipfs_legacy_scan_row_budget(config: &crate::config::Config) -> usize {
|
||||
config.ipfs_max_legacy_scan_rows
|
||||
}
|
||||
|
||||
/// The key sealing legacy-scan continuation tokens (INV-13), DERIVED from the node's
|
||||
/// persistent identity rather than minted per boot.
|
||||
///
|
||||
/// HKDF-SHA256 over the node's Ed25519 seed, the same key material
|
||||
/// `load_or_create_keypair` reads back from its PKCS#8 PEM on every boot, so the
|
||||
/// derived key is byte-identical after a restart or a rolling deploy.
|
||||
///
|
||||
/// Two properties the derivation has to carry, both load-bearing:
|
||||
///
|
||||
/// * DOMAIN SEPARATION. The `info` string below is unique to this use, so the
|
||||
/// derived key is not the signing seed and is not any other secret derived from
|
||||
/// it. HKDF is one-way, so a leaked token key yields nothing about the signing
|
||||
/// key and cannot be turned against a signature.
|
||||
/// * A VERSION component, carried in the same `info` string
|
||||
/// ([`SCAN_TOKEN_KEY_INFO`]) alongside a fixed [`SCAN_TOKEN_KEY_SALT`]. Bumping
|
||||
/// the version rotates every token key on the next boot without touching the
|
||||
/// identity, the token format, or any caller, so rotation is a constant change
|
||||
/// rather than a fork of this derivation.
|
||||
pub(crate) fn derive_scan_token_key(keypair: &Keypair) -> [u8; 32] {
|
||||
use hmac::{Hmac, Mac};
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
|
||||
let seed = keypair.to_seed();
|
||||
// HKDF-Extract: PRK = HMAC(salt, ikm).
|
||||
let mut extract =
|
||||
HmacSha256::new_from_slice(SCAN_TOKEN_KEY_SALT).expect("HMAC takes a key of any size");
|
||||
extract.update(seed.as_slice());
|
||||
let prk = extract.finalize().into_bytes();
|
||||
// HKDF-Expand, one block: T(1) = HMAC(PRK, info || 0x01). One 32-byte output
|
||||
// needs exactly one block of SHA-256, so there is no counter loop to get wrong.
|
||||
let mut expand =
|
||||
HmacSha256::new_from_slice(prk.as_slice()).expect("HMAC takes a key of any size");
|
||||
expand.update(SCAN_TOKEN_KEY_INFO);
|
||||
expand.update(&[0x01]);
|
||||
expand.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
/// Work-budget capacity for [`ipfs_work_rate_limiter`](Self#structfield.ipfs_work_rate_limiter)
|
||||
/// (R6, KTD6), DERIVED from the route limit rather than a new operator knob. The route
|
||||
/// limiter (`ipfs_rate_limiter`) charges once per request; this separate bucket absorbs
|
||||
/// the resolver's per-probe/per-walk work charges so both the route "requests per hour"
|
||||
/// contract and the amplification bound hold. Floor: at least one complete COMBINED
|
||||
/// resolution per window, the provenance phase's walks plus a full legacy search
|
||||
/// (probes plus page tolls), so a single default-config resolution cannot
|
||||
/// self-throttle part-way and recreate the admit-then-429 for a legitimate caller.
|
||||
/// `GITLAWB_IPFS_RATE_LIMIT=0`
|
||||
/// disables the route brake and this derived bucket alike (a 0-capacity limiter admits
|
||||
/// everything).
|
||||
///
|
||||
/// The floor carries a WALK term (#173 round 15, F2). The provenance visibility walk
|
||||
/// in `gate_and_serve` debits this SAME bucket (its `!legacy_scan` charge), once per
|
||||
/// path-scoped source, before the legacy fallback runs at all, and the walk cap is
|
||||
/// charged per phase. A floor counting only the search therefore under-sizes the
|
||||
/// window by up to that cap exactly when the floor binds (a route limit set below
|
||||
/// it): the provenance phase spends from the budget the floor reserved for the
|
||||
/// search, the fallback 429s short of its configured reach, and the retry re-pays the
|
||||
/// same provenance charges. The term is
|
||||
/// `min(MAX_HISTORY_WALKS_PER_REQUEST, ipfs_max_repos_walked)` because that is what
|
||||
/// the resolver's own `walk_cap` in `gate_and_serve` evaluates to. The two
|
||||
/// expressions are separate, not one shared value: this one reads the CONSTANT
|
||||
/// `crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST`, while `walk_cap` reads the
|
||||
/// `AppState` seam `state.ipfs_max_history_walks`. They agree only because every
|
||||
/// construction seeds that seam from that constant: the production one in `main.rs`,
|
||||
/// and the two test ones in `auth`'s test module and `test_support`. Nothing
|
||||
/// mechanically ties the two expressions beyond this comment and its counterpart at
|
||||
/// `walk_cap` (no shared helper, no type, no assertion outside the one fixture that
|
||||
/// pins the seam as a precondition), so the two `min()`s have to be moved together
|
||||
/// by hand, and a construction that seeded the seam from anything else would size
|
||||
/// the floor for a cap the resolver does not enforce. It reads the
|
||||
/// CONSTANT and the CONFIG knob for the same reason the page term below reads the
|
||||
/// constant page size: `ipfs_max_history_walks` is an `AppState` test seam, and
|
||||
/// sizing a production floor from a seam would inflate the budget by whatever a test
|
||||
/// chose.
|
||||
///
|
||||
/// The PROBE term is the LEGACY-PROBE knob, not `ipfs_max_repos_walked`. Those were
|
||||
/// one field before the walk cap and the probe budget were split apart, and sizing
|
||||
/// the probe term off the walk cap would silently read 64 instead of 256. That is a
|
||||
/// statement about which knob sizes the PROBE term; it is not a claim that the walk
|
||||
/// cap has no place in the floor, since the walk term above is added to this one
|
||||
/// rather than substituted for it.
|
||||
///
|
||||
/// The floor also carries the scan's PAGE toll (#173 round 13, F2): every page the
|
||||
/// legacy scan buys is charged to this same bucket, so a deep scan spends
|
||||
/// `ceil(ipfs_max_legacy_scan_rows / LEGACY_SCAN_PAGE_ROWS)` tokens on pages on top
|
||||
/// of its probes. Leaving those out would 429 an honest caller part-way down their
|
||||
/// own continuation-token ladder, which is the F6 admit-then-429 shape in a new
|
||||
/// place. The page term uses the CONSTANT page size, not `AppState`'s field: the
|
||||
/// field is a test seam that shrinks pages to make paging observable, and sizing a
|
||||
/// production floor from it would inflate the budget by whatever a test chose.
|
||||
pub(crate) fn ipfs_work_budget(config: &crate::config::Config) -> usize {
|
||||
if config.ipfs_rate_limit == 0 {
|
||||
return 0;
|
||||
}
|
||||
let pages = config
|
||||
.ipfs_max_legacy_scan_rows
|
||||
.div_ceil(crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS);
|
||||
let walks = std::cmp::min(
|
||||
crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST as usize,
|
||||
config.ipfs_max_repos_walked,
|
||||
);
|
||||
config
|
||||
.ipfs_rate_limit
|
||||
.max(config.ipfs_max_legacy_probes + pages + walks)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds the OUTSTANDING post-push encryption-task set by per-repo coalescing
|
||||
@@ -1226,3 +1472,80 @@ mod repo_write_lease_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scan_token_key_tests {
|
||||
use super::AppState;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::scan_token::{open_scan_token, seal_scan_token, ScanPosition};
|
||||
|
||||
const CID: &str = "bafkreiscantokenkeyfixtureaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
fn pos() -> ScanPosition {
|
||||
ScanPosition {
|
||||
created_at_key: "2020-01-01T12:00:00+00:00".to_string(),
|
||||
id: "did:key:z6MkScanTokenOwner/repo".to_string(),
|
||||
// 40 hex: the production shape, since the node's own repos are sha1.
|
||||
sha256_hex: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The RESTART case. A token minted before a restart must still open after it, or a
|
||||
/// caller laddering a deep inventory is silently returned to the front of the scan on
|
||||
/// every rolling deploy and can never reach a holder buried past one window.
|
||||
///
|
||||
/// The second key is derived from the identity RELOADED THROUGH ITS ON-DISK PEM,
|
||||
/// which is exactly what `load_or_create_keypair` does on boot, so this exercises the
|
||||
/// real restart path rather than a clone of the in-memory keypair.
|
||||
#[test]
|
||||
fn scan_token_key_survives_a_restart_of_the_same_identity() {
|
||||
let kp = Keypair::generate();
|
||||
let pem = kp.to_pem().expect("the identity serializes");
|
||||
let reloaded = Keypair::from_pem(&pem).expect("the identity reloads");
|
||||
|
||||
let before = AppState::derive_scan_token_key(&kp);
|
||||
let after = AppState::derive_scan_token_key(&reloaded);
|
||||
|
||||
let token = seal_scan_token(&before, CID, &pos(), i64::MAX - 1).expect("seal");
|
||||
assert_eq!(
|
||||
open_scan_token(&after, CID, &token, 0),
|
||||
Some(pos()),
|
||||
"a continuation minted before a restart must open after it: the node's \
|
||||
identity is the same, so the derived sealing key must be too"
|
||||
);
|
||||
}
|
||||
|
||||
/// The must-not: a DIFFERENT node identity must derive a DIFFERENT key, so a token is
|
||||
/// no more portable between nodes than it was when the key was random per boot.
|
||||
#[test]
|
||||
fn scan_token_key_does_not_open_under_a_different_identity() {
|
||||
let mine = Keypair::generate();
|
||||
let theirs = Keypair::generate();
|
||||
|
||||
let token = seal_scan_token(
|
||||
&AppState::derive_scan_token_key(&mine),
|
||||
CID,
|
||||
&pos(),
|
||||
i64::MAX - 1,
|
||||
)
|
||||
.expect("seal");
|
||||
assert_eq!(
|
||||
open_scan_token(&AppState::derive_scan_token_key(&theirs), CID, &token, 0),
|
||||
None,
|
||||
"a continuation sealed by one node must not open under another node's identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// Domain separation, executed rather than asserted in prose: the derived token key
|
||||
/// must not be the signing seed itself. Compromising a token key must not hand an
|
||||
/// attacker the material that signs.
|
||||
#[test]
|
||||
fn scan_token_key_is_not_the_signing_seed() {
|
||||
let kp = Keypair::generate();
|
||||
assert_ne!(
|
||||
AppState::derive_scan_token_key(&kp),
|
||||
*kp.to_seed(),
|
||||
"the token key must be a DERIVED secret, never the Ed25519 signing seed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -701,6 +701,7 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
if mode == MirrorMode::Promisor {
|
||||
args.push("--filter=blob:limit=10g");
|
||||
}
|
||||
args.push("--");
|
||||
args.push(remote_url);
|
||||
args.push(local_str);
|
||||
|
||||
@@ -729,7 +730,10 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
|
||||
git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?;
|
||||
git_run(&[
|
||||
"-C", local_str, "remote", "set-url", "origin", "--", remote_url,
|
||||
])
|
||||
.await?;
|
||||
|
||||
match mode {
|
||||
MirrorMode::Promisor => {
|
||||
@@ -1006,6 +1010,82 @@ mod tests {
|
||||
assert_eq!(probe, PromisorProbe::NotPromisor);
|
||||
}
|
||||
|
||||
/// #374: `clone_repo` interpolates the peer's origin URL into a `git
|
||||
/// clone --mirror` argument list. Without a `--` delimiter git parses a
|
||||
/// remote beginning with `-` as an option instead of a repository.
|
||||
///
|
||||
/// The delimiter is what forces the same string to be a repository, and
|
||||
/// git says so in its own words: it reports the whole argument as a
|
||||
/// repository it cannot find. Undelimited, git consumes it as an option
|
||||
/// and fails against the *destination* instead (`fatal: repository '<dest>' does not exist`),
|
||||
/// never naming the injected string — which is why this assertion is RED
|
||||
/// before the fix and GREEN after.
|
||||
///
|
||||
/// The payload is deliberately free of `:` and spaces: a colon would make
|
||||
/// git read the argument as an ssh-style `host:path` URL and report a
|
||||
/// hostname rather than a repository, which is a different code path.
|
||||
#[tokio::test]
|
||||
async fn clone_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = clone_repo(injected, &dest, MirrorMode::Plain)
|
||||
.await
|
||||
.expect_err("a remote that is not a repository must fail the clone")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(injected),
|
||||
"git must report the argument as a repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #374: `fetch_repo` interpolates the peer's origin URL into
|
||||
/// `git remote set-url`, a second argv construction with the same contract as
|
||||
/// the clone. It needs its own proof: the clone test cannot reach this path.
|
||||
///
|
||||
/// The assertion is on stored state rather than on the error string, and that
|
||||
/// is deliberate. `git_run` formats its failure as `git {args:?} failed:
|
||||
/// {stderr}`, so the injected value is in the message via the debug-printed
|
||||
/// argv whether or not the delimiter is present — asserting on containment
|
||||
/// would pass with `--` deleted. Delimited, `set-url` succeeds and stores the
|
||||
/// value verbatim; undelimited, git exits 129 with `unknown option` and leaves
|
||||
/// the previous URL in place, so this goes red without the fix.
|
||||
///
|
||||
/// The fetch that follows the rewrite fails, because the stored URL is not a
|
||||
/// repository. That is expected and ignored: the property under test is what
|
||||
/// `set-url` wrote, not whether the subsequent fetch could succeed.
|
||||
#[tokio::test]
|
||||
async fn fetch_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let local = td.path().join("mirror.git");
|
||||
let local_str = local.to_str().unwrap();
|
||||
let placeholder = "http://example.invalid/x.git";
|
||||
|
||||
assert!(Command::new("git")
|
||||
.args(["init", "-q", "--bare", local_str])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", local_str, "remote", "add", "origin", placeholder])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
|
||||
let injected = "--upload-pack=false";
|
||||
let _ = fetch_repo(&local, injected, MirrorMode::Plain).await;
|
||||
|
||||
assert_eq!(
|
||||
git_config_get(local_str, "remote.origin.url")
|
||||
.await
|
||||
.as_deref(),
|
||||
Some(injected),
|
||||
"set-url must store an option-shaped remote verbatim, not reject it as an option"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_unknown_on_git_error() {
|
||||
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
|
||||
@@ -1250,6 +1330,18 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Use a rooted path without a Windows drive prefix in the remote fixture.
|
||||
/// The destination still resolves on the temp directory's current drive,
|
||||
/// while the remote's relative path contains no illegal colon component.
|
||||
fn absolute_slug_path(path: &Path) -> String {
|
||||
assert!(path.is_absolute());
|
||||
path.components()
|
||||
.filter(|part| !matches!(part, std::path::Component::Prefix(_)))
|
||||
.collect::<std::path::PathBuf>()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn process_batch_rejects_slug_escaping_repos_dir(pool: PgPool) {
|
||||
// The verified escape from #272: `PathBuf::join` discards everything
|
||||
@@ -1265,12 +1357,13 @@ mod tests {
|
||||
// fails on but leaves the parent it created, so assert on both.
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let slug = format!("a/{}/escape", escape_dir.display());
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/escape");
|
||||
let escape_target = escape_dir.join("escape.git");
|
||||
|
||||
// Serve the composed URL for real, so a run without the guard genuinely
|
||||
// clones outside the root rather than merely failing at git.
|
||||
let rel = format!("a{}/escape", escape_dir.display());
|
||||
let rel = format!("a{escape_path}/escape");
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
|
||||
let did = "did:key:z6MkAttacker";
|
||||
@@ -1390,6 +1483,7 @@ mod tests {
|
||||
// ── canonical containment before the git call (issue #272) ───────────────
|
||||
|
||||
/// Every ref in `repo`, as one string, for a before/after comparison.
|
||||
#[cfg(unix)]
|
||||
fn refs_of(repo: &Path) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(["-C", repo.to_str().unwrap(), "for-each-ref"])
|
||||
@@ -1722,6 +1816,7 @@ mod tests {
|
||||
/// starvation tests is fixed rather than dependent on how fast the loop
|
||||
/// runs. Two rows enqueued in the same microsecond would otherwise order
|
||||
/// arbitrarily.
|
||||
#[cfg(unix)]
|
||||
async fn enqueue_at(db: &Db, pool: &PgPool, repo: &str, did: &str, enqueued_at: &str) {
|
||||
enqueue(db, repo, did).await;
|
||||
sqlx::query("UPDATE sync_queue SET enqueued_at = $1 WHERE repo = $2")
|
||||
@@ -1915,7 +2010,8 @@ mod tests {
|
||||
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let slug = format!("a/{}/gitlawb-probe", escape_dir.display());
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/gitlawb-probe");
|
||||
let escape_target = escape_dir.join("gitlawb-probe.git");
|
||||
|
||||
// Two things this fixture must get right or the test is green for the
|
||||
@@ -1926,7 +2022,7 @@ mod tests {
|
||||
// run without the guard genuinely clones outside the root instead of
|
||||
// just failing at git. Db::upsert_peer cannot seed this row: it gates on
|
||||
// is_public_http_url, which rejects file://.
|
||||
let rel = format!("a{}/gitlawb-probe", escape_dir.display());
|
||||
let rel = format!("a{escape_path}/gitlawb-probe");
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
let did = "did:key:z6MkAttacker";
|
||||
seed_local_peer(&pool, did, &peer_url).await;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Native Git fixtures shared by timing tests on Unix and Windows.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The fixture delays or hangs in its own process, so the production watchdog
|
||||
/// can terminate it without depending on a shell or descendant process support.
|
||||
pub(super) enum Behavior<'a> {
|
||||
Delay(u64),
|
||||
Hang,
|
||||
HangOid(&'a str),
|
||||
HangRepo(&'a str),
|
||||
LogTypes(&'a Path),
|
||||
}
|
||||
|
||||
pub(super) struct GitShim {
|
||||
_directory: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
impl Deref for GitShim {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Path {
|
||||
&self.executable
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create(name: &str, behavior: Behavior<'_>) -> GitShim {
|
||||
static COMPILED: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
let compiled = COMPILED.get_or_init(|| {
|
||||
let directory = tempfile::tempdir().expect("native Git fixture directory");
|
||||
let source = directory.path().join("git_shim.rs");
|
||||
std::fs::write(&source, include_str!("../tests/fixtures/git_shim.rs"))
|
||||
.expect("write native Git fixture source");
|
||||
let executable = directory
|
||||
.path()
|
||||
.join(format!("git-shim{}", std::env::consts::EXE_SUFFIX));
|
||||
let output =
|
||||
std::process::Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
|
||||
.arg("--edition=2021")
|
||||
.arg("-Dwarnings")
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(executable)
|
||||
.output()
|
||||
.expect("compile native Git fixture with the installed Rust toolchain");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
directory
|
||||
});
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(name)
|
||||
.tempdir()
|
||||
.expect("Git fixture instance");
|
||||
let file_name = format!("git-shim{}", std::env::consts::EXE_SUFFIX);
|
||||
let executable = directory.path().join(&file_name);
|
||||
std::fs::copy(compiled.path().join(file_name), &executable).expect("copy native Git fixture");
|
||||
let config = match behavior {
|
||||
Behavior::Delay(milliseconds) => format!("delay\n{milliseconds}"),
|
||||
Behavior::Hang => "hang\n".to_owned(),
|
||||
Behavior::HangOid(oid) => format!("hang-oid\n{oid}"),
|
||||
Behavior::HangRepo(repo) => format!("hang-repo\n{repo}"),
|
||||
Behavior::LogTypes(path) => format!("log-types\n{}", path.display()),
|
||||
};
|
||||
std::fs::write(directory.path().join("config"), config).expect("configure native Git fixture");
|
||||
GitShim {
|
||||
_directory: directory,
|
||||
executable,
|
||||
}
|
||||
}
|
||||
+16519
-41
File diff suppressed because it is too large.
Load diff
@@ -437,6 +437,31 @@ 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]
|
||||
|
||||
@@ -79,6 +79,19 @@ async fn fire_event_async(
|
||||
let delivery_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Policy skip, not a delivery failure: the stored URL predates the
|
||||
// create-time gate. The "invalid_url" label is documented on
|
||||
// record_webhook_delivery in metrics.rs.
|
||||
if !crate::api::peers::is_public_http_url(&hook.url) {
|
||||
crate::metrics::record_webhook_delivery("invalid_url");
|
||||
tracing::warn!(
|
||||
url = %hook.url,
|
||||
event = %event_name,
|
||||
"webhook delivery skipped: stored URL is not a public http(s) target"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
|
||||
|
||||
let mut req = client
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let config = std::fs::read_to_string(executable.parent().unwrap().join("config"))?;
|
||||
let (mode, parameter) = config.split_once('\n').unwrap_or((&config, ""));
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if mode == "delay" {
|
||||
std::thread::sleep(Duration::from_millis(parameter.parse().unwrap()));
|
||||
}
|
||||
let batch = args.get(1).is_some_and(|arg| arg == "--batch-check");
|
||||
let input = if batch {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes)?;
|
||||
Some(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cwd = std::env::current_dir()?;
|
||||
let hang = mode == "hang"
|
||||
|| (mode == "hang-repo" && cwd.file_name().is_some_and(|name| name == parameter))
|
||||
|| (mode == "hang-oid"
|
||||
&& (args.iter().any(|arg| arg == parameter)
|
||||
|| input
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| String::from_utf8_lossy(bytes).trim() == parameter)));
|
||||
if hang {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
std::process::exit(1);
|
||||
}
|
||||
if mode == "log-types" && batch {
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(parameter)?;
|
||||
writeln!(
|
||||
log,
|
||||
"{} {}",
|
||||
String::from_utf8_lossy(input.as_ref().unwrap()).trim(),
|
||||
cwd.file_name().unwrap().to_string_lossy()
|
||||
)?;
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.args(&args)
|
||||
.stdin(if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::inherit()
|
||||
})
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
if let Some(bytes) = input {
|
||||
child.stdin.take().unwrap().write_all(&bytes)?;
|
||||
}
|
||||
std::process::exit(child.wait()?.code().unwrap_or(1));
|
||||
}
|
||||
@@ -29,6 +29,7 @@ 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
|
||||
@@ -68,6 +69,57 @@ fn inv22_concurrency_gates_present_and_not_bypassed() {
|
||||
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.
|
||||
@@ -95,8 +147,12 @@ fn inv22_concurrency_gates_present_and_not_bypassed() {
|
||||
// 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("#[cfg(test)]")
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
assert!(
|
||||
@@ -169,7 +225,8 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
let repo_store = src("git/repo_store.rs");
|
||||
|
||||
let rel_start = repo_store
|
||||
.find("pub async fn release(mut self")
|
||||
.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")
|
||||
@@ -201,10 +258,10 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
);
|
||||
}
|
||||
|
||||
/// F6/KTD-5 (initial IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
|
||||
/// the scarce walk permits (RAII, held for the whole request) BEFORE its two initial
|
||||
/// metadata queries, and the per-repo loop's first budget gate runs only later. So
|
||||
/// both `list_all_repos` and `list_visibility_rules_for_repos` must be clamped to the
|
||||
/// 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
|
||||
@@ -214,31 +271,44 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
#[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("#[cfg(test)]")
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
for call in [".list_all_repos()", ".list_visibility_rules_for_repos("] {
|
||||
assert_eq!(
|
||||
production.matches(call).count(),
|
||||
1,
|
||||
"F6 guard stale: `{call}` must appear exactly once in the production half \
|
||||
of api/ipfs.rs (the deadline-wrapped handler call) — update this guard"
|
||||
);
|
||||
let idx = production
|
||||
.find(call)
|
||||
.unwrap_or_else(|| panic!("F6 gate: api/ipfs.rs no longer calls `{call}`"));
|
||||
// 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];
|
||||
// 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!(
|
||||
window.contains("tokio::time::timeout("),
|
||||
"F6 gate missing: `{call}` must be 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."
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +331,12 @@ fn f6_ipfs_metadata_queries_are_deadline_wrapped() {
|
||||
#[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("#[cfg(test)]")
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
@@ -321,8 +395,12 @@ fn f2_pinata_enqueues_refs_not_retained_object_lists() {
|
||||
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("#[cfg(test)]")
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
@@ -372,7 +450,7 @@ fn f3_second_writer_leased_until_reap() {
|
||||
/// 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(&admission)` binding, or drop the
|
||||
/// 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() {
|
||||
@@ -386,7 +464,10 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
|
||||
);
|
||||
|
||||
// Every blocking site in the scan takes its own clone...
|
||||
let clones = ipfs.matches("Arc::clone(&admission)").count();
|
||||
// `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 \
|
||||
@@ -443,8 +524,12 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
|
||||
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("#[cfg(test)]")
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
@@ -460,7 +545,7 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() {
|
||||
.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("guard.release(push_succeeded)")
|
||||
.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(")
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! #26 Split PR 4 — `GITLAWB_ENFORCE_OWNER_PUSH` default is consistent across surfaces.
|
||||
//!
|
||||
//! Reviewer 2 (2026-08-28) closed PR #224 with a P2 finding: SECURITY.md and
|
||||
//! README.md both stated the default was `false`, while `Config::enforce_owner_push`
|
||||
//! declared `default_value_t = true` and the runtime, .env.example, and the env
|
||||
//! var table on the README all agreed with the code. Two operator-facing
|
||||
//! surfaces lied about the authorization policy. PR #330 flipped the code
|
||||
//! default and updated .env.example / README env table / docs/RUN-A-NODE.md,
|
||||
//! but missed these two prose mentions.
|
||||
//!
|
||||
//! This guard pins the documentation and example surfaces together. Each one
|
||||
//! is read off the working tree at test time, not a copy in the binary, so a
|
||||
//! revert of the doc edit turns it red:
|
||||
//!
|
||||
//! 1. `SECURITY.md` says the default is `true`.
|
||||
//! 2. `README.md` says the default is `true` in the known-limitations prose.
|
||||
//! 3. `README.md` says the default is `true` in the env-var table row.
|
||||
//! 4. `.env.example` sets `GITLAWB_ENFORCE_OWNER_PUSH=true`.
|
||||
//!
|
||||
//! The runtime declared default (`Config::enforce_owner_push`'s
|
||||
//! `default_value_t = true`) is protected by the
|
||||
//! `enforce_owner_push_is_declared_true_independent_of_the_environment`
|
||||
//! unit test in `config.rs`, which reads the parser directly and is
|
||||
//! env-independent. The two checks together cover the surfaces that
|
||||
//! produced the original doc-vs-code drift; this guard cannot reach into
|
||||
//! the binary crate's `Config` type (it is private to the bin target,
|
||||
//! which has no `lib.rs`) without a larger refactor, so the contract is
|
||||
//! stated as the four documentation/example surfaces above.
|
||||
//!
|
||||
//! The four are checked independently. A check that just verified "at least
|
||||
//! one surface says true" would pass while a stale SECURITY.md lied; a check
|
||||
//! that only verified the binary would not catch the doc drift that
|
||||
//! produced this finding in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn read(rel: &str) -> String {
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join(rel);
|
||||
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// Pin the prose mentions of `GITLAWB_ENFORCE_OWNER_PUSH` so a future doc
|
||||
/// edit cannot reintroduce the "defaults to false" wording the reviewer
|
||||
/// flagged. The wording in the test is what the docs MUST say about the
|
||||
/// default; absence of any prose mention is also a fail because operators
|
||||
/// rely on the docs to learn the default.
|
||||
#[test]
|
||||
fn security_md_states_owner_push_default_is_true() {
|
||||
let security = read("SECURITY.md");
|
||||
let section = section_after(&security, "### Repository write authorization defaults")
|
||||
.unwrap_or_else(|| panic!("SECURITY.md is missing the owner-push section"));
|
||||
|
||||
assert!(
|
||||
section.contains("defaults to `true`"),
|
||||
"SECURITY.md owner-push section must state the default is true. \
|
||||
Found:\n{section}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(§ion),
|
||||
"SECURITY.md owner-push section must not claim the default is false. \
|
||||
Found:\n{section}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_known_limitations_states_owner_push_default_is_true() {
|
||||
let readme = read("README.md");
|
||||
let limitations = section_after(&readme, "Known limitations:")
|
||||
.unwrap_or_else(|| panic!("README.md is missing the Known limitations: section"));
|
||||
|
||||
// The owner-push line is one bullet in the limitations list. Pull the
|
||||
// single line that mentions the env var so the assertion is local to
|
||||
// the claim, not a substring of unrelated text.
|
||||
let line = limitations
|
||||
.lines()
|
||||
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"README.md Known limitations must mention GITLAWB_ENFORCE_OWNER_PUSH.\n\
|
||||
Got:\n{limitations}"
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
line.contains("defaults to `true`") || line.contains("defaults to true"),
|
||||
"README Known-limitations owner-push bullet must state the default is true. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(line),
|
||||
"README Known-limitations owner-push bullet must not claim the default is false. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_env_var_table_states_owner_push_default_is_true() {
|
||||
let readme = read("README.md");
|
||||
let line = readme
|
||||
.lines()
|
||||
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH") && l.contains("|"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("README.md is missing the env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH")
|
||||
});
|
||||
|
||||
assert!(
|
||||
line.contains("Defaults to `true`") || line.contains("defaults to `true`"),
|
||||
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must state the default is true. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(line),
|
||||
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must not claim the default is false. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_example_sets_owner_push_true() {
|
||||
let env = read(".env.example");
|
||||
let line = env
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("GITLAWB_ENFORCE_OWNER_PUSH"))
|
||||
.unwrap_or_else(|| panic!(".env.example is missing GITLAWB_ENFORCE_OWNER_PUSH"));
|
||||
|
||||
assert!(
|
||||
line.contains("=true"),
|
||||
".env.example GITLAWB_ENFORCE_OWNER_PUSH must be set to true. Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A claim that the default is false can hide in many phrasings. Match the
|
||||
/// forms that have appeared in the wild on this branch: "defaults to `false`",
|
||||
/// "defaults to false", "default is false", "is `false` for compatibility",
|
||||
/// "is false for compatibility". The match is intentionally narrow — false
|
||||
/// positives would suppress a real doc edit.
|
||||
fn section_claims_default_is_false(section: &str) -> bool {
|
||||
let lower = section.to_ascii_lowercase();
|
||||
lower.contains("defaults to `false`")
|
||||
|| lower.contains("defaults to false")
|
||||
|| lower.contains("default is `false`")
|
||||
|| lower.contains("default is false")
|
||||
|| (lower.contains("`false`") && lower.contains("for compatibility"))
|
||||
}
|
||||
|
||||
/// Return the prose after a marker up to the next heading boundary. The
|
||||
/// marker is normally a markdown heading (e.g. `### Section`); if it has
|
||||
/// no `#` prefix it is treated as a plain-text marker and the slice is
|
||||
/// bounded by the next heading of any level. This matters because a
|
||||
/// plain-text marker would otherwise produce `header_level = 0`, making
|
||||
/// the same-or-higher-level stop condition unsatisfiable and letting the
|
||||
/// slice run to EOF.
|
||||
fn section_after(doc: &str, heading: &str) -> Option<String> {
|
||||
let start = doc.find(heading)?;
|
||||
let after = &doc[start..];
|
||||
let header_level = heading.chars().take_while(|c| *c == '#').count();
|
||||
|
||||
// Skip past the heading line itself.
|
||||
let body_start = after.find('\n')? + 1;
|
||||
let body = &after[body_start..];
|
||||
|
||||
// Stop at the next heading of the same or higher level, or at the next
|
||||
// heading of any level when the marker had no `#` prefix.
|
||||
let mut end = body.len();
|
||||
for line in body.lines() {
|
||||
let level = line.chars().take_while(|c| *c == '#').count();
|
||||
if level > 0 && (header_level == 0 || level <= header_level) {
|
||||
end = line.as_ptr() as usize - body.as_ptr() as usize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(body[..end].to_string())
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
//! 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,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
description = "The Twigpine CLI — identity management, node control, MCP server"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,7 +11,7 @@ name = "gl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
icaptcha-client = { path = "../icaptcha-client" }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -26,6 +26,8 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
dirs = "5"
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
urlencoding = "2"
|
||||
alloy = { version = "1", default-features = false, features = [
|
||||
"contract",
|
||||
@@ -42,3 +44,4 @@ alloy = { version = "1", default-features = false, features = [
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
+27
-28
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -17,7 +16,11 @@ pub struct AgentArgs {
|
||||
pub enum AgentCmd {
|
||||
/// List agents registered on a node
|
||||
List {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
/// Filter by capability (e.g. git:push)
|
||||
#[arg(long)]
|
||||
@@ -29,7 +32,11 @@ pub enum AgentCmd {
|
||||
Show {
|
||||
/// Agent DID or short key segment
|
||||
did: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
},
|
||||
}
|
||||
@@ -57,24 +64,16 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
|
||||
.get(&path)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
// 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 {
|
||||
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: 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 body = crate::http::read_json(resp, "list agents").await?;
|
||||
|
||||
let agents = body["agents"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -89,7 +88,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
|
||||
let short = did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(16)])
|
||||
.map(|s| crate::text::truncate(s, 16))
|
||||
.unwrap_or("?");
|
||||
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
|
||||
let caps = agent["capabilities"]
|
||||
@@ -117,24 +116,15 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
|
||||
.get(&format!("/api/v1/agents/{did}"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
// Keep the bespoke 404 hint ahead of read_json; other statuses route through it.
|
||||
if resp.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: 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 agent = crate::http::read_json(resp, "agent").await?;
|
||||
|
||||
let full_did = agent["did"].as_str().unwrap_or("?");
|
||||
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
|
||||
@@ -215,11 +205,14 @@ 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]
|
||||
@@ -230,11 +223,14 @@ 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]
|
||||
@@ -261,6 +257,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"agent not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -268,6 +265,8 @@ 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]
|
||||
|
||||
+146
-60
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -39,7 +38,11 @@ pub enum BountyCmd {
|
||||
/// Deadline in seconds (default 7 days)
|
||||
#[arg(long)]
|
||||
deadline: Option<i64>,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -51,7 +54,11 @@ pub enum BountyCmd {
|
||||
/// Filter by status: open, claimed, submitted, completed
|
||||
#[arg(long)]
|
||||
status: Option<String>,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -60,7 +67,11 @@ pub enum BountyCmd {
|
||||
Show {
|
||||
/// Bounty ID
|
||||
id: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -72,7 +83,11 @@ pub enum BountyCmd {
|
||||
/// Your wallet address for payout
|
||||
#[arg(long)]
|
||||
wallet: Option<String>,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -84,7 +99,11 @@ pub enum BountyCmd {
|
||||
/// Pull request ID
|
||||
#[arg(long)]
|
||||
pr: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -96,7 +115,11 @@ pub enum BountyCmd {
|
||||
/// On-chain payout tx hash
|
||||
#[arg(long)]
|
||||
tx_hash: Option<String>,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
@@ -105,14 +128,22 @@ pub enum BountyCmd {
|
||||
Cancel {
|
||||
/// Bounty ID
|
||||
id: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show bounty stats and leaderboard
|
||||
Stats {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
},
|
||||
}
|
||||
@@ -189,13 +220,7 @@ async fn cmd_create(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
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 body = crate::http::read_json(resp, "create bounty").await?;
|
||||
|
||||
let id = body["id"].as_str().unwrap_or("?");
|
||||
println!("Bounty created: {id}");
|
||||
@@ -231,11 +256,14 @@ async fn cmd_list(
|
||||
u
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
let body = crate::http::read_json(
|
||||
client
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?,
|
||||
"bounties",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bounties = body["bounties"].as_array();
|
||||
if let Some(arr) = bounties {
|
||||
@@ -247,7 +275,7 @@ async fn cmd_list(
|
||||
let title = b["title"].as_str().unwrap_or("?");
|
||||
let amount = b["amount"].as_i64().unwrap_or(0);
|
||||
let st = b["status"].as_str().unwrap_or("?");
|
||||
let short_id = &id[..8.min(id.len())];
|
||||
let short_id = crate::text::truncate(id, 8);
|
||||
println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}");
|
||||
}
|
||||
}
|
||||
@@ -259,13 +287,7 @@ 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 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}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "show bounty").await?;
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&body)?);
|
||||
Ok(())
|
||||
@@ -289,13 +311,7 @@ async fn cmd_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}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "claim").await?;
|
||||
|
||||
println!("Bounty {id} claimed. Deadline starts now.");
|
||||
Ok(())
|
||||
@@ -314,13 +330,7 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
|
||||
)
|
||||
.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}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "submit").await?;
|
||||
|
||||
println!("Bounty {id} submitted with PR {pr}. Awaiting creator approval.");
|
||||
Ok(())
|
||||
@@ -344,13 +354,7 @@ async fn cmd_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}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "approve").await?;
|
||||
|
||||
println!("Bounty {id} approved! Payout released to agent.");
|
||||
Ok(())
|
||||
@@ -369,13 +373,7 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
)
|
||||
.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}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "cancel").await?;
|
||||
|
||||
println!("Bounty {id} cancelled. Tokens refunded.");
|
||||
Ok(())
|
||||
@@ -383,8 +381,11 @@ 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);
|
||||
let resp = client.get("/api/v1/bounties/stats").await?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
// 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 open = body["open"].as_i64().unwrap_or(0);
|
||||
let claimed = body["claimed"].as_i64().unwrap_or(0);
|
||||
@@ -402,7 +403,7 @@ async fn cmd_stats(node: String) -> Result<()> {
|
||||
let did = entry["did"].as_str().unwrap_or("?");
|
||||
let count = entry["completed"].as_i64().unwrap_or(0);
|
||||
let earned = entry["total_earned"].as_i64().unwrap_or(0);
|
||||
let short = &did[did.len().saturating_sub(8)..];
|
||||
let short = crate::text::truncate_suffix(did, 8);
|
||||
println!(
|
||||
" {}. ...{short} {count} bounties {earned} $GITLAWB",
|
||||
i + 1
|
||||
@@ -521,6 +522,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -528,6 +530,8 @@ 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]
|
||||
@@ -544,4 +548,86 @@ 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;
|
||||
}
|
||||
}
|
||||
+1159
-63
File diff suppressed because it is too large.
Load diff
+40
-18
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -15,7 +14,11 @@ pub struct ChangelogArgs {
|
||||
/// Maximum number of events to show
|
||||
#[arg(long, default_value = "20")]
|
||||
pub limit: usize,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
pub node: String,
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
@@ -31,7 +34,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
};
|
||||
|
||||
if repo.is_empty() {
|
||||
anyhow::bail!("no repo specified — pass <repo> or run from inside a gitlawb repo");
|
||||
anyhow::bail!("no repo specified — pass <repo> or run from inside a Twigpine repo");
|
||||
}
|
||||
|
||||
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
|
||||
@@ -42,12 +45,7 @@ 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: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let did = info["did"].as_str().context("node missing DID")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -64,13 +62,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
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 body = crate::http::read_json(resp, "changelog").await?;
|
||||
|
||||
let events = body["events"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -83,14 +75,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
|
||||
for event in &events {
|
||||
let ts = event["timestamp"].as_str().unwrap_or("?");
|
||||
let date = &ts[..ts.len().min(10)];
|
||||
let date = crate::text::truncate(ts, 10);
|
||||
|
||||
match event["type"].as_str().unwrap_or("") {
|
||||
"commit" => {
|
||||
let sha = event["sha"].as_str().unwrap_or("?");
|
||||
let msg = event["message"].as_str().unwrap_or("?");
|
||||
let first_line = msg.lines().next().unwrap_or(msg);
|
||||
let short_sha = &sha[..sha.len().min(8)];
|
||||
let short_sha = crate::text::truncate(sha, 8);
|
||||
println!(" {date} commit {short_sha} {first_line}");
|
||||
}
|
||||
"pr_merged" => {
|
||||
@@ -212,6 +204,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -223,6 +216,8 @@ 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]
|
||||
@@ -270,4 +265,31 @@ 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;
|
||||
}
|
||||
}
|
||||
+355
-16
@@ -1,4 +1,4 @@
|
||||
//! `gl clone`: clean partial clone of a gitlawb repo with private subtrees.
|
||||
//! `gl clone`: clean partial clone of a Twigpine 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,9 +12,58 @@ use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::http::{sanitize_node_msg, 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>.
|
||||
@@ -27,7 +76,11 @@ pub struct CloneArgs {
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "https://node.twigpine.com",
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
pub node: String,
|
||||
|
||||
/// Arweave gateway for B3 manifest discovery/fetch when a node cannot supply
|
||||
@@ -110,8 +163,8 @@ pub fn setup_partial_clone(
|
||||
|
||||
if withheld_globs.is_empty() {
|
||||
match branch {
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", remote_url, dest_str])?,
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, "--", remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", "--", remote_url, dest_str])?,
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -121,6 +174,7 @@ pub fn setup_partial_clone(
|
||||
"-q",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
"--",
|
||||
remote_url,
|
||||
dest_str,
|
||||
])?;
|
||||
@@ -312,15 +366,30 @@ async fn recover_encrypted_blobs(
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => continue,
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
let Ok(envelope) = env_resp.bytes().await else {
|
||||
continue;
|
||||
let envelope = match env_resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn_skip(oid, &format!("reading the envelope failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let plaintext = match open_blob(&envelope, keypair) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("warning: could not decrypt {oid}: {e}");
|
||||
emit_warning(&format!("warning: could not decrypt {oid}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -338,7 +407,9 @@ async fn recover_encrypted_blobs(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -510,7 +581,7 @@ fn merge_manifests(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Option B3 fallback recovery, with no dependency on a gitlawb node API. Query
|
||||
/// Option B3 fallback recovery, with no dependency on a Twigpine 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
|
||||
@@ -642,7 +713,14 @@ 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,
|
||||
_ => continue,
|
||||
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;
|
||||
}
|
||||
};
|
||||
if let Ok(parsed) = m.json::<Manifest>().await {
|
||||
manifests.push((parsed, r.height));
|
||||
@@ -684,10 +762,31 @@ 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,
|
||||
_ => continue,
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
let Ok(envelope) = env_resp.bytes().await else {
|
||||
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;
|
||||
}
|
||||
};
|
||||
// open_blob succeeds only if this caller is a recipient: this is the
|
||||
// authorization gate (no node, no DID check needed).
|
||||
@@ -708,7 +807,9 @@ async fn recover_from_arweave(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -801,9 +902,42 @@ 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)
|
||||
@@ -961,6 +1095,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_plain_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], None).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_with_branch_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let branch_out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
td.path().join("origin").to_str().unwrap(),
|
||||
"branch",
|
||||
"--show-current",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
let branch = String::from_utf8(branch_out.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], Some(&branch)).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
/// #374: every `setup_partial_clone` shape interpolates `remote_url` into a
|
||||
/// `git clone` argv. The `--` delimiter is what forces an option-shaped
|
||||
/// remote to be read as a repository rather than parsed as an option.
|
||||
///
|
||||
/// The assertion is on git's own words — `repository '<value>'`, single-quoted
|
||||
/// — and not merely on the value appearing somewhere in the error. `git_global`
|
||||
/// formats its failure as `git {args:?} failed: {stderr}`, so the injected
|
||||
/// string is already present in the debug-printed argv whether or not the
|
||||
/// delimiter is there; asserting on containment alone would pass with `--`
|
||||
/// deleted. Undelimited, git consumes the value as an option, leaving the
|
||||
/// destination as the only positional, and names *that* instead
|
||||
/// (`repository '<dest>' does not exist`) — so this goes red without the fix.
|
||||
///
|
||||
/// The payload carries no `:` or space: a colon would make git read the
|
||||
/// argument as an ssh-style `host:path` URL and report a hostname, which is a
|
||||
/// different parse.
|
||||
fn assert_option_shaped_remote_is_a_repository(withheld: &[String], branch: Option<&str>) {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("dest");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = setup_partial_clone(&dest, injected, withheld, &[], branch)
|
||||
.expect_err("an option-shaped remote is not a repository and must fail")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(&format!("repository '{injected}'")),
|
||||
"git must name the argument as the repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_clone_never_parses_a_remote_as_a_git_option() {
|
||||
assert_option_shaped_remote_is_a_repository(&[], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// Pins that `--branch <b>` stays ahead of the delimiter: if `--` were moved
|
||||
// before `--branch`, git would read the branch name as a positional instead.
|
||||
assert_option_shaped_remote_is_a_repository(&[], Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// The withheld-globs arm is a separate argv construction
|
||||
// (`--filter=blob:none --no-checkout`) and needs its own proof.
|
||||
assert_option_shaped_remote_is_a_repository(&["/secret/**".to_string()], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_patterns_subtree_and_exact() {
|
||||
assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]);
|
||||
@@ -1255,6 +1468,132 @@ 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.
|
||||
|
||||
+190
-49
@@ -1,10 +1,10 @@
|
||||
//! `gl doctor` — check your gitlawb installation and connectivity.
|
||||
//! `gl doctor` — check your Twigpine 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.gitlawb.com)
|
||||
//! 3. GITLAWB_NODE — env var points at a public node (unset is advisory)
|
||||
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.twigpine.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.gitlawb.com";
|
||||
const PUBLIC_NODE: &str = "https://node.twigpine.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 gitlawb setup");
|
||||
println!("gl doctor — checking your Twigpine setup");
|
||||
println!();
|
||||
|
||||
let dir = args.dir.unwrap_or_else(|| {
|
||||
@@ -80,7 +80,6 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
});
|
||||
|
||||
let mut checks = Vec::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
// ── 1. Identity ───────────────────────────────────────────────────────
|
||||
let pem_path = dir.join("identity.pem");
|
||||
@@ -141,32 +140,16 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
|
||||
match std::env::var("GITLAWB_NODE") {
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Ok(v) if is_loopback_url(&v) => {
|
||||
checks.push(Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(v) if !v.is_empty() => {
|
||||
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
|
||||
}
|
||||
_ => {
|
||||
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.gitlawb.com",
|
||||
));
|
||||
}
|
||||
}
|
||||
checks.push(gitlawb_node_env_check(
|
||||
std::env::var("GITLAWB_NODE").ok().as_deref(),
|
||||
));
|
||||
|
||||
// ── 4. Node connectivity ──────────────────────────────────────────────
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
// GITLAWB_NODE="" binds args.node to "" through clap, and check 3 groups
|
||||
// empty with unset. The probe must apply the same normalization or it
|
||||
// would dial "" and Fail while the env row only Warns.
|
||||
let node_url = effective_node_url(&args.node);
|
||||
let client = NodeClient::new(node_url, None);
|
||||
match client.get("/").await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let info = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
@@ -175,7 +158,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
let short_did = did.chars().take(30).collect::<String>();
|
||||
checks.push(Check::pass(
|
||||
"node",
|
||||
format!("{} — v{version} ({short_did}…)", args.node),
|
||||
format!("{} — v{version} ({short_did}…)", node_url),
|
||||
));
|
||||
// Capability drift: a node newer than this CLI may require features
|
||||
// (RFC 9421 signing, iCaptcha) the CLI doesn't speak.
|
||||
@@ -187,21 +170,21 @@ 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://gitlawb.com/install.sh | sh",
|
||||
"upgrade gl: curl -sSf https://twigpine.com/install.sh | sh",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
checks.push(Check::fail(
|
||||
"node",
|
||||
format!("{} returned HTTP {}", args.node, resp.status()),
|
||||
format!("{} returned HTTP {}", node_url, resp.status()),
|
||||
"check GITLAWB_NODE env var or try gl register --node <url>",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(Check::fail(
|
||||
"node",
|
||||
format!("{} unreachable: {e}", args.node),
|
||||
format!("{} unreachable: {e}", node_url),
|
||||
"check your internet connection or set GITLAWB_NODE",
|
||||
));
|
||||
}
|
||||
@@ -241,7 +224,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://gitlawb.com/install.sh | sh",
|
||||
"curl -sSf https://twigpine.com/install.sh | sh",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -285,23 +268,21 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
CheckState::Fail => "✗",
|
||||
};
|
||||
println!(" {icon} {:<24} {}", check.label, check.detail);
|
||||
if matches!(check.state, CheckState::Fail) {
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
let has_failures_now = has_failures(&checks);
|
||||
let has_issues = checks
|
||||
.iter()
|
||||
.any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
|
||||
if !has_issues {
|
||||
println!("Everything looks good. Run `gl quickstart` to create your first repo.");
|
||||
} else {
|
||||
if all_ok {
|
||||
println!("Setup looks good with some warnings:");
|
||||
} else {
|
||||
if has_failures_now {
|
||||
println!("Some checks failed. Suggested fixes:");
|
||||
} else {
|
||||
println!("Setup looks good with some warnings:");
|
||||
}
|
||||
for check in &checks {
|
||||
if matches!(check.state, CheckState::Fail | CheckState::Warn) {
|
||||
@@ -311,14 +292,75 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if !all_ok {
|
||||
if has_failures_now {
|
||||
println!("Run `gl quickstart` for a guided setup.");
|
||||
}
|
||||
}
|
||||
|
||||
// #357: a non-zero exit is the only signal scripting and CI have. Warn-only
|
||||
// rows stay exit 0 (iCaptcha offline, version drift, shell-alias shadowing
|
||||
// are all non-fatal); Fail-class rows (identity, registration, node
|
||||
// reachable, git-remote-gitlawb present, git present) flip to exit 1.
|
||||
// Direct `process::exit` rather than returning Err: the prose above is the
|
||||
// user-facing summary, and an anyhow Error frame would just duplicate it.
|
||||
if has_failures(&checks) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify the `GITLAWB_NODE` env var for `gl doctor`. Extracted so unit
|
||||
/// tests can pin the Warn tier directly (#357): an unset env is advisory, not
|
||||
/// a failure, because `--node` defaults to `https://node.twigpine.com` and the
|
||||
/// CLI works without it. Flipping this back to `Check::fail` must break a
|
||||
/// test, not just change prose.
|
||||
fn gitlawb_node_env_check(env_val: Option<&str>) -> Check {
|
||||
match env_val {
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Some(v) if is_loopback_url(v) => Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
),
|
||||
Some(v) if !v.is_empty() => Check::pass("GITLAWB_NODE", v.to_string()),
|
||||
// `--node` defaults to `https://node.twigpine.com` and the CLI works
|
||||
// fine without the variable, so an unset env is advisory, not a
|
||||
// failure. Warn-class keeps the exit code 0 for a stock working
|
||||
// install; this used to be Fail, which the obvious "return non-zero
|
||||
// on any failure" change would have flipped to exit 1 on every
|
||||
// default-config install (#357).
|
||||
_ => Check::warn(
|
||||
"GITLAWB_NODE",
|
||||
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
|
||||
format!("export GITLAWB_NODE={PUBLIC_NODE}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL the connectivity probe should dial. `GITLAWB_NODE` set but empty
|
||||
/// (or whitespace-only) binds `args.node` to `""` through clap rather than
|
||||
/// to the `default_value`, and check 3 already groups empty with unset, so
|
||||
/// the probe applies the same normalization: an empty effective URL means
|
||||
/// the public node, same as unset.
|
||||
fn effective_node_url(node: &str) -> &str {
|
||||
if node.trim().is_empty() {
|
||||
PUBLIC_NODE
|
||||
} else {
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one check is Fail-class. Warn-only runs (iCaptcha offline,
|
||||
/// version drift, shell-alias shadowing, GITLAWB_NODE unset) keep exit 0;
|
||||
/// this is the gating predicate for #357's exit-status fix.
|
||||
fn has_failures(checks: &[Check]) -> bool {
|
||||
checks.iter().any(|c| matches!(c.state, CheckState::Fail))
|
||||
}
|
||||
|
||||
/// Check if a binary name exists anywhere on PATH.
|
||||
/// True when the rc file contains a real `unalias` command naming `gl` —
|
||||
/// not a comment, and not a longer word like `unalias global`. Ordering
|
||||
@@ -408,7 +450,7 @@ fn which_in_path(name: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from Gitlawb/node (the actual release repo — see install.sh's
|
||||
/// Fetch the latest release tag from Twigpine/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 +464,7 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
|
||||
|
||||
let resp = match client
|
||||
.get(format!(
|
||||
"{github_api_base}/repos/Gitlawb/node/releases/latest"
|
||||
"{github_api_base}/repos/Twigpine/node/releases/latest"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
@@ -457,7 +499,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://gitlawb.com/install.sh | sh",
|
||||
"curl -sSf https://twigpine.com/install.sh | sh",
|
||||
)
|
||||
} else {
|
||||
Check::pass("version", format!("v{current} (up to date)"))
|
||||
@@ -550,7 +592,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.gitlawb.com/localhost"));
|
||||
assert!(!is_loopback_url("https://node.twigpine.com/localhost"));
|
||||
assert!(!is_loopback_url("not a url"));
|
||||
}
|
||||
|
||||
@@ -603,7 +645,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/Gitlawb/node/releases/latest")
|
||||
.mock("GET", "/repos/Twigpine/node/releases/latest")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tag_name":"v9.9.9"}"#)
|
||||
@@ -619,7 +661,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/Gitlawb/node/releases/latest")
|
||||
.mock("GET", "/repos/Twigpine/node/releases/latest")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tag_name":"v0.1.0"}"#)
|
||||
@@ -635,7 +677,7 @@ mod tests {
|
||||
async fn test_check_version_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.mock("GET", "/repos/Twigpine/node/releases/latest")
|
||||
.with_status(403)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -644,4 +686,103 @@ mod tests {
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("GitHub API returned HTTP 403"));
|
||||
}
|
||||
|
||||
/// #357: an all-Ok run does not flip the exit code to non-zero — a healthy
|
||||
/// install must keep returning 0 so scripts like `gl doctor && gl push`
|
||||
/// keep working.
|
||||
#[test]
|
||||
fn exit_predicate_is_false_for_all_ok() {
|
||||
let checks = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::pass("registration", "ok"),
|
||||
Check::pass("git", "ok"),
|
||||
];
|
||||
assert!(!has_failures(&checks));
|
||||
}
|
||||
|
||||
/// #357: Warn-class rows (iCaptcha offline, version drift, shell-alias
|
||||
/// shadowing, GITLAWB_NODE unset on a default-config install) are NOT
|
||||
/// exit-status failures — they are still printed to the user but the
|
||||
/// process exits 0. This is the rule that the previous "any
|
||||
/// Fail-or-Warn row → exit 1" change would have broken for stock
|
||||
/// installs that simply have not set the env var.
|
||||
#[test]
|
||||
fn exit_predicate_is_false_for_warn_only() {
|
||||
let checks = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::warn("GITLAWB_NODE", "unset", "export GITLAWB_NODE=..."),
|
||||
Check::warn("iCaptcha", "offline", "check connectivity"),
|
||||
Check::warn("version", "drift", "upgrade"),
|
||||
Check::warn("shell alias", "omz git plugin", "unalias gl"),
|
||||
];
|
||||
assert!(!has_failures(&checks));
|
||||
}
|
||||
|
||||
/// #357: a single Fail-class row trips the exit code. The set of Fail
|
||||
/// rows is whatever `run` writes through `Check::fail`: identity missing
|
||||
/// or unparseable, registration missing/malformed, node unreachable,
|
||||
/// git-remote-gitlawb absent, git absent.
|
||||
#[test]
|
||||
fn exit_predicate_is_true_when_a_fail_row_is_present() {
|
||||
let checks = vec![
|
||||
Check::pass("registration", "ok"),
|
||||
Check::fail("identity", "missing", "gl identity new"),
|
||||
Check::pass("git", "ok"),
|
||||
];
|
||||
assert!(has_failures(&checks));
|
||||
|
||||
let mixed_warns = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::warn("GITLAWB_NODE", "unset", "export"),
|
||||
Check::fail("node", "unreachable", "check network"),
|
||||
Check::warn("iCaptcha", "offline", "check"),
|
||||
];
|
||||
assert!(has_failures(&mixed_warns));
|
||||
}
|
||||
|
||||
/// #357: `GITLAWB_NODE` unset must be Warn, not Fail. This pins the
|
||||
/// re-tier so flipping it back to `Check::fail` breaks the suite — the
|
||||
/// previous `has_failures`-only tests built their own `Check::warn` rows
|
||||
/// and could not see the re-tier at all.
|
||||
#[test]
|
||||
fn gitlawb_node_env_unset_is_warn() {
|
||||
let check = gitlawb_node_env_check(None);
|
||||
assert!(matches!(check.state, CheckState::Warn));
|
||||
assert!(check.detail.contains("not set"));
|
||||
let empty = gitlawb_node_env_check(Some(""));
|
||||
assert!(matches!(empty.state, CheckState::Warn));
|
||||
}
|
||||
|
||||
/// `GITLAWB_NODE=""` binds `args.node` to `""` through clap, and check 3
|
||||
/// groups empty with unset. The probe must dial the same URL unset
|
||||
/// would — removing the normalization makes check 4 probe `""` and Fail
|
||||
/// while the env row only Warns, so an empty env install exits 1.
|
||||
#[test]
|
||||
fn effective_node_url_treats_empty_and_blank_as_unset() {
|
||||
assert_eq!(effective_node_url(""), PUBLIC_NODE);
|
||||
assert_eq!(effective_node_url(" "), PUBLIC_NODE);
|
||||
assert_eq!(effective_node_url("\t\n"), PUBLIC_NODE);
|
||||
assert_eq!(
|
||||
effective_node_url("http://127.0.0.1:7545"),
|
||||
"http://127.0.0.1:7545"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlawb_node_env_set_is_pass() {
|
||||
let check = gitlawb_node_env_check(Some("https://node.twigpine.com"));
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("https://node.twigpine.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlawb_node_env_loopback_is_pass() {
|
||||
for url in ["http://127.0.0.1:7545", "http://localhost:7545"] {
|
||||
let check = gitlawb_node_env_check(Some(url));
|
||||
assert!(
|
||||
matches!(check.state, CheckState::Ok),
|
||||
"loopback {url} must be Ok, not Warn/Fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user