mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 07:38:18 -10:00
Compare commits
212
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9d02d6bb | ||
|
|
e4c7458fb3 | ||
|
|
0b47e1fc08 | ||
|
|
505c452378 | ||
|
|
50d3cbbe97 | ||
|
|
96d8123e0f | ||
|
|
c380f9cee5 | ||
|
|
3993fd1f5d | ||
|
|
b01d17b7f3 | ||
|
|
1d3987bba6 | ||
|
|
aa68d97a8d | ||
|
|
eeafa1fa53 | ||
|
|
0e2328bd80 | ||
|
|
10e0840dd9 | ||
|
|
d4e5ecf299 | ||
|
|
4d9bd5a18a | ||
|
|
6f58f35c95 | ||
|
|
c68e1b07f8 | ||
|
|
9f340eac5e | ||
|
|
8d7f95fd63 | ||
|
|
c29a91af6b | ||
|
|
241b36633b | ||
|
|
425ebf40e7 | ||
|
|
1fcbbbd89c | ||
|
|
871b8610cf | ||
|
|
a6c13be15b | ||
|
|
af821e09bf | ||
|
|
d192028ee1 | ||
|
|
225644a3ea | ||
|
|
6668525f41 | ||
|
|
5fdf155c7f | ||
|
|
8d38043a4c | ||
|
|
a53c151879 | ||
|
|
01971f7e11 | ||
|
|
659ee00d7d | ||
|
|
63cb52b253 | ||
|
|
5a5d4e1b43 | ||
|
|
c45ec9605d | ||
|
|
8c04043fb5 | ||
|
|
bf4d8cd9d4 | ||
|
|
1c690c125c | ||
|
|
22ec66a846 | ||
|
|
4201ce4d58 | ||
|
|
1cb930cbd5 | ||
|
|
c4609d464a | ||
|
|
68ba49267c | ||
|
|
89c0ad597f | ||
|
|
a64b14150d | ||
|
|
337442e449 | ||
|
|
74f7554894 | ||
|
|
6bd8f41212 | ||
|
|
5af14c203d | ||
|
|
547f9aed10 | ||
|
|
1a841a99e2 | ||
|
|
c10ccf1f05 | ||
|
|
7b365b0dc3 | ||
|
|
218d6f169a | ||
|
|
817698a0d9 | ||
|
|
e969dca718 | ||
|
|
1989fcca11 | ||
|
|
5981257be5 | ||
|
|
91ff1b7237 | ||
|
|
dac29e550c | ||
|
|
dc6b19ddc4 | ||
|
|
89e10d0ff1 | ||
|
|
59378ea4e3 | ||
|
|
f741c72103 | ||
|
|
366120e081 | ||
|
|
68a68a30f9 | ||
|
|
a1b96813ba | ||
|
|
419c2bd230 | ||
|
|
dfe52dc8dd | ||
|
|
bc0895299c | ||
|
|
fa5cd65031 | ||
|
|
4c1d7814d4 | ||
|
|
af5a3888a8 | ||
|
|
ab653e9b94 | ||
|
|
fe07564837 | ||
|
|
eac0cf7bca | ||
|
|
62513c8555 | ||
|
|
1b65f70403 | ||
|
|
0b990d687f | ||
|
|
5b54814929 | ||
|
|
8ccf2b12e4 | ||
|
|
c8a6941941 | ||
|
|
6c9d109c1b | ||
|
|
726ba3361b | ||
|
|
13f6948b3e | ||
|
|
5077883ad1 | ||
|
|
8319c20660 | ||
|
|
030c5bd3a4 | ||
|
|
fa51d428cb | ||
|
|
6b1e1b229b | ||
|
|
24952b16be | ||
|
|
51f72ee3be | ||
|
|
e458cc2d84 | ||
|
|
474b42cfc5 | ||
|
|
6452e996f8 | ||
|
|
753a13b093 | ||
|
|
5df7d2afb3 | ||
|
|
8313c10c5b | ||
|
|
d6434b287b | ||
|
|
895fcf05d8 | ||
|
|
8b3aa1decf | ||
|
|
e8f091e1c4 | ||
|
|
df2a1797d1 | ||
|
|
ebb93e3231 | ||
|
|
b6ebaa4b1e | ||
|
|
73bf132c1a | ||
|
|
7cdfb843ff | ||
|
|
14481df288 | ||
|
|
6a405f65f1 | ||
|
|
730432d0cd | ||
|
|
17f9edad47 | ||
|
|
eb697364bf | ||
|
|
bc826df2ec | ||
|
|
050b6d2832 | ||
|
|
f91b224bab | ||
|
|
cf7fe46df2 | ||
|
|
33063baf5a | ||
|
|
b231d32703 | ||
|
|
6032fd2d95 | ||
|
|
feefaf4f9e | ||
|
|
fad18bc923 | ||
|
|
1501bd1bfd | ||
|
|
6ef2effe77 | ||
|
|
d9f8f414eb | ||
|
|
35f71e515d | ||
|
|
03dde22e98 | ||
|
|
a261f7d289 | ||
|
|
4f424693b8 | ||
|
|
afa65e0a59 | ||
|
|
b89dec4ce6 | ||
|
|
16f4d31900 | ||
|
|
9bfe7d5d30 | ||
|
|
1219c21469 | ||
|
|
fed460cf4b | ||
|
|
9308b9cc27 | ||
|
|
3ced62fab0 | ||
|
|
7e5e6aabd0 | ||
|
|
7fa12bebff | ||
|
|
ade53cbf47 | ||
|
|
19291dfd64 | ||
|
|
7d32a79e96 | ||
|
|
99449cf00b | ||
|
|
ea84dc5927 | ||
|
|
799759f1fc | ||
|
|
c8e8de2949 | ||
|
|
de325f9f46 | ||
|
|
1b1a9bab70 | ||
|
|
eb0460ae81 | ||
|
|
d93f78400c | ||
|
|
72fe5cb3bf | ||
|
|
5b46796b36 | ||
|
|
6afa236e65 | ||
|
|
e1ab240c7a | ||
|
|
e383047d71 | ||
|
|
9f4a8c7d2f | ||
|
|
b897e27cf2 | ||
|
|
e866584784 | ||
|
|
48dbb75ac6 | ||
|
|
671cee184f | ||
|
|
cc9615961f | ||
|
|
e489968fde | ||
|
|
c866ccb5c8 | ||
|
|
fab61e4188 | ||
|
|
6df972d294 | ||
|
|
1b3a5f6064 | ||
|
|
2eaf8b1086 | ||
|
|
ec36e7f2d7 | ||
|
|
79910fc169 | ||
|
|
2259a248ce | ||
|
|
00618603ad | ||
|
|
d3ead53aee | ||
|
|
bda9cb102e | ||
|
|
cdc62a703a | ||
|
|
98ebb47216 | ||
|
|
7c23a66da9 | ||
|
|
90bd9a6270 | ||
|
|
d5de423393 | ||
|
|
ca7a7074dd | ||
|
|
68043f5844 | ||
|
|
f592378bdc | ||
|
|
0c767d1585 | ||
|
|
b900151468 | ||
|
|
dd3672ef0e | ||
|
|
ec201effb6 | ||
|
|
7671df98c7 | ||
|
|
1e1058ca29 | ||
|
|
5aa37a17a0 | ||
|
|
39fab32e91 | ||
|
|
4f2a36fcb4 | ||
|
|
c5d19df8d6 | ||
|
|
e4c14ccd01 | ||
|
|
a4beba2eea | ||
|
|
b2939ad990 | ||
|
|
6c7bbaa193 | ||
|
|
39e1f12255 | ||
|
|
87b6d2d498 | ||
|
|
5204502678 | ||
|
|
d0f96684ed | ||
|
|
45408309f8 | ||
|
|
8816684c71 | ||
|
|
6176fbb697 | ||
|
|
08fb3a02df | ||
|
|
b519251e00 | ||
|
|
0fdaaef3f7 | ||
|
|
9ca626e415 | ||
|
|
222fb570a3 | ||
|
|
c97fe3a551 | ||
|
|
2c6cef6ad0 | ||
|
|
ba82ae785e |
No files matched your search
+127
-4
@@ -24,8 +24,11 @@ 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.
|
||||
GITLAWB_DB_MAX_CONNECTIONS=20
|
||||
# remembering admin tooling opens its own pool. Each concurrent write pins one
|
||||
# connection for its whole duration (the connection-affine advisory lock), so the
|
||||
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
|
||||
# headroom — keep this comfortably above that (default 48 for pushes 32).
|
||||
GITLAWB_DB_MAX_CONNECTIONS=48
|
||||
# Seconds a request waits for a pool connection before failing with 503.
|
||||
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
|
||||
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
|
||||
@@ -109,15 +112,135 @@ GITLAWB_MAX_PACK_BYTES=2147483648
|
||||
|
||||
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
|
||||
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
|
||||
# worker and, on push, the repo write lock. Does NOT cover the info/refs
|
||||
# advertisement or the withheld-blob path, which remain unbounded. Default 600.
|
||||
# worker and, on push, the repo write lock. Also bounds both info/refs
|
||||
# advertisements, the withheld-blob pack build, and the push-side candidate
|
||||
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
|
||||
# On the path-scoped upload-pack path the withheld-blob classification walk and
|
||||
# the pack serve share ONE deadline, so this value bounds their COMBINED duration
|
||||
# rather than giving each stage a full budget: a walk that consumes it leaves the
|
||||
# serve nothing and the clone gets a 504. Serving large path-scoped repos may
|
||||
# need a higher value here than when each stage was budgeted separately.
|
||||
# Must be 1..=3153600000 (100 years): the node derives deadlines from this value,
|
||||
# and a larger one cannot be represented. Default 600.
|
||||
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
|
||||
|
||||
# Max seconds the storage-ACQUISITION phase of a served git op may run before the
|
||||
# request is shed with a 503, separate from the git-run timeout above. A
|
||||
# concurrency permit is taken before this phase and GITLAWB_GIT_SERVICE_TIMEOUT_SECS
|
||||
# only starts once git spawns, so without this a stalled backend (a hung Tigris
|
||||
# HEAD/GET, or a hung pg advisory-lock iteration on push) pins the permit and drains
|
||||
# the pool until every later request 503s. On expiry the permit is released
|
||||
# (fail-closed). Kept separate because acquisition and git execution are distinct
|
||||
# cost centers. Must be positive; set very large to effectively disable. Default 30.
|
||||
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS=30
|
||||
|
||||
# Max concurrent git READ ops (upload-pack + the upload-pack info/refs
|
||||
# advertisement) served at once, a global pool separate from the push pool below.
|
||||
# The anon receive-pack info/refs advertisement has its OWN pool (see below), not
|
||||
# this one. Over-cap sheds a clean 503 + Retry-After. Anonymous reads draw from
|
||||
# here, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER (below) so one
|
||||
# caller cannot monopolize the pool. Default 128.
|
||||
GITLAWB_MAX_CONCURRENT_GIT_OPS=128
|
||||
|
||||
# Max concurrent git-receive-pack (push) POST operations, in a pool separate from
|
||||
# the read pool (GITLAWB_MAX_CONCURRENT_GIT_OPS) so anonymous reads cannot shed an
|
||||
# authenticated push at admission. The anon receive-pack info/refs advertisement
|
||||
# runs in a SEPARATE pool of the same size (disjoint from this one), so an
|
||||
# advertisement flood cannot shed a push either. Over-cap sheds a 503 +
|
||||
# Retry-After. Default 32.
|
||||
GITLAWB_MAX_CONCURRENT_GIT_PUSHES=32
|
||||
|
||||
# Max pushes PARKED at once waiting for the per-repo write lease of the SAME repo.
|
||||
# A parked push holds its already-buffered pack body in memory, so this bounds that
|
||||
# memory for a hot repo. Past the cap the newest push sheds a clean 503 +
|
||||
# Retry-After ("repo is busy with another push") instead of joining the queue; a
|
||||
# push to a DIFFERENT repo is never affected. The lease holder is not counted.
|
||||
# Raising GITLAWB_MAX_CONCURRENT_GIT_PUSHES does not raise this; set it explicitly.
|
||||
# Default 8.
|
||||
GITLAWB_REPO_LEASE_MAX_WAITERS=8
|
||||
|
||||
# Max concurrent post-push pin loops (IPFS + Pinata pin_new_objects) across all
|
||||
# repos. The per-repo encrypt-task set caps the pin-task COUNT; this caps how many
|
||||
# of those loops RUN CONCURRENTLY, and so how much MB-scale object-id list memory is
|
||||
# held WHILE BEING PINNED. It does not bound retained memory in general: on the local
|
||||
# IPFS path the caller builds its list before this pool is entered, so a loop parked
|
||||
# waiting for a permit still holds its full list, and how many park is capped only
|
||||
# per repo. Do not size memory from this knob alone. A loop DEFERS (waits) when the
|
||||
# pool is full, never drops a pin. Default 8.
|
||||
GITLAWB_MAX_CONCURRENT_PIN_TASKS=8
|
||||
|
||||
# Max concurrent read ops (upload-pack + the upload-pack info/refs advertisement)
|
||||
# a single caller may hold, so one caller cannot monopolize the read pool. Keyed
|
||||
# on the resolved SOURCE IP, never the DID: a signature does not move a caller off
|
||||
# this cap. The source-IP key is only as granular as GITLAWB_TRUSTED_PROXY below:
|
||||
# left unset, a node behind an edge/NAT keys all callers on the edge IP and this
|
||||
# collapses to one global cap. Set GITLAWB_TRUSTED_PROXY for per-client keying; a
|
||||
# high-fanout caller (CI behind one NAT) then needs the operator to raise this.
|
||||
# Default 16.
|
||||
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
|
||||
|
||||
# Two further per-source concurrency caps exist on the PUSH side but have NO
|
||||
# environment variable of their own: both are DERIVED as
|
||||
# GITLAWB_MAX_CONCURRENT_GIT_PUSHES / 8, with a floor of 1. They are not settable
|
||||
# independently, so raising the push pool raises both proportionally, and the
|
||||
# default push pool of 32 gives each a cap of 4.
|
||||
# * The anon receive-pack info/refs advertisement cap: one source IP may hold at
|
||||
# most this many slots in the dedicated advert pool, so saturating that pool
|
||||
# takes ~8 distinct source IPs.
|
||||
# * The authenticated receive-pack POST cap: one source IP may hold at most this
|
||||
# many WRITE-pool slots. This one is load-bearing for push availability, since
|
||||
# it is acquired before the global write permit: without it, one host minting
|
||||
# disposable did:key identities could open enough slow pushes to monopolize the
|
||||
# write pool and 503 every other source (owner enforcement defaults off, and
|
||||
# the push rate limiter caps arrival rate, not in-flight concurrency).
|
||||
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
|
||||
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
|
||||
|
||||
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
|
||||
# Max receive-pack requests (info/refs advertisement + push POST) per client
|
||||
# IP per hour. 0 disables. Default 600.
|
||||
GITLAWB_PUSH_RATE_LIMIT=600
|
||||
|
||||
# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
|
||||
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
|
||||
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
|
||||
# so it is bounded to keep a permissionless caller from fanning out unbounded
|
||||
# concurrent walks and exhausting blocking-pool threads + PIDs.
|
||||
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
|
||||
# the served-git pools). Over-cap sheds a 503. Default 32.
|
||||
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
|
||||
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
|
||||
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
|
||||
# Default 4.
|
||||
GITLAWB_IPFS_WALK_PER_SOURCE=4
|
||||
# Max 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.
|
||||
GITLAWB_IPFS_MAX_REPOS_WALKED=64
|
||||
# Ceiling on repos one /ipfs request may VISIT past the visibility gate. Each
|
||||
# visit costs a repo acquire — on a Tigris cache miss a full archive download,
|
||||
# so this is also the worst-case object-store fetch count per request — plus a
|
||||
# cat-file probe. On exhaustion the scan stops and sheds a retryable 503.
|
||||
# Default 1024.
|
||||
GITLAWB_IPFS_MAX_REPO_VISITS=1024
|
||||
# Absolute wall-clock budget for one admitted /ipfs request's acquire+walk
|
||||
# lifetime (all stages of the whole scan). Per-stage clamps bound the acquire
|
||||
# and walk stages to the remaining budget, and no stage starts once it is
|
||||
# exhausted (the scan then sheds a retryable 503). The object-type probe and
|
||||
# content-read cat-file subprocesses are budget-checked before starting AND each
|
||||
# run under their own deadline (the lesser of GITLAWB_GIT_SERVICE_TIMEOUT_SECS and
|
||||
# the remaining budget), reaped via process-group teardown, so a hung cat-file
|
||||
# cannot hold the request's walk slot past it. One hang path is still unbounded:
|
||||
# the probe's object-store readability check is a plain filesystem sweep with
|
||||
# nothing to reap, so a wedged filesystem can hold the slot past the deadline.
|
||||
# Must be 1..=3153600000 (100 years): the node derives an Instant deadline from
|
||||
# this value, and a larger one cannot be represented. Default 600.
|
||||
GITLAWB_IPFS_REQUEST_BUDGET_SECS=600
|
||||
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
|
||||
# from the concurrency caps above). 0 disables. Default 600.
|
||||
GITLAWB_IPFS_RATE_LIMIT=600
|
||||
|
||||
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
|
||||
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
|
||||
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
|
||||
|
||||
@@ -55,6 +55,19 @@ jobs:
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
|
||||
release-tag-validation:
|
||||
name: release tag validation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test release tag resolver
|
||||
run: scripts/test-resolve-release-tag.sh
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.toolchain }})
|
||||
runs-on: ubuntu-latest
|
||||
@@ -283,3 +296,35 @@ jobs:
|
||||
|
||||
- name: Check gitlawb-core dependency allowlist
|
||||
run: bash scripts/check-gitlawb-core-deps.sh
|
||||
|
||||
# release-please only rewrites a crate's version when the line carries the
|
||||
# `# x-release-please-version` annotation AND the manifest is listed under
|
||||
# extra-files in release-please-config.json. Miss either half and the crate
|
||||
# is skipped silently at release time, freezing at its creation version:
|
||||
# icaptcha-client was added as a workspace member after the config was last
|
||||
# written and sat at 0.4.0 through four releases before anyone noticed.
|
||||
# Exhaustive-by-construction, like the allowlist gate above: the required set
|
||||
# is derived from `cargo metadata`, so a newly added crate reds CI whether or
|
||||
# not its author knew this gate existed.
|
||||
release-versioning:
|
||||
name: release versioning coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: release-versioning
|
||||
|
||||
- name: Check every workspace crate is release-managed
|
||||
run: bash scripts/check-release-versioning.sh
|
||||
@@ -70,9 +70,15 @@ jobs:
|
||||
const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs"));
|
||||
// A test can live in a tests/ dir, a *_test.rs file, or (the common Rust
|
||||
// pattern) an inline #[test] / #[cfg(test)] block added inside the source file.
|
||||
// Async tests use a namespaced attribute (#[tokio::test], #[async_std::test],
|
||||
// possibly with args like #[tokio::test(flavor = ...)]) rather than the bare
|
||||
// #[test]. Match only on added lines, with the attribute at the start of the
|
||||
// line after horizontal whitespace, covering bare, cfg(test), namespaced,
|
||||
// optional leading ::, and raw-identifier (r#test) forms. No head-file fetch
|
||||
// or lexer: needs-tests is advisory, not a merge gate.
|
||||
const addsInlineTest = files.some(f =>
|
||||
f.filename.endsWith(".rs") && f.patch &&
|
||||
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
|
||||
/^\+[ \t]*#\[[ \t]*(?:cfg[ \t]*\([ \t]*test[ \t]*\)|(?:::[ \t]*)?(?:[\w-]+[ \t]*::[ \t]*)*(?:r#)?test\b)/m.test(f.patch));
|
||||
const touchedTests =
|
||||
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
|
||||
if (changedRust && !touchedTests) want.add("needs-tests");
|
||||
|
||||
@@ -216,6 +216,11 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
@@ -223,13 +228,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing — a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
@@ -311,8 +310,14 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.docker.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
@@ -320,13 +325,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
@@ -542,6 +541,11 @@ jobs:
|
||||
contents: read
|
||||
id-token: write # npm trusted publishing (OIDC) + provenance
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
@@ -549,13 +553,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Contributor guide for coding agents
|
||||
|
||||
This file carries the rules a patch author cannot recover from the code alone, for humans and coding agents alike. For build, test, and PR basics, read `CONTRIBUTING.md` first.
|
||||
|
||||
## Authorization invariants (the rules PRs most often break)
|
||||
|
||||
Every repo-scoped endpoint must bind the caller to an authorization decision before serving or mutating anything. The `known_ungated` allowlist in `authz_guard::every_repo_scoped_handler_is_gated` is empty on main: `list_webhooks`, `list_replicas`, and `list_protected_branches` are gated (`list_webhooks` with read-visibility then owner; the other two with `authorize_repo_read`). Do not re-open those surfaces as exceptions.
|
||||
|
||||
- Owner-only mutations (visibility, webhooks, protected branches, merges) are gated against the repo owner. Three gate forms are in use and all are recognized: webhooks and merges call `require_repo_owner` (`crates/gitlawb-node/src/api/mod.rs`), visibility uses its module-local `require_owner`, and protected branches compare inline with `did_matches`. Use the form the surrounding module already uses.
|
||||
- Not every write is owner-only, and that is by design: `star_repo`/`unstar_repo` bind to the signer (they still require repo read access), `register_replica`/`unregister_replica` bind to the replica's own DID, the bounty actions (claim, submit, approve, cancel, dispute) have their own multi-party rules, and closing a PR or issue is owner-or-author. Do not add owner gates to these.
|
||||
- Content-serving reads (blobs, trees, raw files) gate through `authorize_repo_read` with the specific path being served, so a withheld subtree is denied even on an otherwise-public repo; repo-level reads (listings) pass `"/"` by the helper's own contract. The source scans below cannot check this argument's value, so review will.
|
||||
- A route that reaches repo data through a global id, with no repo in its path, or through an extractor shape the scans do not recognize still needs the same gate; the scans cannot see those.
|
||||
- Read-surface denials must not reveal existence: a caller who may not read a repo (or a withheld subtree) gets the same 404 as a missing repo, never a 403. (Owner-gated mutations currently return 403 after the repo lookup; do not widen that shape to read paths.)
|
||||
- Two tests in module `authz_guard` (`crates/gitlawb-node/src/api/mod.rs`) scan handler sources and fail on a repo-scoped handler without a recognized gate: `every_in_scope_mutation_has_its_gate` and `every_repo_scoped_handler_is_gated`. If they fail on your handler, add the gate. The second test's `known_ungated` allowlist is empty; never add an entry to it without an open issue and review.
|
||||
|
||||
## Adding or changing routes
|
||||
|
||||
- A new gated handler needs tests proving its route-specific gate fires: exercise an unauthorized authenticated caller and an anonymous caller where applicable, assert the exact denial status each way, and assert the body leaks nothing. The non-owner tests in `crates/gitlawb-node/src/test_support.rs` show the shape for owner-gated routes.
|
||||
- A new mutation handler also gets a row in `every_in_scope_mutation_has_its_gate` naming its gate type; the per-handler row is the point of that test.
|
||||
- A table-driven deny suite covering every deny-bearing route is in review (#194, #195). If it is present in your checkout, add a probe row there for any new deny-bearing route.
|
||||
|
||||
## Verifying signatures and certificates
|
||||
|
||||
- Derive the verifying key from something outside the artifact you are checking: the node's own DID, a pinned or configured value, a peers lookup. A key read from a field of the object being verified (`Did::from_str(&cert.node_did)`) only proves the artifact is self-consistent, and identities here are permissionless, so anyone can mint a keypair, name it in the artifact, self-sign, and be told it is valid. A mismatch against that anchor has to fail the result, not log a warning.
|
||||
- A handler whose correct answer is "valid" needs a positive case driven by a trusted, independently anchored artifact, and a rejection case for a well-formed, fully populated forged artifact. Invalid-only suites miss an implementation that rejects everything; a forged artifact that still returns valid is the regression that matters.
|
||||
- The fields a signature covers are a versioned format. Adding one invalidates every artifact already signed, so give the payload a version, keep a path that verifies the older form, and add a test that verifies an artifact signed before your change.
|
||||
|
||||
## Removing a serving path
|
||||
|
||||
When a fix removes a path that used to serve content, the same PR must add a test asserting the removed path now denies. The deletion is what turns that test green; without it the path can quietly come back.
|
||||
|
||||
## Database migrations
|
||||
|
||||
Schema changes are code-defined in `MIGRATIONS` (`crates/gitlawb-node/src/db/mod.rs`). Always append a new versioned entry; never edit an entry that has merged (operators deploy from main). Test runs build the schema from scratch, so editing an applied version stays green in CI while breaking every existing deployment.
|
||||
|
||||
## Client behavior (`gl`, `git-remote-gitlawb`)
|
||||
|
||||
When a node denies a request, surface the denial to the user. Never render a denial as an empty list or a silent success; that turns an authorization boundary into invisible data loss.
|
||||
|
||||
## Toolchain and CI reality
|
||||
|
||||
- MSRV is Rust 1.91 (`rust-version` in the workspace `Cargo.toml`). The MSRV CI job runs `cargo check --workspace --all-targets` on exactly 1.91, so check that an API you rely on is stable there; the full test suite runs on stable.
|
||||
- The PR workflow runs these jobs on every PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
|
||||
- Conventional commit titles (`feat:`, `fix:`, `docs:`, ...) are required by convention and drive releases, but no CI job checks them; a bad title fails review, not the pipeline.
|
||||
+5
-2
@@ -31,7 +31,9 @@ crates/
|
||||
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN, ref certs)
|
||||
├── gitlawb-node/ axum HTTP server, git smart HTTP, P2P, GraphQL
|
||||
├── gl/ CLI — identity, repos, MCP server, Base L2 names
|
||||
└── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
|
||||
├── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
|
||||
├── gitlawb-attest/ provenance attestations for ref-update certificates
|
||||
└── icaptcha-client/ client for the iCaptcha proof-of-intelligence service
|
||||
docs/ Operator guides
|
||||
scripts/ Build helpers
|
||||
```
|
||||
@@ -54,6 +56,7 @@ Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://
|
||||
3. **No breaking changes without discussion.** Open an issue first for protocol-level changes.
|
||||
4. **Conventional commits.** Use `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Releases are automated by [release-please](https://github.com/googleapis/release-please) — your commit prefixes drive the next version bump.
|
||||
5. **Format and lint.** Run `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` before submitting. CI will reject anything that fails these.
|
||||
6. **Security-surface rules.** [`AGENTS.md`](AGENTS.md) carries the authorization invariants PRs most often break; it is written for coding agents and humans alike, so read it before touching endpoints, visibility, or migrations.
|
||||
|
||||
## What gets merged, what gets closed
|
||||
|
||||
@@ -105,7 +108,7 @@ cargo test -p gitlawb-node
|
||||
|
||||
- **TypeScript SDK** (`@gitlawb/sdk`) — client library for the HTTP API
|
||||
- **Python SDK** (`gitlawb`) — for ML/agent pipeline integration
|
||||
- **UCAN chain validation** — complete the auth middleware
|
||||
- **UCAN authorization** — add trusted issuer anchoring, capability checks, and authorization-aware revocation
|
||||
- **Filecoin storage tier** — wire up cold storage deals
|
||||
- **Documentation** — guides, tutorials, API examples
|
||||
- **Node operators** — run a public node and report issues
|
||||
|
||||
Generated
+5
-2
@@ -3378,10 +3378,13 @@ version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3539,9 +3542,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Gitlawb Node is the open-source node software behind the Gitlawb network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
|
||||
|
||||
Gitlawb is not trying to be only “another git host.” The long-term direction is:
|
||||
Gitlawb is not trying to be only "another git host." The long-term direction is:
|
||||
|
||||
```txt
|
||||
Decentralized GitHub
|
||||
@@ -19,7 +19,7 @@ The mission is simple: once code is pushed to the network, it should not disappe
|
||||
|
||||
## What is in this repository?
|
||||
|
||||
This is a Rust workspace with four crates:
|
||||
This is a Rust workspace with six crates:
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
@@ -27,6 +27,8 @@ This is a Rust workspace with four crates:
|
||||
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
|
||||
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
|
||||
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
|
||||
| `gitlawb-attest` | Pluggable external provenance attestations for ref-update certificates. |
|
||||
| `icaptcha-client` | Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
|
||||
|
||||
---
|
||||
|
||||
@@ -34,12 +36,12 @@ This is a Rust workspace with four crates:
|
||||
|
||||
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
|
||||
|
||||
- **Own your identity** — every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
|
||||
- **Signed writes by default** — write requests use RFC 9421 HTTP Signatures instead of passwords.
|
||||
- **Git-native transport** — repositories are still real git repositories served over smart HTTP.
|
||||
- **Agent-native workflows** — the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
|
||||
- **Peer-aware delivery** — nodes can announce, discover, gossip, and sync with each other.
|
||||
- **App CDN direction** — the network can evolve from decentralized code storage into code + asset + app delivery.
|
||||
- **Own your identity**: every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
|
||||
- **Signed writes by default**: write requests use RFC 9421 HTTP Signatures instead of passwords.
|
||||
- **Git-native transport**: repositories are still real git repositories served over smart HTTP.
|
||||
- **Agent-native workflows**: the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
|
||||
- **Peer-aware delivery**: nodes can announce, discover, gossip, and sync with each other.
|
||||
- **App CDN direction**: the network can evolve from decentralized code storage into code + asset + app delivery.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,6 +56,7 @@ Good today:
|
||||
- Bare git repository storage.
|
||||
- Git smart-HTTP clone/fetch/push.
|
||||
- RFC 9421-signed writes.
|
||||
- Repository and path-scoped visibility enforcement for repository and Git content reads, with 404-shaped repository denials.
|
||||
- DID identities.
|
||||
- `gl` CLI workflows.
|
||||
- libp2p peer discovery/gossip foundation.
|
||||
@@ -63,11 +66,13 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Private repository read enforcement is not wired yet. Treat public nodes as public infrastructure unless you restrict access at your proxy/firewall.
|
||||
- UCAN chain validation and revocation are not complete.
|
||||
- Repository write authorization is not capability-complete yet; HTTP signatures prove identity, not full authorization policy.
|
||||
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
|
||||
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
|
||||
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
|
||||
|
||||
See:
|
||||
|
||||
@@ -189,14 +194,14 @@ For public-network use, make sure `GITLAWB_NODE` points to the node you want. Th
|
||||
|
||||
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
|
||||
|
||||
1. **RFC 9421 HTTP Signatures** — every write is signed by your identity key. `gl`
|
||||
1. **RFC 9421 HTTP Signatures**: every write is signed by your identity key. `gl`
|
||||
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
|
||||
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
|
||||
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
|
||||
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
|
||||
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
|
||||
locally (arithmetic / algebra / sequence), and **retries the same signed request**
|
||||
with the `x-icaptcha-proof` header — no manual steps, no env vars.
|
||||
with the `x-icaptcha-proof` header. No manual steps, no env vars.
|
||||
|
||||
```bash
|
||||
gl identity new # create did:key identity
|
||||
@@ -215,14 +220,14 @@ Notes:
|
||||
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
|
||||
solving and use, the client transparently solves a fresh one and retries.
|
||||
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
|
||||
`git push` is **signed-only** (owner signature is the gate — no per-push challenge);
|
||||
`git push` is **signed-only** (owner signature is the gate, no per-push challenge);
|
||||
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
|
||||
clear `404`, never a placeholder.
|
||||
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
|
||||
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
|
||||
`https` origin whose host is allowlisted (that URL or the public default), and
|
||||
sends the bearer token **only** to your configured origin — never to a URL a
|
||||
node advertises — so a hostile node can't capture the key or redirect the solve.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -309,7 +314,7 @@ POST /api/v1/peers/announce
|
||||
POST /api/v1/sync/notify
|
||||
```
|
||||
|
||||
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Once all live peers upgrade, operators can set:
|
||||
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Staged rollout relaxes who may announce, not who owns a peer row. An unsigned announce may register a previously unseen peer, and it may refresh an existing row whose `http_url` is unchanged, but changing an existing peer's `http_url` requires an RFC 9421 signature from that peer's own DID and is refused with 403 otherwise. An unsigned announce can only register a `did:key`, since that is the only method whose verifying key can be resolved and therefore the only kind of row anyone could ever correct through the signed path; any other DID is refused with 400. Once all live peers upgrade, operators can set:
|
||||
|
||||
```bash
|
||||
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
|
||||
@@ -343,7 +348,19 @@ Important node settings:
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
|
||||
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
|
||||
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
|
||||
| `GITLAWB_MAX_CONCURRENT_GIT_OPS` | Max concurrent served git READ ops (upload-pack and its `info/refs` advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER`. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
|
||||
| `GITLAWB_MAX_CONCURRENT_GIT_PUSHES` | Max concurrent `git-receive-pack` POST operations, in a pool separate from the read pool. The anon receive-pack `info/refs` advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (`/8`, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
|
||||
| `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER` | Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as `GITLAWB_TRUSTED_PROXY`: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
|
||||
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Default 8. |
|
||||
| `GITLAWB_REPO_LEASE_MAX_WAITERS` | Max pushes parked at once waiting for the same repo's write lease. Each waiter pins its buffered pack body, so this bounds that memory for a hot repo; past the cap the newest push sheds a 503 + Retry-After instead of queueing. Pushes to other repos are unaffected, and the lease holder is not counted. Default 8. |
|
||||
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
|
||||
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
|
||||
| `GITLAWB_IPFS_MAX_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_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_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
|
||||
@@ -454,8 +471,8 @@ Short-term priorities:
|
||||
2. Add Docker and installer smoke tests.
|
||||
3. Improve operator docs and `gl doctor` checks.
|
||||
4. Harden peer writes and publish the signed-peer rollout plan.
|
||||
5. Implement repo write authorization: owner checks, protected branches, and UCAN capability checks.
|
||||
6. Implement private-read enforcement or remove private repo affordances until it exists.
|
||||
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
|
||||
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
|
||||
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
|
||||
|
||||
Product direction:
|
||||
|
||||
+32
-22
@@ -28,10 +28,11 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
- Every git object is content-addressed via CIDv1 (SHA-256)
|
||||
- Tamper-evident by construction — a modified object changes its CID
|
||||
|
||||
**UCAN capability tokens**
|
||||
- Bootstrap UCAN tokens issued at registration
|
||||
- Capability-scoped: `git:push`, `git:fetch`, `issue:create`, `pr:open`
|
||||
- JWT-format tokens with expiry
|
||||
**UCAN token validation**
|
||||
- Bootstrap UCAN tokens are issued at registration.
|
||||
- A supplied token's signature, audience, expiry, and proof-chain attenuation are validated.
|
||||
- Tokens use a signed JSON wire format with expiry.
|
||||
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
|
||||
|
||||
**Smart contracts (Base Sepolia testnet)**
|
||||
- `GitlawbDIDRegistry` — on-chain DID → document registry
|
||||
@@ -48,21 +49,20 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (Planned for v0.2)
|
||||
## Known Limitations
|
||||
|
||||
These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
|
||||
### UCAN chain validation
|
||||
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
|
||||
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
|
||||
- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization.
|
||||
- **Fix target:** v0.2
|
||||
### Repository write authorization defaults
|
||||
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
|
||||
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
|
||||
|
||||
### UCAN revocation
|
||||
- Issued UCAN tokens cannot be revoked before expiry.
|
||||
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
|
||||
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Until revocation/blocklisting is implemented, operators should remove compromised DIDs directly from their local database.
|
||||
- **Fix target:** v0.2
|
||||
### UCAN delegation and revocation
|
||||
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
|
||||
- Agent lifecycle revocation is not checked by HTTP Signature authorization. Removing or revoking an agent record does not itself block a compromised DID from authenticating.
|
||||
- **Impact:** Do not use UCANs for collaborator permissions or rely on agent-record revocation as a key-compromise response.
|
||||
- **Mitigation:** Keep sensitive deployments behind operational network controls and enable owner-only push enforcement where it fits the deployment until trusted delegation and authorization-aware revocation are implemented.
|
||||
|
||||
### git-receive-pack authentication
|
||||
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
|
||||
@@ -71,10 +71,20 @@ These are **documented, accepted limitations** for the current live release and
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### Private repository reads
|
||||
- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release.
|
||||
- **Impact:** Do not store private repositories or secrets on public nodes.
|
||||
- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer.
|
||||
- **Fix target:** v0.2
|
||||
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
|
||||
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
|
||||
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
|
||||
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
|
||||
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
|
||||
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
|
||||
|
||||
### Pull-request review enforcement
|
||||
- Pull-request review comments are not yet threaded or line-anchored, and merges do not enforce required approvals.
|
||||
- **Impact:** Teams must use their own review policy or external controls for merge approval requirements.
|
||||
|
||||
### GraphQL mutation coverage
|
||||
- Existing GraphQL mutations require an authenticated signer, but a mutation-specific source-level guardrail has not yet been added for future mutations.
|
||||
- **Impact:** A new mutation could accidentally omit its signer check without an explicit test fence.
|
||||
|
||||
### Peer route hardening rollout
|
||||
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
|
||||
@@ -101,7 +111,7 @@ These are **documented, accepted limitations** for the current live release and
|
||||
| Key storage | PKCS#8 PEM, 0600 permissions |
|
||||
| Content hashing | SHA-256 via CIDv1 |
|
||||
| HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) |
|
||||
| UCAN tokens | JWT (Ed25519 signatures) |
|
||||
| UCAN tokens | Signed JSON object (Ed25519 signature) |
|
||||
| On-chain | ECDSA secp256k1 (Base L2 / Ethereum) |
|
||||
|
||||
---
|
||||
@@ -109,7 +119,7 @@ These are **documented, accepted limitations** for the current live release and
|
||||
## Threat Model
|
||||
|
||||
gitlawb is designed to be secure against:
|
||||
- **Unauthorized writes** — HTTP Signature auth on all write endpoints
|
||||
- **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
|
||||
|
||||
@@ -19,3 +19,20 @@ tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
# Job Object bindings for the real_git_fetch harness: the Windows analog of the
|
||||
# unix process-group teardown, so the whole `git fetch` tree can be killed
|
||||
# together on both the timeout and clean-exit paths (INV-22). Win32_Security is
|
||||
# required by CreateJobObjectW, and Win32_System_Threading by
|
||||
# JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
@@ -276,27 +276,32 @@ fn handle_connect<R: Read>(
|
||||
|
||||
// ── Phase 2: pack exchange (POST /<service>) ──────────────────────────────
|
||||
//
|
||||
// The two services behave differently with their write pipe:
|
||||
// The two services frame Phase 2 differently:
|
||||
//
|
||||
// git-upload-pack (clone/fetch):
|
||||
// Client sends pkt-line want/have negotiation ending with "done\n",
|
||||
// but does NOT close its write pipe — it waits for the pack response.
|
||||
// We must detect the terminal "done\n" pkt-line to know when to POST.
|
||||
// Git speaks the stateful native protocol over `connect`: it sends its
|
||||
// wants, a flush, then have-batches each terminated by a flush, and blocks
|
||||
// for the server's ACK/NAK after every flush before it sends more haves or
|
||||
// the terminal "done\n". The node serves `git upload-pack --stateless-rpc`,
|
||||
// which keeps no state between POSTs, so we run a per-round loop: POST a
|
||||
// self-contained request once per flush-terminated batch and stream each
|
||||
// response back to git, until the "done" round returns the pack. Collapsing
|
||||
// this into one POST deadlocks a multi-round fetch (#117).
|
||||
//
|
||||
// git-receive-pack (push):
|
||||
// Client sends ref-update commands + complete PACK blob, then closes
|
||||
// its write pipe. read_to_end is safe and correct here.
|
||||
// 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.
|
||||
|
||||
let request_body = if service == "git-upload-pack" {
|
||||
read_upload_pack_request(stdin).context("reading upload-pack request")?
|
||||
} else {
|
||||
let mut buf = Vec::new();
|
||||
stdin
|
||||
.read_to_end(&mut buf)
|
||||
.context("reading receive-pack request")?;
|
||||
buf
|
||||
};
|
||||
let post_url = format!("{}/{}", repo_base, service);
|
||||
|
||||
if service == "git-upload-pack" {
|
||||
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")?;
|
||||
tracing::debug!("pack request: {} bytes from git", request_body.len());
|
||||
|
||||
if request_body.is_empty() {
|
||||
@@ -305,32 +310,14 @@ fn handle_connect<R: Read>(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let post_url = format!("{}/{}", repo_base, service);
|
||||
tracing::debug!("POST {post_url} ({} bytes)", request_body.len());
|
||||
|
||||
let req = build_pack_post_request(&client, &post_url, service, &request_body, signing_key);
|
||||
|
||||
// Attach the body after signing so the pack bytes are moved, not cloned —
|
||||
// packs can be large and the clone doubled peak memory on push.
|
||||
let pack_resp = req
|
||||
.body(request_body)
|
||||
.send()
|
||||
.with_context(|| format!("POST {post_url}"))?;
|
||||
|
||||
if !pack_resp.status().is_success() {
|
||||
let status = pack_resp.status();
|
||||
let body = read_error_body(pack_resp);
|
||||
let path = format!("/{service}");
|
||||
bail!("{}", http_error_message("POST", &path, status, &body, None));
|
||||
}
|
||||
|
||||
let pack_bytes = pack_resp.bytes().context("reading pack response")?;
|
||||
tracing::debug!("pack response: {} bytes from node", pack_bytes.len());
|
||||
|
||||
stdout.write_all(&pack_bytes)?;
|
||||
stdout.flush()?;
|
||||
|
||||
Ok(())
|
||||
post_pack_round(
|
||||
&client,
|
||||
&post_url,
|
||||
service,
|
||||
signing_key,
|
||||
request_body,
|
||||
&mut stdout,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Smart-protocol request builders ───────────────────────────────────────────
|
||||
@@ -424,33 +411,55 @@ fn parse_gitlawb_url(url: &str) -> Result<(String, String, String)> {
|
||||
Ok((did_string.to_string(), short_owner, repo_name))
|
||||
}
|
||||
|
||||
/// Read a complete git-upload-pack request from the pkt-line stream.
|
||||
/// How a single upload-pack negotiation round ended.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
enum RoundEnd {
|
||||
/// A flush pkt (`0000`) ended the round; more negotiation may follow.
|
||||
Flush,
|
||||
/// The terminal `done\n` pkt ended the round; the pack follows.
|
||||
Done,
|
||||
/// The stream ended (git closed its write pipe).
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Read ONE round of git's upload-pack request from the pkt-line stream.
|
||||
///
|
||||
/// For upload-pack, git sends its want/have negotiation ending with the pkt-line
|
||||
/// `"done\n"` but does NOT close its write pipe afterwards — it waits for the
|
||||
/// server's pack response. We detect the terminal "done\n" and stop reading.
|
||||
///
|
||||
/// We also handle the flush-only case (`"0000"`) that git sends when it already
|
||||
/// has everything it needs (up-to-date clone).
|
||||
fn read_upload_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
/// Git speaks the stateful native protocol over the `connect` capability: it
|
||||
/// sends its `want` lines, a flush, then `have` batches each terminated by a
|
||||
/// flush, and blocks for the server's ACK/NAK after every flush before sending
|
||||
/// more haves or the terminal `done\n`. Each call returns one such round: the
|
||||
/// pkt-lines up to (but not including) the terminating flush or `done`, plus
|
||||
/// which terminator ended it. `negotiate_upload_pack` reassembles these into
|
||||
/// self-contained stateless-RPC POST bodies. Returning at the flush, instead of
|
||||
/// buffering past it waiting for `done`, is what lets a multi-round fetch make
|
||||
/// progress rather than deadlock (#117).
|
||||
fn read_upload_pack_round<R: Read>(stdin: &mut R) -> Result<(Vec<u8>, RoundEnd)> {
|
||||
let mut content = Vec::new();
|
||||
|
||||
loop {
|
||||
// Read the 4-byte hex pkt-line length prefix
|
||||
// Read the 4-byte hex pkt-line length prefix.
|
||||
let mut len_bytes = [0u8; 4];
|
||||
match stdin.read_exact(&mut len_bytes) {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||
return Ok((content, RoundEnd::Eof));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
buf.extend_from_slice(&len_bytes);
|
||||
|
||||
let len_hex = std::str::from_utf8(&len_bytes).unwrap_or("0000");
|
||||
let pkt_len = usize::from_str_radix(len_hex, 16).unwrap_or(0);
|
||||
// A malformed 4-byte length header must NOT be silently collapsed to a
|
||||
// 0000 flush (#192 F5): that masks corrupted/desynchronized protocol input
|
||||
// and can emit accumulated have lines as a synthesized flush round.
|
||||
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:?}");
|
||||
};
|
||||
|
||||
if pkt_len == 0 {
|
||||
// Flush pkt "0000" — keep buffering (more pkt-lines may follow)
|
||||
continue;
|
||||
// Flush pkt "0000": this round is complete.
|
||||
return Ok((content, RoundEnd::Flush));
|
||||
}
|
||||
|
||||
if pkt_len < 4 {
|
||||
@@ -462,16 +471,149 @@ fn read_upload_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
|
||||
stdin
|
||||
.read_exact(&mut data)
|
||||
.context("reading pkt-line data")?;
|
||||
buf.extend_from_slice(&data);
|
||||
|
||||
// "done\n" signals the end of the want/have negotiation
|
||||
// "done\n" ends the negotiation; the pack follows. The terminator is
|
||||
// reported out-of-band and re-emitted by the caller, so it is not
|
||||
// appended to `content`.
|
||||
if data == b"done\n" {
|
||||
tracing::debug!("upload-pack: got 'done', request complete");
|
||||
break;
|
||||
tracing::debug!("upload-pack: got 'done', round complete");
|
||||
return Ok((content, RoundEnd::Done));
|
||||
}
|
||||
|
||||
content.extend_from_slice(&len_bytes);
|
||||
content.extend_from_slice(&data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// each POST at "/"). A non-2xx status surfaces through the sanitized error path
|
||||
/// rather than being rendered as an empty or successful fetch (INV-6/INV-8).
|
||||
fn post_pack_round(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
service: &str,
|
||||
signing_key: Option<&Keypair>,
|
||||
body: Vec<u8>,
|
||||
stdout: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
tracing::debug!("POST {post_url} ({} bytes)", body.len());
|
||||
let req = build_pack_post_request(client, post_url, service, &body, signing_key);
|
||||
// Attach the body after signing so the bytes are moved, not cloned.
|
||||
let resp = req
|
||||
.body(body)
|
||||
.send()
|
||||
.with_context(|| format!("POST {post_url}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err_body = read_error_body(resp);
|
||||
let path = format!("/{service}");
|
||||
bail!(
|
||||
"{}",
|
||||
http_error_message("POST", &path, status, &err_body, None)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
let bytes = resp.bytes().context("reading pack response")?;
|
||||
tracing::debug!("pack response: {} bytes from node", bytes.len());
|
||||
stdout.write_all(&bytes)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drive a git-upload-pack fetch as a v0 smart-HTTP stateless-RPC client.
|
||||
///
|
||||
/// Git (over `connect`) speaks the stateful native protocol; the node serves
|
||||
/// `git upload-pack --stateless-rpc`, which keeps no state between POSTs. Bridge
|
||||
/// the two: capture the want block once, then for each flush-terminated have
|
||||
/// batch POST a self-contained request (the wants plus every have accumulated so
|
||||
/// far, then exactly one terminator) and stream the ACK/NAK back to git so it can
|
||||
/// continue. The `done` round returns the pack. Every POST re-sends the wants and
|
||||
/// all prior haves because the server is stateless; leaving an intermediate flush
|
||||
/// in the body would truncate the negotiation, so each body carries exactly one
|
||||
/// terminator.
|
||||
fn negotiate_upload_pack<R: Read>(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
service: &str,
|
||||
signing_key: Option<&Keypair>,
|
||||
stdin: &mut R,
|
||||
stdout: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
// Opening round: the want block, a bare `done`, or an empty/flush-only
|
||||
// request from an up-to-date or aborted fetch.
|
||||
let (wants, wend) = read_upload_pack_round(stdin)?;
|
||||
match wend {
|
||||
// Up-to-date with an empty request, truncated, or aborted before a
|
||||
// terminator: nothing safe to POST.
|
||||
RoundEnd::Eof => return Ok(()),
|
||||
// A `done` with no preceding want-section flush: the bare-`done` request
|
||||
// the single-round tests feed. Forward it verbatim as one POST.
|
||||
RoundEnd::Done => {
|
||||
let mut body = wants;
|
||||
body.extend_from_slice(b"0009done\n");
|
||||
return post_pack_round(client, post_url, service, signing_key, body, stdout);
|
||||
}
|
||||
// Normal: the want section ended with its flush; negotiate the haves.
|
||||
// (An empty want block here is a flush-only opener and falls through to
|
||||
// the loop, which POSTs nothing and returns at EOF.)
|
||||
RoundEnd::Flush => {}
|
||||
}
|
||||
|
||||
// The node is stateless between POSTs, so re-send the wants and every have so
|
||||
// far in each round.
|
||||
let mut acc_haves: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
let (batch, bend) = read_upload_pack_round(stdin)?;
|
||||
|
||||
// Only POST when a round carries new haves or a terminator. A round with no
|
||||
// new haves needs no request: an empty EOF ends the fetch, and a bare flush
|
||||
// (git never sends a content-free flush mid-negotiation, but a malformed
|
||||
// stream could) is skipped so a run of empty flushes cannot amplify into one
|
||||
// signed POST each. A `done` round with no haves still POSTs (the fresh-clone
|
||||
// case), handled by the terminator match below.
|
||||
match (batch.is_empty(), bend) {
|
||||
(true, RoundEnd::Eof) => return Ok(()),
|
||||
(true, RoundEnd::Flush) => continue,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
acc_haves.extend_from_slice(&batch);
|
||||
|
||||
// Compose a self-contained stateless-RPC request: wants + one flush + all
|
||||
// accumulated haves + exactly one terminator. No intermediate flush
|
||||
// survives into the body, or the stateless server treats the first flush
|
||||
// after the haves as end-of-request and truncates the negotiation.
|
||||
let mut body = wants.clone();
|
||||
body.extend_from_slice(b"0000");
|
||||
body.extend_from_slice(&acc_haves);
|
||||
|
||||
let final_round = match bend {
|
||||
RoundEnd::Flush => {
|
||||
body.extend_from_slice(b"0000");
|
||||
false
|
||||
}
|
||||
// Only a real `done` pkt finalizes the stateless request.
|
||||
RoundEnd::Done => {
|
||||
body.extend_from_slice(b"0009done\n");
|
||||
true
|
||||
}
|
||||
// A nonempty batch terminated by EOF (no flush, no done) means git
|
||||
// ABORTED mid-negotiation (#192, F1). Terminate WITHOUT POSTing:
|
||||
// synthesizing a `done` here would make the node generate and stream a
|
||||
// full pack — signed, for a private fetch — to a client that has gone
|
||||
// away. The accumulated haves are discarded; there is no one to serve.
|
||||
RoundEnd::Eof => return Ok(()),
|
||||
};
|
||||
|
||||
post_pack_round(client, post_url, service, signing_key, body, stdout)?;
|
||||
|
||||
if final_round {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the HTTP smart-protocol service announcement from a GET /info/refs response.
|
||||
@@ -1100,6 +1242,35 @@ mod tests {
|
||||
"a tampered @path must fail verification"
|
||||
);
|
||||
}
|
||||
|
||||
// #117: a multi-round POST body (wants + flush + accumulated haves + done)
|
||||
// signs and verifies over its EXACT bytes. Each per-round POST a private
|
||||
// fetch emits is a different accumulated body, and build_pack_post_request
|
||||
// signs the same bytes it sends, so every round is independently accepted
|
||||
// by the node verifier, not just the single-round `done` body.
|
||||
let mut acc = Vec::new();
|
||||
acc.extend_from_slice(&pkt(&format!(
|
||||
"want {} multi_ack_detailed side-band-64k\n",
|
||||
"a".repeat(40)
|
||||
)));
|
||||
acc.extend_from_slice(b"0000");
|
||||
acc.extend_from_slice(&pkt(&format!("have {}\n", "1".repeat(40))));
|
||||
acc.extend_from_slice(&pkt(&format!("have {}\n", "2".repeat(40))));
|
||||
acc.extend_from_slice(&pkt("done\n"));
|
||||
let post_url = "http://node.example/zOwner/myrepo/git-upload-pack";
|
||||
let req = build_pack_post_request(&client, post_url, "git-upload-pack", &acc, Some(&kp))
|
||||
.body(acc.clone())
|
||||
.build()
|
||||
.unwrap();
|
||||
node_verifies(
|
||||
"POST",
|
||||
&path_and_query(&req),
|
||||
&acc,
|
||||
&header(&req, "signature-input"),
|
||||
&header(&req, "signature"),
|
||||
&header(&req, "content-digest"),
|
||||
)
|
||||
.expect("a multi-round accumulated POST body must verify under the node's verifier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1399,4 +1570,603 @@ mod tests {
|
||||
assert!(help.contains("GITLAWB_NODE"));
|
||||
assert!(help.ends_with('\n'));
|
||||
}
|
||||
|
||||
// ── #117 multi-round fetch negotiation ───────────────────────────────────
|
||||
|
||||
/// Encode a git pkt-line: 4-byte hex length (incl. the 4 bytes) + data.
|
||||
fn pkt(data: &str) -> Vec<u8> {
|
||||
format!("{:04x}{}", data.len() + 4, data).into_bytes()
|
||||
}
|
||||
|
||||
/// A realistic capability-bearing opening want line (git puts its capability
|
||||
/// list on the first want), so the multi-round tests exercise the want shape
|
||||
/// real git actually sends, not a bare `want <sha>`.
|
||||
fn want_line(sha: &str) -> Vec<u8> {
|
||||
pkt(&format!(
|
||||
"want {sha} multi_ack_detailed side-band-64k ofs-delta agent=git/2.43\n"
|
||||
))
|
||||
}
|
||||
|
||||
/// A reader that yields `seed`, then BLOCKS until `gate` fires. Models git
|
||||
/// holding the upload-pack pipe open after a have-batch flush, waiting for the
|
||||
/// server's ACK/NAK before it sends more. A real pipe blocks here; an in-memory
|
||||
/// Cursor would EOF and hide the bug, which is why the pre-#117 tests missed it.
|
||||
struct BlockAfterSeed {
|
||||
seed: io::Cursor<Vec<u8>>,
|
||||
gate: std::sync::mpsc::Receiver<()>,
|
||||
}
|
||||
impl Read for BlockAfterSeed {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let n = self.seed.read(buf)?;
|
||||
if n > 0 {
|
||||
return Ok(n);
|
||||
}
|
||||
let _ = self.gate.recv(); // block like a live pipe; unblock -> EOF
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// #117 primitive contract (matrix item 1): `read_upload_pack_round` returns at
|
||||
/// a flush-terminated batch instead of buffering past it waiting for `done`.
|
||||
/// The pre-#117 `read_upload_pack_request` blocked here (the deadlock). A
|
||||
/// blocking reader proves the return under live-pipe semantics: if the reader
|
||||
/// buffered past the flush it would block on the gate and the test would time out.
|
||||
#[test]
|
||||
fn read_upload_pack_round_returns_on_flush_without_done() {
|
||||
let mut seed = Vec::new();
|
||||
seed.extend_from_slice(&want_line(&"a".repeat(40)));
|
||||
seed.extend_from_slice(b"0000"); // end of wants
|
||||
for i in 0..32u32 {
|
||||
seed.extend_from_slice(&pkt(&format!("have {:040x}\n", i)));
|
||||
}
|
||||
seed.extend_from_slice(b"0000"); // have-batch flush; git awaits ACK, sends NO done
|
||||
|
||||
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>, RoundEnd)>();
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Opening round (wants) returns at the first flush; the have batch
|
||||
// returns at the second flush. Neither may block for a `done`.
|
||||
let _wants = read_upload_pack_round(&mut reader).unwrap();
|
||||
let haves = read_upload_pack_round(&mut reader).unwrap();
|
||||
let _ = done_tx.send(haves);
|
||||
});
|
||||
|
||||
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 (batch, end) = got.expect(
|
||||
"read_upload_pack_round blocked past a flush-terminated have-batch \
|
||||
waiting for `done` (multi-round fetch deadlock #117)",
|
||||
);
|
||||
assert_eq!(end, RoundEnd::Flush, "a flush terminates the round");
|
||||
assert!(
|
||||
batch.windows(4).any(|w| w == b"have"),
|
||||
"the have batch content is returned to the caller"
|
||||
);
|
||||
}
|
||||
|
||||
/// #117 core (matrix item 2): a multi-round negotiation issues MORE THAN ONE
|
||||
/// POST, and each POST body is a self-contained stateless-RPC request: wants +
|
||||
/// one flush + every have accumulated so far + exactly one terminator. The
|
||||
/// round-2 body is asserted byte-for-byte, which pins both accumulation (round
|
||||
/// 1's have is present) and the one-terminator invariant (no intermediate flush
|
||||
/// survives). Pre-#117 this was a single POST.
|
||||
#[test]
|
||||
fn multi_round_fetch_posts_each_round_with_accumulated_wants_and_haves() {
|
||||
let want_sha = "a".repeat(40);
|
||||
let have1 = "1".repeat(40);
|
||||
let have2 = "2".repeat(40);
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
// Round 1 (flush-terminated): an ACK/NAK continuation, no pack yet.
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\n",
|
||||
},
|
||||
// Round 2 (done): final response plus the (fake) pack.
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfake",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
|
||||
let wants = want_line(&want_sha);
|
||||
let mut stdin_bytes = Vec::new();
|
||||
stdin_bytes.extend_from_slice(&wants);
|
||||
stdin_bytes.extend_from_slice(b"0000"); // end of wants
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {have1}\n")));
|
||||
stdin_bytes.extend_from_slice(b"0000"); // round-1 flush: git awaits ACK
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {have2}\n")));
|
||||
stdin_bytes.extend_from_slice(&pkt("done\n")); // round-2 terminator
|
||||
|
||||
let mut stdin = io::Cursor::new(stdin_bytes);
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("multi-round fetch should complete");
|
||||
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
3,
|
||||
"GET + two POSTs, one per negotiation round"
|
||||
);
|
||||
assert!(
|
||||
request_body(&requests[1])
|
||||
.windows(want_sha.len())
|
||||
.any(|w| w == want_sha.as_bytes()),
|
||||
"round-1 POST must carry the wants"
|
||||
);
|
||||
|
||||
// Round-2 body: wants + one flush + BOTH haves + done, with no flush
|
||||
// between the haves (the accumulation + one-terminator invariant).
|
||||
let mut expected = Vec::new();
|
||||
expected.extend_from_slice(&wants);
|
||||
expected.extend_from_slice(b"0000");
|
||||
expected.extend_from_slice(&pkt(&format!("have {have1}\n")));
|
||||
expected.extend_from_slice(&pkt(&format!("have {have2}\n")));
|
||||
expected.extend_from_slice(&pkt("done\n"));
|
||||
assert_eq!(
|
||||
request_body(&requests[2]),
|
||||
expected,
|
||||
"round-2 POST body must be wants + flush + all accumulated haves + one done terminator"
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract the HTTP request body (bytes after the header/body separator) from a
|
||||
/// captured request string. pkt-lines are ASCII, so the lossy round-trip is exact.
|
||||
fn request_body(request: &str) -> Vec<u8> {
|
||||
match request.split_once("\r\n\r\n") {
|
||||
Some((_, body)) => body.as_bytes().to_vec(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// U4 / INV-8 / INV-12: a private-repo multi-round fetch escalates on the 404
|
||||
/// exactly once, then EVERY per-round POST carries the signature. Must-not: no
|
||||
/// round goes out unsigned (an unsigned round 2 would 404 the owner's own fetch).
|
||||
#[test]
|
||||
fn private_multi_round_signs_every_post() {
|
||||
let kp = Keypair::generate();
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"not found"}"#,
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\n",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfake",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = io::Cursor::new(two_round_stdin());
|
||||
handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin)
|
||||
.expect("private multi-round fetch should complete");
|
||||
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
4,
|
||||
"anon GET, signed GET retry, then two signed POSTs"
|
||||
);
|
||||
assert!(
|
||||
!requests[0].to_lowercase().contains("signature-input"),
|
||||
"first GET is anonymous"
|
||||
);
|
||||
assert!(
|
||||
requests[1].to_lowercase().contains("signature-input"),
|
||||
"GET retry is signed"
|
||||
);
|
||||
assert!(
|
||||
requests[2].to_lowercase().contains("signature-input"),
|
||||
"round-1 POST is signed"
|
||||
);
|
||||
assert!(
|
||||
requests[3].to_lowercase().contains("signature-input"),
|
||||
"round-2 POST is signed: no per-round POST may go out unsigned for a private repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4: a public multi-round fetch stays anonymous on EVERY round even with a
|
||||
/// keypair present. Must-not: no round signs (no DID disclosure on a public fetch).
|
||||
#[test]
|
||||
fn public_multi_round_stays_anonymous_every_post() {
|
||||
let kp = Keypair::generate();
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\n",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfake",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = io::Cursor::new(two_round_stdin());
|
||||
handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin)
|
||||
.expect("public multi-round fetch should complete");
|
||||
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 3, "GET + two POSTs, no retry");
|
||||
for (i, r) in requests.iter().enumerate() {
|
||||
assert!(
|
||||
!r.to_lowercase().contains("signature-input"),
|
||||
"request {i} must stay anonymous on a public fetch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// U4 / INV-8 / INV-12: a denial mid-negotiation (round-2 POST 404) surfaces as
|
||||
/// an error, not a silent empty/successful fetch, and does NOT re-trigger the
|
||||
/// Phase-1 signed-retry escalation (that is a GET-only, Phase-1-only behavior).
|
||||
#[test]
|
||||
fn mid_negotiation_post_denial_surfaces_without_reescalation() {
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\n",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"repository is no longer readable"}"#,
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = io::Cursor::new(two_round_stdin());
|
||||
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("POST /git-upload-pack returned 404 Not Found"),
|
||||
"the mid-negotiation denial must surface, not read as an empty/successful fetch"
|
||||
);
|
||||
assert!(message.contains("repository is no longer readable"));
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
3,
|
||||
"GET + two POSTs, then bail: a POST denial must not re-run the Phase-1 escalation"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two-round upload-pack request: wants, flush, have1, flush (round 1), have2,
|
||||
/// done (round 2). Shared by the U4 signing/denial tests.
|
||||
fn two_round_stdin() -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&want_line(&"a".repeat(40)));
|
||||
v.extend_from_slice(b"0000");
|
||||
v.extend_from_slice(&pkt(&format!("have {}\n", "1".repeat(40))));
|
||||
v.extend_from_slice(b"0000");
|
||||
v.extend_from_slice(&pkt(&format!("have {}\n", "2".repeat(40))));
|
||||
v.extend_from_slice(&pkt("done\n"));
|
||||
v
|
||||
}
|
||||
|
||||
/// U5 (matrix item 5, plumbing only): the node's withheld-blob path
|
||||
/// (`upload_pack_excluding`) ignores negotiation and answers the first POST
|
||||
/// with NAK plus a full self-contained pack. The helper must forward that
|
||||
/// response and terminate without hanging. Whether REAL git accepts a pack
|
||||
/// where it expected an ACK continuation is U7's real-git scenario, not this
|
||||
/// mock (a Cursor never reacts to the response).
|
||||
#[test]
|
||||
fn withheld_shaped_response_is_forwarded_without_hanging() {
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
// Withheld shape: NAK + full pack on the FIRST POST, negotiation ignored.
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfullselfcontainedpack",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
// A single flush-terminated round then EOF: the helper POSTs once, forwards
|
||||
// the NAK+pack, and terminates at EOF rather than looping forever.
|
||||
let mut stdin_bytes = Vec::new();
|
||||
stdin_bytes.extend_from_slice(&want_line(&"a".repeat(40)));
|
||||
stdin_bytes.extend_from_slice(b"0000");
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {}\n", "1".repeat(40))));
|
||||
stdin_bytes.extend_from_slice(b"0000");
|
||||
let mut stdin = io::Cursor::new(stdin_bytes);
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("withheld-shaped fetch should forward the pack and terminate");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"GET + one POST; the forwarded pack does not hang the helper"
|
||||
);
|
||||
}
|
||||
|
||||
/// U6 (matrix item 6): a fresh clone (wants, flush, done) issues exactly ONE POST.
|
||||
#[test]
|
||||
fn fresh_clone_issues_single_post() {
|
||||
let want_sha = "a".repeat(40);
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfake",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin_bytes = Vec::new();
|
||||
stdin_bytes.extend_from_slice(&want_line(&want_sha));
|
||||
stdin_bytes.extend_from_slice(b"0000");
|
||||
stdin_bytes.extend_from_slice(&pkt("done\n"));
|
||||
let mut stdin = io::Cursor::new(stdin_bytes);
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("clone should complete");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 2, "fresh clone is exactly one POST");
|
||||
assert!(request_body(&requests[1])
|
||||
.windows(want_sha.len())
|
||||
.any(|w| w == want_sha.as_bytes()));
|
||||
}
|
||||
|
||||
/// U6 (matrix item 6): an up-to-date / flush-only opener issues ZERO POSTs and
|
||||
/// completes without entering an ACK wait.
|
||||
#[test]
|
||||
fn flush_only_opener_skips_post() {
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
}]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = io::Cursor::new(b"0000".to_vec()); // flush only, nothing to fetch
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("up-to-date fetch should complete");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 1, "flush-only opener issues no POST");
|
||||
}
|
||||
|
||||
/// U6 (matrix item 6): a single-shot fetch git resolved in one round (wants,
|
||||
/// flush, haves, done, no intermediate flush) issues exactly ONE POST. Must-not:
|
||||
/// the fix must not split a single-shot negotiation into multiple POSTs.
|
||||
#[test]
|
||||
fn single_shot_fetch_is_one_post() {
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfake",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin_bytes = Vec::new();
|
||||
stdin_bytes.extend_from_slice(&want_line(&"a".repeat(40)));
|
||||
stdin_bytes.extend_from_slice(b"0000");
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {}\n", "1".repeat(40))));
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {}\n", "2".repeat(40))));
|
||||
stdin_bytes.extend_from_slice(&pkt("done\n")); // done with no intermediate flush
|
||||
let mut stdin = io::Cursor::new(stdin_bytes);
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("fetch should complete");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"a single-shot negotiation must be exactly one POST, not split"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hardening: content-free flush pkts between the wants and the first haves do
|
||||
/// not each fire a POST. Real git never sends a bare mid-negotiation flush, but
|
||||
/// a malformed stream must not amplify into one signed POST per empty flush; the
|
||||
/// loop skips them and POSTs only the round that carries haves + done.
|
||||
#[test]
|
||||
fn repeated_bare_flushes_do_not_amplify_posts() {
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0008NAK\nPACKfake",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin_bytes = Vec::new();
|
||||
stdin_bytes.extend_from_slice(&want_line(&"a".repeat(40)));
|
||||
stdin_bytes.extend_from_slice(b"0000");
|
||||
stdin_bytes.extend_from_slice(b"0000"); // bare flush, no haves
|
||||
stdin_bytes.extend_from_slice(b"0000"); // bare flush, no haves
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {}\n", "1".repeat(40))));
|
||||
stdin_bytes.extend_from_slice(&pkt("done\n"));
|
||||
let mut stdin = io::Cursor::new(stdin_bytes);
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("fetch with stray flushes should complete");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"content-free flushes must not each produce a POST"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Branch-coverage closure: exercise the remaining changed arms ─────────
|
||||
|
||||
/// A reader that fails with a non-EOF io error, to exercise the error
|
||||
/// propagation arm of `read_upload_pack_round` (distinct from a clean EOF).
|
||||
struct FailingReader;
|
||||
impl Read for FailingReader {
|
||||
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
|
||||
Err(io::Error::other("simulated read failure"))
|
||||
}
|
||||
}
|
||||
|
||||
/// G4: a non-EOF read error propagates; it is not swallowed as end-of-stream.
|
||||
#[test]
|
||||
fn read_upload_pack_round_propagates_read_error() {
|
||||
let mut r = FailingReader;
|
||||
assert!(read_upload_pack_round(&mut r).is_err());
|
||||
}
|
||||
|
||||
/// G1: an invalid pkt-line length (1..=3) is rejected rather than underflowing
|
||||
/// `pkt_len - 4`.
|
||||
#[test]
|
||||
fn read_upload_pack_round_rejects_invalid_pkt_length() {
|
||||
let mut r = io::Cursor::new(b"0001".to_vec()); // pkt_len 1, < 4
|
||||
let err = read_upload_pack_round(&mut r).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("invalid pkt-line length"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #192 F5: a non-hex length header (e.g. "zzzz") is a malformed frame and must
|
||||
/// be rejected, not silently collapsed to a 0000 flush.
|
||||
#[test]
|
||||
fn read_upload_pack_round_rejects_non_hex_length() {
|
||||
let mut r = io::Cursor::new(b"zzzzdone\n".to_vec());
|
||||
let err = read_upload_pack_round(&mut r).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("pkt-line"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #192 F5: a non-UTF-8 length header must be rejected, not treated as a flush.
|
||||
#[test]
|
||||
fn read_upload_pack_round_rejects_non_utf8_length() {
|
||||
let mut r = io::Cursor::new(vec![0xffu8, 0xff, 0xff, 0xff]);
|
||||
let err = read_upload_pack_round(&mut r).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("pkt-line"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// G2: an immediately-empty upload-pack request (EOF at the opening round, with
|
||||
/// a 200 advertisement) skips the POST entirely.
|
||||
#[test]
|
||||
fn upload_pack_empty_request_skips_post() {
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
}]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = io::Cursor::new(Vec::<u8>::new()); // immediate EOF
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("empty upload-pack request should skip the POST");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"empty request must issue the GET only, no POST"
|
||||
);
|
||||
}
|
||||
|
||||
/// G3 (#192, F1): a nonempty have-batch terminated by EOF (no flush, no done)
|
||||
/// means git ABORTED mid-negotiation. The helper must terminate WITHOUT POSTing
|
||||
/// — synthesizing a `done` and sending it would make the node generate and
|
||||
/// stream a full pack (signed, for a private fetch) to a client that has gone
|
||||
/// away. Only a real `done` pkt finalizes the stateless request. RED before the
|
||||
/// fix (the folded `Done | Eof` arm POSTs a synthetic done → 2 requests), GREEN
|
||||
/// after (only the info/refs GET, no upload-pack POST).
|
||||
#[test]
|
||||
fn nonempty_eof_batch_aborts_without_posting() {
|
||||
// Only the info/refs GET is served (serve_http accepts exactly N conns). The
|
||||
// fixed helper makes exactly that one request and stops. The pre-fix code
|
||||
// additionally POSTs the synthetic done; that POST hits the now-closed
|
||||
// listener and errors, so handle_connect returns Err and the `.expect` below
|
||||
// fires — the RED. The fixed code returns Ok with one recorded request.
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
}]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin_bytes = Vec::new();
|
||||
stdin_bytes.extend_from_slice(&want_line(&"a".repeat(40)));
|
||||
stdin_bytes.extend_from_slice(b"0000");
|
||||
stdin_bytes.extend_from_slice(&pkt(&format!("have {}\n", "1".repeat(40))));
|
||||
// No trailing flush or done: the stream just ends (EOF) after the have —
|
||||
// git aborted the negotiation.
|
||||
let mut stdin = io::Cursor::new(stdin_bytes);
|
||||
handle_connect(&repo_base, "git-upload-pack", None, &mut stdin)
|
||||
.expect("an aborted (EOF) negotiation is a clean no-op, not an error");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"an aborted EOF must NOT trigger an upload-pack POST (only the info/refs GET)"
|
||||
);
|
||||
}
|
||||
|
||||
/// G5: an empty receive-pack (push) body skips the POST, unchanged from before.
|
||||
#[test]
|
||||
fn receive_pack_empty_body_skips_post() {
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-receive-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
}]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = io::Cursor::new(Vec::<u8>::new()); // empty push
|
||||
handle_connect(&repo_base, "git-receive-pack", None, &mut stdin)
|
||||
.expect("empty receive-pack should skip the POST");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"empty push must issue the GET only, no POST"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@
|
||||
//! by exact match.
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
@@ -111,7 +111,7 @@ impl Attestation {
|
||||
.try_into()
|
||||
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
|
||||
let sig = Signature::from_bytes(&sig_bytes);
|
||||
vk.verify(&bytes, &sig)
|
||||
vk.verify_strict(&bytes, &sig)
|
||||
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
|
||||
|
||||
Ok(vk)
|
||||
@@ -554,4 +554,39 @@ mod tests {
|
||||
let err = tampered.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
|
||||
/// Regression guard for strict verification: a signature forged under a
|
||||
/// weak (small-order) public key satisfies the verification equation
|
||||
/// but must be rejected. The identity point is such a key: with R the
|
||||
/// identity and S = 0, [S]B - [k]A is the identity for any message.
|
||||
#[test]
|
||||
fn verify_rejects_weak_key_signature() {
|
||||
let mut weak_key_bytes = [0u8; 32];
|
||||
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
|
||||
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
|
||||
assert!(weak_vk.is_weak(), "identity point must be a weak key");
|
||||
|
||||
let cert_hash = sample_cert_hash();
|
||||
let mut forged = [0u8; 64];
|
||||
forged[0] = 1; // R = identity point, S = 0
|
||||
let forged_sig = B64U.encode(forged);
|
||||
|
||||
// Build an attestation with the weak key as signer and the forged
|
||||
// signature. The weak-key check happens at verify time.
|
||||
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
|
||||
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&weak_key_bytes);
|
||||
att.signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
att.sig = forged_sig;
|
||||
|
||||
let err = att.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_)),
|
||||
"signature under a weak (small-order) public key must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
|
||||
//! Nodes that receive a certificate with an unknown version MUST reject it.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -135,10 +137,14 @@ impl RefUpdateCert {
|
||||
|
||||
/// Check if this certificate satisfies a threshold of valid signatures
|
||||
/// from the provided set of authorized maintainer DIDs.
|
||||
///
|
||||
/// Counts distinct signer DIDs, not signature entries: a repeated
|
||||
/// signature from the same maintainer counts once.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
let valid = self.verify_all()?;
|
||||
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
|
||||
Ok(count >= threshold)
|
||||
let distinct_signers: HashSet<&Did> =
|
||||
valid.iter().filter(|d| maintainers.contains(d)).collect();
|
||||
Ok(distinct_signers.len() >= threshold)
|
||||
}
|
||||
|
||||
/// Validate the certificate structure (not signatures).
|
||||
@@ -344,6 +350,33 @@ mod tests {
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfies_threshold_rejects_duplicated_signature() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let kp3 = Keypair::generate();
|
||||
let repo_did = kp1.did();
|
||||
|
||||
let mut cert = RefUpdateCert::new(
|
||||
repo_did,
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
// Copy-paste the only real signature onto the certificate a second
|
||||
// time. Same signer, same valid signature, still one real signer.
|
||||
let dup = cert.signatures[0].clone();
|
||||
cert.signatures.push(dup);
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did(), kp3.did()];
|
||||
// Two signature entries but a single distinct signer must not
|
||||
// satisfy a 2-of-3 threshold.
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_always_satisfied() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -80,6 +80,19 @@ impl Did {
|
||||
)));
|
||||
}
|
||||
|
||||
// Refuse an oversized id before decoding. base58 decoding is quadratic
|
||||
// in its input, and both callers of this function take the string from
|
||||
// an untrusted request: the unauthenticated peer-announce gate, and the
|
||||
// HTTP-signature keyid. An ed25519 did:key method-id is a fixed 48
|
||||
// characters, so this bound is slack rather than a behavior change, and
|
||||
// it has to sit ahead of the decode to be worth anything.
|
||||
const MAX_METHOD_ID_LEN: usize = 64;
|
||||
if self.method_id().len() > MAX_METHOD_ID_LEN {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key method-specific id too long".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (_, bytes) =
|
||||
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
@@ -308,4 +321,52 @@ mod tests {
|
||||
assert_eq!(Did::web("example.com").method_id(), "example.com");
|
||||
assert_eq!(Did::gitlawb("z6MkSomeKey").method_id(), "z6MkSomeKey");
|
||||
}
|
||||
|
||||
/// An oversized method-specific id is refused on its length, before the
|
||||
/// base58 decode runs.
|
||||
///
|
||||
/// base58 decoding is quadratic in the input, and `to_verifying_key` is
|
||||
/// reached from two places that take an attacker-supplied string: the
|
||||
/// unauthenticated peer-announce gate, and the HTTP-signature keyid. A
|
||||
/// 64k-character DID measured 8s through the router without this guard
|
||||
/// against 4.6ms with it, on a tokio worker with no spawn_blocking, so a
|
||||
/// single caller could buy CPU-seconds by the thousand.
|
||||
///
|
||||
/// Asserting the MESSAGE is what makes this load-bearing: an oversized id
|
||||
/// errors either way, so a test that only checked `is_err()` would pass
|
||||
/// with the guard removed, having merely paid the cost it exists to avoid.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_an_oversized_method_id_before_decoding() {
|
||||
let oversized = format!("did:key:z{}", "z".repeat(64_000));
|
||||
let did: Did = oversized.parse().expect("method is still `key`");
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let err = did.to_verifying_key().expect_err("must be refused");
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("too long"),
|
||||
"must be refused on length, not after the decode: {err}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(1),
|
||||
"refusal must not pay the quadratic decode: took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bound must not reject a real key. Every `did:key` this project
|
||||
/// mints is a fixed 48 characters, so the limit is slack, not a behavior
|
||||
/// change.
|
||||
#[test]
|
||||
fn to_verifying_key_still_accepts_a_real_did_key() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let did = Did::from_verifying_key(&signing.verifying_key());
|
||||
assert_eq!(
|
||||
did.method_id().len(),
|
||||
48,
|
||||
"did:key method-id is fixed-width"
|
||||
);
|
||||
did.to_verifying_key().expect("a real did:key must resolve");
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,9 @@ impl Keypair {
|
||||
|
||||
/// Verify an Ed25519 signature.
|
||||
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
|
||||
use ed25519_dalek::Verifier;
|
||||
let sig = Signature::from_bytes(sig_bytes);
|
||||
verifying_key
|
||||
.verify(msg, &sig)
|
||||
.verify_strict(msg, &sig)
|
||||
.map_err(|_| Error::SignatureInvalid)
|
||||
}
|
||||
|
||||
@@ -209,6 +208,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_weak_key_signature() {
|
||||
// Regression guard for strict verification: a signature forged under a
|
||||
// weak (small-order) public key satisfies the verification equation
|
||||
// but must be rejected. The identity point is such a key: with R the
|
||||
// identity and S = 0, [S]B - [k]A is the identity for any message.
|
||||
let mut weak_key_bytes = [0u8; 32];
|
||||
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
|
||||
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
|
||||
assert!(weak_vk.is_weak(), "identity point must be a weak key");
|
||||
|
||||
let msg = b"forged message";
|
||||
let mut forged = [0u8; 64];
|
||||
forged[0] = 1; // R = identity point, S = 0
|
||||
|
||||
let result = verify(&weak_vk, msg, &forged);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"signature under a weak (small-order) public key must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_payload_round_trip() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -77,3 +77,6 @@ libp2p-dns = { version = "0.44.0", features = ["tokio"] }
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
|
||||
# on which timer fired instead of on wall clock.
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -26,14 +26,61 @@ pub async fn list_anchors(
|
||||
Query(q): Query<ListAnchorsQuery>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let limit = q.limit.min(200);
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let anchors = state
|
||||
.db
|
||||
.list_arweave_anchors(q.repo.as_deref(), limit)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Internal)?;
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
"count": anchors.len(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod closed_pool_tests {
|
||||
use super::*;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable.
|
||||
#[sqlx::test]
|
||||
async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
pool.close().await;
|
||||
|
||||
let resp = Router::new()
|
||||
.route("/api/v1/arweave/anchors", axum::routing::get(list_anchors))
|
||||
.with_state(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/arweave/anchors")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"closed-pool outage must be retryable 503, not 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
serde_json::json!({
|
||||
"error": crate::error::DB_UNAVAILABLE_CODE,
|
||||
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1440,7 +1440,8 @@ mod ref_updates_feed_tests {
|
||||
|
||||
// A DB error in the gate fails closed as 500, not swallowed into an empty 200 (the
|
||||
// regression the old get_repo().ok().flatten() allowed). Inject by dropping a
|
||||
// column get_repo selects so its query errors.
|
||||
// column get_repo selects so its query errors. Also pin the anonymous body to
|
||||
// the exact opaque object (#226).
|
||||
#[sqlx::test]
|
||||
async fn repo_events_db_error_fails_closed_500(pool: PgPool) {
|
||||
let state = test_state(pool.clone()).await;
|
||||
@@ -1463,6 +1464,18 @@ mod ref_updates_feed_tests {
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"a DB error must fail closed (500), never serve an empty 200"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
let v: serde_json::Value = serde_json::from_str(&body).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
serde_json::json!({
|
||||
"error": "db_error",
|
||||
"message": crate::error::DB_ERROR_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Symmetric to the gate DB-error test: a DB error in the CERT fetch (after the gate
|
||||
|
||||
+2740
-38
File diff suppressed because it is too large.
Load diff
@@ -17,9 +17,39 @@ use gitlawb_core::did::Did;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::{PeerWriteAuthority, PeerWriteDenied};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Map an `upsert_peer` failure onto its response class.
|
||||
///
|
||||
/// The boundary's denials arrive inside an anyhow chain, so they are downcast
|
||||
/// back out the way `From<anyhow::Error> for AppError` already recovers sqlx
|
||||
/// errors. Without this a refusal renders as a 500, which is the mis-rendered
|
||||
/// denial this gate exists to avoid. An authority refusal is 403; naming a DID
|
||||
/// method that can never authenticate, or a DID whose verifying key cannot be
|
||||
/// resolved, is a fact about the input's form, so both are the 400 validation
|
||||
/// class, and the unresolvable one carries its own code so it reads the same to
|
||||
/// a client as the auth middleware's answer for an unresolvable keyid. The match
|
||||
/// stays exhaustive with no wildcard: the compile error a new denial causes here
|
||||
/// is what finds every mapping site.
|
||||
fn peer_write_error(err: anyhow::Error) -> AppError {
|
||||
match err.downcast::<PeerWriteDenied>() {
|
||||
Ok(denied) => match &denied {
|
||||
PeerWriteDenied::UnsupportedDidMethod { .. } => {
|
||||
AppError::BadRequest(denied.to_string())
|
||||
}
|
||||
PeerWriteDenied::UnresolvableDid { .. } => {
|
||||
AppError::UnresolvableDid(denied.to_string())
|
||||
}
|
||||
PeerWriteDenied::UnprovenRepoint { .. } | PeerWriteDenied::ProofDidMismatch { .. } => {
|
||||
AppError::Forbidden(denied.to_string())
|
||||
}
|
||||
},
|
||||
Err(other) => AppError::from(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AnnounceRequest {
|
||||
/// The DID of the announcing node
|
||||
@@ -187,18 +217,30 @@ pub async fn announce(
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
|
||||
if let Some(Extension(auth)) = auth {
|
||||
// The extension's DID is cryptographically derived, never body-supplied:
|
||||
// auth/mod.rs resolves the verifying key FROM the keyid DID itself, so what
|
||||
// is carried through to the upsert boundary below is a proof, not a claim.
|
||||
let proven_did: Option<String> = if let Some(Extension(auth)) = auth {
|
||||
// 403, not 400: this caller proved control of some DID and spent that
|
||||
// proof on a different DID's row, which is a refusal of authority over
|
||||
// the targeted row: the same denial as an unsigned repoint, and two
|
||||
// callers denied for the same reason must not get different response
|
||||
// classes. The 400 class stays for input-form failures: a malformed
|
||||
// DID, a non-public URL, a self-announce, and a DID method that can
|
||||
// never authenticate.
|
||||
if auth.0 != announced_did.to_string() {
|
||||
return Err(AppError::BadRequest(
|
||||
return Err(AppError::Forbidden(
|
||||
"Signature keyid must match announced DID".into(),
|
||||
));
|
||||
}
|
||||
Some(auth.0)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
did = %announced_did,
|
||||
"accepted unsigned peer announce; set GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true after all peers upgrade"
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Validate the URL is a public http(s) endpoint. The announce route is
|
||||
// reachable unauthenticated (until all peers sign), so without this an
|
||||
@@ -228,7 +270,20 @@ pub async fn announce(
|
||||
));
|
||||
}
|
||||
|
||||
state.db.upsert_peer(&req.did, &req.http_url).await?;
|
||||
// The authority declaration, not a re-statement of the check above: the
|
||||
// boundary carries the proven DID and re-checks it against the row itself,
|
||||
// so the keyid comparison above is an early rejection rather than the only
|
||||
// gate. The other writer (the bootstrap announce-back in main.rs) never
|
||||
// reaches this handler at all.
|
||||
let authority = match proven_did.as_deref() {
|
||||
Some(did) => PeerWriteAuthority::Proven(did),
|
||||
None => PeerWriteAuthority::Unproven,
|
||||
};
|
||||
state
|
||||
.db
|
||||
.upsert_peer(&req.did, &req.http_url, authority)
|
||||
.await
|
||||
.map_err(peer_write_error)?;
|
||||
|
||||
tracing::info!(did = %req.did, url = %req.http_url, "peer announced");
|
||||
|
||||
@@ -443,21 +498,15 @@ pub async fn ping_peer(
|
||||
.find(|p| p.did == did)
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("peer {did} not found")))?;
|
||||
|
||||
// Async ping
|
||||
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
|
||||
// Use the shared no-redirect client: bare `reqwest::get` follows redirects,
|
||||
// so a peer could answer with `302 -> http://127.0.0.1/` and turn the ping
|
||||
// into an SSRF probe.
|
||||
let ok = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
|
||||
let _ = state.db.mark_peer_ping(&did, ok).await;
|
||||
// Use DB-aware readiness rather than process liveness: `/health` stays 200
|
||||
// during a mid-life database outage, while `/ready` reports 503. A 404-only
|
||||
// fallback keeps pre-readiness peers compatible. The shared helper also
|
||||
// preserves the no-redirect SSRF guard on peer-controlled URLs.
|
||||
let ok = crate::ping_peer_readiness(&state.http_client, &peer.http_url).await;
|
||||
|
||||
// This anonymous diagnostic endpoint is intentionally read-only. The
|
||||
// periodic gossip loop owns the persisted federation-reachability gate and
|
||||
// requires consecutive failures before marking a peer unreachable.
|
||||
Ok(Json(serde_json::json!({
|
||||
"did": did,
|
||||
"http_url": peer.http_url,
|
||||
@@ -564,7 +613,7 @@ mod tests {
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{middleware, Extension, Router};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
@@ -770,12 +819,84 @@ mod tests {
|
||||
let did = Keypair::generate().did().to_string();
|
||||
state
|
||||
.db
|
||||
.upsert_peer(&did, "https://peer.example.com")
|
||||
.upsert_peer(
|
||||
&did,
|
||||
"https://peer.example.com",
|
||||
crate::db::PeerWriteAuthority::Unproven,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
did
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn manual_ping_uses_readiness_without_mutating_federation_gate(pool: PgPool) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(503)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut state = test_state(pool.clone()).await;
|
||||
state.http_client =
|
||||
Arc::new(crate::build_http_client().expect("production HTTP client should build"));
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO peers (did, http_url, last_seen, last_ping_ok, announced_at)
|
||||
VALUES ($1, $2, $3, TRUE, $3)",
|
||||
)
|
||||
.bind(&did)
|
||||
.bind(server.url())
|
||||
.bind(now)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = Router::new()
|
||||
.route("/api/v1/peers/{did}/ping", get(super::ping_peer))
|
||||
.with_state(state.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/api/v1/peers/{did}/ping"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["reachable"], false);
|
||||
ready.assert_async().await;
|
||||
health.assert_async().await;
|
||||
|
||||
let peer = state
|
||||
.db
|
||||
.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|peer| peer.did == did)
|
||||
.unwrap();
|
||||
assert!(
|
||||
peer.last_ping_ok,
|
||||
"an anonymous manual sample must not rewrite the federation gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_notify_unsigned_flood_is_throttled(pool: PgPool) {
|
||||
// notify still accepts an unsigned known-peer notification (rolling-upgrade
|
||||
@@ -1155,6 +1276,57 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// U1 (#273). An unsigned announce must not repoint an existing peer's row.
|
||||
///
|
||||
/// Kills the unconditional `ON CONFLICT(did) DO UPDATE SET http_url = $2` in
|
||||
/// `Db::upsert_peer`. The failure message carries BOTH observed values,
|
||||
/// because the gate is the observed rewrite (2xx AND the attacker URL
|
||||
/// stored), not merely a failing assertion: a 429 from the peer-write brake,
|
||||
/// a fixture URL `is_public_http_url` rejects, or a DB error would all read
|
||||
/// as "assertion failed" while proving nothing about this defect.
|
||||
#[sqlx::test]
|
||||
async fn unsigned_announce_cannot_repoint_an_existing_peer(pool: PgPool) {
|
||||
const HONEST: &str = "https://honest-peer.example.com";
|
||||
const ATTACKER: &str = "https://attacker.example.com";
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
state
|
||||
.db
|
||||
.upsert_peer(&did, HONEST, crate::db::PeerWriteAuthority::Unproven)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored_url = |peers: Vec<crate::db::PeerRecord>| {
|
||||
peers.into_iter().find(|p| p.did == did).map(|p| p.http_url)
|
||||
};
|
||||
assert_eq!(
|
||||
stored_url(state.db.list_peers().await.unwrap()).as_deref(),
|
||||
Some(HONEST),
|
||||
"seed did not take; the case below would pass vacuously"
|
||||
);
|
||||
|
||||
// Clone before build_router consumes state, so the row can be read back.
|
||||
let db = state.db.clone();
|
||||
let body = format!(r#"{{"did":"{did}","http_url":"{ATTACKER}"}}"#);
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.77:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let stored = stored_url(db.list_peers().await.unwrap())
|
||||
.expect("peer row vanished; neither the allowed nor the denied outcome");
|
||||
|
||||
assert_eq!(
|
||||
stored, HONEST,
|
||||
"unsigned announce repointed an existing peer (observed status {status}, stored http_url {stored})"
|
||||
);
|
||||
}
|
||||
|
||||
// Build a request carrying a REAL RFC 9421 signature (not just an injected
|
||||
// extension), so it passes require_signature on the full production router.
|
||||
fn real_signed_trigger(peer: &str) -> Request<Body> {
|
||||
@@ -1265,4 +1437,607 @@ mod tests {
|
||||
"a 429 on a sync route must not say 'push': {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── #273: the announce authority contract ─────────────────────────────────
|
||||
//
|
||||
// U4 drives the handler mounted alone, because signed_request_as injects an
|
||||
// AuthenticatedDid extension without a real signature and require_signature
|
||||
// would reject that on the full router. U5 then proves the same properties
|
||||
// through build_router with REAL RFC 9421 signatures, where a signature can
|
||||
// be pointed at a row it does not own.
|
||||
|
||||
const OWNER_URL: &str = "https://owner-peer.example.com";
|
||||
const MOVED_URL: &str = "https://moved-peer.example.com";
|
||||
const HIJACK_URL: &str = "https://hijacker.example.com";
|
||||
const WEB_DID: &str = "did:web:squatter.example.com";
|
||||
const KEYID_MISMATCH: &str = "Signature keyid must match announced DID";
|
||||
|
||||
/// Every column a consumer sees, so a case that claims "unchanged" compares
|
||||
/// the whole row rather than the one field it happened to think of.
|
||||
type PeerSnapshot = (String, String, Option<String>, bool, String);
|
||||
|
||||
async fn snapshot(db: &crate::db::Db, did: &str) -> Option<PeerSnapshot> {
|
||||
db.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.did == did)
|
||||
.map(|p| {
|
||||
(
|
||||
p.did,
|
||||
p.http_url,
|
||||
p.last_seen,
|
||||
p.last_ping_ok,
|
||||
p.announced_at,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed a peer row that has EARNED reachability, asserting the seed took,
|
||||
/// so no case below can pass vacuously against a row that was never written
|
||||
/// or against a flag that was already false. Without the granted ping a
|
||||
/// "must be unreachable after this" assertion compares false to false and
|
||||
/// stays green with the production code that clears the flag removed.
|
||||
async fn seed_peer_row(state: &AppState, did: &str, url: &str) -> PeerSnapshot {
|
||||
state
|
||||
.db
|
||||
.upsert_peer(did, url, crate::db::PeerWriteAuthority::Unproven)
|
||||
.await
|
||||
.expect("seed");
|
||||
state.db.mark_peer_ping(did, true).await.expect("seed ping");
|
||||
let seeded = snapshot(&state.db, did).await.expect("seed did not take");
|
||||
assert_eq!(seeded.1, url, "seed did not take");
|
||||
assert!(
|
||||
seeded.3,
|
||||
"the seeded row must start reachable, or a later unreachable assertion is vacuous"
|
||||
);
|
||||
seeded
|
||||
}
|
||||
|
||||
/// Status, error code, and message together, so a denial is asserted by its
|
||||
/// exact class and stated reason rather than by "not 2xx".
|
||||
async fn status_and_error(resp: axum::response::Response) -> (StatusCode, String, String) {
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"error body must be JSON: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
});
|
||||
let field = |k: &str| v[k].as_str().unwrap_or_default().to_string();
|
||||
(status, field("error"), field("message"))
|
||||
}
|
||||
|
||||
fn announce_body(did: &str, url: &str) -> String {
|
||||
format!(r#"{{"did":"{did}","http_url":"{url}"}}"#)
|
||||
}
|
||||
|
||||
fn announce_only(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/v1/peers/announce", post(super::announce))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn announce_as(did: &str, body: &str) -> Request<Body> {
|
||||
signed_request_as(
|
||||
did,
|
||||
Method::POST,
|
||||
"/api/v1/peers/announce",
|
||||
Body::from(body.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// U4 scenario 1, and the first test the keyid branch has ever had: a caller
|
||||
/// who proved control of one DID must not announce another's. Kills
|
||||
/// neutralizing the handler's keyid comparison, and kills demoting this
|
||||
/// denial back to the 400 validation class where the unsigned repoint below
|
||||
/// is a 403 for the same reason.
|
||||
#[sqlx::test]
|
||||
async fn announce_keyid_mismatch_is_a_403_denial(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let hijacker = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(&hijacker, &announce_body(&victim, HIJACK_URL)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"a proof spent on another DID's row is an authority refusal: {message}"
|
||||
);
|
||||
assert_eq!(code, "forbidden", "denial body must name the 403 class");
|
||||
assert!(
|
||||
message.contains(KEYID_MISMATCH),
|
||||
"the denial must name the keyid mismatch, got {message:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&state.db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a refused announce"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenario 2: the allowed signed case. A caller whose keyid IS the row
|
||||
/// repoints it. Kills a gate that refuses proven writes too.
|
||||
#[sqlx::test]
|
||||
async fn announce_with_matching_keyid_repoints_its_own_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate().did().to_string();
|
||||
seed_peer_row(&state, &owner, OWNER_URL).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(&owner, &announce_body(&owner, MOVED_URL)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let after = snapshot(&state.db, &owner).await.expect("row vanished");
|
||||
assert_eq!(after.1, MOVED_URL, "a proven repoint must land");
|
||||
}
|
||||
|
||||
/// U4 scenario 3: an unsigned caller cannot move an existing row, and the
|
||||
/// refusal renders as a 403 with its reason, never as a 500 or as success.
|
||||
#[sqlx::test]
|
||||
async fn announce_unsigned_repoint_is_a_403_denial(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&victim, HIJACK_URL),
|
||||
"203.0.113.90:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"an unproven repoint is an authority refusal: {message}"
|
||||
);
|
||||
assert_eq!(code, "forbidden", "denial body must name the 403 class");
|
||||
assert!(
|
||||
message.contains("unproven announce cannot change an existing peer"),
|
||||
"the denial must name the repoint refusal, got {message:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&state.db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a refused repoint"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenarios 4 and 5, the allowed unsigned class: discovery of an unseen
|
||||
/// did:key still inserts, and an honest peer re-announcing the IDENTICAL URL
|
||||
/// still refreshes liveness. The URL is reused byte for byte because the
|
||||
/// comparison is exact, so a trailing slash would make this pass as a
|
||||
/// repoint rejection instead. Kills a gate widened until it breaks
|
||||
/// discovery or honest re-announces.
|
||||
#[sqlx::test]
|
||||
async fn announce_unsigned_insert_and_identical_url_reannounce_are_accepted(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let body = announce_body(&did, OWNER_URL);
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.91:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "discovery must stay open");
|
||||
let before = snapshot(&state.db, &did).await.expect("row not inserted");
|
||||
assert_eq!(before.1, OWNER_URL);
|
||||
assert!(!before.3, "a never-probed peer inserts unreachable");
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.92:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"an identical-URL re-announce is a liveness signal, not a repoint"
|
||||
);
|
||||
let after = snapshot(&state.db, &did).await.expect("row vanished");
|
||||
assert_eq!(after.1, OWNER_URL, "URL untouched");
|
||||
assert_eq!(after.3, before.3, "reachability untouched");
|
||||
assert_eq!(after.4, before.4, "announced_at untouched");
|
||||
let parse = |s: &Option<String>| {
|
||||
chrono::DateTime::parse_from_rfc3339(s.as_deref().expect("last_seen is set")).unwrap()
|
||||
};
|
||||
assert!(
|
||||
parse(&after.2) > parse(&before.2),
|
||||
"a same-URL re-announce must advance last_seen"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenario 6 (R8): a DID method that can never authenticate is refused
|
||||
/// in the 400 validation class, with nothing written. This one IS about the
|
||||
/// input's form, not the caller's authority, which is why it is not a 403.
|
||||
#[sqlx::test]
|
||||
async fn announce_of_a_did_web_is_a_400_validation_failure(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(WEB_DID, OWNER_URL),
|
||||
"203.0.113.93:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unsupported DID method is a validation failure: {message}"
|
||||
);
|
||||
assert_eq!(code, "bad_request");
|
||||
assert!(
|
||||
message.contains("methodNotSupported"),
|
||||
"the rejection must carry the methodNotSupported shape, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, WEB_DID).await.is_none(),
|
||||
"a rejected announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sibling of the case above, and the one it must not swallow: the
|
||||
/// method IS did:key, so methodNotSupported would be a false statement
|
||||
/// about the input. The value is the W3C secp256k1 did:key test vector,
|
||||
/// which decodes cleanly and is simply not an ed25519 key. Still the 400
|
||||
/// validation class, still nothing written.
|
||||
#[sqlx::test]
|
||||
async fn announce_of_an_unresolvable_did_key_is_a_400_with_the_cause(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme";
|
||||
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(did, OWNER_URL),
|
||||
"203.0.113.99:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unresolvable did:key is a validation failure: {message}"
|
||||
);
|
||||
assert_eq!(
|
||||
code, "unresolvable_did",
|
||||
"the code must name the cause, the same way the signed path does for a keyid it \
|
||||
cannot resolve; a client distinguishing this from the other 400s on this route \
|
||||
must not have to substring-match the message: {message:?}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("cannot resolve DID"),
|
||||
"the rejection must report resolution, not method support, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, did).await.is_none(),
|
||||
"a rejected announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
// ── U5: the same matrix through the production router, real signatures ────
|
||||
|
||||
/// An announce carrying a REAL RFC 9421 signature, signed AS `kp` while the
|
||||
/// body names whatever DID the caller chooses. That split is the point: it
|
||||
/// is what lets a valid signature be pointed at a row it does not own,
|
||||
/// which is the RUSTSEC-2022-0009 shape.
|
||||
fn real_signed_announce(kp: &Keypair, body: &str, peer: &str) -> Request<Body> {
|
||||
let path = "/api/v1/peers/announce";
|
||||
let s = sign_request(kp, "POST", path, body.as_bytes());
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(path)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("content-digest", s.content_digest)
|
||||
.header("signature-input", s.signature_input)
|
||||
.header("signature", s.signature)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
|
||||
req
|
||||
}
|
||||
|
||||
/// U5 scenario 1: the lowest-privilege caller there is, fully
|
||||
/// unauthenticated, still seeds an unseen did:key row through the whole
|
||||
/// production stack.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_announce_inserts_an_unseen_did(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&did, OWNER_URL),
|
||||
"203.0.113.94:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let row = snapshot(&db, &did).await.expect("row not inserted");
|
||||
assert_eq!(row.1, OWNER_URL);
|
||||
}
|
||||
|
||||
/// U5 scenario 2: the defect this change closes, driven end to end. An
|
||||
/// unsigned repoint is refused with its reason and the row does not move.
|
||||
/// Kills reverting the unproven arm to an unconditional DO UPDATE.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_announce_cannot_repoint_an_existing_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&victim, HIJACK_URL),
|
||||
"203.0.113.95:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The row assertion leads: the property is that the row does not move.
|
||||
// A response-class regression is a second, separate failure below.
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
snapshot(&db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a refused unsigned repoint \
|
||||
(observed status {status})"
|
||||
);
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "observed body: {message}");
|
||||
assert_eq!(code, "forbidden");
|
||||
}
|
||||
|
||||
/// U5 scenario 3: the allowed signed case through the real verification
|
||||
/// middleware. The row was seeded under this keypair's own did:key, so the
|
||||
/// signature names the row it moves.
|
||||
#[sqlx::test]
|
||||
async fn router_signature_from_the_rows_own_did_repoints_it(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
seed_peer_row(&state, &owner_did, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(real_signed_announce(
|
||||
&owner,
|
||||
&announce_body(&owner_did, MOVED_URL),
|
||||
"203.0.113.96:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a real signature from the row's own DID must be admitted"
|
||||
);
|
||||
let row = snapshot(&db, &owner_did).await.expect("row vanished");
|
||||
assert_eq!(row.1, MOVED_URL, "a proven repoint must land");
|
||||
assert!(
|
||||
!row.3,
|
||||
"a repointed peer must re-earn reachability even when the repoint is signed"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 scenario 4, the RUSTSEC-2022-0009 shape: a signature that verifies
|
||||
/// perfectly, spent on a row it does not own. The key must be bound to the
|
||||
/// ROW, not to anything the request carries. The assertion names the keyid
|
||||
/// denial specifically, because the boundary's own DID comparison would
|
||||
/// also refuse this write, so a status-only assertion would stay green with
|
||||
/// the handler branch neutralized.
|
||||
#[sqlx::test]
|
||||
async fn router_signature_from_another_did_cannot_repoint_this_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let victim = Keypair::generate().did().to_string();
|
||||
let hijacker = Keypair::generate();
|
||||
let before = seed_peer_row(&state, &victim, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(real_signed_announce(
|
||||
&hijacker,
|
||||
&announce_body(&victim, HIJACK_URL),
|
||||
"203.0.113.97:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"a valid signature over another DID's row must be refused: {message}"
|
||||
);
|
||||
assert_eq!(code, "forbidden");
|
||||
assert!(
|
||||
message.contains(KEYID_MISMATCH),
|
||||
"the denial must name the keyid mismatch, got {message:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&db, &victim).await.expect("row vanished"),
|
||||
before,
|
||||
"the stored row must be byte-identical after a mismatched signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 scenario 5: an honest unsigned peer re-announcing the identical URL
|
||||
/// through the full stack refreshes last_seen and moves nothing else. The
|
||||
/// seeded string is reused byte for byte, since the comparison is exact.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_identical_url_reannounce_refreshes_last_seen(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let before = seed_peer_row(&state, &did, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&did, OWNER_URL),
|
||||
"203.0.113.98:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let after = snapshot(&db, &did).await.expect("row vanished");
|
||||
assert_eq!(after.1, OWNER_URL, "URL untouched");
|
||||
assert_eq!(after.3, before.3, "reachability untouched");
|
||||
assert_eq!(after.4, before.4, "announced_at untouched");
|
||||
let parse = |s: &Option<String>| {
|
||||
chrono::DateTime::parse_from_rfc3339(s.as_deref().expect("last_seen is set")).unwrap()
|
||||
};
|
||||
assert!(
|
||||
parse(&after.2) > parse(&before.2),
|
||||
"a same-URL re-announce must advance last_seen"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 scenario 6 (R8) through the full stack: a did:web announce is refused
|
||||
/// in the validation class and writes nothing. Such a row could never be
|
||||
/// corrected by anyone, since only a did:key can ever satisfy the proven
|
||||
/// path. Kills neutralizing the method gate on the unproven arm.
|
||||
#[sqlx::test]
|
||||
async fn router_unsigned_announce_of_a_did_web_is_refused(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let db = state.db.clone();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(WEB_DID, OWNER_URL),
|
||||
"203.0.113.99:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, code, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unsupported DID method must be refused: {message}"
|
||||
);
|
||||
assert_eq!(code, "bad_request");
|
||||
assert!(
|
||||
message.contains("methodNotSupported"),
|
||||
"the rejection must carry the methodNotSupported shape, got {message:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&db, WEB_DID).await.is_none(),
|
||||
"a rejected announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5, flag-on mode: with require_signed_peer_writes the write group moves
|
||||
/// under add_auth_layers, so the unsigned caller dies at the auth layer with
|
||||
/// a 401 instead of reaching the handler's 403. The authority rules
|
||||
/// themselves do not change: a signature over another DID's row is still
|
||||
/// refused, and the row's own key still moves it. The flag is applied BEFORE
|
||||
/// build_router, which reads it at build time.
|
||||
#[sqlx::test]
|
||||
async fn announce_authority_matrix_in_signed_writes_mode(pool: PgPool) {
|
||||
let mut state = test_state(pool).await;
|
||||
require_signed_peer_writes(&mut state);
|
||||
let owner = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let hijacker = Keypair::generate();
|
||||
let before = seed_peer_row(&state, &owner_did, OWNER_URL).await;
|
||||
let db = state.db.clone();
|
||||
let router = crate::server::build_router(state);
|
||||
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&announce_body(&owner_did, HIJACK_URL),
|
||||
"203.0.113.100:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"with the flag on, an unsigned announce dies at the auth layer"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&db, &owner_did).await.expect("row vanished"),
|
||||
before,
|
||||
"the row must be byte-identical after the 401"
|
||||
);
|
||||
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(real_signed_announce(
|
||||
&hijacker,
|
||||
&announce_body(&owner_did, HIJACK_URL),
|
||||
"203.0.113.101:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (status, _, message) = status_and_error(resp).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"a signature over another DID's row is still refused: {message}"
|
||||
);
|
||||
assert!(message.contains(KEYID_MISMATCH), "got {message:?}");
|
||||
assert_eq!(
|
||||
snapshot(&db, &owner_did).await.expect("row vanished"),
|
||||
before,
|
||||
"the row must be byte-identical after the mismatched signature"
|
||||
);
|
||||
|
||||
let resp = router
|
||||
.oneshot(real_signed_announce(
|
||||
&owner,
|
||||
&announce_body(&owner_did, MOVED_URL),
|
||||
"203.0.113.102:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the row's own key still moves it with the flag on"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&db, &owner_did).await.expect("row vanished").1,
|
||||
MOVED_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
+7136
-178
File diff suppressed because it is too large.
Load diff
@@ -520,6 +520,22 @@ mod tests {
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
encrypt_inflight: crate::state::EncryptInflight::new(),
|
||||
repo_write_leases: crate::state::RepoWriteLeases::new(8),
|
||||
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
git_push_advert_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_ipfs_walk_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
git_bin: "git".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Upper bound on `git_service_timeout_secs` and `ipfs_request_budget_secs`, in seconds
|
||||
/// (100 years).
|
||||
///
|
||||
/// Two consumers now, so a future tightening moves both. `ipfs_request_budget_secs`
|
||||
/// derives only the `Instant` addition in `get_by_cid`, not the lease-steal multiply
|
||||
/// below, but it shares this ceiling because the defect class and the "set it very large
|
||||
/// to disable" contract are the same.
|
||||
///
|
||||
/// The knob is not just stored, it is arithmetic input: the write path derives the
|
||||
/// per-repo lease steal bound from it (`* 2 + 60`), and #174 routed it into
|
||||
/// `build_filtered_pack` and `blob_paths`, which each build a deadline as
|
||||
/// `Instant::now() + Duration::from_secs(this)`. That addition panics on overflow in
|
||||
/// RELEASE as well as debug, so an unbounded `u64` here turns an operator typo into a
|
||||
/// serve-path crash rather than a very long timeout. Bounding at parse time keeps every
|
||||
/// derived duration in range at once, instead of hardening each site as it is found.
|
||||
///
|
||||
/// The ceiling is representability, NOT a policy view of a sane timeout, and that
|
||||
/// distinction is what sets the number. The help text has always told operators to set
|
||||
/// this very large to disable the bound, so values like `999999999` (~31 years) are
|
||||
/// working production "off" settings; a tighter, tidier cap would fail those nodes at
|
||||
/// boot on upgrade over a value that was never the defect. 100 years clears every such
|
||||
/// setting while staying a factor of about 5.85 under the ~584-year ceiling of a
|
||||
/// `u64`-nanosecond `Instant`, which is the tightest representation on any platform we
|
||||
/// build for. Every value that worked before still parses; only the ones that would have
|
||||
/// panicked are rejected.
|
||||
pub const GIT_SERVICE_TIMEOUT_SECS_MAX: u64 = 100 * 365 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
|
||||
pub struct Config {
|
||||
@@ -170,26 +197,54 @@ pub struct Config {
|
||||
/// Maximum wall-clock time a single served git operation (upload-pack /
|
||||
/// receive-pack through `run_git_service`) may run before it is aborted and
|
||||
/// its process group torn down, in seconds. Bounds a git that neither
|
||||
/// finishes nor disconnects. Must be positive; set it very large to
|
||||
/// effectively disable the bound. Default: 600s (10 min), generous for large
|
||||
/// clones. Does not cover the ref advertisement (`info/refs`) or the
|
||||
/// withheld-blob fetch path (`upload_pack_excluding`, a blocking
|
||||
/// `spawn_blocking` a tokio timeout cannot cancel); both remain unbounded.
|
||||
/// finishes nor disconnects. Must be positive and at most
|
||||
/// [`GIT_SERVICE_TIMEOUT_SECS_MAX`] (100 years, the largest value every derived
|
||||
/// deadline can represent); setting it very large is still the way to disable the
|
||||
/// bound. Default: 600s (10 min), generous for large clones. Also bounds the ref
|
||||
/// advertisement
|
||||
/// (`info/refs`) and the withheld-blob pack build (`upload_pack_excluding`'s
|
||||
/// pack-objects stage), which now share the same timeout + process-group
|
||||
/// teardown (#174).
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_GIT_SERVICE_TIMEOUT_SECS",
|
||||
default_value_t = 600,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
value_parser = clap::value_parser!(u64).range(1..=GIT_SERVICE_TIMEOUT_SECS_MAX)
|
||||
)]
|
||||
pub git_service_timeout_secs: u64,
|
||||
|
||||
/// Maximum wall-clock time the storage-acquisition phase of a served git
|
||||
/// operation may run before the request is shed with a 503, in seconds. This
|
||||
/// bounds `RepoStore::{acquire,acquire_fresh,acquire_write}` — the Tigris
|
||||
/// HEAD/GET on a read/advert acquire and the advisory-lock retry loop (incl. a
|
||||
/// per-iteration `pg_try_advisory_lock` that can block on a hung Postgres pool)
|
||||
/// on a write acquire. A concurrency permit is taken BEFORE this phase, and
|
||||
/// `git_service_timeout_secs` only starts once git spawns, so without this the
|
||||
/// acquire phase is unbounded: a stalled backend pins the permit and drains the
|
||||
/// pool until every later request 503s. On expiry the permit is released and a
|
||||
/// bounded 503 + Retry-After is returned (fail-closed). Kept separate from
|
||||
/// `git_service_timeout_secs` because acquisition and git execution are distinct
|
||||
/// cost centers — one shared budget would let a slow acquire starve git. Must be
|
||||
/// positive; set it very large to effectively disable the bound. Default: 30s.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS",
|
||||
default_value_t = 30,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub git_acquire_timeout_secs: u64,
|
||||
|
||||
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
|
||||
/// (connections open lazily). Size against the database server's
|
||||
/// max_connections, remembering admin tooling opens its own pool.
|
||||
/// max_connections, remembering admin tooling opens its own pool. Each
|
||||
/// concurrent write pins one pooled connection for its whole duration (the
|
||||
/// advisory lock in `repo_store::acquire_write` is connection-affine), so this
|
||||
/// must exceed `max_concurrent_git_pushes` by `DB_POOL_APP_HEADROOM` or slow
|
||||
/// pushes starve every other DB path — enforced by `Config::validate`.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_MAX_CONNECTIONS",
|
||||
default_value_t = 20,
|
||||
default_value_t = 48,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub db_max_connections: u32,
|
||||
@@ -234,6 +289,256 @@ pub struct Config {
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_retry_max_secs: u64,
|
||||
|
||||
/// Maximum number of served git operations (upload-pack / receive-pack /
|
||||
/// info-refs) allowed to run concurrently. Beyond this the node sheds the
|
||||
/// request with a clean 503 + Retry-After instead of spawning another git
|
||||
/// subprocess and risking PID/thread exhaustion. Portable backstop: the
|
||||
/// compose `pids_limit` is not present on Fly, whose connection-concurrency
|
||||
/// cap is a different axis (500 connections each fan out to git +
|
||||
/// pack-objects + threads). Size below the process budget with headroom.
|
||||
///
|
||||
/// This is the READ pool (`git_read_semaphore`): upload-pack and the UPLOAD-PACK
|
||||
/// `info/refs` advertisement only. The authenticated push POST draws from a
|
||||
/// separate write pool (`max_concurrent_git_pushes`) that anonymous reads can
|
||||
/// never reach, and each read caller is additionally bounded by
|
||||
/// `max_concurrent_reads_per_caller`, so an anonymous flood cannot shed the actual
|
||||
/// push nor monopolize reads (#174). The anon-reachable RECEIVE-PACK `info/refs`
|
||||
/// advertisement draws from its OWN dedicated pool (sized like the write pool but
|
||||
/// disjoint), so an advertisement flood can never occupy a permit the
|
||||
/// authenticated push POST needs at admission (#174).
|
||||
///
|
||||
/// A permit is held for the whole op. Every git subprocess that STREAMS is
|
||||
/// duration-bounded and reaps its process group on disconnect: upload-pack,
|
||||
/// receive-pack, and both info/refs advertisements run under
|
||||
/// `git_service_timeout_secs` with `process_group(0)` teardown, and the
|
||||
/// withheld-blob (`upload_pack_excluding`) pack-objects stage plus the push-side
|
||||
/// candidate-discovery children (`rev-list` / `cat-file`) now run under the same
|
||||
/// bounded runner with process-group teardown, so a stuck git child no longer
|
||||
/// holds its slot indefinitely (#174 closed the duration/cancellation gaps this
|
||||
/// comment previously tracked).
|
||||
///
|
||||
/// Default: 128. Must be between 1 and 1_048_576; the ceiling keeps the value
|
||||
/// well under tokio's `Semaphore` permit limit so an oversized value is a
|
||||
/// clean CLI error rather than a boot-time panic.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_GIT_OPS",
|
||||
default_value_t = 128,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub max_concurrent_git_ops: usize,
|
||||
|
||||
/// Maximum number of concurrent `git-receive-pack` (push) operations. The
|
||||
/// authenticated push POST draws from this dedicated pool, separate from
|
||||
/// `max_concurrent_git_ops` (reads), so a flood of anonymous reads cannot shed an
|
||||
/// authenticated push at admission (#174). The anon-reachable receive-pack
|
||||
/// `info/refs` advertisement runs in a SEPARATE pool of the same size (derived
|
||||
/// from this knob), disjoint from this one, so an advertisement flood cannot
|
||||
/// occupy a POST's slot either (#174). Beyond this a push sheds a clean 503 +
|
||||
/// Retry-After.
|
||||
///
|
||||
/// Default: 32. Must be between 1 and 1_048_576 (the ceiling keeps the value
|
||||
/// under tokio's `Semaphore` permit limit so an oversized value is a clean CLI
|
||||
/// error rather than a boot-time panic).
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_GIT_PUSHES",
|
||||
default_value_t = 32,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub max_concurrent_git_pushes: usize,
|
||||
|
||||
/// Maximum number of pushes that may be PARKED on one repo's in-process write lease
|
||||
/// at once. Same-repo pushes are serialized (block-and-wait), and a parked push has
|
||||
/// already had its entire pack buffered by axum, so an unbounded queue on a contended
|
||||
/// repo is unbounded buffered memory held for up to `git_service_timeout_secs * 2 +
|
||||
/// 60` (1260s at defaults). Past this cap the newest push sheds a clean 503 +
|
||||
/// Retry-After instead of joining the queue.
|
||||
///
|
||||
/// The trade: raising it lets more same-repo pushes wait their turn (fewer 503s for a
|
||||
/// hot repo, more memory pinned by waiters); lowering it sheds sooner. Only pushes to
|
||||
/// the SAME repo count, and only ones parked right now, so the cap can never deny a
|
||||
/// push to a different repo. The holder is deliberately not counted: a holder whose
|
||||
/// cleanup never ran would otherwise pin a slot forever and wedge the repo, which is
|
||||
/// the failure the `steal_after` reclaim exists to survive.
|
||||
///
|
||||
/// Default: 8, a quarter of the default `max_concurrent_git_pushes` (32). Raising the
|
||||
/// push pool does not raise this; set it explicitly. Must be between 1 and 1_048_576.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_REPO_LEASE_MAX_WAITERS",
|
||||
default_value_t = 8,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub repo_lease_max_waiters: usize,
|
||||
|
||||
/// Max concurrent post-push pin loops (`ipfs_pin` and `pinata`
|
||||
/// `pin_new_objects`) across all repos. `EncryptInflight` bounds the outstanding
|
||||
/// pin-task COUNT to one per repo, but each pin loop holds a full per-push
|
||||
/// object-id list (up to `git_max_pack_bytes` worth of OIDs) while it walks it,
|
||||
/// so N distinct repos could hold N such lists at once. This caps how many run
|
||||
/// concurrently (#174 F6). Beyond it a pin loop DEFERS (waits) and never drops,
|
||||
/// since a dropped pin would lose the object's replication copy.
|
||||
///
|
||||
/// It does not cap the memory itself: the local IPFS path builds its list before
|
||||
/// taking a permit, so tasks parked on this pool still hold theirs, and how many
|
||||
/// park is capped only per repo. Lowering this knob bounds concurrent pinning, not
|
||||
/// how much an actor pushing to many repos can retain.
|
||||
///
|
||||
/// Default: 8. Must be between 1 and 1_048_576.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_PIN_TASKS",
|
||||
default_value_t = 8,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub max_concurrent_pin_tasks: usize,
|
||||
|
||||
/// Maximum concurrent read operations (`upload-pack` and the upload-pack
|
||||
/// `info/refs` advertisement) a single caller may hold at once, so one caller
|
||||
/// cannot monopolize the `max_concurrent_git_ops` read pool (#174). Callers are
|
||||
/// keyed on the RESOLVED SOURCE IP, never the DID — a signature does not move a
|
||||
/// caller off this cap, so an authenticated client cannot mint DIDs to escape it.
|
||||
/// IMPORTANT: the source-IP key is only as granular as `GITLAWB_TRUSTED_PROXY`.
|
||||
/// Left unset (the default), a node behind an edge/NAT keys all callers on the
|
||||
/// edge IP, so this cap collapses to a single global cap rather than per-client.
|
||||
/// Set `GITLAWB_TRUSTED_PROXY` to key on the real client; a high-fanout caller (a
|
||||
/// CI fleet behind one NAT) then needs the operator to raise this. Over-cap for a
|
||||
/// caller sheds a clean 503 + Retry-After.
|
||||
///
|
||||
/// Default: 16. Must be between 1 and 1_048_576.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_READS_PER_CALLER",
|
||||
default_value_t = 16,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub max_concurrent_reads_per_caller: usize,
|
||||
|
||||
/// Maximum number of concurrent `GET /ipfs/{cid}` requests that may run their
|
||||
/// visibility walk at once. The publicly-reachable `/ipfs/{cid}` route runs
|
||||
/// `allowed_blob_set_for_caller_bounded` in `spawn_blocking` — a full-history
|
||||
/// git walk (up to `git_service_timeout_secs`) — for each candidate repo. It
|
||||
/// draws from THIS pool, not any served-git pool: a distinct public cost center
|
||||
/// on a distinct surface, so sharing a git pool would let anonymous /ipfs
|
||||
/// traffic shed authenticated git ops (the auth-boundary trap). A permit is
|
||||
/// held for the whole request (across the repo loop) so it reflects real
|
||||
/// blocking-thread occupancy, not merely the tokio wait. Beyond this the request
|
||||
/// sheds a clean 503 + Retry-After. Must be between 1 and 1_048_576; the ceiling
|
||||
/// keeps the value under tokio's `Semaphore` permit limit so an oversized value
|
||||
/// is a clean CLI error rather than a boot-time panic. Default: 32.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_IPFS_WALKS",
|
||||
default_value_t = 32,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub max_concurrent_ipfs_walks: usize,
|
||||
|
||||
/// Maximum concurrent `/ipfs/{cid}` walk requests a single source may hold at
|
||||
/// once, so one source cannot monopolize `max_concurrent_ipfs_walks` (#174).
|
||||
/// Callers are keyed on the RESOLVED SOURCE IP (`client_key`/`GITLAWB_TRUSTED_PROXY`),
|
||||
/// never the DID — `/ipfs` accepts any `did:key` via `optional_signature` with no
|
||||
/// admission step, so keying on the DID would let one host mint disposable DIDs to
|
||||
/// multiply its budget. A request with no resolvable key (no trusted header, no
|
||||
/// peer) is bounded by the global pool only, never this sub-cap. Over-cap sheds a
|
||||
/// clean 503 + Retry-After. Must be between 1 and 1_048_576. Default: 4.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_WALK_PER_SOURCE",
|
||||
default_value_t = 4,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub ipfs_walk_per_source: usize,
|
||||
|
||||
/// Upper bound on the number of EXPENSIVE visibility walks
|
||||
/// (`allowed_blob_set_for_caller_bounded`, a full-history git walk in a
|
||||
/// blocking thread) a single `/ipfs/{cid}` request may run. Only a blob in a
|
||||
/// path-scoped repo costs a walk, so the cap counts exactly those candidates
|
||||
/// — cheap probe-only visits are bounded by `ipfs_max_repo_visits` instead
|
||||
/// (counting them here would starve a plain public copy past the cap out of
|
||||
/// its 200). On exhaustion the walk-needing repo is skipped WITHOUT a verdict
|
||||
/// and the scan continues; if the request then finds the object nowhere it
|
||||
/// sheds a retryable 503 + Retry-After rather than misreport existing content
|
||||
/// absent with a 404. The handler still short-circuits the moment it serves.
|
||||
/// Must be between 1 and 1_048_576. Default: 64.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_MAX_REPOS_WALKED",
|
||||
default_value_t = 64,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub ipfs_max_repos_walked: usize,
|
||||
|
||||
/// Ceiling on the number of repos a single `/ipfs/{cid}` request may VISIT —
|
||||
/// pass the repo-level visibility gate into the acquire + `cat-file` probe.
|
||||
/// Each visit costs a `RepoStore::acquire` (on a Tigris cache miss that is a
|
||||
/// full repo-archive download from object storage, so the worst-case
|
||||
/// object-store fetch count for one request equals this ceiling) plus a git
|
||||
/// probe subprocess. On exhaustion the scan STOPS — unlike
|
||||
/// `ipfs_max_repos_walked`, which skips just the walk-needing repo, there is
|
||||
/// no cheaper way to keep scanning — and the request sheds a retryable 503 +
|
||||
/// Retry-After rather than a false 404. Must be between 1 and 1_048_576.
|
||||
/// Default: 1024.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_MAX_REPO_VISITS",
|
||||
default_value_t = 1024,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub ipfs_max_repo_visits: usize,
|
||||
|
||||
/// Absolute wall-clock budget for one admitted `GET /ipfs/{cid}` request's
|
||||
/// acquire+walk lifetime, in seconds. `max_concurrent_ipfs_walks` bounds how
|
||||
/// MANY requests hold walk slots; this bounds how LONG one admitted request
|
||||
/// may keep its slot. Without it, each repo iteration draws a fresh
|
||||
/// `git_acquire_timeout_secs` and each expensive walk a fresh
|
||||
/// `git_service_timeout_secs`, so one request scanning many repos could hold
|
||||
/// a scarce walk slot for hours. Every stage (acquire, `cat-file` probe,
|
||||
/// visibility walk, content read) starts only while budget remains, and the
|
||||
/// acquire wait and walk deadline are clamped to `min(their own timeout,
|
||||
/// remaining budget)`; a stage is never started with zero remaining. On
|
||||
/// exhaustion the scan stops without a verdict and the request sheds a
|
||||
/// retryable 503 + Retry-After rather than a false 404. The clamps bound
|
||||
/// only the acquire and walk stages (overshoot there is the walk watchdog's
|
||||
/// SIGTERM grace + SIGKILL settle); the `object_type` /
|
||||
/// `read_object_content` probe subprocesses are budget-checked before they
|
||||
/// start AND each run under their own deadline (the lesser of
|
||||
/// `git_service_timeout_secs` and the remaining budget), reaped by
|
||||
/// process-group teardown, so a hung `cat-file` cannot hold the request's walk
|
||||
/// slot past it. Still unbounded: the probe's `object_store_readable` check is a
|
||||
/// synchronous filesystem sweep with nothing to reap, so a wedged filesystem can
|
||||
/// hold the slot past the deadline.
|
||||
/// Must be positive, and no larger than `GIT_SERVICE_TIMEOUT_SECS_MAX`. The ceiling is
|
||||
/// representability, NOT a policy view of a sane budget: `get_by_cid` derives the
|
||||
/// request deadline as `Instant::now() + Duration::from_secs(this)`, and that addition
|
||||
/// is an explicit overflow check rather than a debug-only one, so a value near the top
|
||||
/// of the `u64` range aborts every `/ipfs/{cid}` request in a release build instead of
|
||||
/// setting a very long budget. The ceiling sits well below where that starts, about a
|
||||
/// factor of 5.85 (see the constant's own note), so it is a conservative margin rather than the
|
||||
/// exact overflow point; rejecting at parse time keeps the unrepresentable values out
|
||||
/// of every reachable configuration. Setting it very large is still the way to
|
||||
/// effectively disable the budget, and the documented sentinels (`999999999`,
|
||||
/// `1000000000`) are well inside the range.
|
||||
/// Default: 600s (10 min), matching `git_service_timeout_secs` so a single full-length
|
||||
/// walk still fits.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_REQUEST_BUDGET_SECS",
|
||||
default_value_t = 600,
|
||||
value_parser = clap::value_parser!(u64).range(1..=GIT_SERVICE_TIMEOUT_SECS_MAX)
|
||||
)]
|
||||
pub ipfs_request_budget_secs: u64,
|
||||
|
||||
/// Per-client-IP rate limit for `GET /ipfs/{cid}`, in requests per hour. The
|
||||
/// route is publicly reachable (`optional_signature`) 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 `GITLAWB_TRUSTED_PROXY`. `0` disables. Default: 600.
|
||||
#[arg(long, env = "GITLAWB_IPFS_RATE_LIMIT", default_value_t = 600)]
|
||||
pub ipfs_rate_limit: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -250,6 +555,36 @@ impl Config {
|
||||
}
|
||||
PathBuf::from(&self.key_path)
|
||||
}
|
||||
|
||||
/// DB connections reserved for everything other than held write-locks: auth
|
||||
/// lookups, visibility-rule reads, the post-receive tail's own DB writes, and
|
||||
/// admin tooling. A write pins one pooled connection for its whole duration, so
|
||||
/// the pool must clear the concurrent-write cap by at least this margin.
|
||||
pub const DB_POOL_APP_HEADROOM: u32 = 8;
|
||||
|
||||
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
|
||||
/// catches combinations that ship a denial-of-service under otherwise-valid
|
||||
/// values. Call once at startup and fail fast on `Err`.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// A write pins one pooled connection for its whole duration (the
|
||||
// connection-affine advisory lock in repo_store::acquire_write), and
|
||||
// concurrent writes are capped at max_concurrent_git_pushes. If the pool
|
||||
// does not exceed that cap by DB_POOL_APP_HEADROOM, a burst of slow pushes
|
||||
// drains every connection and every other DB path 503s. (#174 F1)
|
||||
let floor = (self.max_concurrent_git_pushes as u64) + (Self::DB_POOL_APP_HEADROOM as u64);
|
||||
if (self.db_max_connections as u64) < floor {
|
||||
return Err(format!(
|
||||
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least max_concurrent_git_pushes ({}) \
|
||||
+ {} headroom = {}: each concurrent write pins one pooled connection for its whole \
|
||||
duration, so a smaller pool lets a burst of slow pushes starve every other DB path.",
|
||||
self.db_max_connections,
|
||||
self.max_concurrent_git_pushes,
|
||||
Self::DB_POOL_APP_HEADROOM,
|
||||
floor
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -272,4 +607,370 @@ mod tests {
|
||||
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 (RED-before/GREEN-after): the upper bound is what keeps every duration
|
||||
/// derived from this knob in range — the lease steal bound's `* 2 + 60` on the write
|
||||
/// path, and the `Instant::now() + Duration::from_secs(..)` deadlines in
|
||||
/// `build_filtered_pack` / `blob_paths` on the serve path, which panic on overflow in
|
||||
/// release builds too. Checked at parse time so no reachable configuration can carry a
|
||||
/// value those sites cannot represent.
|
||||
///
|
||||
/// The pre-existing "set it very large to disable the bound" settings are asserted
|
||||
/// alongside the rejections on purpose. The bound exists to exclude unrepresentable
|
||||
/// values, not to impose a view of a reasonable timeout, so a node that has been
|
||||
/// running on ~31 years must not start failing at boot on upgrade.
|
||||
#[test]
|
||||
fn git_service_timeout_rejects_values_no_derived_duration_can_represent() {
|
||||
let parse = |secs: u64| {
|
||||
Config::try_parse_from([
|
||||
"gitlawb-node",
|
||||
"--git-service-timeout-secs",
|
||||
&secs.to_string(),
|
||||
])
|
||||
};
|
||||
|
||||
// Large "disable the bound" values that predate the ceiling still parse.
|
||||
for disable in [1_000_000_000, 999_999_999] {
|
||||
assert_eq!(
|
||||
parse(disable)
|
||||
.unwrap_or_else(|e| panic!("{disable} was a working setting: {e}"))
|
||||
.git_service_timeout_secs,
|
||||
disable
|
||||
);
|
||||
}
|
||||
|
||||
// At the ceiling: accepted, and every derived duration still fits.
|
||||
let at_max =
|
||||
parse(GIT_SERVICE_TIMEOUT_SECS_MAX).expect("the documented maximum must parse");
|
||||
assert_eq!(
|
||||
at_max.git_service_timeout_secs,
|
||||
GIT_SERVICE_TIMEOUT_SECS_MAX
|
||||
);
|
||||
assert!(at_max
|
||||
.git_service_timeout_secs
|
||||
.checked_mul(2)
|
||||
.and_then(|v| v.checked_add(60))
|
||||
.is_some());
|
||||
assert!(std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(
|
||||
at_max.git_service_timeout_secs
|
||||
))
|
||||
.is_some());
|
||||
|
||||
// Past the ceiling, and the top of the u64 range clap used to accept — the value
|
||||
// that panics `Instant::now() + Duration::from_secs(..)` outright.
|
||||
for over in [GIT_SERVICE_TIMEOUT_SECS_MAX + 1, u64::MAX] {
|
||||
assert!(
|
||||
parse(over).is_err(),
|
||||
"{over} is past the representable ceiling and must be rejected at parse time"
|
||||
);
|
||||
}
|
||||
assert!(std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(u64::MAX))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_pin_tasks_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).max_concurrent_pin_tasks,
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-pin-tasks", "2"])
|
||||
.max_concurrent_pin_tasks,
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-pin-tasks", "0"]).is_err()
|
||||
);
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-pin-tasks", "1048577"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_git_ops_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).max_concurrent_git_ops,
|
||||
128
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-git-ops", "8"])
|
||||
.max_concurrent_git_ops,
|
||||
8
|
||||
);
|
||||
// 0 permits would shed every served-git request with a 503; clap must reject it.
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-ops", "0"]).is_err());
|
||||
// Above the ceiling would panic tokio's Semaphore::new at boot (permits >
|
||||
// usize::MAX >> 3); clap must reject it as a clean CLI error instead.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-ops", "1048577"])
|
||||
.is_err()
|
||||
);
|
||||
// The ceiling itself is accepted.
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-git-ops", "1048576"])
|
||||
.max_concurrent_git_ops,
|
||||
1_048_576
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_git_pushes_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).max_concurrent_git_pushes,
|
||||
32
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "8"])
|
||||
.max_concurrent_git_pushes,
|
||||
8
|
||||
);
|
||||
// 0 permits would shed every push with a 503; clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "0"]).is_err()
|
||||
);
|
||||
// Above the ceiling would panic tokio's Semaphore::new at boot; clap rejects it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "1048577"])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-git-pushes", "1048576"])
|
||||
.max_concurrent_git_pushes,
|
||||
1_048_576
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_ipfs_walks_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).max_concurrent_ipfs_walks,
|
||||
32
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "4"])
|
||||
.max_concurrent_ipfs_walks,
|
||||
4
|
||||
);
|
||||
// 0 permits would shed every /ipfs walk with a 503; clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "0"]).is_err()
|
||||
);
|
||||
// Above the ceiling would panic tokio's Semaphore::new at boot; clap rejects it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "1048577"])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-ipfs-walks", "1048576"])
|
||||
.max_concurrent_ipfs_walks,
|
||||
1_048_576
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_walk_per_source_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(Config::parse_from(["gitlawb-node"]).ipfs_walk_per_source, 4);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--ipfs-walk-per-source", "2"])
|
||||
.ipfs_walk_per_source,
|
||||
2
|
||||
);
|
||||
// 0 would shed every /ipfs walk from a keyed source; clap must reject it.
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--ipfs-walk-per-source", "0"]).is_err());
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--ipfs-walk-per-source", "1048577"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_max_repos_walked_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).ipfs_max_repos_walked,
|
||||
64
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "8"])
|
||||
.ipfs_max_repos_walked,
|
||||
8
|
||||
);
|
||||
// 0 would walk no repos (serve nothing); clap must reject it.
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "0"]).is_err());
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "1048577"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_max_repo_visits_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).ipfs_max_repo_visits,
|
||||
1024
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--ipfs-max-repo-visits", "8"])
|
||||
.ipfs_max_repo_visits,
|
||||
8
|
||||
);
|
||||
// 0 would visit no repos (serve nothing); clap must reject it.
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--ipfs-max-repo-visits", "0"]).is_err());
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--ipfs-max-repo-visits", "1048577"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_request_budget_secs_defaults_to_600_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).ipfs_request_budget_secs,
|
||||
600
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--ipfs-request-budget-secs", "30"])
|
||||
.ipfs_request_budget_secs,
|
||||
30
|
||||
);
|
||||
// 0 would expire every /ipfs request at its first stage (unconditional
|
||||
// 503); clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--ipfs-request-budget-secs", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 (RED-before/GREEN-after): the upper bound is what keeps the deadline derived
|
||||
/// from this knob in range. `get_by_cid` builds the request budget as
|
||||
/// `Instant::now() + Duration::from_secs(this)` (api/ipfs.rs), and that addition is an
|
||||
/// explicit overflow check rather than a debug-only one, so an oversized value aborts
|
||||
/// every `/ipfs/{cid}` request in a release build instead of setting a very long budget.
|
||||
/// The route is anon-reachable, so the failure is operator-triggered but publicly felt.
|
||||
/// Checked at parse time so no reachable configuration can carry a value the deadline
|
||||
/// cannot represent.
|
||||
///
|
||||
/// The large "disable the bound" settings are asserted alongside the rejections on
|
||||
/// purpose, the same way the `git_service_timeout_secs` sibling does it. The ceiling
|
||||
/// exists to exclude unrepresentable values, not to impose a view of a reasonable
|
||||
/// budget, so a node already running on such a value must not start failing at boot on
|
||||
/// upgrade. A test that only checked the boundary would pass with a far tighter cap.
|
||||
#[test]
|
||||
fn ipfs_request_budget_rejects_values_no_derived_duration_can_represent() {
|
||||
let parse = |secs: u64| {
|
||||
Config::try_parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-request-budget-secs",
|
||||
&secs.to_string(),
|
||||
])
|
||||
};
|
||||
|
||||
// Large "disable the bound" values that predate the ceiling still parse.
|
||||
for disable in [1_000_000_000, 999_999_999] {
|
||||
assert_eq!(
|
||||
parse(disable)
|
||||
.unwrap_or_else(|e| panic!("{disable} was a working setting: {e}"))
|
||||
.ipfs_request_budget_secs,
|
||||
disable
|
||||
);
|
||||
}
|
||||
|
||||
// At the ceiling: accepted, and the derived deadline still fits. This knob feeds
|
||||
// only the `Instant` addition (no multiply derivation like the lease steal bound),
|
||||
// so there is no `checked_mul` clause to carry over from the sibling test.
|
||||
let at_max =
|
||||
parse(GIT_SERVICE_TIMEOUT_SECS_MAX).expect("the documented maximum must parse");
|
||||
assert_eq!(
|
||||
at_max.ipfs_request_budget_secs,
|
||||
GIT_SERVICE_TIMEOUT_SECS_MAX
|
||||
);
|
||||
assert!(std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(
|
||||
at_max.ipfs_request_budget_secs
|
||||
))
|
||||
.is_some());
|
||||
|
||||
// Past the ceiling, and the top of the u64 range clap used to accept. Only the
|
||||
// latter actually panics `Instant::now() + Duration::from_secs(..)`; the ceiling
|
||||
// sits well below that, which is the conservative margin the constant documents.
|
||||
for over in [GIT_SERVICE_TIMEOUT_SECS_MAX + 1, u64::MAX] {
|
||||
assert!(
|
||||
parse(over).is_err(),
|
||||
"{over} is past the representable ceiling and must be rejected at parse time"
|
||||
);
|
||||
}
|
||||
assert!(std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(u64::MAX))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_reads_per_caller_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).max_concurrent_reads_per_caller,
|
||||
16
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--max-concurrent-reads-per-caller", "4"])
|
||||
.max_concurrent_reads_per_caller,
|
||||
4
|
||||
);
|
||||
// 0 would shed every read from a keyed caller; clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--max-concurrent-reads-per-caller", "0"])
|
||||
.is_err()
|
||||
);
|
||||
assert!(Config::try_parse_from([
|
||||
"gitlawb-node",
|
||||
"--max-concurrent-reads-per-caller",
|
||||
"1048577"
|
||||
])
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--max-concurrent-reads-per-caller",
|
||||
"1048576"
|
||||
])
|
||||
.max_concurrent_reads_per_caller,
|
||||
1_048_576
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F1: a connection-affine write lock pins a pooled connection per
|
||||
/// concurrent write, so the pool must clear `max_concurrent_git_pushes` by
|
||||
/// `DB_POOL_APP_HEADROOM` or a push burst starves every other DB path.
|
||||
/// `validate()` must reject an under-sized pool at boot.
|
||||
#[test]
|
||||
fn db_pool_must_clear_the_git_push_cap() {
|
||||
// Shipped defaults validate (48 >= 32 + 8).
|
||||
Config::parse_from(["gitlawb-node"])
|
||||
.validate()
|
||||
.expect("default config must validate");
|
||||
|
||||
// An under-sized pool relative to the push cap is rejected (20 < 32 + 8).
|
||||
let under = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"20",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
under.validate().is_err(),
|
||||
"db_max_connections 20 below max_concurrent_git_pushes 32 + headroom must be rejected"
|
||||
);
|
||||
|
||||
// Exactly at the floor validates (40 == 32 + 8).
|
||||
let at_floor = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"40",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
at_floor.validate().is_ok(),
|
||||
"db_max_connections at the floor (pushes + headroom) must validate"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1085
-33
File diff suppressed because it is too large.
Load diff
@@ -170,7 +170,7 @@ pub async fn encrypt_and_pin(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope).await {
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
Ok(_) => {
|
||||
tracing::warn!(oid = %oid, "pin_git_object returned no cid; skipping");
|
||||
|
||||
@@ -35,6 +35,15 @@ pub enum AppError {
|
||||
#[error("invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
/// A DID was well-formed enough to carry to a resolver but no verifying key
|
||||
/// could be derived from it. Its own code rather than plain `bad_request`
|
||||
/// because the auth middleware already answers `unresolvable_did` for the
|
||||
/// same failure on a request's keyid, and a client should not have to
|
||||
/// substring-match a message to tell this apart from the other validation
|
||||
/// failures on the same route.
|
||||
#[error("unresolvable did: {0}")]
|
||||
UnresolvableDid(String),
|
||||
|
||||
#[error("too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
@@ -47,6 +56,9 @@ pub enum AppError {
|
||||
#[error("git service timed out: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("server overloaded: {0}")]
|
||||
Overloaded(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
@@ -60,6 +72,14 @@ pub enum AppError {
|
||||
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
|
||||
pub const DB_UNAVAILABLE_MESSAGE: &str = "database is temporarily unavailable";
|
||||
|
||||
/// Generic client-facing message for `AppError::Internal`. The real error is
|
||||
/// logged server-side; never put sqlx/anyhow detail in the HTTP body (#226).
|
||||
pub const INTERNAL_ERROR_MESSAGE: &str = "an internal error occurred";
|
||||
|
||||
/// Generic client-facing message for non-unavailable `AppError::Db`. Query /
|
||||
/// schema errors stay in logs; the HTTP body must not leak them (#226).
|
||||
pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
@@ -132,6 +152,9 @@ impl IntoResponse for AppError {
|
||||
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
|
||||
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::UnresolvableDid(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "unresolvable_did", msg.clone())
|
||||
}
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
|
||||
}
|
||||
@@ -147,12 +170,35 @@ impl IntoResponse for AppError {
|
||||
DB_UNAVAILABLE_CODE,
|
||||
DB_UNAVAILABLE_MESSAGE.into(),
|
||||
),
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
AppError::Internal(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
e.to_string(),
|
||||
),
|
||||
// 503 with a Retry-After (attached after this match — the shared tail
|
||||
// can't carry per-variant headers). This is the single place Overloaded
|
||||
// becomes a response, so it can never ship a 503 without the retry hint.
|
||||
AppError::Overloaded(msg) => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", msg.clone())
|
||||
}
|
||||
// Opaque body + server log: bare `?` on sqlx paths becomes `AppError::Db`
|
||||
// via `From`, so this arm (not `Internal`) is the common leak for open
|
||||
// routes like GET /api/v1/repos and GET /api/v1/peers (#226).
|
||||
AppError::Db(e) => {
|
||||
tracing::error!(error = %e, "database error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"db_error",
|
||||
DB_ERROR_MESSAGE.into(),
|
||||
)
|
||||
}
|
||||
// Opaque body: handlers that map with `.map_err(AppError::Internal)`
|
||||
// (e.g. GET /ipfs/{cid}) land here; other DB failures usually hit `Db`.
|
||||
// Log `{e:#}` so context-wrapped anyhow chains keep the leaf cause
|
||||
// (Display alone is only the outermost layer; see api/repos.rs).
|
||||
AppError::Internal(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "internal error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
INTERNAL_ERROR_MESSAGE.into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
@@ -160,7 +206,17 @@ impl IntoResponse for AppError {
|
||||
"message": message,
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
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(_)) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
axum::http::HeaderValue::from_static("1"),
|
||||
);
|
||||
}
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,4 +238,93 @@ mod tests {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overloaded_maps_to_503_with_retry_after() {
|
||||
let resp = AppError::Overloaded("x".into()).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
/// #226: raw sqlx/DB detail must never appear in the Internal 500 body.
|
||||
#[tokio::test]
|
||||
async fn internal_error_body_is_opaque() {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let leak = "error returned from database: relation \"repos\" does not exist";
|
||||
let resp = AppError::Internal(anyhow::anyhow!("{leak}")).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
// Exact object: a new `detail` field with different sensitive text must
|
||||
// also fail, not only a repeat of the original error string.
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"error": "internal_error",
|
||||
"message": INTERNAL_ERROR_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// #226: `AppError::Db` query errors (the common `?` path) must also be opaque.
|
||||
#[tokio::test]
|
||||
async fn db_error_body_is_opaque() {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let resp = AppError::Db(sqlx::Error::Protocol(
|
||||
"error returned from database: column \"is_public\" does not exist".into(),
|
||||
))
|
||||
.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"error": "db_error",
|
||||
"message": DB_ERROR_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Connection-level failures must stay 503 `db_unavailable`, not collapse
|
||||
/// into the opaque 500 `db_error` arm if `db_unavailable` loses a variant.
|
||||
#[tokio::test]
|
||||
async fn db_pool_timeout_stays_503_unavailable() {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let resp = AppError::Db(sqlx::Error::PoolTimedOut).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"error": DB_UNAVAILABLE_CODE,
|
||||
"message": DB_UNAVAILABLE_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// #251: bare `?` on anyhow-wrapped sqlx relies on this downcast so a
|
||||
/// closed pool becomes 503 `db_unavailable`, not 500 `internal_error`.
|
||||
#[test]
|
||||
fn pool_closed_via_anyhow_from_is_503_db_unavailable() {
|
||||
let err: AppError = anyhow::Error::from(sqlx::Error::PoolClosed).into();
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,9 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Env var that forces the push path to always full-scan, bypassing the delta
|
||||
/// optimization (KTD7 kill-switch). Reuses the already-tested fallback branch
|
||||
@@ -64,11 +64,21 @@ pub enum PinCandidates {
|
||||
/// Fail-closed: any condition where the introduced set cannot be safely
|
||||
/// determined returns [`PinCandidates::FullScanRequired`] rather than a partial
|
||||
/// set, so the caller full-scans instead of silently under-pinning.
|
||||
pub fn resolve_push_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> PinCandidates {
|
||||
// KTD7 kill-switch: force the (already-tested) full-scan fallback. The env
|
||||
// read is split out from the pure logic so the resolver stays unit-testable
|
||||
// without touching process-global state.
|
||||
resolve_push_delta_inner(repo_path, new_tips, old_tips, force_full_scan())
|
||||
pub fn resolve_push_delta(
|
||||
repo_path: &Path,
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> PinCandidates {
|
||||
resolve_push_delta_inner(
|
||||
repo_path,
|
||||
new_tips,
|
||||
old_tips,
|
||||
force_full_scan(),
|
||||
git_bin,
|
||||
deadline,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the force-full-scan kill-switch env var is set.
|
||||
@@ -83,6 +93,8 @@ fn resolve_push_delta_inner(
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
force_full_scan: bool,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> PinCandidates {
|
||||
if force_full_scan {
|
||||
tracing::debug!("{FORCE_FULL_SCAN_ENV} set — forcing full scan");
|
||||
@@ -102,7 +114,7 @@ fn resolve_push_delta_inner(
|
||||
// commit). Bare `cat-file -t` returns `tag` for an annotated tag, and
|
||||
// `for-each-ref %(*objecttype)` peels only one level — neither is correct.
|
||||
for tip in new_tips {
|
||||
match peeled_object_type(repo_path, tip) {
|
||||
match peeled_object_type(repo_path, tip, git_bin, deadline) {
|
||||
Some(t) if t == "commit" => {}
|
||||
other => {
|
||||
tracing::debug!(
|
||||
@@ -115,7 +127,7 @@ fn resolve_push_delta_inner(
|
||||
}
|
||||
}
|
||||
|
||||
match rev_list_delta(repo_path, new_tips, old_tips) {
|
||||
match rev_list_delta(repo_path, new_tips, old_tips, git_bin, deadline) {
|
||||
Ok(oids) => PinCandidates::Delta(oids),
|
||||
Err(e) => {
|
||||
tracing::debug!(err = %e, "push-delta rev-list failed — forcing full scan");
|
||||
@@ -126,41 +138,43 @@ fn resolve_push_delta_inner(
|
||||
|
||||
/// Return the fully-peeled object type of `sha` (e.g. `commit`, `tree`,
|
||||
/// `blob`), or `None` if the object is missing/unpeelable or git errored.
|
||||
fn peeled_object_type(repo_path: &Path, sha: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["cat-file", "-t", &format!("{sha}^{{}}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
fn peeled_object_type(
|
||||
repo_path: &Path,
|
||||
sha: &str,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
let peel = format!("{sha}^{{}}");
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "-t", &peel],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)
|
||||
.ok()?;
|
||||
Some(String::from_utf8_lossy(&out).trim().to_string())
|
||||
}
|
||||
|
||||
/// Run `git rev-list --objects --no-object-names <new> --not <old>` and return
|
||||
/// the bare OID set. Decides on `status.success()` *before* parsing stdout, so
|
||||
/// a walk that prints a valid prefix then errors mid-walk is discarded.
|
||||
fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Result<Vec<String>> {
|
||||
fn rev_list_delta(
|
||||
repo_path: &Path,
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut args: Vec<&str> = vec!["rev-list", "--objects", "--no-object-names"];
|
||||
args.extend_from_slice(new_tips);
|
||||
if !old_tips.is_empty() {
|
||||
args.push("--not");
|
||||
args.extend_from_slice(old_tips);
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git rev-list for push delta")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git rev-list failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let out =
|
||||
crate::git::visibility_pack::run_bounded_git(git_bin, &args, repo_path, b"", deadline)?;
|
||||
let stdout = String::from_utf8_lossy(&out);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
@@ -175,23 +189,19 @@ fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Res
|
||||
/// reconciliation sweep relies on. It returns *all* objects (including
|
||||
/// unreachable/dangling ones), which is what the sweep needs to catch
|
||||
/// stragglers — do not swap it for a reachability walk.
|
||||
pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<Vec<String>> {
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&[
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let stdout = String::from_utf8_lossy(&out);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
@@ -203,23 +213,23 @@ pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
|
||||
/// `--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.
|
||||
pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
pub fn list_all_objects_with_type(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&[
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname) %(objecttype)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let stdout = String::from_utf8_lossy(&out);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
@@ -235,8 +245,12 @@ pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, Strin
|
||||
/// 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).
|
||||
pub fn all_blob_oids(repo_path: &Path) -> Result<HashSet<String>> {
|
||||
Ok(list_all_objects_with_type(repo_path)?
|
||||
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)
|
||||
@@ -268,22 +282,68 @@ pub struct PinCandidateSet {
|
||||
/// it can never leak because the withheld/fail-closed filter still runs on
|
||||
/// whatever set is returned. `full_scan` rides on the returned set so the caller
|
||||
/// knows when the dangling-inclusive filter is required.
|
||||
///
|
||||
/// `scan_sem` is the post-receive scan admission pool (`git_encrypt_semaphore`,
|
||||
/// #174 F4): both git-spawning stages — the per-tip `cat-file` probe + delta
|
||||
/// rev-list, and the full-scan fallback — run under ONE permit held for the
|
||||
/// whole blocking scan. The deletion-only fast path (no new tips) spawns no git
|
||||
/// and never parks.
|
||||
///
|
||||
/// `force_full_scan` forces the full-scan fallback regardless of the tips — the
|
||||
/// coalesced-drain `PendingWork::FullScan` marker (#174 F5). It composes with
|
||||
/// the env kill-switch (either forces), and it disqualifies the empty-tips fast
|
||||
/// path: a forced-scan call with no tips must still enumerate the repo, never
|
||||
/// silently resolve to an empty delta (which would pin nothing and lose the
|
||||
/// drained pushes' work — the F5 bug shape).
|
||||
pub async fn resolve_candidates_for_push(
|
||||
scan_sem: std::sync::Arc<tokio::sync::Semaphore>,
|
||||
repo_path: PathBuf,
|
||||
new_tips: Vec<String>,
|
||||
old_tips: Vec<String>,
|
||||
git_bin: String,
|
||||
timeout: Duration,
|
||||
force_full_scan: bool,
|
||||
) -> PinCandidateSet {
|
||||
let force = force_full_scan || self::force_full_scan();
|
||||
// No-git fast path: a deletion-only push resolves to an empty delta without
|
||||
// spawning any git child, so it must not park on the scan pool. A forced
|
||||
// full scan (caller flag or kill-switch) disqualifies it — the scan spawns git.
|
||||
if new_tips.is_empty() && !force {
|
||||
tracing::info!(delta = 0usize, repo = %repo_path.display(), "pin candidate set from push delta");
|
||||
return PinCandidateSet {
|
||||
candidates: Vec::new(),
|
||||
full_scan: false,
|
||||
};
|
||||
}
|
||||
// Scan admission (#174 F4): DEFER, never shed — a dropped scan would
|
||||
// silently under-pin this push. The permit moves into the blocking closure
|
||||
// so a started scan always completes holding it. Residuals at
|
||||
// `acquire_scan_permit`.
|
||||
let permit =
|
||||
crate::state::acquire_scan_permit(scan_sem, &repo_path, "pin-candidate scan").await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
// ONE shared deadline for the whole scan, per jatmn ("the same deadline").
|
||||
let deadline = Instant::now() + timeout;
|
||||
let new_refs: Vec<&str> = new_tips.iter().map(String::as_str).collect();
|
||||
let old_refs: Vec<&str> = old_tips.iter().map(String::as_str).collect();
|
||||
match resolve_push_delta(&repo_path, &new_refs, &old_refs) {
|
||||
// `force` already folds in the env kill-switch, so the forced arm skips the
|
||||
// delta machinery outright; the unforced arm goes through the normal
|
||||
// resolver (whose own env read is false here by construction).
|
||||
let resolved = if force {
|
||||
tracing::debug!("full scan forced (coalesced-drain marker or kill-switch)");
|
||||
PinCandidates::FullScanRequired
|
||||
} else {
|
||||
resolve_push_delta(&repo_path, &new_refs, &old_refs, &git_bin, deadline)
|
||||
};
|
||||
match resolved {
|
||||
PinCandidates::Delta(objs) => {
|
||||
tracing::info!(delta = objs.len(), repo = %repo_path.display(), "pin candidate set from push delta");
|
||||
PinCandidateSet { candidates: objs, full_scan: false }
|
||||
}
|
||||
PinCandidates::FullScanRequired => {
|
||||
tracing::warn!(repo = %repo_path.display(), "pin delta unavailable (non-commit tip, git error, or force-full-scan) — full-scan fallback");
|
||||
match list_all_objects(&repo_path) {
|
||||
match list_all_objects(&repo_path, &git_bin, deadline) {
|
||||
Ok(objs) => PinCandidateSet { candidates: objs, full_scan: true },
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_path.display(), err = %e, "full-scan fallback failed; pinning nothing this push (reconciliation sweep backstops)");
|
||||
@@ -304,8 +364,13 @@ pub async fn resolve_candidates_for_push(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn td() -> std::time::Instant {
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(600)
|
||||
}
|
||||
|
||||
/// Minimal git helper for building test repos.
|
||||
struct Repo {
|
||||
_td: TempDir,
|
||||
@@ -363,9 +428,10 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c2], &[&c1]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got: HashSet<String> =
|
||||
delta(resolve_push_delta(&repo.path, &[&c2], &[&c1], "git", td()))
|
||||
.into_iter()
|
||||
.collect();
|
||||
// The new blob b.txt and commit c2 are in the delta; the old blob a.txt
|
||||
// and commit c1 are not.
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
@@ -383,7 +449,7 @@ mod tests {
|
||||
// genuinely new objects, never fewer.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[]))
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[], "git", td()))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&c1));
|
||||
@@ -399,9 +465,15 @@ mod tests {
|
||||
// Rewrite history: reset to base, commit a different file.
|
||||
repo.git(&["reset", "-q", "--hard", &base]);
|
||||
let new_tip = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&new_tip], &[&old_tip]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got: HashSet<String> = delta(resolve_push_delta(
|
||||
&repo.path,
|
||||
&[&new_tip],
|
||||
&[&old_tip],
|
||||
"git",
|
||||
td(),
|
||||
))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&new_tip), "new tip in delta");
|
||||
assert!(got.contains(&repo.rev(&format!("{new_tip}:c.txt"))));
|
||||
// No error; force-push computes new-minus-old cleanly.
|
||||
@@ -413,7 +485,7 @@ mod tests {
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
// All updates were deletions => new_tips empty after the caller strips zeros.
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[ZERO]),
|
||||
resolve_push_delta(&repo.path, &[], &[ZERO], "git", td()),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
@@ -424,7 +496,7 @@ mod tests {
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&blob], &[]),
|
||||
resolve_push_delta(&repo.path, &[&blob], &[], "git", td()),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a blob tip must force full scan (rev-list would exit 0 and walk it)"
|
||||
);
|
||||
@@ -436,7 +508,7 @@ mod tests {
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tree], &[]),
|
||||
resolve_push_delta(&repo.path, &[&tree], &[], "git", td()),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
@@ -449,7 +521,7 @@ mod tests {
|
||||
repo.git(&["tag", "-a", "treetag", "-m", "x", &tree]);
|
||||
let tag = repo.rev("treetag");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tag], &[]),
|
||||
resolve_push_delta(&repo.path, &[&tag], &[], "git", td()),
|
||||
PinCandidates::FullScanRequired,
|
||||
"annotated tag peeling to a tree must force full scan"
|
||||
);
|
||||
@@ -465,7 +537,7 @@ mod tests {
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&t2], &[]),
|
||||
resolve_push_delta(&repo.path, &[&t2], &[], "git", td()),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
@@ -480,7 +552,7 @@ mod tests {
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
repo.git(&["tag", "-a", "rel", "-m", "release", &c1]);
|
||||
let tag = repo.rev("rel");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[]))
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[], "git", td()))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -501,7 +573,7 @@ mod tests {
|
||||
let t1 = repo.rev("t1");
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[]))
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[], "git", td()))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -516,9 +588,16 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let set =
|
||||
resolve_candidates_for_push(repo.path.clone(), vec![c2.clone()], vec![c1.clone()])
|
||||
.await;
|
||||
let set = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![c2.clone()],
|
||||
vec![c1.clone()],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(!set.full_scan, "happy-path delta is not a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
@@ -536,20 +615,206 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
let all: HashSet<String> = list_all_objects(&repo.path).unwrap().into_iter().collect();
|
||||
let set = resolve_candidates_for_push(repo.path.clone(), vec![blob], vec![]).await;
|
||||
let all: HashSet<String> = list_all_objects(&repo.path, "git", td())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let set = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![blob],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(set.full_scan, "non-commit tip is signalled as a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
assert_eq!(got, all, "non-commit tip falls back to full repo scan");
|
||||
}
|
||||
|
||||
/// F4 defer proof 2: `resolve_candidates_for_push`'s git stages (the per-tip
|
||||
/// cat-file type probe + delta rev-list, and the full-scan fallback) run under a
|
||||
/// scan-admission permit: with a zero-permit pool the call parks and spawns no
|
||||
/// git; once a permit is available the SAME call runs (defer, not shed). On
|
||||
/// ungated code the git runs regardless of the pool (RED).
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_defers_when_scan_pool_exhausted() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let marker = dir.path().join("git.ran");
|
||||
// Fake git records ANY invocation; cat-file reports a commit tip so the
|
||||
// delta stage proceeds, rev-list yields an empty delta.
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
format!(
|
||||
"#!/bin/sh\necho ran >> \"{}\"\ncase \"$1\" in\n cat-file) echo commit ;;\n *) : ;;\nesac\nexit 0\n",
|
||||
marker.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
let git_bin = fake.to_str().unwrap().to_string();
|
||||
let tip = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
|
||||
|
||||
let sem: Arc<Semaphore> = Arc::new(Semaphore::new(0));
|
||||
let blocked = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
resolve_candidates_for_push(
|
||||
sem.clone(),
|
||||
dir.path().to_path_buf(),
|
||||
vec![tip.clone()],
|
||||
vec![],
|
||||
git_bin.clone(),
|
||||
Duration::from_secs(5),
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"the pin-candidate scan must defer (park on admission) when the pool is exhausted"
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"the scan's git must not spawn while its admission permit is unavailable (F4)"
|
||||
);
|
||||
|
||||
// Release admission: the SAME scan now runs (defer, not shed).
|
||||
sem.add_permits(1);
|
||||
let set = resolve_candidates_for_push(
|
||||
sem,
|
||||
dir.path().to_path_buf(),
|
||||
vec![tip],
|
||||
vec![],
|
||||
git_bin,
|
||||
Duration::from_secs(5),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"once admission is available the deferred scan runs its git"
|
||||
);
|
||||
assert!(!set.full_scan, "commit tip + empty rev-list is a delta");
|
||||
assert!(set.candidates.is_empty());
|
||||
}
|
||||
|
||||
/// F4 fast-path negative arm: a deletion-only push (no new tips, kill-switch
|
||||
/// off) computes its empty delta without spawning ANY git child, so it must
|
||||
/// complete without acquiring from the scan pool — even at zero permits. The
|
||||
/// per-tip cat-file probe means every push with a non-empty new tip DOES spawn
|
||||
/// git, so this is the only genuinely git-free stage.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_no_git_fast_path_skips_admission() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let marker = dir.path().join("git.ran");
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
format!("#!/bin/sh\necho ran >> \"{}\"\nexit 0\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
|
||||
let set = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
resolve_candidates_for_push(
|
||||
Arc::new(Semaphore::new(0)),
|
||||
dir.path().to_path_buf(),
|
||||
vec![],
|
||||
vec!["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string()],
|
||||
fake.to_str().unwrap().to_string(),
|
||||
Duration::from_secs(5),
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the no-new-tips fast path must not park on the scan pool");
|
||||
assert_eq!(
|
||||
set,
|
||||
PinCandidateSet {
|
||||
candidates: Vec::new(),
|
||||
full_scan: false
|
||||
}
|
||||
);
|
||||
assert!(!marker.exists(), "the fast path must spawn no git at all");
|
||||
}
|
||||
|
||||
/// #174 F5: the coalesced-drain FullScan marker is signalled via the explicit
|
||||
/// `force_full_scan` flag, and a flagged call with NO tips must still enumerate
|
||||
/// the whole repo (full_scan=true, non-empty candidates). The RED arm of the
|
||||
/// encoding question: if the marker were encoded as a plain empty-tips call
|
||||
/// (flag off — the fast path), the drain would resolve to an empty delta and
|
||||
/// pin nothing, silently losing the coalesced pushes' work.
|
||||
#[tokio::test]
|
||||
async fn forced_full_scan_with_no_tips_enumerates_the_repo() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let all: HashSet<String> = list_all_objects(&repo.path, "git", td())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(!all.is_empty(), "fixture repo has objects");
|
||||
|
||||
let set = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(set.full_scan, "the forced call is signalled as a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
assert_eq!(
|
||||
got, all,
|
||||
"a forced full scan with no tips enumerates the repo — never an empty delta"
|
||||
);
|
||||
|
||||
// The discriminator: the SAME empty-tips call without the flag is the
|
||||
// deletion-only fast path (empty delta, pin nothing). The two must differ,
|
||||
// or the marker encoding has collapsed into the silent-loss shape.
|
||||
let unforced = resolve_candidates_for_push(
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
repo.path.clone(),
|
||||
vec![],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(!unforced.full_scan);
|
||||
assert!(unforced.candidates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_oid_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let bogus = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[bogus], &[]),
|
||||
resolve_push_delta(&repo.path, &[bogus], &[], "git", td()),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a missing/corrupt tip OID must force full scan"
|
||||
);
|
||||
@@ -564,7 +829,7 @@ mod tests {
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let old_tree = repo.rev(&format!("{c1}^{{tree}}"));
|
||||
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree]);
|
||||
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree], "git", td());
|
||||
match result {
|
||||
PinCandidates::FullScanRequired => {} // safe: caller full-scans
|
||||
PinCandidates::Delta(objs) => {
|
||||
@@ -583,10 +848,15 @@ mod tests {
|
||||
// branch2 from base advances independently
|
||||
repo.git(&["checkout", "-q", "-b", "branch2", &base]);
|
||||
let b2 = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> =
|
||||
delta(resolve_push_delta(&repo.path, &[&b1, &b2], &[&base, &base]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let got: HashSet<String> = delta(resolve_push_delta(
|
||||
&repo.path,
|
||||
&[&b1, &b2],
|
||||
&[&base, &base],
|
||||
"git",
|
||||
td(),
|
||||
))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&b1), "branch1 new commit");
|
||||
assert!(got.contains(&b2), "branch2 new commit");
|
||||
}
|
||||
@@ -595,7 +865,7 @@ mod tests {
|
||||
fn empty_repo_no_tips_yields_empty_delta() {
|
||||
let repo = Repo::new();
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[]),
|
||||
resolve_push_delta(&repo.path, &[], &[], "git", td()),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
@@ -609,12 +879,12 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
assert_eq!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], true),
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], true, "git", td()),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
// And with the flag off, the same push yields a Delta.
|
||||
assert!(matches!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], false),
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], false, "git", td()),
|
||||
PinCandidates::Delta(_)
|
||||
));
|
||||
}
|
||||
@@ -624,7 +894,7 @@ mod tests {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
repo.commit_file("b.txt", "two\n");
|
||||
let all = list_all_objects(&repo.path).unwrap();
|
||||
let all = list_all_objects(&repo.path, "git", td()).unwrap();
|
||||
// 2 commits + 2 trees + 2 blobs = 6 objects.
|
||||
assert_eq!(all.len(), 6, "got: {all:?}");
|
||||
}
|
||||
@@ -642,7 +912,7 @@ mod tests {
|
||||
std::fs::write(repo.path.join("orphan.bin"), b"dangling\n").unwrap();
|
||||
let dangling = repo.git(&["hash-object", "-w", "orphan.bin"]);
|
||||
|
||||
let blobs = all_blob_oids(&repo.path).unwrap();
|
||||
let blobs = all_blob_oids(&repo.path, "git", td()).unwrap();
|
||||
assert!(blobs.contains(&reachable_blob), "reachable blob present");
|
||||
assert!(
|
||||
blobs.contains(&dangling),
|
||||
@@ -652,7 +922,7 @@ mod tests {
|
||||
assert!(!blobs.contains(&tree), "tree is not a blob");
|
||||
|
||||
// The typed lister tags each object; spot-check the dangling blob's type.
|
||||
let typed = list_all_objects_with_type(&repo.path).unwrap();
|
||||
let typed = list_all_objects_with_type(&repo.path, "git", td()).unwrap();
|
||||
assert!(
|
||||
typed
|
||||
.iter()
|
||||
@@ -660,4 +930,96 @@ mod tests {
|
||||
"dangling object is typed as a blob"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn list_all_objects_times_out_on_a_hung_git_instead_of_blocking() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
// Fake git: hang on cat-file (bounded to 30s so a broken test can't wedge).
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
"#!/bin/sh\ncase \"$1\" in\n cat-file) i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n *) : ;;\nesac\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
let git_bin = fake.to_str().unwrap().to_string();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = dir.path().to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(list_all_objects(
|
||||
&path,
|
||||
&git_bin,
|
||||
Instant::now() + Duration::from_millis(150),
|
||||
));
|
||||
});
|
||||
let res = rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"list_all_objects must return within the watchdog budget, not block on a hung git",
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"a hung git must make list_all_objects error out, not hang"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 finding 2: the DELTA-path children — `peeled_object_type` (cat-file),
|
||||
/// `rev_list_delta` (rev-list) — and `list_all_objects_with_type` (cat-file) are
|
||||
/// the remaining candidate-discovery execs (the common push path). Each must
|
||||
/// return within the watchdog budget on a hung git rather than block and pin the
|
||||
/// write permit. Revert any one to a bare `Command::output()` and its arm blocks
|
||||
/// past the recv budget (RED).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn delta_path_exec_fns_time_out_on_a_hung_git() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
// Fake git hangs on BOTH cat-file and rev-list (bounded 30s so a broken
|
||||
// test can't leak a permanent orphan or wedge the suite).
|
||||
let fake = dir.path().join("fakegit");
|
||||
std::fs::write(
|
||||
&fake,
|
||||
"#!/bin/sh\ncase \"$1\" in\n cat-file|rev-list) i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n *) : ;;\nesac\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
let git_bin = fake.to_str().unwrap().to_string();
|
||||
let path = dir.path().to_path_buf();
|
||||
|
||||
// Run `f` on a thread and require it to RETURN (not block) within 10s.
|
||||
fn returns_within<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"a delta-path exec fn must return within the watchdog budget, not block on a hung git",
|
||||
)
|
||||
}
|
||||
let dl = || Instant::now() + Duration::from_millis(150);
|
||||
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
|
||||
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
|
||||
assert!(
|
||||
returns_within(move || peeled_object_type(&p, sha, &g, d)).is_none(),
|
||||
"peeled_object_type must time out to None on a hung cat-file"
|
||||
);
|
||||
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
|
||||
assert!(
|
||||
returns_within(move || rev_list_delta(&p, &[sha], &[], &g, d)).is_err(),
|
||||
"rev_list_delta must error out on a hung rev-list"
|
||||
);
|
||||
let (p, g, d) = (path.clone(), git_bin.clone(), dl());
|
||||
assert!(
|
||||
returns_within(move || list_all_objects_with_type(&p, &g, d)).is_err(),
|
||||
"list_all_objects_with_type must error out on a hung cat-file"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,25 @@ impl TigrisClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only constructor with an explicit S3 endpoint, region, and static
|
||||
/// credentials — no env-var reads, so parallel tests cannot race each other's
|
||||
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
|
||||
/// the client at a non-routable endpoint to exercise acquire-stall paths.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(aws_config::Region::new("auto"))
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
Self {
|
||||
s3: S3Client::new(&config),
|
||||
bucket: bucket.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
|
||||
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
|
||||
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
|
||||
|
||||
@@ -4,11 +4,321 @@
|
||||
//! content is held back.
|
||||
|
||||
use crate::db::VisibilityRule;
|
||||
use crate::git::store;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Fixed budget bounding the whole withheld-blob classification walk (#174 U3).
|
||||
/// The walk is fast for a real repo; this bound exists to reap a hung or
|
||||
/// pathologically slow git child so it cannot pin a served-git permit (the read
|
||||
/// permit on the upload-pack serve path, the write permit on the receive-pack
|
||||
/// post-push replication path) past the deadline. Every caller funnels through
|
||||
/// `blob_paths`, so bounding here bounds both paths at one seam. Production callers
|
||||
/// pass the operator-configured `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` instead; this
|
||||
/// fixed budget only backs the `git_bin`-less test wrappers.
|
||||
#[cfg(test)]
|
||||
const WALK_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// How long the process-group watchdog waits after SIGTERM before escalating to
|
||||
/// SIGKILL, giving a well-behaved git child time to clean up its `*.lock` files. Only
|
||||
/// paid on a timeout (already the exceptional path).
|
||||
#[cfg(unix)]
|
||||
const WATCHDOG_TERM_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Run one git child under a shared `deadline` with process-group teardown,
|
||||
/// BLOCKING, and return its stdout. The child runs in its own process group; a
|
||||
/// watchdog thread SIGTERMs (lets git clean up its `*.lock` files), then SIGKILLs,
|
||||
/// the whole group if the deadline passes before the child is reaped, so a hung or
|
||||
/// slow git can pin neither a served-git permit nor a blocking thread past the
|
||||
/// deadline (jatmn's "retain admission until they are reaped"). This is the
|
||||
/// blocking-side counterpart of `smart_http::drive_git_child`, needed because the
|
||||
/// walk's callers run it inside `spawn_blocking`, which an async timeout cannot
|
||||
/// cancel. Returns [`crate::git::smart_http::GitServiceTimeout`] on the deadline so
|
||||
/// the serve handler maps it to 504. `git_bin` is injectable so a fake `git` can
|
||||
/// drive the teardown in tests without mutating the process-global PATH;
|
||||
/// `stdin_bytes` feeds children that read stdin (empty for the arg-only children).
|
||||
/// Returns true if `pid` (a process-group leader we spawned) has terminated, WITHOUT
|
||||
/// reaping it. `waitid(..., WNOWAIT)` reports the exit state but leaves the child
|
||||
/// waitable, so the caller's later `child.wait()` still collects the status and the
|
||||
/// pid/pgid stays live until then — which is what keeps the watchdog's `kill(-pgid)`
|
||||
/// teardown from ever racing a recycled pgid. Used to distinguish "the child actually
|
||||
/// exited" from "the child merely closed stdout" after the drain returns (#174 P1-a).
|
||||
#[cfg(unix)]
|
||||
fn child_terminated_without_reaping(pid: i32) -> bool {
|
||||
// SAFETY: waitid writes only into the zeroed siginfo and borrows no Rust memory;
|
||||
// WNOWAIT leaves the child unreaped, WNOHANG makes the probe non-blocking.
|
||||
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
pid as libc::id_t,
|
||||
&mut info,
|
||||
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
|
||||
)
|
||||
};
|
||||
// rc == 0 with si_pid == 0 means "no state change yet" (still running); a non-zero
|
||||
// si_pid means the child has entered a waitable, exited state. EINTR/other errors
|
||||
// (rc != 0) are treated as "not yet terminated" and the caller re-polls.
|
||||
rc == 0 && unsafe { info.si_pid() } != 0
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn run_bounded_git_raw(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
|
||||
let label = args.first().copied().unwrap_or("git");
|
||||
let mut child = std::process::Command::new(git_bin)
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {label}"))?;
|
||||
// With process_group(0) the child leads its own group, so pgid == its pid.
|
||||
let pgid = child.id() as i32;
|
||||
|
||||
// Watchdog: on the deadline, tear the WHOLE process group down — SIGTERM, a grace
|
||||
// for a well-behaved child to clean up its `*.lock` files, then an UNCONDITIONAL
|
||||
// SIGKILL of the group. It never stands down on leader-reap alone: a group member
|
||||
// that ignores SIGTERM while the leader exits cleanly would otherwise escape the
|
||||
// SIGKILL and keep running past the deadline (finding 3, #174). The main thread
|
||||
// defers reaping the leader until this thread returns (see below), so the leader's
|
||||
// pid is still unreaped while every `kill(-pgid)` fires and the pgid cannot have
|
||||
// been recycled — which is why this no longer needs the old `reaped` short-circuit.
|
||||
// Kept off the main thread because the main thread's stdout drain is exactly what
|
||||
// blocks until a hung child is torn down.
|
||||
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||
let watchdog = std::thread::spawn(move || -> bool {
|
||||
let wait = deadline.saturating_duration_since(Instant::now());
|
||||
match done_rx.recv_timeout(wait) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => false,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
// SAFETY: kill(2) takes only integers and borrows no Rust memory;
|
||||
// ESRCH on an already-gone group is ignored.
|
||||
unsafe { libc::kill(-pgid, libc::SIGTERM) };
|
||||
// Fixed grace: because the main thread defers the leader's reap, a
|
||||
// fully-exited group still shows a zombie leader here, so polling for
|
||||
// ESRCH cannot detect early completion — just wait the grace, then
|
||||
// SIGKILL. On a group of only zombies the SIGKILL is a harmless no-op;
|
||||
// on a SIGTERM-ignoring member it is what actually kills it.
|
||||
std::thread::sleep(WATCHDOG_TERM_GRACE);
|
||||
unsafe { libc::kill(-pgid, libc::SIGKILL) };
|
||||
// Brief settle so the SIGKILL is delivered before the main thread
|
||||
// reaps the leader and frees the pgid. A wedged (D-state) member
|
||||
// survives even SIGKILL — the documented residual, as in smart_http.
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
if unsafe { libc::kill(-pgid, 0) } == 0 {
|
||||
tracing::warn!(
|
||||
pgid,
|
||||
"withheld-walk git survived SIGKILL past the watchdog cap (uninterruptible I/O?)"
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Feed stdin on a writer thread and drain stderr on a reader thread so the main
|
||||
// thread can drain stdout concurrently; writing all of stdin (or draining one
|
||||
// pipe) before the others can deadlock once a pipe buffer fills.
|
||||
let mut stdin = child.stdin.take();
|
||||
let input = stdin_bytes.to_vec();
|
||||
let writer = std::thread::spawn(move || {
|
||||
if let Some(mut s) = stdin.take() {
|
||||
let _ = s.write_all(&input);
|
||||
}
|
||||
});
|
||||
let mut stderr = child.stderr.take().context("git stderr was not piped")?;
|
||||
let err_reader = std::thread::spawn(move || {
|
||||
let mut err = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut err);
|
||||
err
|
||||
});
|
||||
let mut stdout = child.stdout.take().context("git stdout was not piped")?;
|
||||
let mut out = Vec::new();
|
||||
// Blocking drain, unblocked by the child closing stdout on exit. The watchdog's
|
||||
// SIGTERM/SIGKILL is what makes a hung child exit; a git wedged in uninterruptible
|
||||
// (D-state) I/O survives even SIGKILL, so this drain and the wait below can block
|
||||
// until the kernel returns, pinning the walk thread and its permit. That residual
|
||||
// is unreachable in userspace (no signal reaps a D-state process) and matches the
|
||||
// async `reap_group_on_timeout`, which likewise only warns and gives up there.
|
||||
let read_result = stdout.read_to_end(&mut out);
|
||||
// The drain has returned, but that only means all stdout write ends are closed —
|
||||
// NOT that the child has exited. A group member, or the leader itself, can close
|
||||
// stdout and keep running; standing the watchdog down on the drain alone (as the
|
||||
// old code did) would then let `child.wait()` block forever on that live child,
|
||||
// past the deadline, pinning the walk thread and its permit (finding P1-a, #174).
|
||||
// So stand the watchdog down only once the child has ACTUALLY terminated, detected
|
||||
// WITHOUT reaping (waitid + WNOWAIT) so the leader's pid stays unreaped and its
|
||||
// pgid un-recycled until the watchdog finishes and we join it below. Past the
|
||||
// deadline the watchdog owns the teardown, so we stop polling and let it run the
|
||||
// full SIGTERM -> grace -> SIGKILL; joining it before `child.wait()` keeps every
|
||||
// `kill(-pgid)` firing while the pid is still unreaped and guarantees a
|
||||
// stdout-closing-then-hanging member has been SIGKILLed rather than left running.
|
||||
loop {
|
||||
if child_terminated_without_reaping(pgid) {
|
||||
let _ = done_tx.send(());
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
let killed = watchdog.join().unwrap_or(false);
|
||||
let status = child.wait().context("git wait failed")?;
|
||||
let err = err_reader.join().unwrap_or_default();
|
||||
let _ = writer.join();
|
||||
read_result.context("failed to read git stdout")?;
|
||||
// The watchdog runs off a wall clock that can race a child finishing right at the
|
||||
// deadline. A child that exited on its own (success) is not a timeout even if the
|
||||
// watchdog fired late; only a child that did not exit successfully is a genuine
|
||||
// timeout, which keeps a walk completing at its budget from a spurious 504.
|
||||
if killed && !status.success() {
|
||||
return Err(crate::git::smart_http::GitServiceTimeout.into());
|
||||
}
|
||||
Ok((status, out, err))
|
||||
}
|
||||
|
||||
/// Bounded git returning only stdout, `bail!`ing on any nonzero exit. The thin
|
||||
/// wrapper the walk callers use. Probes that must distinguish exit classes —
|
||||
/// `git cat-file` absence vs an object-store access failure — call
|
||||
/// [`run_bounded_git_raw`] and classify the status/stderr themselves.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn run_bounded_git(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<u8>> {
|
||||
let label = args.first().copied().unwrap_or("git");
|
||||
let (status, out, err) = run_bounded_git_raw(git_bin, args, repo_path, stdin_bytes, deadline)?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("git {label} failed: {}", String::from_utf8_lossy(&err));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Non-Unix fallback for [`run_bounded_git`]. Windows and other non-Unix targets
|
||||
/// have no process-group teardown (`process_group(0)` / `kill(-pgid)` are Unix-only),
|
||||
/// so this bounds a single child on its own: threads feed stdin and drain stderr
|
||||
/// while the main thread drains stdout, and a watchdog thread kills the child at the
|
||||
/// deadline (which closes stdout and unblocks the drain). The child is shared with
|
||||
/// the watchdog behind a mutex that the main thread does NOT hold while draining, so
|
||||
/// the watchdog can always acquire it to kill. Best-effort — it reaps only the direct
|
||||
/// child, not a descendant group — which is why the hardened, group-aware path above
|
||||
/// is gated to Unix, the only target the served node actually runs on (the Windows
|
||||
/// release binary is best-effort / `continue-on-error` in CI). Kept in lockstep with
|
||||
/// the Unix version's signature and result semantics so every caller compiles on all
|
||||
/// targets (#174).
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn run_bounded_git_raw(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
|
||||
let label = args.first().copied().unwrap_or("git");
|
||||
let mut child = std::process::Command::new(git_bin)
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {label}"))?;
|
||||
|
||||
let mut stdin = child.stdin.take();
|
||||
let input = stdin_bytes.to_vec();
|
||||
let writer = std::thread::spawn(move || {
|
||||
if let Some(mut s) = stdin.take() {
|
||||
let _ = s.write_all(&input);
|
||||
}
|
||||
});
|
||||
let mut stderr = child.stderr.take().context("git stderr was not piped")?;
|
||||
let err_reader = std::thread::spawn(move || {
|
||||
let mut err = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut err);
|
||||
err
|
||||
});
|
||||
let mut stdout = child.stdout.take().context("git stdout was not piped")?;
|
||||
|
||||
// Share the child with the watchdog. The main thread drains stdout WITHOUT
|
||||
// holding this lock, so the watchdog can always acquire it to kill on timeout;
|
||||
// killing closes stdout and unblocks the drain below.
|
||||
let child = std::sync::Arc::new(std::sync::Mutex::new(child));
|
||||
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||
let watchdog = {
|
||||
let child = child.clone();
|
||||
std::thread::spawn(move || -> bool {
|
||||
let wait = deadline.saturating_duration_since(Instant::now());
|
||||
match done_rx.recv_timeout(wait) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => false,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let read_result = stdout.read_to_end(&mut out);
|
||||
// The drain has returned (child exited or was killed), so taking the lock here
|
||||
// cannot deadlock against the watchdog.
|
||||
let status = child
|
||||
.lock()
|
||||
.expect("git child mutex poisoned")
|
||||
.wait()
|
||||
.context("git wait failed")?;
|
||||
let _ = done_tx.send(());
|
||||
let killed = watchdog.join().unwrap_or(false);
|
||||
let err = err_reader.join().unwrap_or_default();
|
||||
let _ = writer.join();
|
||||
read_result.context("failed to read git stdout")?;
|
||||
if killed && !status.success() {
|
||||
return Err(crate::git::smart_http::GitServiceTimeout.into());
|
||||
}
|
||||
Ok((status, out, err))
|
||||
}
|
||||
|
||||
/// Non-Unix thin wrapper matching the Unix [`run_bounded_git`] semantics.
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn run_bounded_git(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<u8>> {
|
||||
let label = args.first().copied().unwrap_or("git");
|
||||
let (status, out, err) = run_bounded_git_raw(git_bin, args, repo_path, stdin_bytes, deadline)?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("git {label} failed: {}", String::from_utf8_lossy(&err));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fail closed unless every ref ultimately resolves to a commit (a ref pointing
|
||||
/// directly at a blob or tree, or an annotated tag — even a nested one — of such
|
||||
@@ -21,19 +331,15 @@ use std::path::Path;
|
||||
/// Full peeling is why this is not `for-each-ref %(*objecttype)`, which
|
||||
/// dereferences only one tag level and so misclassifies a tag-of-a-tag-of-a-
|
||||
/// commit as a non-commit.
|
||||
fn assert_all_refs_are_commits(repo_path: &Path) -> Result<()> {
|
||||
let refs = std::process::Command::new("git")
|
||||
.args(["for-each-ref", "--format=%(refname)"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("git for-each-ref failed")?;
|
||||
if !refs.status.success() {
|
||||
anyhow::bail!(
|
||||
"git for-each-ref failed: {}",
|
||||
String::from_utf8_lossy(&refs.stderr)
|
||||
);
|
||||
}
|
||||
let refs_stdout = String::from_utf8_lossy(&refs.stdout);
|
||||
fn assert_all_refs_are_commits(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<()> {
|
||||
let refs_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["for-each-ref", "--format=%(refname)"],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let refs_stdout = String::from_utf8_lossy(&refs_out);
|
||||
let refnames: Vec<&str> = refs_stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
@@ -43,59 +349,25 @@ fn assert_all_refs_are_commits(repo_path: &Path) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Peel every ref in one `git cat-file --batch-check` pass: one
|
||||
// `<refname>^{}` query per line, one output line per input line, in order.
|
||||
// The stdin write runs on a separate thread so this thread can drain stdout
|
||||
// concurrently. cat-file echoes the full query on a `<query> missing` line,
|
||||
// so output scales with refname length (not a fixed size per ref); writing
|
||||
// all of stdin before reading any stdout would deadlock both pipes once the
|
||||
// child's stdout buffer fills. Dropping `stdin` at the end of the closure
|
||||
// sends EOF.
|
||||
// Peel every ref in one `git cat-file --batch-check` pass: one `<refname>^{}`
|
||||
// query per line, one output line per input line, in order. cat-file echoes the
|
||||
// full query on a `<query> missing` line, so output scales with refname length;
|
||||
// run_bounded_git drains stdout concurrently with the stdin write, so the pipe
|
||||
// cannot deadlock, and the whole peel is bounded by the shared walk deadline.
|
||||
let queries = refnames
|
||||
.iter()
|
||||
.map(|r| format!("{r}^{{}}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
use std::io::Write;
|
||||
let mut child = std::process::Command::new("git")
|
||||
.args(["cat-file", "--batch-check=%(objecttype)"])
|
||||
.current_dir(repo_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn git cat-file")?;
|
||||
// Feed stdin on a writer thread so this thread can drain stdout via
|
||||
// wait_with_output concurrently; a None handle (the pipe vanished) becomes a
|
||||
// broken-pipe write error. wait_with_output reaps the child unconditionally
|
||||
// before any error is surfaced, so no path drops it unwaited (#53), and the
|
||||
// writer is joined only after the drain so the join cannot deadlock.
|
||||
let writer = child
|
||||
.stdin
|
||||
.take()
|
||||
.map(|mut stdin| std::thread::spawn(move || stdin.write_all(queries.as_bytes())));
|
||||
let peel_result = child.wait_with_output();
|
||||
let write_result = match writer {
|
||||
Some(handle) => handle
|
||||
.join()
|
||||
.map_err(|_| anyhow::anyhow!("git cat-file stdin writer thread panicked"))?,
|
||||
None => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"git cat-file stdin unavailable",
|
||||
)),
|
||||
};
|
||||
// Surface a write error only if the process didn't already fail with a
|
||||
// clearer status.
|
||||
let peel = peel_result.context("git cat-file failed")?;
|
||||
if !peel.status.success() {
|
||||
anyhow::bail!(
|
||||
"git cat-file --batch-check failed: {}",
|
||||
String::from_utf8_lossy(&peel.stderr)
|
||||
);
|
||||
}
|
||||
write_result.context("failed to write to git cat-file stdin")?;
|
||||
let peel_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "--batch-check=%(objecttype)"],
|
||||
repo_path,
|
||||
queries.as_bytes(),
|
||||
deadline,
|
||||
)?;
|
||||
|
||||
let peel_stdout = String::from_utf8_lossy(&peel.stdout);
|
||||
let peel_stdout = String::from_utf8_lossy(&peel_out);
|
||||
let types: Vec<&str> = peel_stdout.lines().map(str::trim).collect();
|
||||
// A short read means at least one ref went unclassified — fail closed.
|
||||
if types.len() != refnames.len() {
|
||||
@@ -147,47 +419,46 @@ fn assert_all_refs_are_commits(repo_path: &Path) -> Result<()> {
|
||||
/// Fails closed: if commit enumeration or any tree walk fails, returns an error so
|
||||
/// the caller aborts the serve/pin rather than producing a partial (under-withheld)
|
||||
/// set.
|
||||
fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
assert_all_refs_are_commits(repo_path)?;
|
||||
fn blob_paths(repo_path: &Path, git_bin: &str, timeout: Duration) -> Result<Vec<(String, String)>> {
|
||||
// One deadline spans the whole walk (the ref check, the HEAD probe, rev-list,
|
||||
// and every per-commit ls-tree), so a slow or hung walk is bounded as a unit
|
||||
// rather than granting each git child a fresh timeout.
|
||||
let deadline = Instant::now() + timeout;
|
||||
assert_all_refs_are_commits(repo_path, git_bin, deadline)?;
|
||||
|
||||
// Enumerate every reachable commit, not just ref tips. `--all` walks all refs;
|
||||
// append HEAD so a detached HEAD (reachable by rev-list/upload-pack but in no
|
||||
// ref) is still classified. When HEAD does not resolve (unborn branch on an
|
||||
// empty repo) `--all` alone yields nothing, which is correct — no objects exist.
|
||||
let head = store::head_commit(repo_path).context("resolve HEAD failed")?;
|
||||
// empty repo) `--all` alone yields nothing, which is correct: no objects exist.
|
||||
// The HEAD probe is a bounded `git rev-parse --verify HEAD` (a clean exit means
|
||||
// HEAD resolves), replacing the previously unbounded `store::head_commit` child.
|
||||
let head_resolves = run_bounded_git(
|
||||
git_bin,
|
||||
&["rev-parse", "--verify", "HEAD"],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)
|
||||
.is_ok();
|
||||
let mut rev_args = vec!["rev-list", "--all"];
|
||||
if head.is_some() {
|
||||
if head_resolves {
|
||||
rev_args.push("HEAD");
|
||||
}
|
||||
let commits = std::process::Command::new("git")
|
||||
.args(&rev_args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("git rev-list --all failed")?;
|
||||
if !commits.status.success() {
|
||||
anyhow::bail!(
|
||||
"git rev-list --all failed: {}",
|
||||
String::from_utf8_lossy(&commits.stderr)
|
||||
);
|
||||
}
|
||||
let commits_stdout = String::from_utf8_lossy(&commits.stdout);
|
||||
let commits_out = run_bounded_git(git_bin, &rev_args, repo_path, b"", deadline)?;
|
||||
let commits_stdout = String::from_utf8_lossy(&commits_out);
|
||||
let mut out: HashSet<(String, String)> = HashSet::new();
|
||||
for commit in commits_stdout.lines() {
|
||||
let commit = commit.trim();
|
||||
if commit.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let listing = std::process::Command::new("git")
|
||||
.args(["ls-tree", "-rz", commit])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("git ls-tree -rz failed")?;
|
||||
if !listing.status.success() {
|
||||
anyhow::bail!(
|
||||
"git ls-tree -rz {commit} failed: {}",
|
||||
String::from_utf8_lossy(&listing.stderr)
|
||||
);
|
||||
}
|
||||
let listing_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["ls-tree", "-rz", commit],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
// `-z` NUL-delimits records and emits paths raw; plain `git ls-tree -r`
|
||||
// C-quotes any path with non-ASCII or special bytes (e.g. café.txt becomes
|
||||
// "secret/caf\303\251.txt"), and that quoted literal would not match a
|
||||
@@ -198,7 +469,7 @@ fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
// path (e.g. a non-UTF-8 directory name) with U+FFFD, and the mangled string
|
||||
// would no longer match its deny rule — the same under-withholding class, one
|
||||
// layer down. Fail closed instead so the caller aborts rather than leaks.
|
||||
let Ok(listing_stdout) = std::str::from_utf8(&listing.stdout) else {
|
||||
let Ok(listing_stdout) = std::str::from_utf8(&listing_out) else {
|
||||
anyhow::bail!(
|
||||
"git ls-tree -rz {commit} returned a non-UTF-8 path; \
|
||||
refusing to produce a partial (under-withheld) set"
|
||||
@@ -229,6 +500,7 @@ fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
///
|
||||
/// The whole-repo "/" gate is handled by the caller before this function runs:
|
||||
/// if "/" denies, the caller gets a 404 and never reaches the filtered serve.
|
||||
#[cfg(test)]
|
||||
pub fn withheld_blob_oids(
|
||||
repo_path: &Path,
|
||||
rules: &[VisibilityRule],
|
||||
@@ -236,7 +508,33 @@ pub fn withheld_blob_oids(
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> Result<HashSet<String>> {
|
||||
let pairs = blob_paths(repo_path)?;
|
||||
withheld_blob_oids_bounded(
|
||||
repo_path,
|
||||
"git",
|
||||
WALK_TIMEOUT,
|
||||
rules,
|
||||
is_public,
|
||||
owner_did,
|
||||
caller,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`withheld_blob_oids`] with an injectable `git_bin` and walk `timeout`. Served
|
||||
/// handlers call this with the operator-configured git binary and
|
||||
/// `GITLAWB_GIT_SERVICE_TIMEOUT_SECS`, so the whole walk is bounded by the same
|
||||
/// budget as the other served-git ops and a fake `git` can drive its teardown in
|
||||
/// tests. The `git_bin`-less wrapper above keeps the fixed [`WALK_TIMEOUT`] for the
|
||||
/// classification tests that run against real git.
|
||||
pub fn withheld_blob_oids_bounded(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
timeout: Duration,
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> Result<HashSet<String>> {
|
||||
let pairs = blob_paths(repo_path, git_bin, timeout)?;
|
||||
Ok(withheld_from_pairs(
|
||||
&pairs, rules, is_public, owner_did, caller,
|
||||
))
|
||||
@@ -304,6 +602,7 @@ pub fn replicable_objects(all: Vec<String>, withheld: &HashSet<String>) -> Vec<S
|
||||
/// at an allowed path is included even when also denied elsewhere (its content
|
||||
/// is public elsewhere). A dangling blob is absent from the reachable walk, so
|
||||
/// it is never in this set and the fail-closed filter drops it (#99).
|
||||
#[cfg(test)]
|
||||
pub fn replicable_blob_set(
|
||||
repo_path: &Path,
|
||||
rules: &[VisibilityRule],
|
||||
@@ -313,6 +612,21 @@ pub fn replicable_blob_set(
|
||||
allowed_blob_set_for_caller(repo_path, rules, is_public, owner_did, None)
|
||||
}
|
||||
|
||||
/// [`replicable_blob_set`] with an injectable `git_bin` and walk `timeout`, for the
|
||||
/// fail-closed full-scan pin path on the receive-pack side.
|
||||
pub fn replicable_blob_set_bounded(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
timeout: Duration,
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
) -> Result<HashSet<String>> {
|
||||
allowed_blob_set_for_caller_bounded(
|
||||
repo_path, git_bin, timeout, rules, is_public, owner_did, None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Reachable blob OIDs that visibility ALLOWS `caller` at some path. The
|
||||
/// caller-aware generalization of `replicable_blob_set` (which is the anonymous
|
||||
/// `caller = None` case). Used by `GET /ipfs/{cid}` to gate fail-closed against
|
||||
@@ -325,6 +639,7 @@ pub fn replicable_blob_set(
|
||||
/// elsewhere (its content is readable to this caller elsewhere). Trees and
|
||||
/// commits are NOT included here; the caller decides per object type whether
|
||||
/// the allow-set applies (it does not for trees/commits — KTD3).
|
||||
#[cfg(test)]
|
||||
pub fn allowed_blob_set_for_caller(
|
||||
repo_path: &Path,
|
||||
rules: &[VisibilityRule],
|
||||
@@ -332,7 +647,29 @@ pub fn allowed_blob_set_for_caller(
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> Result<HashSet<String>> {
|
||||
let pairs = blob_paths(repo_path)?;
|
||||
allowed_blob_set_for_caller_bounded(
|
||||
repo_path,
|
||||
"git",
|
||||
WALK_TIMEOUT,
|
||||
rules,
|
||||
is_public,
|
||||
owner_did,
|
||||
caller,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`allowed_blob_set_for_caller`] with an injectable `git_bin` and walk `timeout`,
|
||||
/// for the `GET /ipfs/{cid}` gate.
|
||||
pub fn allowed_blob_set_for_caller_bounded(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
timeout: Duration,
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> Result<HashSet<String>> {
|
||||
let pairs = blob_paths(repo_path, git_bin, timeout)?;
|
||||
let mut allowed = HashSet::new();
|
||||
for (oid, path) in &pairs {
|
||||
if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow {
|
||||
@@ -365,14 +702,28 @@ pub fn replicable_objects_fail_closed(
|
||||
/// owner plus any reader DID that `visibility_check` Allows at some path the
|
||||
/// blob appears at. Least-privilege: a reader of one private subtree is not a
|
||||
/// recipient of a blob that only lives in another.
|
||||
#[cfg(test)]
|
||||
pub fn withheld_blob_recipients(
|
||||
repo_path: &Path,
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
) -> Result<HashMap<String, BTreeSet<String>>> {
|
||||
withheld_blob_recipients_bounded(repo_path, "git", WALK_TIMEOUT, rules, is_public, owner_did)
|
||||
}
|
||||
|
||||
/// [`withheld_blob_recipients`] with an injectable `git_bin` and walk `timeout`, for
|
||||
/// the receive-pack encrypt-then-pin path.
|
||||
pub fn withheld_blob_recipients_bounded(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
timeout: Duration,
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
) -> Result<HashMap<String, BTreeSet<String>>> {
|
||||
// One history walk feeds both the withheld set and the recipient mapping.
|
||||
let pairs = blob_paths(repo_path)?;
|
||||
let pairs = blob_paths(repo_path, git_bin, timeout)?;
|
||||
let withheld = withheld_from_pairs(&pairs, rules, is_public, owner_did, None);
|
||||
if withheld.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
@@ -402,6 +753,300 @@ pub fn withheld_blob_recipients(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write an executable fake `git` shell script into `dir` and return its path,
|
||||
/// so a test can drive the walk's process-group teardown without a real git and
|
||||
/// without mutating the process-global PATH (the crate's only injection seam).
|
||||
#[cfg(unix)]
|
||||
fn write_fake_git(dir: &Path, body: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let p = dir.join("fakegit");
|
||||
std::fs::write(&p, body).unwrap();
|
||||
let mut perm = std::fs::metadata(&p).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&p, perm).unwrap();
|
||||
p.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// #174 U3: the withheld-blob walk is bounded at the shared `blob_paths` seam, so
|
||||
/// a hung git child cannot pin the caller's permit past the deadline. A fake git
|
||||
/// that hangs on `rev-list` must make `blob_paths` return `GitServiceTimeout`
|
||||
/// within the watchdog budget (not block for the child's lifetime), and the
|
||||
/// child's process group must be reaped (its recorded leader PID gone). Every
|
||||
/// caller (upload-pack serve, receive-pack replication) funnels through
|
||||
/// `blob_paths`, so this seam-level proof covers both permit pools. Neutralize
|
||||
/// the watchdog SIGTERM and this hangs past the recv budget (RED).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn blob_paths_times_out_and_reaps_a_hung_walk() {
|
||||
use std::time::Duration;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Fast on every stage except rev-list, which records its own (group-leader)
|
||||
// PID and then hangs. `sleep 30` bounds the worst case if the watchdog is
|
||||
// ever broken, so a regression cannot wedge the suite for 300s.
|
||||
let body = "#!/bin/sh\ncase \"$1\" in\n rev-list) echo $$ > revlist.pid ; sleep 30 ;;\n rev-parse) echo deadbeef ;;\n *) : ;;\nesac\nexit 0\n";
|
||||
let git_bin = write_fake_git(tmp.path(), body);
|
||||
|
||||
// Run the walk on a thread with a short budget; the recv_timeout succeeding
|
||||
// is itself proof the walk did not block on the hung child.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let path = tmp.path().to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(blob_paths(&path, &git_bin, Duration::from_millis(200)));
|
||||
});
|
||||
let result = rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"blob_paths must return within the watchdog budget, not hang on a stuck git child",
|
||||
);
|
||||
let err = result.expect_err("a hung rev-list must abort the walk with an error");
|
||||
assert!(
|
||||
err.downcast_ref::<crate::git::smart_http::GitServiceTimeout>()
|
||||
.is_some(),
|
||||
// `{err:#}` prints the whole anyhow chain. Plain `{err}` shows only the top
|
||||
// context ("failed to spawn git for-each-ref") and drops the underlying io
|
||||
// error, which left a real beta-lane CI failure undiagnosable.
|
||||
"a hung walk must abort with GitServiceTimeout (mapped to 504), got: {err:#}"
|
||||
);
|
||||
|
||||
// The recorded process-group leader must be gone: the watchdog reaps the
|
||||
// whole group before blob_paths returns, so no orphaned git lingers.
|
||||
let pid: i32 = std::fs::read_to_string(tmp.path().join("revlist.pid"))
|
||||
.expect("the fake git must have recorded its rev-list PID")
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("recorded PID must parse");
|
||||
let mut gone = false;
|
||||
for _ in 0..200 {
|
||||
// SAFETY: kill(2) with signal 0 only probes existence; ESRCH (-1) means
|
||||
// the process is gone. Borrows no Rust memory.
|
||||
if unsafe { libc::kill(pid, 0) } != 0 {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
gone,
|
||||
"the hung git child (pid {pid}) must be reaped, not orphaned, after the walk aborts"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 (F1 status-gate, vetted by execution): a child that exits SUCCESSFULLY is
|
||||
/// never reported as a timeout even when the watchdog fires, so a walk finishing
|
||||
/// right at its deadline is not a spurious 504. The fake only exits when signalled
|
||||
/// and exits 0 on SIGTERM, so with a deadline already elapsed the watchdog always
|
||||
/// reaches its kill path (killed == true) yet the child's status is success.
|
||||
/// Drop the `!status.success()` guard and this returns GitServiceTimeout (RED).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_bounded_git_success_at_the_deadline_is_not_a_timeout() {
|
||||
use std::time::{Duration, Instant};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let body = "#!/bin/sh\ntrap 'exit 0' TERM\nsleep 30 &\nwait\n";
|
||||
let git_bin = write_fake_git(tmp.path(), body);
|
||||
let out = run_bounded_git(
|
||||
&git_bin,
|
||||
&["rev-list"],
|
||||
tmp.path(),
|
||||
b"",
|
||||
Instant::now() + Duration::from_millis(100),
|
||||
);
|
||||
assert!(
|
||||
out.is_ok(),
|
||||
"a child that exited successfully must not be reported as a timeout even if the watchdog fired: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 (F3, vetted by execution): a child that IGNORES SIGTERM is still reaped
|
||||
/// via the watchdog's SIGKILL escalation, so it cannot pin the walk thread or its
|
||||
/// permit. The fake traps SIGTERM and keeps sleeping; run_bounded_git must still
|
||||
/// return (via SIGKILL at the grace step) with a timeout error and the group must
|
||||
/// be gone. (A truly uninterruptible D-state child, which no signal can reap, is
|
||||
/// the documented residual this teardown, like the async twin, cannot cover.)
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_bounded_git_reaps_a_sigterm_ignoring_child_via_sigkill() {
|
||||
use std::time::{Duration, Instant};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let body = "#!/bin/sh\ntrap '' TERM\necho $$ > pid\nwhile true; do sleep 1; done\n";
|
||||
let git_bin = write_fake_git(tmp.path(), body);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = tmp.path().to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(run_bounded_git(
|
||||
&git_bin,
|
||||
&["rev-list"],
|
||||
&path,
|
||||
b"",
|
||||
Instant::now() + Duration::from_millis(100),
|
||||
));
|
||||
});
|
||||
let out = rx
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("run_bounded_git must return via SIGKILL even for a SIGTERM-ignoring child");
|
||||
assert!(
|
||||
out.is_err(),
|
||||
"a SIGTERM-ignoring child killed by SIGKILL is a timeout, not a success: {out:?}"
|
||||
);
|
||||
let pid: i32 = std::fs::read_to_string(tmp.path().join("pid"))
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mut gone = false;
|
||||
for _ in 0..300 {
|
||||
if unsafe { libc::kill(pid, 0) } != 0 {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
gone,
|
||||
"the SIGTERM-ignoring child (pid {pid}) must be reaped via SIGKILL, not left running"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 finding 3 (jatmn/CodeRabbit): a group MEMBER that ignores SIGTERM must
|
||||
/// still be SIGKILLed even when the group LEADER exits cleanly on SIGTERM. The
|
||||
/// leader traps SIGTERM to exit 0, but first spawns a descendant (`sh -c`, so its
|
||||
/// `$$` is its OWN pid — a `( )` subshell's `$$` is the parent's) that ignores
|
||||
/// SIGTERM and closes its inherited stdout/stderr. When the watchdog SIGTERMs the
|
||||
/// group, the leader exits, its stdout closes, the main drain unblocks, and the
|
||||
/// leader is reaped — the exact window a `reaped`-gated watchdog stands down in,
|
||||
/// before escalating to SIGKILL. The descendant must be dead when run_bounded_git
|
||||
/// returns; a teardown that stands down on leader-reap leaves it running (RED).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_bounded_git_sigkills_a_sigterm_ignoring_descendant_after_leader_exits() {
|
||||
use std::time::{Duration, Instant};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Both loops are bounded (~30s) so a broken teardown cannot leak a permanent
|
||||
// orphan or wedge the suite; the assertion fires well before then.
|
||||
let body = "#!/bin/sh\n\
|
||||
case \"$1\" in\n\
|
||||
rev-list)\n\
|
||||
sh -c 'trap \"\" TERM; echo $$ > desc.pid; exec 1>&- 2>&-; i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done' &\n\
|
||||
trap 'exit 0' TERM\n\
|
||||
i=0; while [ $i -lt 30 ]; do sleep 1; i=$((i+1)); done ;;\n\
|
||||
*) : ;;\n\
|
||||
esac\n";
|
||||
let git_bin = write_fake_git(tmp.path(), body);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = tmp.path().to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(run_bounded_git(
|
||||
&git_bin,
|
||||
&["rev-list"],
|
||||
&path,
|
||||
b"",
|
||||
Instant::now() + Duration::from_millis(100),
|
||||
));
|
||||
});
|
||||
let _ = rx
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("run_bounded_git must return within the watchdog budget");
|
||||
|
||||
// Wait for the descendant to record its OWN pid, then assert it is gone.
|
||||
let desc_pid_path = tmp.path().join("desc.pid");
|
||||
let mut desc: Option<i32> = None;
|
||||
for _ in 0..200 {
|
||||
if let Some(p) = std::fs::read_to_string(&desc_pid_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<i32>().ok())
|
||||
{
|
||||
desc = Some(p);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let desc = desc.expect("the fake leader must have spawned and recorded a descendant");
|
||||
let mut gone = false;
|
||||
for _ in 0..300 {
|
||||
if unsafe { libc::kill(desc, 0) } != 0 {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
// Kill it regardless so a RED run leaks no orphan.
|
||||
unsafe { libc::kill(desc, libc::SIGKILL) };
|
||||
assert!(
|
||||
gone,
|
||||
"a SIGTERM-ignoring descendant (pid {desc}) must be SIGKILLed even after the leader exits cleanly, not orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U1 (P1-a, RED-before/GREEN-after): the group LEADER closes its own
|
||||
/// stdout/stderr BEFORE the deadline and then keeps running. On the pre-fix code
|
||||
/// the stdout drain returns EOF early, `done_tx.send` stands the watchdog down
|
||||
/// before it ever fires (`recv` gets `Ok` -> `false`, no kill), and `child.wait()`
|
||||
/// then blocks on the still-alive leader — pinning the walk thread and its read/
|
||||
/// write permit past the deadline, bypassing GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
|
||||
/// This is distinct from the descendant case above: there the leader sleeps until
|
||||
/// the deadline so the watchdog DOES time out; here the drain-EOF races ahead of
|
||||
/// the deadline. The fix keeps the watchdog armed until the child is actually
|
||||
/// reaped, so the deadline SIGTERM still fires and the call returns within budget.
|
||||
/// A pre-fix build blocks on `child.wait()` past the recv budget (RED).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_bounded_git_reaps_a_leader_that_closes_stdout_then_hangs() {
|
||||
use std::time::{Duration, Instant};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// rev-list records its (leader) pid, closes stdout+stderr so the drain EOFs
|
||||
// immediately, then sleeps without trapping TERM. `sleep 30` bounds the worst
|
||||
// case so a RED run cannot wedge the suite; the recv budget fires first.
|
||||
//
|
||||
// The deadline below is deliberately far larger than the work it bounds: the
|
||||
// watchdog tears the whole group down at the deadline, so a deadline tight
|
||||
// enough to race `/bin/sh` reaching its first statement kills the leader
|
||||
// BEFORE it records its pid, and the `leader.pid` read below then fails with
|
||||
// NotFound. That is a harness race, not a product failure, and it is exactly
|
||||
// what a loaded CI runner reproduced (test (beta), 2026-08-04). Sizing it at
|
||||
// 2s keeps the property under test intact, since the pre-fix build blocks on
|
||||
// `child.wait()` for the child's full `sleep 30` and still trips the 10s recv
|
||||
// budget below (RED), while leaving a 20x margin over the ~100ms that a
|
||||
// healthy start actually needs.
|
||||
let body = "#!/bin/sh\ncase \"$1\" in\n rev-list) echo $$ > leader.pid; exec 1>&- 2>&-; sleep 30 ;;\n *) : ;;\nesac\nexit 0\n";
|
||||
let git_bin = write_fake_git(tmp.path(), body);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = tmp.path().to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(run_bounded_git(
|
||||
&git_bin,
|
||||
&["rev-list"],
|
||||
&path,
|
||||
b"",
|
||||
Instant::now() + Duration::from_secs(2),
|
||||
));
|
||||
});
|
||||
let out = rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"run_bounded_git must return within the watchdog budget when the leader closes stdout then hangs, not block on child.wait()",
|
||||
);
|
||||
assert!(
|
||||
out.is_err(),
|
||||
"a leader killed at the deadline (no TERM trap) is a timeout, not a success: {out:?}"
|
||||
);
|
||||
let pid: i32 = std::fs::read_to_string(tmp.path().join("leader.pid"))
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mut gone = false;
|
||||
for _ in 0..300 {
|
||||
if unsafe { libc::kill(pid, 0) } != 0 {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
// Kill it regardless so a RED run leaks no orphan.
|
||||
unsafe { libc::kill(pid, libc::SIGKILL) };
|
||||
assert!(
|
||||
gone,
|
||||
"the hung leader (pid {pid}) must be killed and reaped at the deadline, not left running"
|
||||
);
|
||||
}
|
||||
|
||||
use crate::db::VisibilityMode;
|
||||
use chrono::Utc;
|
||||
use std::process::Command;
|
||||
@@ -735,7 +1380,12 @@ mod tests {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
let all_blobs = crate::git::push_delta::all_blob_oids(&work).unwrap();
|
||||
let all_blobs = crate::git::push_delta::all_blob_oids(
|
||||
&work,
|
||||
"git",
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(600),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
all_blobs.contains(&dangling_oid),
|
||||
"precondition: the dangling blob is in the all-objects universe"
|
||||
|
||||
@@ -14,6 +14,68 @@ use subscription::SubscriptionRoot;
|
||||
|
||||
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||
|
||||
/// Client-facing message for GraphQL resolver failures that wrap a real
|
||||
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
|
||||
/// detail in the GraphQL `errors` array (#250).
|
||||
///
|
||||
/// Kept as its own constant on this PR's base (main still renders
|
||||
/// `AppError::Db` with `e.to_string()`). If/when #247's `DB_ERROR_MESSAGE`
|
||||
/// lands, fold this into that shared constant.
|
||||
pub const GRAPHQL_DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
fn anyhow_has_sqlx(e: &anyhow::Error) -> bool {
|
||||
e.chain().any(|c| c.downcast_ref::<sqlx::Error>().is_some())
|
||||
}
|
||||
|
||||
/// Map an `anyhow` failure from the db layer to a GraphQL error.
|
||||
///
|
||||
/// - Real DB faults (`sqlx::Error` anywhere in the chain) → opaque client
|
||||
/// message + `error!` log with the full `{e:#}` cause chain.
|
||||
/// - Application/business errors (e.g. claim race, not-in-claimed-state) →
|
||||
/// keep the actionable message; log at `warn!` so they are not mistaken for
|
||||
/// infrastructure failures (#250 review).
|
||||
pub(crate) fn graphql_db_err(e: anyhow::Error) -> async_graphql::Error {
|
||||
if anyhow_has_sqlx(&e) {
|
||||
tracing::error!(error = %format!("{e:#}"), "graphql database error");
|
||||
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
|
||||
} else {
|
||||
tracing::warn!(error = %format!("{e:#}"), "graphql application error");
|
||||
async_graphql::Error::new(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an `AppError` from a shared collector (e.g. ref-update feed) to a
|
||||
/// GraphQL error.
|
||||
///
|
||||
/// Fail closed: only explicitly curated variants surface their `Display`
|
||||
/// text. Unnamed variants (including `Git`, which may embed on-disk paths)
|
||||
/// render opaque so a future addition cannot leak by default (#255 review).
|
||||
pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error {
|
||||
match e {
|
||||
crate::error::AppError::Db(sql) => graphql_db_err(sql.into()),
|
||||
crate::error::AppError::Internal(err) => {
|
||||
tracing::error!(error = %format!("{err:#}"), "graphql internal error");
|
||||
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
|
||||
}
|
||||
// Curated client-safe variants — `Display` is intentional API text.
|
||||
safe @ (crate::error::AppError::RepoNotFound(_)
|
||||
| crate::error::AppError::RepoExists(_)
|
||||
| crate::error::AppError::NotFound(_)
|
||||
| crate::error::AppError::Unauthorized(_)
|
||||
| crate::error::AppError::Forbidden(_)
|
||||
| crate::error::AppError::BadRequest(_)
|
||||
| crate::error::AppError::TooManyRequests(_)
|
||||
| crate::error::AppError::Incomplete(_)) => {
|
||||
tracing::warn!(error = %safe, "graphql application error");
|
||||
async_graphql::Error::new(safe.to_string())
|
||||
}
|
||||
other => {
|
||||
tracing::error!(error = %other, "graphql unclassified AppError (opaque)");
|
||||
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_schema(
|
||||
db: Arc<Db>,
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
@@ -25,3 +87,105 @@ pub fn build_schema(
|
||||
.data(task_event_tx)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_opaques_sqlx_chain() {
|
||||
let leak = "error returned from database: column \"is_public\" does not exist";
|
||||
// Context layer must not hide sqlx from the chain walk (db helpers
|
||||
// wrap with `.context(...)` in several places).
|
||||
let err = graphql_db_err(
|
||||
anyhow::Error::from(sqlx::Error::Protocol(leak.into())).context("loading repos"),
|
||||
);
|
||||
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!err.message.contains("is_public"));
|
||||
assert!(!err.message.contains(leak));
|
||||
assert!(!err.message.contains("loading repos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_keeps_business_message() {
|
||||
let msg = "task not claimable: not found or already claimed";
|
||||
let err = graphql_db_err(anyhow::anyhow!("{msg}"));
|
||||
assert_eq!(err.message, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_app_err_opaques_db_and_internal() {
|
||||
let leak = "column \"is_public\" does not exist";
|
||||
let db_err = graphql_app_err(crate::error::AppError::Db(sqlx::Error::Protocol(
|
||||
leak.into(),
|
||||
)));
|
||||
assert_eq!(db_err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!db_err.message.contains("is_public"));
|
||||
|
||||
let internal = graphql_app_err(crate::error::AppError::Internal(anyhow::anyhow!(
|
||||
"loading repo: {leak}"
|
||||
)));
|
||||
assert_eq!(internal.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!internal.message.contains("is_public"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_app_err_keeps_safe_variant_messages() {
|
||||
let err = graphql_app_err(crate::error::AppError::NotFound("widget".into()));
|
||||
assert!(
|
||||
err.message.contains("widget"),
|
||||
"safe NotFound message must reach the client: {}",
|
||||
err.message
|
||||
);
|
||||
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
|
||||
let err = graphql_app_err(crate::error::AppError::BadRequest("bad cid".into()));
|
||||
assert!(
|
||||
err.message.contains("bad cid"),
|
||||
"safe BadRequest message must reach the client: {}",
|
||||
err.message
|
||||
);
|
||||
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_app_err_opaques_unclassified_variants() {
|
||||
// `Git` may embed on-disk paths from libgit2; fail closed.
|
||||
let path = "/var/lib/gitlawb/repos/owner/secret.git";
|
||||
let err = graphql_app_err(crate::error::AppError::Git(format!(
|
||||
"failed to open '{path}'"
|
||||
)));
|
||||
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!err.message.contains(path));
|
||||
assert!(!err.message.contains("failed to open"));
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
#[test]
|
||||
fn every_graphql_map_err_uses_opaque_helpers() {
|
||||
for (file, src) in [
|
||||
("query.rs", include_str!("query.rs")),
|
||||
("mutation.rs", include_str!("mutation.rs")),
|
||||
] {
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or(line);
|
||||
let Some(idx) = code.find(".map_err(") else {
|
||||
continue;
|
||||
};
|
||||
let after = code[idx + ".map_err(".len()..].trim_start();
|
||||
let ok = after.starts_with("crate::graphql::graphql_db_err")
|
||||
|| after.starts_with("crate::graphql::graphql_app_err")
|
||||
|| after.starts_with("|_|")
|
||||
|| after.starts_with("|_ ");
|
||||
assert!(
|
||||
ok,
|
||||
"{file}:{}: `.map_err(` must use graphql_db_err / graphql_app_err \
|
||||
or discard (`|_|`): {line}",
|
||||
lineno + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl MutationRoot {
|
||||
};
|
||||
db.create_task(&task)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ impl MutationRoot {
|
||||
let task = db
|
||||
.claim_task(&id, &assignee_did)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
@@ -108,7 +108,7 @@ impl MutationRoot {
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
@@ -118,7 +118,7 @@ impl MutationRoot {
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref())
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -149,7 +149,7 @@ impl MutationRoot {
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
@@ -160,7 +160,7 @@ impl MutationRoot {
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -218,8 +218,9 @@ mod tests {
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// 3. Signed as the claimed assignee → passes the auth gate (any remaining
|
||||
// error is the missing task, not an auth error).
|
||||
// 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;
|
||||
@@ -228,6 +229,47 @@ mod tests {
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
assert!(
|
||||
errs.contains("task not claimable"),
|
||||
"claim race / missing task must keep its business message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"business error must not be rewritten as opaque DB error: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #250: mutation DB faults must be opaque; create_task hits agent_tasks.
|
||||
#[sqlx::test]
|
||||
async fn create_task_db_error_message_is_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let delegator = "did:key:zGQLDELEGATORAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let q = format!(
|
||||
r#"mutation {{
|
||||
createTask(
|
||||
delegatorDid: "{delegator}",
|
||||
input: {{ kind: "build", capability: "repo:write" }}
|
||||
) {{ id }}
|
||||
}}"#
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(delegator.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"sqlx fault must be opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("column") && !errs.contains("status"),
|
||||
"schema text leaked: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
|
||||
|
||||
@@ -14,7 +14,7 @@ impl QueryRoot {
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.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
|
||||
@@ -27,7 +27,7 @@ impl QueryRoot {
|
||||
let rules_by_repo = db
|
||||
.list_visibility_rules_for_repos(&ids)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
Ok(repos
|
||||
.into_iter()
|
||||
@@ -71,7 +71,7 @@ impl QueryRoot {
|
||||
let updates =
|
||||
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
|
||||
// Resolve the trusted display owner_did per row, identical to the REST
|
||||
// feed: the stored wire value is untrusted, so it is echoed only when it
|
||||
@@ -86,7 +86,7 @@ impl QueryRoot {
|
||||
let owner_dids = db
|
||||
.resolve_ref_update_owner_dids(&pairs)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
let resolved: Vec<RefUpdateType> = updates
|
||||
.into_iter()
|
||||
@@ -110,13 +110,21 @@ impl QueryRoot {
|
||||
ctx: &Context<'_>,
|
||||
status: Option<String>,
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(default = 50)] limit: i64,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
|
||||
// rejects with 2201W, which would otherwise trip the opaque DB path
|
||||
// and write an error-level log on every probe (#250 review).
|
||||
let limit = limit.clamp(0, 200);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
}
|
||||
|
||||
@@ -125,7 +133,7 @@ impl QueryRoot {
|
||||
let t = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(t.map(AgentTaskType::from))
|
||||
}
|
||||
}
|
||||
@@ -419,4 +427,91 @@ mod tests {
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&authed(&schema, q, "did:key:z6MkQuar").await), 0);
|
||||
}
|
||||
|
||||
/// #250: anonymous GraphQL query DB failures must not leak sqlx/schema text.
|
||||
#[sqlx::test]
|
||||
async fn repos_query_db_error_message_is_opaque(pool: PgPool) {
|
||||
let db = db(pool.clone()).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", true))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN is_public")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ repos { name ownerDid } }").await;
|
||||
assert!(
|
||||
!resp.errors.is_empty(),
|
||||
"DB failure must surface as a GraphQL error"
|
||||
);
|
||||
for err in &resp.errors {
|
||||
assert_eq!(
|
||||
err.message,
|
||||
crate::graphql::GRAPHQL_DB_ERROR_MESSAGE,
|
||||
"raw DB detail leaked into GraphQL error: {}",
|
||||
err.message
|
||||
);
|
||||
assert!(
|
||||
!err.message.contains("is_public") && !err.message.contains("column"),
|
||||
"schema text leaked: {}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #250: negative tasks(limit) must not hit Postgres (and must not 500-log).
|
||||
#[sqlx::test]
|
||||
async fn tasks_negative_limit_clamped(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
}
|
||||
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
|
||||
#[sqlx::test]
|
||||
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for i in 0..201 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-ceil-{i}"),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: OWNER.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
|
||||
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not a list");
|
||||
};
|
||||
rows.len()
|
||||
}
|
||||
}
|
||||
+1035
-17
File diff suppressed because it is too large.
Load diff
+570
-54
@@ -31,6 +31,7 @@ use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use clap::Parser;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -74,6 +75,13 @@ async fn main() -> Result<()> {
|
||||
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
|
||||
bootstrap::merge_seeds(&mut config);
|
||||
|
||||
// Fail fast on config combinations that are individually in-range but jointly
|
||||
// unsafe — notably a DB pool too small for the concurrent-write cap, which
|
||||
// would let a push burst starve every other DB path (#174 F1).
|
||||
config
|
||||
.validate()
|
||||
.map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?;
|
||||
|
||||
if !config.public_read {
|
||||
warn!(
|
||||
"GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha"
|
||||
@@ -378,7 +386,81 @@ async fn main() -> Result<()> {
|
||||
sync_trigger_rate_limiter,
|
||||
peer_write_rate_limiter,
|
||||
shutdown_tx: shutdown_tx.clone(),
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_git_ops)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(
|
||||
config.max_concurrent_git_pushes,
|
||||
)),
|
||||
// Anon receive-pack advertisements get their OWN pool, same size as the
|
||||
// write pool but disjoint, so filling it (which takes many source IPs, each
|
||||
// capped by git_push_advert_per_caller) never occupies a permit the
|
||||
// authenticated POST needs (#174).
|
||||
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(
|
||||
config.max_concurrent_git_pushes,
|
||||
)),
|
||||
// Bounds concurrent detached post-push encryption walks, sized from the push
|
||||
// pool (no separate knob — Q1): completed pushes cannot outnumber active
|
||||
// encryption walks past this (#174 P1-e).
|
||||
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(
|
||||
config.max_concurrent_git_pushes,
|
||||
)),
|
||||
// Bounds how many post-push pin loops run concurrently across all repos (#174 F6),
|
||||
// independent of the per-repo encrypt-task coalescing below. Not a bound on the
|
||||
// MB-scale object-id lists themselves: parked tasks still hold theirs (see the
|
||||
// field doc on AppState::pin_semaphore).
|
||||
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_pin_tasks)),
|
||||
// Coalesces the DETACHED post-push encryption tasks per repo so a rapid pusher
|
||||
// cannot grow the outstanding parked-waiter set past one task per repo (#174
|
||||
// P2-2). No knob: it is a natural cap (one entry per distinct repo), not a
|
||||
// sized pool.
|
||||
encrypt_inflight: crate::state::EncryptInflight::new(),
|
||||
// Per-repo in-process write-lease serializer (#174 U2/F3): supplements the pg
|
||||
// advisory lock so a disconnected push's still-reaping git group can't be raced
|
||||
// by a second same-node push. The map is naturally capped (one entry per contended
|
||||
// repo, freed when unreferenced); the sized knob is how many pushes may PARK on
|
||||
// one repo, since each parked push holds a fully buffered pack.
|
||||
repo_write_leases: crate::state::RepoWriteLeases::new(config.repo_lease_max_waiters),
|
||||
git_read_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
config.max_concurrent_reads_per_caller,
|
||||
),
|
||||
// Per-source cap on the receive-pack advertisement, sized to an eighth of the
|
||||
// write pool (min 1): one resolved client key (rate_limit::client_key) can hold
|
||||
// at most this many slots in the DEDICATED advert pool (git_push_advert_semaphore,
|
||||
// disjoint from the write pool), so saturating that pool takes ~8 distinct keys
|
||||
// (#174). That bounds an IPv4 or single-address caller; a caller controlling many
|
||||
// addresses (an IPv6 /64 is 2^64 keys) still gets one cap per address, since
|
||||
// client_key uses the full IP with no prefix folding. Narrowing the keying is a
|
||||
// deferred design call, not something these caps claim to solve. Sized off the
|
||||
// write pool only because the advert pool is created at the same size; an advert
|
||||
// flood cannot touch a write permit.
|
||||
git_push_advert_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
rate_limit::per_source_push_cap(config.max_concurrent_git_pushes),
|
||||
),
|
||||
// Per-source cap on the authenticated receive-pack POST, sized like the advert
|
||||
// cap: one resolved client key can hold at most this many write-pool slots, so
|
||||
// monopolizing the pool takes ~8 distinct keys (#174 P1-d). Same residual as
|
||||
// above: keys are full IPs, so a caller with many addresses has many caps.
|
||||
git_write_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
rate_limit::per_source_push_cap(config.max_concurrent_git_pushes),
|
||||
),
|
||||
// Bounds concurrent /ipfs visibility walks — a distinct public cost center, so
|
||||
// its own pool + per-source sub-cap + per-IP rate limiter, never a git pool
|
||||
// (#174 P1-3). The per-source map is bounded (reject-before-insert, INV-15).
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(
|
||||
config.max_concurrent_ipfs_walks,
|
||||
)),
|
||||
git_ipfs_walk_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
config.ipfs_walk_per_source,
|
||||
),
|
||||
ipfs_rate_limiter: rate_limit::RateLimiter::new_bounded(
|
||||
config.ipfs_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
git_bin: "git".to_string(),
|
||||
};
|
||||
if config.ipfs_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled");
|
||||
}
|
||||
|
||||
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
|
||||
// we still set the gauge to 0 so dashboards don't show "no data".
|
||||
@@ -408,22 +490,14 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let rl = state.rate_limiter.clone();
|
||||
let create_ip_rl = state.create_ip_rate_limiter.clone();
|
||||
let push_rl = state.push_rate_limiter.clone();
|
||||
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
|
||||
let peer_write_rl = state.peer_write_rate_limiter.clone();
|
||||
let sweep_state = state.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
rl.cleanup().await;
|
||||
create_ip_rl.cleanup().await;
|
||||
push_rl.cleanup().await;
|
||||
sync_trigger_rl.cleanup().await;
|
||||
peer_write_rl.cleanup().await;
|
||||
sweep_rate_limiters(&sweep_state).await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
@@ -733,8 +807,8 @@ fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) ->
|
||||
db_startup,
|
||||
};
|
||||
// Everything answers 503 with the same body — including /health and
|
||||
// /ready, so peer pings (which treat any 2xx /health as alive) and
|
||||
// uptime monitors correctly see a node that cannot serve traffic.
|
||||
// /ready, so peer readiness probes and uptime monitors correctly see a
|
||||
// node that cannot serve traffic.
|
||||
// `/` additionally carries the node identity for probing peers.
|
||||
Router::new()
|
||||
.route("/", get(degraded_node_info))
|
||||
@@ -866,7 +940,7 @@ async fn gossip_task(
|
||||
}
|
||||
|
||||
// Reuse the shared no-redirect client for every gossip outbound call (the
|
||||
// bootstrap announce POST and the periodic peer /health ping). Peer URLs are
|
||||
// bootstrap announce POST and the periodic peer /ready ping). Peer URLs are
|
||||
// attacker-influenceable, so a 3xx to a private address must not be followed.
|
||||
// Do NOT fall back to reqwest::Client::new(): its default follows redirects
|
||||
// and would reintroduce the SSRF closed here (#93).
|
||||
@@ -921,8 +995,33 @@ async fn gossip_task(
|
||||
json.get("node_url").and_then(|v| v.as_str()),
|
||||
) {
|
||||
if !their_url.is_empty() {
|
||||
let _ = state.db.upsert_peer(their_did, their_url).await;
|
||||
tracing::info!(did = %their_did, url = %their_url, "bootstrap peer added");
|
||||
// Unproven, unconditionally: their_did and
|
||||
// their_url come straight out of the contacted
|
||||
// peer's JSON response body, with no signature
|
||||
// and no proof the claimed DID belongs to the
|
||||
// peer we reached. This path therefore seeds
|
||||
// new peers and can never repoint an existing
|
||||
// row. The upsert's result is matched rather
|
||||
// than discarded, because "bootstrap peer
|
||||
// added" printed over a refused write is a
|
||||
// denial rendering as success. Non-fatal to the
|
||||
// loop either way.
|
||||
match state
|
||||
.db
|
||||
.upsert_peer(
|
||||
their_did,
|
||||
their_url,
|
||||
db::PeerWriteAuthority::Unproven,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(did = %their_did, url = %their_url, "bootstrap peer added")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(did = %their_did, url = %their_url, err = %e, "bootstrap peer announce-back rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,6 +1036,8 @@ async fn gossip_task(
|
||||
|
||||
// Periodic ping every 5 minutes — exit on shutdown.
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut failed_once: HashSet<String> = HashSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
@@ -944,10 +1045,13 @@ async fn gossip_task(
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for peer in peers {
|
||||
let ok = ping_peer_health(&client, &peer.http_url).await;
|
||||
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
|
||||
}
|
||||
gossip_ping_round(
|
||||
state.db.as_ref(),
|
||||
client.as_ref(),
|
||||
&mut failed_once,
|
||||
peers,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
@@ -959,6 +1063,106 @@ async fn gossip_task(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rate_limiter_sweep_tests {
|
||||
use crate::rate_limit::RateLimiter;
|
||||
use std::time::Duration;
|
||||
|
||||
// Every per-key limiter the router mounts must be swept by the periodic
|
||||
// task, the `/ipfs` one included: a limiter left out keeps expired keys
|
||||
// until its map fills and the inline capacity sweep fires. Fails on the
|
||||
// pre-fix sweeper, which skipped `ipfs_rate_limiter`.
|
||||
#[tokio::test]
|
||||
async fn sweep_evicts_expired_keys_from_every_limiter() {
|
||||
let window = Duration::from_millis(30);
|
||||
let mut state = crate::test_support::test_state_lazy();
|
||||
state.rate_limiter = RateLimiter::new(10, window);
|
||||
state.create_ip_rate_limiter = RateLimiter::new(10, window);
|
||||
state.push_rate_limiter = RateLimiter::new(10, window);
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
|
||||
state.peer_write_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_rate_limiter = RateLimiter::new(10, window);
|
||||
|
||||
let limiters = |s: &crate::state::AppState| {
|
||||
[
|
||||
s.rate_limiter.clone(),
|
||||
s.create_ip_rate_limiter.clone(),
|
||||
s.push_rate_limiter.clone(),
|
||||
s.sync_trigger_rate_limiter.clone(),
|
||||
s.peer_write_rate_limiter.clone(),
|
||||
s.ipfs_rate_limiter.clone(),
|
||||
]
|
||||
};
|
||||
for l in limiters(&state) {
|
||||
assert!(l.check("1.2.3.4").await);
|
||||
assert_eq!(l.tracked_keys().await, 1);
|
||||
}
|
||||
|
||||
tokio::time::sleep(window * 3).await;
|
||||
super::sweep_rate_limiters(&state).await;
|
||||
|
||||
for (i, l) in limiters(&state).into_iter().enumerate() {
|
||||
assert_eq!(l.tracked_keys().await, 0, "limiter {i} was not swept");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict expired entries from every per-key rate limiter on the state.
|
||||
///
|
||||
/// Named and driven off `AppState` so the periodic sweeper stays in step with
|
||||
/// the limiters the router actually mounts: adding a limiter field and
|
||||
/// forgetting it here leaves its keys pinned until the map hits `max_keys` and
|
||||
/// the inline capacity sweep runs (the `/ipfs` limiter was missed this way).
|
||||
async fn sweep_rate_limiters(state: &AppState) {
|
||||
state.rate_limiter.cleanup().await;
|
||||
state.create_ip_rate_limiter.cleanup().await;
|
||||
state.push_rate_limiter.cleanup().await;
|
||||
state.sync_trigger_rate_limiter.cleanup().await;
|
||||
state.peer_write_rate_limiter.cleanup().await;
|
||||
state.ipfs_rate_limiter.cleanup().await;
|
||||
}
|
||||
|
||||
async fn gossip_ping_round(
|
||||
db: &Db,
|
||||
client: &reqwest::Client,
|
||||
failed_once: &mut HashSet<String>,
|
||||
peers: Vec<db::PeerRecord>,
|
||||
) {
|
||||
{
|
||||
let current_dids: HashSet<&str> = peers.iter().map(|peer| peer.did.as_str()).collect();
|
||||
failed_once.retain(|did| current_dids.contains(did.as_str()));
|
||||
}
|
||||
for peer in peers {
|
||||
let ok = ping_peer_readiness(client, &peer.http_url).await;
|
||||
match peer_ping_db_update(failed_once, &peer.did, ok) {
|
||||
Some(reachable) => {
|
||||
if let Err(error) = db.mark_peer_ping(&peer.did, reachable).await {
|
||||
warn!(did = %peer.did, err = %error, "failed to persist peer readiness");
|
||||
}
|
||||
}
|
||||
None => warn!(
|
||||
did = %peer.did,
|
||||
"peer readiness probe failed once; preserving previous federation status"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether one readiness sample should update the persisted federation
|
||||
/// gate. A success is authoritative immediately. A failure must repeat on the
|
||||
/// next gossip tick before it can mark a peer unreachable, so one transient
|
||||
/// database hiccup does not hide the peer's repositories for five minutes.
|
||||
fn peer_ping_db_update(failed_once: &mut HashSet<String>, did: &str, ready: bool) -> Option<bool> {
|
||||
if ready {
|
||||
failed_once.remove(did);
|
||||
Some(true)
|
||||
} else if failed_once.insert(did.to_owned()) {
|
||||
None
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shared node HTTP client used for every outbound fan-out (sync
|
||||
/// trigger, profile/repo fetches, gossip announce + peer pings).
|
||||
///
|
||||
@@ -969,26 +1173,96 @@ async fn gossip_task(
|
||||
/// runs, not a hand-rolled equivalent.
|
||||
fn build_http_client() -> reqwest::Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(OUTBOUND_HTTP_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Ping a peer's `/health` endpoint and report whether it answered 2xx.
|
||||
const OUTBOUND_HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Ping a peer's DB-aware `/ready` endpoint and report whether it answered 2xx.
|
||||
///
|
||||
/// A 404 falls back to `/health` for compatibility with nodes released before
|
||||
/// `/ready` existed. Other readiness failures, including 503 during a database
|
||||
/// outage, fail closed and must not use the liveness-only endpoint. The complete
|
||||
/// `/ready`-then-`/health` probe shares one timeout budget.
|
||||
///
|
||||
/// Takes the client by reference so callers supply the shared, no-redirect
|
||||
/// `state.http_client`. Peer URLs are attacker-influenceable, so a `3xx` to a
|
||||
/// private address must not be followed. Do NOT call this with a bare
|
||||
/// `reqwest::Client::new()`: its default follows redirects and would
|
||||
/// reintroduce the SSRF this guards against (#93).
|
||||
async fn ping_peer_health(client: &reqwest::Client, http_url: &str) -> bool {
|
||||
let url = format!("{}/health", http_url.trim_end_matches('/'));
|
||||
client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
pub(crate) async fn ping_peer_readiness(client: &reqwest::Client, http_url: &str) -> bool {
|
||||
ping_peer_readiness_with_timeout(client, http_url, OUTBOUND_HTTP_TIMEOUT).await
|
||||
}
|
||||
|
||||
async fn ping_peer_readiness_with_timeout(
|
||||
client: &reqwest::Client,
|
||||
http_url: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> bool {
|
||||
let base_url = http_url.trim_end_matches('/');
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let readiness = client.get(format!("{base_url}/ready")).send().await;
|
||||
|
||||
match readiness {
|
||||
Ok(response) if response.status().is_success() => true,
|
||||
Ok(response) if response.status() == reqwest::StatusCode::NOT_FOUND => {
|
||||
info!(
|
||||
peer_url = %http_url,
|
||||
"peer has no readiness endpoint; falling back to legacy health probe"
|
||||
);
|
||||
match client.get(format!("{base_url}/health")).send().await {
|
||||
Ok(response) if response.status().is_success() => true,
|
||||
Ok(response) => {
|
||||
warn!(
|
||||
peer_url = %http_url,
|
||||
status = %response.status(),
|
||||
"legacy peer health probe reported unhealthy"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
peer_url = %http_url,
|
||||
err = %error,
|
||||
"legacy peer health probe failed"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
warn!(
|
||||
peer_url = %http_url,
|
||||
status = %response.status(),
|
||||
"peer readiness probe reported unready"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
peer_url = %http_url,
|
||||
err = %error,
|
||||
"peer readiness probe failed"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(ready) => ready,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
peer_url = %http_url,
|
||||
timeout_ms = timeout.as_millis(),
|
||||
"peer readiness probe timed out"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
@@ -1026,20 +1300,130 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::ping_peer_health;
|
||||
use super::{
|
||||
gossip_ping_round, peer_ping_db_update, ping_peer_readiness,
|
||||
ping_peer_readiness_with_timeout,
|
||||
};
|
||||
use axum::{http::StatusCode, routing::get, Router};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// Build the client exactly as production does (super::build_http_client) so
|
||||
// these tests bind the redirect guarantee to the real shared client the
|
||||
// node runs. A regression that makes build_http_client follow redirects
|
||||
// fails ping_peer_health_does_not_follow_redirect.
|
||||
// fails ping_peer_readiness_does_not_follow_redirect.
|
||||
fn production_http_client() -> reqwest::Client {
|
||||
super::build_http_client().expect("failed to build production http client")
|
||||
}
|
||||
|
||||
// A peer answering `/health` with a 302 toward an internal address must not
|
||||
#[test]
|
||||
fn peer_ping_requires_two_failures_before_marking_unreachable() {
|
||||
let mut failed_once = HashSet::new();
|
||||
let did = "did:key:z6MkPeer";
|
||||
|
||||
assert_eq!(
|
||||
peer_ping_db_update(&mut failed_once, did, false),
|
||||
None,
|
||||
"one transient failure must preserve the persisted federation gate"
|
||||
);
|
||||
assert_eq!(
|
||||
peer_ping_db_update(&mut failed_once, did, false),
|
||||
Some(false),
|
||||
"a sustained failure must mark the peer unreachable"
|
||||
);
|
||||
assert_eq!(
|
||||
peer_ping_db_update(&mut failed_once, did, true),
|
||||
Some(true),
|
||||
"a success must restore reachability immediately"
|
||||
);
|
||||
assert_eq!(
|
||||
peer_ping_db_update(&mut failed_once, did, false),
|
||||
None,
|
||||
"a success must reset the consecutive-failure state"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn gossip_ping_round_requires_two_failures_before_persisting_unreachable(pool: PgPool) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(503)
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let did = "did:key:z6MkGossipHysteresis";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO peers (did, http_url, last_seen, last_ping_ok, announced_at)
|
||||
VALUES ($1, $2, $3, TRUE, $3)",
|
||||
)
|
||||
.bind(did)
|
||||
.bind(server.url())
|
||||
.bind(now)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut failed_once = HashSet::from(["did:key:z6MkRemovedPeer".to_owned()]);
|
||||
gossip_ping_round(
|
||||
state.db.as_ref(),
|
||||
&production_http_client(),
|
||||
&mut failed_once,
|
||||
state.db.list_peers().await.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!failed_once.contains("did:key:z6MkRemovedPeer"),
|
||||
"failure tracking must prune peers outside the current snapshot"
|
||||
);
|
||||
assert!(
|
||||
failed_once.contains(did),
|
||||
"the first failed sample must be retained for the next round"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|peer| peer.did == did)
|
||||
.unwrap()
|
||||
.last_ping_ok,
|
||||
"one transient failure must preserve the persisted federation gate"
|
||||
);
|
||||
|
||||
gossip_ping_round(
|
||||
state.db.as_ref(),
|
||||
&production_http_client(),
|
||||
&mut failed_once,
|
||||
state.db.list_peers().await.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!state
|
||||
.db
|
||||
.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|peer| peer.did == did)
|
||||
.unwrap()
|
||||
.last_ping_ok,
|
||||
"two consecutive failed rounds must persist the peer as unreachable"
|
||||
);
|
||||
ready.assert_async().await;
|
||||
}
|
||||
|
||||
// A peer answering `/ready` with a 302 toward an internal address must not
|
||||
// be followed: the redirect target must never be requested (#93).
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_does_not_follow_redirect() {
|
||||
async fn ping_peer_readiness_does_not_follow_redirect() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let internal = server
|
||||
.mock("GET", "/internal-metadata")
|
||||
@@ -1047,6 +1431,154 @@ mod gossip_ssrf_tests {
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let _ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/internal-metadata", server.url()))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_readiness(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(!ok, "a 302 must not count as a healthy peer");
|
||||
// expect(0) is enforced only at assert time; this fails if the redirect
|
||||
// was followed to the internal target.
|
||||
internal.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_readiness_reports_success_on_200() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(200)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_readiness(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(ok, "a 200 /ready must count as a ready peer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_readiness_ignores_liveness_only_health() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(503)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_readiness(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(!ok, "a peer with an unavailable database must not be ready");
|
||||
health.assert_async().await;
|
||||
ready.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_readiness_falls_back_for_legacy_peer() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(404)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_readiness(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(
|
||||
ok,
|
||||
"a legacy peer with a healthy /health must remain reachable"
|
||||
);
|
||||
ready.assert_async().await;
|
||||
health.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_readiness_legacy_fallback_shares_deadline() {
|
||||
let health_requests = Arc::new(AtomicUsize::new(0));
|
||||
let health_requests_for_route = Arc::clone(&health_requests);
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/ready",
|
||||
get(|| async {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
StatusCode::NOT_FOUND
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/health",
|
||||
get(move || {
|
||||
let health_requests = Arc::clone(&health_requests_for_route);
|
||||
async move {
|
||||
health_requests.fetch_add(1, Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
StatusCode::OK
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("failed to bind delayed legacy peer");
|
||||
let peer_url = format!(
|
||||
"http://{}",
|
||||
listener
|
||||
.local_addr()
|
||||
.expect("delayed legacy peer has no local address")
|
||||
);
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("delayed legacy peer failed");
|
||||
});
|
||||
|
||||
let ok = ping_peer_readiness_with_timeout(
|
||||
&production_http_client(),
|
||||
&peer_url,
|
||||
// Each response arrives within 350 ms, but the two serial requests
|
||||
// cannot both finish within one 350 ms probe budget.
|
||||
Duration::from_millis(350),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!ok, "the fallback must not start a fresh timeout budget");
|
||||
assert_eq!(
|
||||
health_requests.load(Ordering::Relaxed),
|
||||
1,
|
||||
"the delayed 404 should reach the legacy fallback"
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_readiness_legacy_fallback_does_not_follow_redirect() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let internal = server
|
||||
.mock("GET", "/internal-metadata")
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let _ready = server
|
||||
.mock("GET", "/ready")
|
||||
.with_status(404)
|
||||
.create_async()
|
||||
.await;
|
||||
let _health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(302)
|
||||
@@ -1054,33 +1586,17 @@ mod gossip_ssrf_tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
|
||||
let ok = ping_peer_readiness(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(!ok, "a 302 must not count as a healthy peer");
|
||||
// expect(0) is enforced only at assert time; this fails if the redirect
|
||||
// was followed to the internal target.
|
||||
assert!(!ok, "the legacy fallback must not follow redirects");
|
||||
internal.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_reports_success_on_200() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(ok, "a 200 /health must count as a healthy peer");
|
||||
}
|
||||
|
||||
// A transport error (nothing listening) must map to unhealthy, never a
|
||||
// spurious healthy — the .unwrap_or(false) arm.
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_reports_unhealthy_on_connection_error() {
|
||||
let ok = ping_peer_health(&production_http_client(), "http://127.0.0.1:1").await;
|
||||
assert!(!ok, "a connection error must count as an unhealthy peer");
|
||||
async fn ping_peer_readiness_reports_unready_on_connection_error() {
|
||||
let ok = ping_peer_readiness(&production_http_client(), "http://127.0.0.1:1").await;
|
||||
assert!(!ok, "a connection error must count as an unready peer");
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,15 @@ static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
/// more than once is a silent no-op. MUST be called from `main()` after
|
||||
/// the node DID is known.
|
||||
pub fn init(version: &str, node_did: &str) {
|
||||
if REGISTRY.get().is_some() {
|
||||
return;
|
||||
}
|
||||
// Guard with Once so two concurrent callers cannot both pass an unsynchronized
|
||||
// `REGISTRY.get()` check and then race on `OnceLock::set(...).expect(...)`,
|
||||
// panicking the loser (#192 F4). Once runs the body exactly once, so the
|
||||
// `.expect("set X once")` calls below can never observe an already-set slot.
|
||||
static INIT: std::sync::Once = std::sync::Once::new();
|
||||
INIT.call_once(|| init_inner(version, node_did));
|
||||
}
|
||||
|
||||
fn init_inner(version: &str, node_did: &str) {
|
||||
let registry = Registry::new();
|
||||
|
||||
let info = IntGaugeVec::new(
|
||||
@@ -218,6 +223,17 @@ pub fn record_fetch(repo: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: current `gitlawb_fetches_total` value for `repo` (0 if the registry
|
||||
/// is not initialized). Lets api-layer tests assert the completed-fetch count with
|
||||
/// a unique label instead of scraping the encoded text.
|
||||
#[cfg(test)]
|
||||
pub fn fetch_count_for_test(repo: &str) -> u64 {
|
||||
FETCHES
|
||||
.get()
|
||||
.map(|c| c.with_label_values(&[repo]).get())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Record one HTTP signature check that passed.
|
||||
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
|
||||
pub fn record_auth_success(route: &str) {
|
||||
@@ -286,11 +302,11 @@ pub fn encode() -> Result<String, prometheus::Error> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: these tests are not run in parallel by default (cargo runs
|
||||
// tests in a binary in parallel via threads but the OnceLock guard
|
||||
// means only the first init() call succeeds; subsequent ones are
|
||||
// no-ops). The encode test below is structured so it's safe to run
|
||||
// alongside other tests in the same binary.
|
||||
// Note: cargo runs tests in a binary in parallel via threads, and several
|
||||
// tests here call init(). init() is guarded by std::sync::Once, so concurrent
|
||||
// callers run the body exactly once and never race on the OnceLock setters
|
||||
// (#192 F4). The encode test below is structured so it's safe to run alongside
|
||||
// other tests in the same binary regardless of ordering.
|
||||
|
||||
#[test]
|
||||
fn encode_after_init_returns_prometheus_text() {
|
||||
@@ -321,6 +337,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #192 F4: `init` is idempotent and safe to call repeatedly. The panic that
|
||||
/// hit concurrent callers (both passing the old unsynchronized `REGISTRY.get()`
|
||||
/// check, then racing on `OnceLock::set(...).expect(...)`) is fixed by the
|
||||
/// `Once` guard. That race is inherently non-deterministic to assert in the
|
||||
/// shared-binary suite — a 32-thread barrier stress-test reproduces it only in
|
||||
/// isolation (verified: RED before the `Once` fix, GREEN after). This guard
|
||||
/// covers the deterministic half of the contract: a repeat call is a no-op,
|
||||
/// never a panic, and the registry stays usable.
|
||||
#[test]
|
||||
fn init_is_idempotent_no_panic_on_repeat() {
|
||||
init("0.0.0-a", "did:key:a");
|
||||
init("0.0.0-b", "did:key:b");
|
||||
assert!(
|
||||
encode().is_ok(),
|
||||
"registry must stay usable after a repeated init"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_helpers_are_noops_before_init() {
|
||||
// These don't panic. They also don't show up in encode() output
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! no-op, so nodes without Pinata backing work fine.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Pin a single git object's raw bytes on Pinata (v3 API).
|
||||
///
|
||||
@@ -72,25 +73,77 @@ pub async fn pin_object(
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin: 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. The twin in `ipfs_pin.rs` mirrors
|
||||
/// this shape — change both in lockstep. Objects already recorded with a
|
||||
/// `pinata_cid` are skipped. Returns `(sha_hex, cid)` pairs for each newly
|
||||
/// pinned object.
|
||||
/// 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.
|
||||
///
|
||||
/// # 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:
|
||||
///
|
||||
/// - 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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn pin_new_objects(
|
||||
client: &reqwest::Client,
|
||||
upload_url: &str,
|
||||
jwt: &str,
|
||||
repo_path: &std::path::Path,
|
||||
git_bin: &str,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
batch_budget: Duration,
|
||||
) -> Vec<(String, String)> {
|
||||
if jwt.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + batch_budget;
|
||||
let total = object_list.len();
|
||||
let mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
for (attempted, sha) in object_list.into_iter().enumerate() {
|
||||
// Top of the iteration, before any of this object's work: an object is never
|
||||
// started with a remainder too small to cover a bounded read's teardown. The
|
||||
// gate is shared with the IPFS loop so the two cannot drift apart in how they
|
||||
// report a truncated batch. Consumed as a guard only: the read below runs against
|
||||
// the absolute batch deadline, and `pin_object` takes no per-request override, so
|
||||
// the remainder has no other consumer here.
|
||||
if crate::ipfs_pin::batch_budget_gate("Pinata", deadline, pinned.len(), total - attempted)
|
||||
.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
match db.has_pinata_cid(&sha).await {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
@@ -100,13 +153,73 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
}
|
||||
|
||||
let data = match crate::git::store::read_object(repo_path, &sha) {
|
||||
Ok(Some((_kind, bytes))) => bytes,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
// Read raw object content, bounded and reaped, under `spawn_blocking`: this is
|
||||
// synchronous blocking work (child spawn, pipe drain, watchdog join), so calling
|
||||
// it from the runtime task would block a worker thread for its whole duration.
|
||||
// Placement mirrors the `/ipfs` serve path and the IPFS pin loop.
|
||||
//
|
||||
// The read runs against the ABSOLUTE batch deadline, not against the remainder
|
||||
// measured at the top of the iteration: the `has_pinata_cid` round-trip above sits
|
||||
// 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;
|
||||
let read_path = repo_path.to_path_buf();
|
||||
let read_sha = sha.clone();
|
||||
let read_git = git_bin.to_string();
|
||||
let read = tokio::task::spawn_blocking(move || {
|
||||
crate::git::store::read_object_bounded(&read_git, &read_path, &read_sha, read_deadline)
|
||||
})
|
||||
.await;
|
||||
let data = match read {
|
||||
Ok(Ok(Some((_kind, bytes)))) => bytes,
|
||||
// A verified absence, and the only outcome that is not a fault.
|
||||
Ok(Ok(None)) => continue,
|
||||
// A Transient fault does NOT by itself mean the store is gone. It also covers
|
||||
// a spawn or watchdog-timeout failure of the reaped child, an unaffordable
|
||||
// confirming re-probe, and, because readability is judged FOR one oid, a
|
||||
// single unreadable `objects/<xx>` fan-out, which is 1/256 of the store. So
|
||||
// re-check store-wide before deciding what the fault costs.
|
||||
Ok(Err(e @ crate::git::store::ProbeError::Transient(_))) => {
|
||||
if !crate::git::store::object_store_readable_store_wide(repo_path) {
|
||||
// Genuinely store-wide: every remaining object fails identically, and
|
||||
// continuing would spawn one doomed bounded child per object and spend
|
||||
// the batch budget reaping them.
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
err = %e,
|
||||
unattempted = total - attempted,
|
||||
"object store unreadable while pinning to Pinata; stopping the batch"
|
||||
);
|
||||
break;
|
||||
}
|
||||
// The store still reads store-wide, so the fault is object-scoped or
|
||||
// transient to this read. Breaking would forfeit a healthy store's
|
||||
// remaining objects permanently: the documented recovery re-derives the
|
||||
// same list and breaks at the same index.
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
err = %e,
|
||||
"transient fault reading git object for Pinata; the object store is \
|
||||
still readable store-wide, so this costs only this object"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// The store is readable and git still failed: a corrupt object, or a
|
||||
// repo-wide fault git reports immediately. Either way it is per-object work
|
||||
// that stays inside the budget, and breaking would forfeit a healthy store's
|
||||
// remaining objects over one bad one, permanently (a later full-scan push
|
||||
// re-offers the same object and breaks in the same place).
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to read git object for Pinata");
|
||||
continue;
|
||||
}
|
||||
// A panic in the read closure leaves no evidence that the failure is
|
||||
// object-scoped, so fail toward the conservative arm.
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "bounded git read task failed; stopping the batch");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match pin_object(client, upload_url, jwt, &sha, &data).await {
|
||||
@@ -132,6 +245,626 @@ pub async fn pin_new_objects(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write `n` loose blobs into a fresh bare repo and return their oids. The read
|
||||
/// path shells to `git cat-file`, so the objects must genuinely exist on disk: a
|
||||
/// fabricated oid would `continue` past the upload and the loop scenarios below
|
||||
/// would prove nothing. Copied from `ipfs_pin.rs`'s test mod rather than shared,
|
||||
/// since test mods are private.
|
||||
fn seed_loose_blobs(repo_path: &std::path::Path, n: usize) -> Vec<String> {
|
||||
crate::git::store::init_bare(repo_path).expect("init bare repo");
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(["hash-object", "-w", "--stdin"])
|
||||
.current_dir(repo_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn git hash-object");
|
||||
{
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin")
|
||||
.write_all(format!("pinata loop object {i}\n").as_bytes())
|
||||
.expect("write stdin");
|
||||
}
|
||||
let out = child.wait_with_output().expect("hash-object output");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git hash-object: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A live Pinata-shaped endpoint that answers each upload with a v3 `data.cid`
|
||||
/// body after `delays[i]` for the i-th request it accepts (the last entry
|
||||
/// repeats), counting the requests it received.
|
||||
///
|
||||
/// Hand rolled rather than driven with `mockito` like the `pin_object` tests
|
||||
/// above: mockito has no per-response delay primitive, and the batch-budget test
|
||||
/// needs uploads that are slow enough to exhaust the budget partway. Drains the
|
||||
/// full request, headers plus the declared `Content-Length` body, before
|
||||
/// sleeping: answering early and closing would surface as a write failure on the
|
||||
/// client and turn a slow-but-healthy upload into a different failure shape.
|
||||
/// Same fixture shape as `ipfs_pin.rs`'s `delaying_endpoint`.
|
||||
async fn delaying_pinata_endpoint(
|
||||
delays: Vec<Duration>,
|
||||
requests: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
) -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let mut seen = 0usize;
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let delay = *delays
|
||||
.get(seen)
|
||||
.or_else(|| delays.last())
|
||||
.unwrap_or(&Duration::ZERO);
|
||||
seen += 1;
|
||||
requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let mut acc = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = match sock.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
acc.extend_from_slice(&buf[..n]);
|
||||
if let Some(hdr_end) =
|
||||
acc.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
|
||||
{
|
||||
let headers = String::from_utf8_lossy(&acc[..hdr_end]).to_lowercase();
|
||||
let len: usize = headers
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("content-length:"))
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
if acc.len() >= hdr_end + len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
let body = br#"{"data":{"cid":"QmPinataBatchTestCid","name":"git.bin"}}"#;
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(head.as_bytes()).await;
|
||||
let _ = sock.write_all(body).await;
|
||||
let _ = sock.flush().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// A `tracing` sink a test can read back, so the truncation warn and its sink
|
||||
/// label can be asserted on rather than assumed. Installed with `set_default`,
|
||||
/// which is thread-local and scoped to the guard, so it cannot bleed into any
|
||||
/// other test in the binary.
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLogs(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
impl CapturedLogs {
|
||||
fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for CapturedLogs {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
|
||||
type Writer = CapturedLogs;
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_logs() -> (CapturedLogs, tracing::subscriber::DefaultGuard) {
|
||||
let logs = CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_writer(logs.clone())
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_ansi(false)
|
||||
.finish();
|
||||
let guard = tracing::subscriber::set_default(subscriber);
|
||||
(logs, guard)
|
||||
}
|
||||
|
||||
/// Write an executable `/bin/sh` script. Copied per module rather than shared:
|
||||
/// `store.rs`, `visibility_pack.rs` and `ipfs_pin.rs` each keep their own, since
|
||||
/// their test mods are private and not reachable from here.
|
||||
#[cfg(unix)]
|
||||
fn write_script(path: &std::path::Path, body: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(path, body).expect("write fake git");
|
||||
let mut perm = std::fs::metadata(path).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perm).unwrap();
|
||||
}
|
||||
|
||||
/// The permit-hold bound on this branch. `pin_new_objects` runs under the same
|
||||
/// deferring `pin_semaphore` as the IPFS loop, so without a batch deadline the
|
||||
/// hold is O(N) with N chosen by the pusher. Five objects against an endpoint
|
||||
/// that takes 2s each, under a 5.5s budget, must stop partway: the batch is
|
||||
/// truncated and the remainder is left unattempted with exactly one warn naming
|
||||
/// how many, labelled for this sink.
|
||||
///
|
||||
/// The windows are deliberately loose. Four pins would need every upload to
|
||||
/// answer in under 1.4s, which the endpoint's own 2s sleep forbids, and one pin
|
||||
/// needs only the first upload to land inside 5.5s, so both bounds hold with
|
||||
/// more than a second of slack on a loaded box.
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_stops_the_batch_at_its_deadline(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("slow.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 5);
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint = delaying_pinata_endpoint(
|
||||
vec![Duration::from_secs(2)],
|
||||
std::sync::Arc::clone(&requests),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (logs, _guard) = capture_logs();
|
||||
let client = reqwest::Client::new();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
oids,
|
||||
&db,
|
||||
Duration::from_millis(5500),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("wedge guard: a 5.5s budget cannot take 30s");
|
||||
|
||||
assert!(
|
||||
(1..=3).contains(&pinned.len()),
|
||||
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
|
||||
pinned.len()
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
pinned.len(),
|
||||
"no upload may be issued for an object the budget stopped short of"
|
||||
);
|
||||
let text = logs.text();
|
||||
let warns: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|l| l.contains("pin batch deadline reached"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
warns.len(),
|
||||
1,
|
||||
"the deadline must be reported exactly once for the batch, not per object: {text}"
|
||||
);
|
||||
assert!(
|
||||
warns[0].contains("Pinata"),
|
||||
"the truncation warn must name this sink, not the twin's: {}",
|
||||
warns[0]
|
||||
);
|
||||
let unattempted: usize = warns[0]
|
||||
.split("unattempted=")
|
||||
.nth(1)
|
||||
.and_then(|s| {
|
||||
s.split(|c: char| !c.is_ascii_digit())
|
||||
.next()
|
||||
.and_then(|d| d.parse().ok())
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
|
||||
assert!(
|
||||
unattempted >= 1 && unattempted + pinned.len() <= 5,
|
||||
"unattempted={unattempted} with {} pinned is not a partial batch of five",
|
||||
pinned.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F3 on this branch: the git read runs while the `pin_semaphore` permit is
|
||||
/// held, so a wedged `git cat-file` used to hold that permit for as long as the
|
||||
/// child lived, with no deadline and no reaping, on a path a pusher drives. With
|
||||
/// the read bounded, a git that never answers costs the batch its budget plus one
|
||||
/// watchdog teardown and no more.
|
||||
///
|
||||
/// The fake traps SIGTERM and sleeps a BOUNDED 30s, following the fixture in
|
||||
/// `visibility_pack.rs`: with the deadline neutralized the read would otherwise
|
||||
/// leave the blocking closure and its child alive long after the test-level
|
||||
/// timeout fires, wedging the run instead of reporting a failure. The endpoint is
|
||||
/// never reached, since no object's bytes are ever produced.
|
||||
///
|
||||
/// The batch ends on the BUDGET, not on the fault arm: the repo is a healthy bare
|
||||
/// store, so the timeout's `Transient` verdict is object-scoped and the loop moves on,
|
||||
/// only to find the budget spent. Capturing that warn is not decoration. `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 deadline tests running beside this one.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_returns_by_budget_with_a_hung_git(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("wedged.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 3);
|
||||
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(25),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
"http://127.0.0.1:9",
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
fake.to_str().unwrap(),
|
||||
oids,
|
||||
&db,
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"a wedged git must not hold the pin permit past the batch budget: the read is \
|
||||
bounded and reaped, so this cannot reach the outer timeout",
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(20),
|
||||
"elapsed {elapsed:?} must stay inside the budget plus one watchdog teardown"
|
||||
);
|
||||
let text = logs.text();
|
||||
assert_eq!(
|
||||
text.lines()
|
||||
.filter(|l| l.contains("pin batch deadline reached"))
|
||||
.count(),
|
||||
1,
|
||||
"one wedged read must spend the whole budget and stop the batch there, exactly \
|
||||
once: {text}"
|
||||
);
|
||||
|
||||
// The child's process group must be gone once the call returns; a bounded read
|
||||
// that leaves the child running has only moved the hold somewhere else.
|
||||
let pid: i32 = std::fs::read_to_string(repo_path.join("pid"))
|
||||
.expect("the fake git must have recorded its pid, or it was never on the read path")
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mut gone = false;
|
||||
for _ in 0..200 {
|
||||
// SAFETY: kill(2) with signal 0 only probes existence; ESRCH means gone.
|
||||
if unsafe { libc::kill(pid, 0) } != 0 {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
gone,
|
||||
"the reaped fake git ({pid}) must not outlive the call"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// continued past all of them. Copied from `ipfs_pin.rs`'s test mod, like the fixtures
|
||||
/// above, since test mods are private.
|
||||
#[cfg(unix)]
|
||||
fn counting_git(dir: &std::path::Path, log: &std::path::Path) -> String {
|
||||
let fake = dir.join("counting-git");
|
||||
write_script(
|
||||
&fake,
|
||||
&format!(
|
||||
"#!/bin/sh\necho \"$*\" >> {}\nexec git \"$@\"\n",
|
||||
log.display()
|
||||
),
|
||||
);
|
||||
fake.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// How many objects the loop actually attempted, read off the invocation log.
|
||||
///
|
||||
/// Counts `--batch-check` invocations, not log lines and not oid occurrences: the type
|
||||
/// 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.
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter(|l| l.contains("--batch-check"))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The store-wide fault arm, the twin of the IPFS loop's. When the object store cannot
|
||||
/// be read at all every remaining object fails identically, so continuing would spawn
|
||||
/// one doomed bounded child per object and burn the batch budget on reaping them.
|
||||
///
|
||||
/// The fixture looks wrong and is not: with the objects LOOSE and only `objects/pack`
|
||||
/// unreadable, git still resolves each object, but it prints an `error:` diagnostic
|
||||
/// that the probe routes to a fault before the present/missing parse, so the read
|
||||
/// reaches the fault classification and (the store being unreadable) returns
|
||||
/// `Transient`, which the store-wide re-check then confirms really is store-wide.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_breaks_the_batch_on_an_unreadable_store(pool: sqlx::PgPool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
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("unreadable.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 5);
|
||||
let log = tmp.path().join("calls.log");
|
||||
let git_bin = counting_git(tmp.path(), &log);
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint =
|
||||
delaying_pinata_endpoint(vec![Duration::ZERO], std::sync::Arc::clone(&requests)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let pack_dir = repo_path.join("objects").join("pack");
|
||||
let chmod = |mode: u32| {
|
||||
let mut perms = std::fs::metadata(&pack_dir).unwrap().permissions();
|
||||
perms.set_mode(mode);
|
||||
std::fs::set_permissions(&pack_dir, perms).unwrap();
|
||||
};
|
||||
chmod(0o000);
|
||||
// Root bypasses permission bits, so witness the exact operation the probe performs
|
||||
// and skip rather than falsely fail.
|
||||
let genuinely_unreadable = std::fs::read_dir(&pack_dir).is_err();
|
||||
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
&git_bin,
|
||||
oids,
|
||||
&db,
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an immediately-faulting store cannot take 30s");
|
||||
let attempted = objects_attempted(&log);
|
||||
chmod(0o755); // restore BEFORE any assertion that can panic, so TempDir cleans up
|
||||
|
||||
if genuinely_unreadable {
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
"nothing can be pinned through a store that cannot be read: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
attempted, 1,
|
||||
"a store-wide fault must break the batch after the first object, not spawn \
|
||||
one doomed bounded child per object: {attempted} of 5 objects were read"
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"no upload may be issued for an object whose bytes were never read"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The must-not direction of the arm above, the twin of the IPFS loop's. One corrupt
|
||||
/// loose object among healthy ones is a `Deterministic` fault (the store is readable,
|
||||
/// git still fails), and the documented recovery path cannot repair it: a later
|
||||
/// full-scan push re-offers the same object and would break at the same place, so
|
||||
/// breaking here stops the repo replicating permanently.
|
||||
///
|
||||
/// Deliberately not the bad-config corruption, which is repo-wide: all five objects
|
||||
/// would fault and the test would pin the store-wide case rather than the object-scoped
|
||||
/// one this arm rests on.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_continues_past_a_deterministic_fault(pool: sqlx::PgPool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
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("corrupt.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 5);
|
||||
let log = tmp.path().join("calls.log");
|
||||
let git_bin = counting_git(tmp.path(), &log);
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint =
|
||||
delaying_pinata_endpoint(vec![Duration::ZERO], std::sync::Arc::clone(&requests)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Overwrite exactly one loose object with non-zlib garbage (0o444 by default).
|
||||
let victim = repo_path
|
||||
.join("objects")
|
||||
.join(&oids[0][0..2])
|
||||
.join(&oids[0][2..]);
|
||||
assert!(victim.is_file(), "fixture must leave the blob loose");
|
||||
let mut perms = std::fs::metadata(&victim).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
std::fs::set_permissions(&victim, perms).unwrap();
|
||||
std::fs::write(&victim, b"garbage not a zlib stream").unwrap();
|
||||
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
&git_bin,
|
||||
oids,
|
||||
&db,
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an immediate endpoint and four healthy objects cannot take 30s");
|
||||
|
||||
assert_eq!(
|
||||
objects_attempted(&log),
|
||||
5,
|
||||
"an object-scoped fault must not stop the batch: every object must be read"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
4,
|
||||
"one corrupt object must cost only itself: the other four must still pin"
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
4,
|
||||
"exactly the readable objects may reach the endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
/// The normal direction, and the dedup branch driven both ways: on a healthy
|
||||
/// store with a healthy endpoint and a generous budget every object pins and the
|
||||
/// CID is recorded, and a second call over the same list uploads nothing because
|
||||
/// `has_pinata_cid` now answers true for all of them.
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_pins_every_object_then_skips_the_recorded_ones(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("healthy.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 3);
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint =
|
||||
delaying_pinata_endpoint(vec![Duration::ZERO], std::sync::Arc::clone(&requests)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
oids.clone(),
|
||||
&db,
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an immediate endpoint and three healthy objects cannot take 30s");
|
||||
|
||||
assert_eq!(pinned.len(), 3, "every healthy object must pin: {pinned:?}");
|
||||
for (i, (sha, cid)) in pinned.iter().enumerate() {
|
||||
assert_eq!(sha, &oids[i], "the pairs must carry the objects' own oids");
|
||||
assert_eq!(cid, "QmPinataBatchTestCid");
|
||||
assert!(
|
||||
db.has_pinata_cid(sha).await.unwrap(),
|
||||
"a pinned object must be recorded so the next batch skips it"
|
||||
);
|
||||
}
|
||||
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 3);
|
||||
|
||||
let again = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
oids,
|
||||
&db,
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a fully deduped batch cannot take 30s");
|
||||
assert!(again.is_empty(), "already-recorded objects must be skipped");
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
3,
|
||||
"the skip must happen before the upload, not after it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The no-op configuration still short-circuits with the budgeted signature: an
|
||||
/// empty JWT must return before any git child is spawned and before any request
|
||||
/// is issued. The `git_bin` here records every invocation, so "git was never
|
||||
/// touched" is observed rather than assumed.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_with_an_empty_jwt_touches_neither_git_nor_the_endpoint(
|
||||
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("unconfigured.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 2);
|
||||
let log = tmp.path().join("calls.log");
|
||||
let fake = tmp.path().join("counting-git");
|
||||
write_script(
|
||||
&fake,
|
||||
&format!(
|
||||
"#!/bin/sh\necho \"$*\" >> {}\nexec git \"$@\"\n",
|
||||
log.display()
|
||||
),
|
||||
);
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint =
|
||||
delaying_pinata_endpoint(vec![Duration::ZERO], std::sync::Arc::clone(&requests)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"",
|
||||
&repo_path,
|
||||
fake.to_str().unwrap(),
|
||||
oids,
|
||||
&db,
|
||||
Duration::from_secs(60),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an unconfigured sink returns immediately");
|
||||
|
||||
assert!(pinned.is_empty(), "an empty JWT pins nothing");
|
||||
assert!(
|
||||
!log.exists(),
|
||||
"no git child may be spawned when the sink is not configured"
|
||||
);
|
||||
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pin_skipped_when_jwt_empty() {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
@@ -121,6 +121,13 @@ impl RateLimiter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Number of keys currently tracked. Tests use it to observe what a sweep
|
||||
/// reclaimed; there is no production reader.
|
||||
#[cfg(test)]
|
||||
pub async fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().await.len()
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -132,6 +139,110 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-source concurrency cap derived from the write-pool size: one resolved client
|
||||
/// key (see [`client_key`]) may hold at most an eighth of the pool, so saturating it
|
||||
/// takes ~8 distinct keys. Real for an IPv4 or single-address caller; a caller with a
|
||||
/// routed IPv6 /64 has 2^64 keys, because `client_key` returns the full address with
|
||||
/// no prefix folding. Narrowing that keying is a deferred decision, so this cap is a
|
||||
/// bound per key, not a bound per operator.
|
||||
///
|
||||
/// Floored at 1 because the value feeds [`PerCallerConcurrency`], where a cap of 0
|
||||
/// would shed EVERY receive-pack advertisement and break all pushes. The floor is
|
||||
/// load-bearing at the minimum write-pool size (1), which integer-divides to 0.
|
||||
pub(crate) fn per_source_push_cap(max_concurrent_git_pushes: usize) -> usize {
|
||||
(max_concurrent_git_pushes / 8).max(1)
|
||||
}
|
||||
|
||||
/// A bounded per-caller CONCURRENCY limiter — distinct from [`RateLimiter`], which
|
||||
/// caps request RATE. Each caller key may hold at most `per_caller` in-flight
|
||||
/// permits at once; beyond that [`try_acquire`](Self::try_acquire) returns `None`
|
||||
/// and the caller sheds. Used to stop one caller (a single anonymous source-IP or
|
||||
/// DID) monopolizing the served-git read pool (#174).
|
||||
///
|
||||
/// The key map is self-bounding: a key is removed the instant its in-flight count
|
||||
/// reaches zero, so it never holds more keys than there are concurrently-active
|
||||
/// callers (itself bounded by the read semaphore). A `max_keys` reject-before-insert
|
||||
/// backstop guarantees a key farm can never grow the map even if that invariant
|
||||
/// weakened — a NEW key at the cap is rejected WITHOUT allocating an entry (INV-15).
|
||||
///
|
||||
/// Uses a `std::sync::Mutex` (not the file's `tokio::sync::Mutex`) because the
|
||||
/// permit's `Drop` must release synchronously; the critical section holds no await.
|
||||
#[derive(Clone)]
|
||||
pub struct PerCallerConcurrency {
|
||||
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
|
||||
per_caller: usize,
|
||||
max_keys: usize,
|
||||
}
|
||||
|
||||
/// RAII permit from [`PerCallerConcurrency::try_acquire`]. On drop it decrements
|
||||
/// the caller's in-flight count and removes the key when it reaches zero.
|
||||
pub struct PerCallerPermit {
|
||||
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl PerCallerConcurrency {
|
||||
pub fn new(per_caller: usize, max_keys: usize) -> Self {
|
||||
Self {
|
||||
state: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
per_caller: per_caller.max(1),
|
||||
max_keys: max_keys.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor with the default key bound.
|
||||
pub fn with_default_max_keys(per_caller: usize) -> Self {
|
||||
Self::new(per_caller, DEFAULT_MAX_KEYS)
|
||||
}
|
||||
|
||||
/// `Some(permit)` when the caller is under its cap and the map has room;
|
||||
/// `None` (shed) otherwise. Reject-before-insert: a new key at `max_keys` is
|
||||
/// rejected without allocating.
|
||||
pub fn try_acquire(&self, key: &str) -> Option<PerCallerPermit> {
|
||||
// Recover from a poisoned lock rather than panicking: the critical section
|
||||
// is pure counter arithmetic and cannot itself panic, so a poisoned mutex
|
||||
// would only ever come from an unrelated abort, and a slightly-off count
|
||||
// self-heals as permits drop. A panic here would instead brick the limiter
|
||||
// for every caller (each subsequent lock re-panics).
|
||||
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match map.get_mut(key) {
|
||||
Some(count) => {
|
||||
if *count >= self.per_caller {
|
||||
return None;
|
||||
}
|
||||
*count += 1;
|
||||
}
|
||||
None => {
|
||||
if map.len() >= self.max_keys {
|
||||
return None;
|
||||
}
|
||||
map.insert(key.to_string(), 1);
|
||||
}
|
||||
}
|
||||
Some(PerCallerPermit {
|
||||
state: self.state.clone(),
|
||||
key: key.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PerCallerPermit {
|
||||
fn drop(&mut self) {
|
||||
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(count) = map.get_mut(&self.key) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
map.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
let limiter = request.extensions().get::<RateLimiter>().cloned();
|
||||
|
||||
@@ -288,6 +399,63 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn per_caller_concurrency_caps_one_caller_and_frees_on_drop() {
|
||||
let lim = PerCallerConcurrency::new(2, 100);
|
||||
let p1 = lim.try_acquire("did:key:zA").expect("first under cap");
|
||||
let p2 = lim.try_acquire("did:key:zA").expect("second under cap");
|
||||
assert!(
|
||||
lim.try_acquire("did:key:zA").is_none(),
|
||||
"a third in-flight op for the same caller sheds (over the per-caller cap)"
|
||||
);
|
||||
// A DIFFERENT caller is unaffected — the cap is per-caller, not global.
|
||||
let _other = lim
|
||||
.try_acquire("did:key:zB")
|
||||
.expect("a different caller has its own budget");
|
||||
drop(p1);
|
||||
assert!(
|
||||
lim.try_acquire("did:key:zA").is_some(),
|
||||
"freeing one in-flight slot lets the same caller back in"
|
||||
);
|
||||
drop(p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_caller_concurrency_map_is_self_bounding_and_reject_before_insert() {
|
||||
// Self-bounding: acquire+drop many distinct keys — the map never grows
|
||||
// because a key is removed the instant its in-flight count hits zero.
|
||||
let lim = PerCallerConcurrency::new(4, 3);
|
||||
for i in 0..50 {
|
||||
let _p = lim.try_acquire(&format!("k{i}"));
|
||||
}
|
||||
assert_eq!(
|
||||
lim.tracked_keys(),
|
||||
0,
|
||||
"keys with zero in-flight ops are removed, so an acquire+drop flood leaves the map empty"
|
||||
);
|
||||
// Reject-before-insert: HOLD max_keys distinct keys, then a new key sheds
|
||||
// WITHOUT growing the map past the cap (INV-15 — a rejected request never
|
||||
// allocates an entry).
|
||||
let held: Vec<_> = (0..3)
|
||||
.map(|i| lim.try_acquire(&format!("h{i}")).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lim.tracked_keys(),
|
||||
3,
|
||||
"three distinct callers held concurrently"
|
||||
);
|
||||
assert!(
|
||||
lim.try_acquire("h3").is_none(),
|
||||
"a new key at max_keys is rejected"
|
||||
);
|
||||
assert_eq!(
|
||||
lim.tracked_keys(),
|
||||
3,
|
||||
"the rejected new key did not allocate an entry (reject-before-insert)"
|
||||
);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_within_limit() {
|
||||
let limiter = RateLimiter::new(3, Duration::from_secs(60));
|
||||
|
||||
@@ -214,9 +214,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// identity and can apply per-repo visibility (#110); anonymous callers stay
|
||||
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
|
||||
// stays unsigned — gating the pin index is tracked separately (#121).
|
||||
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
|
||||
// request can drive a full-history git walk, so the per-IP rate limiter is the
|
||||
// outermost layer (rejects a flood before the walk-admission work), mirroring the
|
||||
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
|
||||
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
|
||||
let ipfs_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.ipfs_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn(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)));
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -605,7 +605,7 @@ async fn replicate_encrypted_blobs(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match crate::ipfs_pin::pin_git_object(ipfs_api, &blob.oid, &envelope).await {
|
||||
match crate::ipfs_pin::pin_git_object(ipfs_api, &blob.oid, &envelope, None).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
if cid != blob.cid {
|
||||
warn!(oid = %blob.oid, expected = %blob.cid, got = %cid, "replicated envelope CID mismatch; skipping record");
|
||||
|
||||
@@ -82,6 +82,26 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
// Generous — no test drives the handler-level shed (git_permit is unit-tested).
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
encrypt_inflight: crate::state::EncryptInflight::new(),
|
||||
repo_write_leases: crate::state::RepoWriteLeases::new(8),
|
||||
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
git_push_advert_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
8,
|
||||
),
|
||||
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
// Generous — a test that drives the /ipfs walk shed overrides these directly.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +129,25 @@ pub(crate) fn signed_request_as(did: &str, method: Method, uri: &str, body: Body
|
||||
.expect("request builder")
|
||||
}
|
||||
|
||||
/// A local endpoint whose TCP accept succeeds instantly but that never writes an
|
||||
/// HTTP response, so any request against it stalls deterministically until the
|
||||
/// caller's own timeout. (A non-routable address hangs only if the network
|
||||
/// blackholes the SYN — a fast RST would end the stall early and make a timeout
|
||||
/// test pass for the wrong reason.) The accepted sockets are parked in the
|
||||
/// spawned task, which dies with the test's runtime, so the peer never sees a
|
||||
/// close mid-test.
|
||||
pub(crate) async fn silent_http_endpoint() -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
held.push(sock);
|
||||
}
|
||||
});
|
||||
endpoint
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -187,6 +226,207 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// PR3 (#62): the served-git concurrency cap sheds at the HTTP layer before the
|
||||
/// DB. The held `git_permit` acquire now sits after the per-source cap, so the
|
||||
/// cheap early shed is carried by an explicit `available_permits() == 0` check at
|
||||
/// the top of the handler (the held permit remains the authoritative bound further
|
||||
/// down). That check is a permit-less snapshot: it spares a request's DB work once
|
||||
/// the pool is ALREADY saturated, which is the case this test drives, and it does
|
||||
/// not bound the DB window in general. DB-free here because an exhausted semaphore
|
||||
/// sheds before any DB/disk access, so a lazy state works. Remove the early-shed block
|
||||
/// from git_info_refs and this goes red (the request falls through to the DB and
|
||||
/// returns something other than 503).
|
||||
#[tokio::test]
|
||||
async fn git_info_refs_sheds_with_503_when_semaphore_exhausted() {
|
||||
let mut state = test_state_lazy();
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.with_state(state);
|
||||
let resp = router
|
||||
.oneshot(anon_get(
|
||||
"/alice/repo.git/info/refs?service=git-upload-pack",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted git semaphore must shed info/refs with 503 before touching the DB"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|h| h.to_str().ok()),
|
||||
Some("1"),
|
||||
"the 503 shed must carry Retry-After"
|
||||
);
|
||||
}
|
||||
|
||||
/// PR3 (#62) sibling of the info/refs shed test: git-upload-pack carries the same
|
||||
/// explicit `available_permits() == 0` early check at the top, so an ALREADY
|
||||
/// exhausted semaphore must shed the request with a 503 before its DB/disk work.
|
||||
/// That is the case the permit-less snapshot does deliver; it is not an admission
|
||||
/// bound on the DB window. Anonymous-reachable, so no auth injection is needed.
|
||||
/// Remove the early-shed block from git_upload_pack and this goes red.
|
||||
#[tokio::test]
|
||||
async fn git_upload_pack_sheds_with_503_when_semaphore_exhausted() {
|
||||
let mut state = test_state_lazy();
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-upload-pack",
|
||||
axum::routing::post(crate::api::repos::git_upload_pack),
|
||||
)
|
||||
.with_state(state);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/alice/repo.git/git-upload-pack")
|
||||
.body(Body::from(&b"0000"[..]))
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted git semaphore must shed git-upload-pack with 503 before touching the DB"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|h| h.to_str().ok()),
|
||||
Some("1"),
|
||||
"the 503 shed must carry Retry-After"
|
||||
);
|
||||
}
|
||||
|
||||
/// PR3 (#62) receive-pack sibling of the info/refs shed test: the early shed
|
||||
/// selects the dedicated ADVERT pool for a git-receive-pack advertisement (#174),
|
||||
/// so an ALREADY exhausted advert pool sheds the advert with 503 before its DB/disk
|
||||
/// work (the case the permit-less snapshot delivers, not an admission bound on the
|
||||
/// DB window), while the write pool (reserved for authenticated POSTs) is left
|
||||
/// free here.
|
||||
/// Flip the pool selection back to the write pool, or remove the early-shed
|
||||
/// block, and this goes red.
|
||||
#[tokio::test]
|
||||
async fn git_info_refs_receive_pack_sheds_with_503_when_advert_pool_exhausted() {
|
||||
let mut state = test_state_lazy();
|
||||
state.git_push_advert_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.with_state(state);
|
||||
let resp = router
|
||||
.oneshot(anon_get(
|
||||
"/alice/repo.git/info/refs?service=git-receive-pack",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted ADVERT pool must shed the receive-pack advertisement with 503 before touching the DB"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|h| h.to_str().ok()),
|
||||
Some("1"),
|
||||
"the 503 shed must carry Retry-After"
|
||||
);
|
||||
}
|
||||
|
||||
/// PR3 (#62) sibling for the push path: git-receive-pack requires an
|
||||
/// AuthenticatedDid extension (production: require_signature injects it), so the
|
||||
/// request carries one via signed_request_as — without it the Extension
|
||||
/// extractor 500s before the handler body reaches the shed. What sits at the top of
|
||||
/// the handler is a permit-less `available_permits() == 0` peek, NOT the permit
|
||||
/// itself: the authoritative held acquire is taken after the per-repo lease, so a
|
||||
/// lease-blocked waiter pins no write slot. An ALREADY exhausted pool is the case
|
||||
/// the peek delivers, so the request sheds 503 before its DB work here. Remove the
|
||||
/// early-shed block from git_receive_pack and this goes red.
|
||||
#[tokio::test]
|
||||
async fn git_receive_pack_sheds_with_503_when_semaphore_exhausted() {
|
||||
let mut state = test_state_lazy();
|
||||
state.git_write_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.with_state(state);
|
||||
let owner = "did:key:zRECVSHEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let resp = router
|
||||
.oneshot(signed_request_as(
|
||||
owner,
|
||||
Method::POST,
|
||||
"/alice/repo.git/git-receive-pack",
|
||||
Body::from(&b"0000"[..]),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted write pool must shed git-receive-pack with 503 before touching the DB"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|h| h.to_str().ok()),
|
||||
Some("1"),
|
||||
"the 503 shed must carry Retry-After"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 (SC1, load-bearing): a saturated READ pool must NOT shed an
|
||||
/// authenticated push — the write pool is a separate budget. Read pool at zero,
|
||||
/// write pool with capacity: the push proceeds PAST admission (it then errors on
|
||||
/// the placeholder DB, but crucially it is not a 503). Route git-receive-pack
|
||||
/// back to the read pool and this goes red — that is the isolation proof.
|
||||
#[tokio::test]
|
||||
async fn git_receive_pack_not_shed_by_exhausted_read_pool() {
|
||||
let mut state = test_state_lazy();
|
||||
// Read pool exhausted as if a flood of anonymous clones held every slot.
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
// Write pool keeps its default capacity from test_state_lazy.
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.with_state(state);
|
||||
let owner = "did:key:zRECVCROSSBOUNDARYAAAAAAAAAAAAAAAAAAAAA";
|
||||
let resp = router
|
||||
.oneshot(signed_request_as(
|
||||
owner,
|
||||
Method::POST,
|
||||
"/alice/repo.git/git-receive-pack",
|
||||
Body::from(&b"0000"[..]),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted READ pool must not shed a push — the write pool is a separate budget (#174)"
|
||||
);
|
||||
}
|
||||
|
||||
/// N7: merge_pr is owner-only. A non-owner is rejected by require_repo_owner
|
||||
/// before any git work (so no on-disk repo is needed for the rejection).
|
||||
#[sqlx::test]
|
||||
@@ -3285,7 +3525,10 @@ mod tests {
|
||||
/// the handler skips the whole repo rather than serving. Asserts no leak of the
|
||||
/// withheld blob AND that even the *public* blob in that repo is withheld — the
|
||||
/// latter distinguishes fail-closed-skip from normal per-blob withholding and
|
||||
/// would serve 200 if the error arm wrongly proceeded.
|
||||
/// would serve 200 if the error arm wrongly proceeded. The skip carries no
|
||||
/// VERDICT (F2), so the response is the retryable truncation 503, not a 404
|
||||
/// claiming the object is absent — never-serve-unproven and never-404-unproven
|
||||
/// hold together.
|
||||
#[sqlx::test]
|
||||
async fn ipfs_cid_walk_error_fails_closed(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
@@ -3330,7 +3573,8 @@ mod tests {
|
||||
.await
|
||||
.expect("deny rule");
|
||||
|
||||
// Withheld secret CID under a walk error → 404, no leak.
|
||||
// Withheld secret CID under a walk error → the repo is skipped without a
|
||||
// verdict, so the scan is truncated (503), and nothing leaks.
|
||||
let (st, body) = cid_parts(
|
||||
cid_router(&state)
|
||||
.oneshot(cid_anon(&secret_cid))
|
||||
@@ -3340,17 +3584,17 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"walk error must not serve the withheld blob"
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"walk error must not serve the withheld blob — the unproven skip sheds 503"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("TOP SECRET"),
|
||||
"walk-error 404 must not leak the secret"
|
||||
"walk-error 503 must not leak the secret"
|
||||
);
|
||||
|
||||
// The PUBLIC blob in the same repo is also 404: the walk error fails closed
|
||||
// by skipping the whole repo, not by serving. Without the fail-closed arm
|
||||
// this would serve 200, so this assertion is the load-bearing discriminator.
|
||||
// The PUBLIC blob in the same repo is also not served: the walk error fails
|
||||
// closed by skipping the whole repo. Without the fail-closed arm this would
|
||||
// serve 200, so this assertion is the load-bearing discriminator.
|
||||
let (st, _) = cid_parts(
|
||||
cid_router(&state)
|
||||
.oneshot(cid_anon(&public_cid))
|
||||
@@ -3360,8 +3604,9 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"walk error fails closed: repo skipped, even the public blob is not served"
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"walk error fails closed: repo skipped without a verdict, even the public \
|
||||
blob is not served and the scan sheds 503"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
//! #174 U6 — INV-22 completeness guard (rung-raising).
|
||||
//!
|
||||
//! INV-22: a permit held per op recovers only if every path that holds it is also
|
||||
//! duration-bounded and reaps the process group before releasing admission, and every
|
||||
//! detached git/blocking task carries its own admission. PR #174 fixed five paths
|
||||
//! (U1-U5) that violated this. Each fix has a per-unit RED/GREEN regression; together
|
||||
//! those form the five-revert matrix. This guard adds the missing piece: a source-scan
|
||||
//! tripwire that fails when a NEW site reintroduces the class, or when one of the five
|
||||
//! gates is removed.
|
||||
//!
|
||||
//! It lives in `tests/` (a separate crate) on purpose: a guard that scanned the same
|
||||
//! file it lives in would match its own identifier literals and pass vacuously. Here
|
||||
//! the scanned `src/` files never contain this file's literals, so each check is
|
||||
//! load-bearing — reverting the named gate turns the assertion red.
|
||||
//!
|
||||
//! These are deliberately coarse structural checks, not a parser. They cannot prove a
|
||||
//! gate is *correct* (the per-unit tests do that); they prove a gate is *present and
|
||||
//! not bypassed*, which is what stops the class from silently regressing.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn src(rel: &str) -> String {
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(rel);
|
||||
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inv22_concurrency_gates_present_and_not_bypassed() {
|
||||
let repos = src("api/repos.rs");
|
||||
let smart_http = src("git/smart_http.rs");
|
||||
let vis = src("git/visibility_pack.rs");
|
||||
|
||||
// U1 / P1-a: run_bounded_git stands the watchdog down only after confirming the
|
||||
// child actually terminated (WNOWAIT), not on the raw stdout-drain EOF — otherwise
|
||||
// a child that closes stdout then hangs pins the permit past the deadline. The
|
||||
// probe is defined and called, so >= 2 occurrences; reverting the fix removes both.
|
||||
assert!(
|
||||
vis.matches("child_terminated_without_reaping").count() >= 2,
|
||||
"U1/P1-a gate missing: run_bounded_git must confirm child exit via \
|
||||
child_terminated_without_reaping before signalling the watchdog"
|
||||
);
|
||||
|
||||
// U2 / P1-c: on client disconnect KillGroupOnDrop must launch a detached reaper
|
||||
// that runs the full TERM/grace/KILL/reap, not a lone SIGTERM. The reaper is spawned
|
||||
// via a runtime handle in Drop; `Handle::try_current` is unique to that launch (the
|
||||
// timeout path already has an async context and never calls it).
|
||||
assert!(
|
||||
smart_http.contains("Handle::try_current"),
|
||||
"U2/P1-c gate missing: KillGroupOnDrop::drop must launch the full \
|
||||
TERM/grace/KILL reaper on disconnect (Handle::try_current), not a lone SIGTERM"
|
||||
);
|
||||
|
||||
// U4 / P1-d: git_receive_pack must acquire the per-source write sub-cap before the
|
||||
// global write permit. The acquire reads `state.git_write_per_caller`; comments name
|
||||
// the field without the `state.` prefix, so this targets the real acquire site.
|
||||
assert!(
|
||||
repos.contains("state.git_write_per_caller"),
|
||||
"U4/P1-d gate missing: git_receive_pack must acquire the per-source write cap \
|
||||
(state.git_write_per_caller) before the global write permit"
|
||||
);
|
||||
|
||||
// U5 / P1-e: the detached post-push encryption walk must run through the
|
||||
// admission-gated helper, which is wired to the shared encrypt pool.
|
||||
assert!(
|
||||
repos.contains("fn withheld_recipients_gated")
|
||||
&& repos.contains("state.git_encrypt_semaphore"),
|
||||
"U5/P1-e gate missing: the encryption walk must run through \
|
||||
withheld_recipients_gated, which acquires git_encrypt_semaphore"
|
||||
);
|
||||
|
||||
// P1-e non-bypass tripwire: the bounded recipients walk is spawn_blocking'd nowhere
|
||||
// but inside withheld_recipients_gated. A second call site (count > 1) is a new
|
||||
// detached git walk that skips the admission gate — exactly the class U5 closed.
|
||||
assert_eq!(
|
||||
repos.matches("withheld_blob_recipients_bounded").count(),
|
||||
1,
|
||||
"P1-e bypass: the bounded recipients walk must be invoked only inside \
|
||||
withheld_recipients_gated; a new call site bypasses the encrypt-walk admission cap"
|
||||
);
|
||||
|
||||
// U4 / P2-2: the detached post-push encryption task must be gated by the per-repo
|
||||
// coalescing set (`encrypt_inflight.try_begin`) so the OUTSTANDING parked-task set is
|
||||
// bounded to <=1 per repo. Removing the gate lets N rapid pushes spawn N parked
|
||||
// waiters (the unbounded set U4 closed); the semaphore only caps active walks.
|
||||
assert!(
|
||||
repos.contains("state.encrypt_inflight.try_begin"),
|
||||
"U4/P2-2 gate missing: the detached post-push encryption spawn must consult \
|
||||
encrypt_inflight.try_begin to coalesce per repo (bound the outstanding-task set)"
|
||||
);
|
||||
|
||||
// F5: coalescing must REQUEUE, not shed. The in-flight task pins only its own
|
||||
// pre-spawn snapshot, so a coalesced push's tip pairs are recorded on its key and
|
||||
// the task must loop-drain them (`finish_or_take_pending`) before releasing it.
|
||||
// Removing the drain reverts to the silent loss: a coalesced push's pins and
|
||||
// recovery copies are absent until an unrelated later push. Scan only the
|
||||
// production half of the file — the u5 tests in its `mod tests` also name the
|
||||
// drain call, and matching them would make this check vacuous.
|
||||
let repos_production = repos
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
assert!(
|
||||
repos_production.contains("finish_or_take_pending"),
|
||||
"F5 gate missing: the post-push encryption task must loop-drain coalesced \
|
||||
pushes via finish_or_take_pending before releasing its repo key"
|
||||
);
|
||||
|
||||
// F4: every post-receive scan helper admits itself to the shared scan pool via
|
||||
// `crate::state::acquire_scan_permit` BEFORE its spawn_blocking git walk, so a
|
||||
// push burst cannot accumulate unbounded concurrent scans once the write permit
|
||||
// is released. Two halves, both load-bearing: the helper body must actually
|
||||
// acquire the pool (state.rs sits the helper at the end of the file, so the
|
||||
// definition tail contains no other `acquire_owned` to match vacuously), and
|
||||
// within each scan helper the first qualified call precedes the first
|
||||
// `spawn_blocking`. Severing a call site pushes the next occurrence past the
|
||||
// helper's own `spawn_blocking` (or off the end of the file), turning the
|
||||
// assertion red; comments name the helper without the `crate::state::` prefix,
|
||||
// so this targets the real call sites.
|
||||
let state_rs = src("state.rs");
|
||||
let helper_def = state_rs
|
||||
.find("fn acquire_scan_permit")
|
||||
.expect("F4 gate missing: state.rs no longer defines acquire_scan_permit");
|
||||
assert!(
|
||||
state_rs[helper_def..].contains("acquire_owned"),
|
||||
"F4 gate gutted: acquire_scan_permit must acquire the scan pool via acquire_owned"
|
||||
);
|
||||
let push_delta = src("git/push_delta.rs");
|
||||
for (file_src, file, helper) in [
|
||||
(&repos, "api/repos.rs", "fn replication_withheld_set"),
|
||||
(&repos, "api/repos.rs", "fn fail_closed_full_scan_objects"),
|
||||
(
|
||||
&push_delta,
|
||||
"git/push_delta.rs",
|
||||
"fn resolve_candidates_for_push",
|
||||
),
|
||||
] {
|
||||
let start = file_src
|
||||
.find(helper)
|
||||
.unwrap_or_else(|| panic!("{file}: `{helper}` not found"));
|
||||
let tail = &file_src[start..];
|
||||
let acquire = tail
|
||||
.find("crate::state::acquire_scan_permit(")
|
||||
.unwrap_or_else(|| {
|
||||
panic!("F4 gate missing: {file} `{helper}` no longer acquires a scan permit")
|
||||
});
|
||||
let spawn = tail.find("spawn_blocking").unwrap_or_else(|| {
|
||||
panic!("{file}: `{helper}` lost its spawn_blocking walk — update this guard")
|
||||
});
|
||||
assert!(
|
||||
acquire < spawn,
|
||||
"F4 gate bypassed: {file} `{helper}` must acquire its git_encrypt_semaphore \
|
||||
permit BEFORE dispatching the blocking git scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// F4 (repo_store advisory-unlock cancellation safety): `RepoWriteGuard::release`
|
||||
/// must await `pg_advisory_unlock` while `self` still owns the pooled connection,
|
||||
/// and must not mark itself `released` until that await resolves. Either shape,
|
||||
/// reintroduced, re-opens the mid-unlock cancellation leak: taking the connection
|
||||
/// early leaves `Drop` with `conn == None`, and setting `released = true` early
|
||||
/// leaves the `Drop` backstop inert — both strand the session lock on cancellation.
|
||||
///
|
||||
/// Scoped to the `release` fn body: the `Drop` impl legitimately takes the
|
||||
/// connection and unlocks, so a whole-file scan would match it and read as a false
|
||||
/// pass. Reverting either ordering turns this red (proven load-bearing).
|
||||
#[test]
|
||||
fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
let repo_store = src("git/repo_store.rs");
|
||||
|
||||
let rel_start = repo_store
|
||||
.find("pub async fn release(mut self")
|
||||
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
|
||||
let rel_end = repo_store[rel_start..]
|
||||
.find("impl Drop for RepoWriteGuard")
|
||||
.map(|off| rel_start + off)
|
||||
.expect("F4 gate: release fn / Drop impl markers moved — update this guard");
|
||||
let release_body = &repo_store[rel_start..rel_end];
|
||||
|
||||
let unlock = release_body
|
||||
.find("pg_advisory_unlock")
|
||||
.expect("F4 gate: release must still issue pg_advisory_unlock");
|
||||
let before_unlock = &release_body[..unlock];
|
||||
|
||||
// (a) the connection must still be owned by `self` at the unlock await.
|
||||
assert!(
|
||||
!before_unlock.contains("self.conn.take()"),
|
||||
"F4 regression: RepoWriteGuard::release takes self.conn BEFORE awaiting \
|
||||
pg_advisory_unlock. A cancellation during the unlock await then strands the \
|
||||
session advisory lock (Drop sees conn == None and skips its backstop). \
|
||||
Unlock through the still-owned connection instead."
|
||||
);
|
||||
// (b) `released` must not be set before the unlock await — the other
|
||||
// reintroduction shape a single-reorder check on (a) alone is blind to.
|
||||
assert!(
|
||||
!before_unlock.contains("released = true"),
|
||||
"F4 regression: RepoWriteGuard::release sets `released = true` BEFORE awaiting \
|
||||
pg_advisory_unlock. A cancellation during the await then leaves the Drop \
|
||||
backstop inert (it early-returns on released). Set released only AFTER the \
|
||||
unlock await resolves."
|
||||
);
|
||||
}
|
||||
|
||||
/// F6/KTD-5 (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
|
||||
/// remaining request budget — otherwise a query blocked in Postgres pins the walk slot
|
||||
/// for the whole stall, past the budget. This scans the PRODUCTION half of `api/ipfs.rs`
|
||||
/// (the `mod tests` half names the same calls in its own harness and would make the
|
||||
/// check vacuous) and asserts each query call is immediately preceded by a
|
||||
/// `tokio::time::timeout(` wrapper. Removing either wrapper turns this red (proven
|
||||
/// load-bearing).
|
||||
#[test]
|
||||
fn f6_ipfs_metadata_queries_are_deadline_wrapped() {
|
||||
let ipfs = src("api/ipfs.rs");
|
||||
let production = ipfs
|
||||
.split("#[cfg(test)]")
|
||||
.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];
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #174 F2 / KTD-3: the detached post-receive Pinata replication task must enqueue
|
||||
/// only the push's `(ref, old, new)` tuples and RE-DERIVE its object set inside the
|
||||
/// worker once a pin slot frees — it must NOT move the full per-push object list into
|
||||
/// the closure and hold it across the `pin_semaphore` acquire. Retaining the list makes
|
||||
/// every parked task (under a slow Pinata backend) hold an MB-scale OID list, so
|
||||
/// outstanding memory grows O(pushes x object-list). Coalescing/shedding the task is
|
||||
/// forbidden (its per-ref effects are non-idempotent), so the fix bounds the retained
|
||||
/// data, not the task count.
|
||||
///
|
||||
/// Two load-bearing checks, both against the PRODUCTION half of `api/repos.rs` (the
|
||||
/// `mod tests` half names the same identifiers in its own harness and would make the
|
||||
/// scan vacuous): (a) the closure-local `object_list_pinata` binding — the retain form —
|
||||
/// must be gone; reintroducing `let object_list_pinata = object_list;` turns this red.
|
||||
/// (b) the re-derivation (`pinata_object_list_for_refs`) must appear AFTER the Pinata
|
||||
/// pin permit is acquired, so the object list is materialized only inside the pin-bounded
|
||||
/// section and a parked task holds O(ref tuples).
|
||||
#[test]
|
||||
fn f2_pinata_enqueues_refs_not_retained_object_lists() {
|
||||
let repos = src("api/repos.rs");
|
||||
let production = repos
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// (a) the retained-list form must be gone from production.
|
||||
assert!(
|
||||
!production.contains("object_list_pinata"),
|
||||
"F2/KTD-3 regression: the Pinata task retains a full per-push object list \
|
||||
(`object_list_pinata`) across the pin-permit acquire. Enqueue only the ref \
|
||||
tuples and re-derive the object set inside the worker (pinata_object_list_for_refs)."
|
||||
);
|
||||
|
||||
// (b) the re-derivation runs AFTER the pin permit is acquired. Anchor on the
|
||||
// Pinata pin-admission clone so the window is the Pinata task, not the sibling
|
||||
// IPFS/encrypt spawn (which shares `pin_semaphore` but never re-derives).
|
||||
let anchor = production
|
||||
.find("let pin_sem_pinata")
|
||||
.expect("F2 gate stale: the Pinata pin-admission clone (pin_sem_pinata) moved");
|
||||
let tail = &production[anchor..];
|
||||
let acquire = tail
|
||||
.find(".acquire_owned()")
|
||||
.expect("F2 gate: the Pinata task no longer acquires a pin permit (acquire_owned)");
|
||||
let rederive = tail.find("pinata_object_list_for_refs(").expect(
|
||||
"F2 gate missing: the Pinata task must re-derive its object set via \
|
||||
pinata_object_list_for_refs; it can no longer move a pre-resolved list into the closure",
|
||||
);
|
||||
assert!(
|
||||
acquire < rederive,
|
||||
"F2 gate bypassed: the Pinata object-set re-derivation must run AFTER the pin \
|
||||
permit is acquired, so a parked task never holds the MB-scale object list"
|
||||
);
|
||||
|
||||
// The re-derivation is driven by the push's ref tuples (the enqueued unit), not a
|
||||
// retained object list — tie "enqueue ref_updates" to the call explicitly.
|
||||
assert!(
|
||||
tail[rederive..].starts_with("pinata_object_list_for_refs(")
|
||||
&& tail[rederive..]
|
||||
.get(..400)
|
||||
.map(|w| w.contains("ref_updates_clone"))
|
||||
.unwrap_or(false),
|
||||
"F2 gate: pinata_object_list_for_refs must re-derive from the push's ref tuples \
|
||||
(ref_updates_clone), the small unit the parked task retains"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U2 / F3 (second same-repo writer serialized until a disconnected first push's
|
||||
/// git group is reaped): `git_receive_pack` must take the per-repo in-process write
|
||||
/// lease and CARRY it on the write-path `AdmissionGuard` (via `.with_lease`), so the
|
||||
/// lease rides `KillGroupOnDrop`'s detached reaper and frees only after the group is
|
||||
/// reaped — supplementing the pg advisory lock, which `RepoWriteGuard::Drop` releases at
|
||||
/// the disconnect instant. Tying the lease to `RepoWriteGuard` instead (or dropping the
|
||||
/// `.with_lease` carry) reopens the race; the behavioral RED/GREEN test
|
||||
/// (`f3_second_push_serialized_until_disconnected_group_reaped`) is the real bar, this
|
||||
/// is the completeness tripwire. Scanned against the PRODUCTION half of `api/repos.rs`
|
||||
/// (the `mod tests` half names these identifiers in its own harness).
|
||||
#[test]
|
||||
fn f3_second_writer_leased_until_reap() {
|
||||
let repos = src("api/repos.rs");
|
||||
let smart_http = src("git/smart_http.rs");
|
||||
let repos_production = repos
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// The lease is acquired, then carried by the AdmissionGuard (.with_lease), before
|
||||
// receive_pack runs the write. Severing any of the three turns this red.
|
||||
let lease_acquire = repos_production.find("repo_write_leases").expect(
|
||||
"F3 gate missing: git_receive_pack no longer takes the per-repo write lease \
|
||||
(state.repo_write_leases)",
|
||||
);
|
||||
let with_lease = repos_production.find(".with_lease(").expect(
|
||||
"F3 gate missing: the write-path AdmissionGuard no longer carries the lease \
|
||||
(.with_lease). The lease must ride the disconnect reaper via the AdmissionGuard, \
|
||||
NOT RepoWriteGuard (which drops at the disconnect instant, reopening F3).",
|
||||
);
|
||||
let receive = repos_production
|
||||
.find("smart_http::receive_pack(")
|
||||
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack");
|
||||
assert!(
|
||||
lease_acquire < with_lease && with_lease < receive,
|
||||
"F3 gate bypassed: the write lease must be acquired, then carried by the \
|
||||
AdmissionGuard (.with_lease), BEFORE receive_pack runs the write"
|
||||
);
|
||||
|
||||
// The AdmissionGuard must actually hold the lease (Option<RepoWriteLease>) via a
|
||||
// with_lease setter, so it travels into the detached reaper. Removing the field or
|
||||
// method turns this red.
|
||||
assert!(
|
||||
smart_http.contains("_lease: Option<crate::state::RepoWriteLease>")
|
||||
&& smart_http.contains("pub fn with_lease("),
|
||||
"F3 gate missing: AdmissionGuard must hold an Option<RepoWriteLease> set via \
|
||||
with_lease, so the lease rides the guard into KillGroupOnDrop's reaper"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U1 — every blocking walk in the `/ipfs` scan carries the request's walk
|
||||
/// admission.
|
||||
///
|
||||
/// The admission is an `Arc<WalkAdmission>` cloned into each `spawn_blocking`
|
||||
/// closure, so the global + per-source permits release only when the last holder
|
||||
/// drops: a client disconnect leaves the abandoned closure holding the slot, and a
|
||||
/// panicking closure leaves the handler holding it.
|
||||
///
|
||||
/// Only the walk site runs under `state.git_bin`, so only it can be pinned by the
|
||||
/// fake-git harness and mutation-verified dynamically (see
|
||||
/// `get_by_cid_walk_permit_held_through_blocking_walk`). The probe and the content
|
||||
/// read deliberately shell to the real `git`, which is why this structural check
|
||||
/// exists: it binds all three sites, and any blocking site added to this loop
|
||||
/// later, without reversing that deliberate independence.
|
||||
///
|
||||
/// MUTATION (RED): delete any one `Arc::clone(&admission)` binding, or drop the
|
||||
/// clone from inside its closure, and the count falls below three.
|
||||
#[test]
|
||||
fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
|
||||
let ipfs = src("api/ipfs.rs");
|
||||
|
||||
// The shared owner must exist and be built once per request.
|
||||
assert!(
|
||||
ipfs.contains("struct WalkAdmission") && ipfs.contains("Arc::new(WalkAdmission {"),
|
||||
"U1 gate missing: the /ipfs walk admission must be a shared WalkAdmission, \
|
||||
not a handler-local permit pair"
|
||||
);
|
||||
|
||||
// Every blocking site in the scan takes its own clone...
|
||||
let clones = ipfs.matches("Arc::clone(&admission)").count();
|
||||
assert!(
|
||||
clones >= 3,
|
||||
"U1 gate bypassed: expected an admission clone for each of the three \
|
||||
/ipfs spawn_blocking sites (probe, walk, read); found {clones}. A blocking \
|
||||
walk that does not hold the admission lets a disconnect or a panic free the \
|
||||
slot while its git child is still running."
|
||||
);
|
||||
|
||||
// ...and each clone is actually moved INTO the blocking closure, not merely
|
||||
// created in the async frame (which would hold nothing across the join).
|
||||
let held = ipfs.matches("let _admission = ").count();
|
||||
assert!(
|
||||
held >= 3,
|
||||
"U1 gate bypassed: each admission clone must be bound inside its \
|
||||
spawn_blocking closure so the blocking work owns it; found {held} of 3."
|
||||
);
|
||||
|
||||
// The count of blocking sites is itself the thing being covered: if a fourth
|
||||
// appears, it needs an admission clone too and this gate must be revisited.
|
||||
let sites = ipfs.matches("spawn_blocking(move ||").count();
|
||||
assert_eq!(
|
||||
sites, 3,
|
||||
"the /ipfs scan grew or lost a spawn_blocking site ({sites} found, expected 3); \
|
||||
give any new blocking walk its own Arc::clone(&admission) and update this gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U5: the post-receive replication tail is spawned at the DURABILITY BOUNDARY,
|
||||
/// which is the moment receive-pack returns success, not the end of the handler and
|
||||
/// not after `guard.release()`.
|
||||
///
|
||||
/// The tail owes this push its pins, recovery copy, and announcements. Everything
|
||||
/// below the spawn stays in the cancellable request future, so anything the tail is
|
||||
/// spawned after is a window where a client disconnect drops that work while the pack
|
||||
/// is already durable on disk. `guard.release()` is such a window: on success it
|
||||
/// awaits the Tigris upload and then the advisory unlock.
|
||||
///
|
||||
/// The lower bound matters just as much as the upper one: `release` runs on failure
|
||||
/// too, so an ungated spawn would fire for a push git rejected, pinning and announcing
|
||||
/// a half-applied repo. Above `release` the `?` on `receive_result` can no longer be
|
||||
/// what gates it, so the success check is explicit and this gate binds it: the spawn
|
||||
/// must sit inside `if push_succeeded`, and `release` must consume the same flag so
|
||||
/// the two cannot drift apart.
|
||||
///
|
||||
/// This is an ordering check rather than a cancellation-race test on purpose: it is
|
||||
/// the companion to `receive_pack_tail_survives_a_disconnect_during_release`, which
|
||||
/// drives the actual disconnect through a parked `release`. Same instrument the F3
|
||||
/// gate above uses.
|
||||
///
|
||||
/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below
|
||||
/// `guard.release(` and the ordering assertion fails; take it out of the
|
||||
/// `if push_succeeded` block and the failed-push assertion fails.
|
||||
#[test]
|
||||
fn inv22_replication_tail_spawns_at_the_durability_boundary() {
|
||||
let repos = src("api/repos.rs");
|
||||
// Production half only — the tests below name these identifiers too.
|
||||
let production = repos
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
let success_flag = production
|
||||
.find("let push_succeeded = receive_result.is_ok();")
|
||||
.expect(
|
||||
"U5 gate missing: the tail's success gate must be bound from receive_result.is_ok()",
|
||||
);
|
||||
let gate_open = production
|
||||
.find("if push_succeeded {")
|
||||
.expect("U5 gate missing: the tail spawn must be gated on the push having succeeded");
|
||||
let spawn = production
|
||||
.find("tokio::spawn(post_receive_replication_tail(")
|
||||
.expect("U5 gate missing: the replication tail must be spawned by git_receive_pack");
|
||||
let release = production
|
||||
.find("guard.release(push_succeeded)")
|
||||
.expect("U5 gate stale: release must consume the same success flag as the tail gate");
|
||||
let touch = production
|
||||
.find("state.db.touch_repo(")
|
||||
.expect("U5 gate stale: git_receive_pack no longer calls touch_repo");
|
||||
let webhook = production
|
||||
.find("webhooks::fire_event(")
|
||||
.expect("U5 gate stale: git_receive_pack no longer fires push webhooks");
|
||||
|
||||
assert!(
|
||||
success_flag < gate_open && gate_open < spawn,
|
||||
"U5 gate bypassed: the tail must be spawned inside `if push_succeeded`, or a \
|
||||
rejected push spawns a tail that pins and announces a half-applied repo"
|
||||
);
|
||||
// Still inside that block: no `}` may close it between the gate and the spawn.
|
||||
assert!(
|
||||
!production[gate_open + "if push_succeeded {".len()..spawn].contains('}'),
|
||||
"U5 gate bypassed: the tail spawn left the `if push_succeeded` block, so a \
|
||||
rejected push now spawns a tail"
|
||||
);
|
||||
assert!(
|
||||
spawn < release && spawn < touch && spawn < webhook,
|
||||
"U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \
|
||||
and the webhook fan-out, so a disconnect in any of those windows cannot drop \
|
||||
this push's pins, recovery copy, and announcements"
|
||||
);
|
||||
}
|
||||
@@ -203,6 +203,39 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
.context("iCaptcha solver task panicked")?
|
||||
}
|
||||
|
||||
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
|
||||
/// hostile or broken node returning a huge error body — the display is capped
|
||||
/// separately, but the read itself must not be unbounded (INV-6, read half).
|
||||
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while buf.len() < cap {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let take = (cap - buf.len()).min(chunk.len());
|
||||
buf.extend_from_slice(&chunk[..take]);
|
||||
if take < chunk.len() {
|
||||
break; // hit the cap mid-chunk
|
||||
}
|
||||
}
|
||||
_ => break, // end of body or read error — return what we have
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
/// Strip terminal-dangerous characters from (and cap the length of) a
|
||||
/// node-supplied error string before surfacing it. The node a caller talks to
|
||||
/// could be hostile and embed escape sequences in its error body; those must not
|
||||
/// reach the terminal verbatim (INV-6). We drop the C0/C1 control bytes (which
|
||||
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
pub(crate) fn sanitize_node_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -587,4 +620,35 @@ mod tests {
|
||||
ic.challenge.assert();
|
||||
ic.answer.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_controls_bidi_and_caps_length() {
|
||||
// C0 (ESC/BEL) and the Cf bidi override (U+202E) are both removed; the
|
||||
// printable text survives. (Note: a stripped ESC leaves any following
|
||||
// "[31m" as inert literal text — that is the point, so the input here
|
||||
// avoids that residue to keep the expectation unambiguous.)
|
||||
let out = sanitize_node_msg("a\u{1b}\u{07}b\u{202e}c");
|
||||
assert!(
|
||||
!out.chars().any(|c| c.is_control()),
|
||||
"control char leaked: {out:?}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains('\u{202e}'),
|
||||
"RLO bidi override leaked: {out:?}"
|
||||
);
|
||||
assert_eq!(out, "abc");
|
||||
// Length is capped at 200 chars regardless of input size.
|
||||
let long = "x".repeat(250);
|
||||
assert_eq!(sanitize_node_msg(&long).chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_legitimate_and_rtl_text() {
|
||||
// Must not over-strip: a plain word, a genuine RTL SCRIPT letter (Arabic
|
||||
// U+0627, category Lo — NOT a format char), and ZWJ (U+200D, a legitimate
|
||||
// Cf char, e.g. emoji sequences) all survive. Guards the shared predicate
|
||||
// against being widened into a blanket Cf stripper.
|
||||
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
|
||||
assert_eq!(out, "ok \u{0627}\u{200D}b");
|
||||
}
|
||||
}
|
||||
+406
-12
@@ -7,7 +7,7 @@ use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::http::{read_body_capped, sanitize_node_msg, NodeClient};
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -97,6 +97,74 @@ async fn cmd_list(node: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warning text for a local peer-add reply, or `None` when the node
|
||||
/// accepted it.
|
||||
///
|
||||
/// Split out so the refusal path is assertable. The node now answers 403 when
|
||||
/// an unproven caller tries to repoint an existing peer's `http_url`, so a
|
||||
/// reply that arrived and refused must not reach the success line.
|
||||
fn local_add_refusal(status: reqwest::StatusCode, body: &Value) -> Option<String> {
|
||||
if status.is_success() {
|
||||
return None;
|
||||
}
|
||||
// The message is the node's, not ours, so it is defanged before it reaches
|
||||
// the terminal. Only the message: the status and the prose are ours.
|
||||
let msg = sanitize_node_msg(body["message"].as_str().unwrap_or("unknown error"));
|
||||
Some(format!(
|
||||
"warning: local peer list not updated ({status}): {msg}"
|
||||
))
|
||||
}
|
||||
|
||||
/// The error text for a remote announce reply, or `None` when the peer accepted
|
||||
/// it.
|
||||
///
|
||||
/// Split out so the ordering is assertable: the status has to reach the caller
|
||||
/// even when the body does not parse. That case is reachable rather than
|
||||
/// theoretical, because the read above is capped, so a peer answering with a
|
||||
/// body past the cap leaves truncated JSON behind, and `peer_url` is whatever
|
||||
/// the caller passed, so a non-JSON error page is just as easy. Parsing first
|
||||
/// and failing on the parse would swallow the status the user needs.
|
||||
fn remote_announce_failure(status: reqwest::StatusCode, raw: &str) -> Option<String> {
|
||||
if status.is_success() {
|
||||
return None;
|
||||
}
|
||||
// Falls back to the body itself when there is no `message` field, which is
|
||||
// what a truncated or non-JSON reply leaves. Sanitized either way: it is the
|
||||
// peer's text and it is headed for the terminal.
|
||||
let parsed: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
|
||||
let msg = sanitize_node_msg(parsed["message"].as_str().unwrap_or(raw));
|
||||
Some(format!("announce failed ({status}): {msg}"))
|
||||
}
|
||||
|
||||
/// Which line the local peer-add prints, and whether it is a warning.
|
||||
///
|
||||
/// Split out because the SELECTION is what the fix is: `local_add_refusal` was
|
||||
/// already unit-tested six ways, and replacing this whole match with an
|
||||
/// unconditional "Added to local peer list." still left 312 tests green. The
|
||||
/// helper was pinned; the wiring was not, which is the shape
|
||||
/// `unit-test-on-helper-does-not-prove-handler-wiring` names. With the choice
|
||||
/// extracted, the call site is a two-line dispatch carrying no logic.
|
||||
fn local_add_report(status: reqwest::StatusCode, body: &Value) -> (bool, String) {
|
||||
match local_add_refusal(status, body) {
|
||||
Some(warning) => (true, warning),
|
||||
None => (false, " Added to local peer list.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block printed after a peer accepts our announce. Split out so the
|
||||
/// defanging is assertable: the DID and URL are the remote peer's own strings,
|
||||
/// and `peer_url` is caller-chosen, so this is the least trusted body the
|
||||
/// command handles. Only the display is sanitized; the values sent on to our
|
||||
/// local node stay verbatim, since that is data rather than terminal output and
|
||||
/// the node runs its own validation on them.
|
||||
fn announced_peer_summary(their_did: &str, their_url: &str, peer_count: u64) -> String {
|
||||
format!(
|
||||
"Announced to peer node:\n DID: {}\n URL: {}\n Their peers: {peer_count}\n",
|
||||
sanitize_node_msg(their_did),
|
||||
sanitize_node_msg(their_url),
|
||||
)
|
||||
}
|
||||
|
||||
async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let my_did = keypair.did().to_string();
|
||||
@@ -127,21 +195,26 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
.await
|
||||
.context("failed to connect to peer")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
// `peer_url` is fully caller-chosen, so this body is the least trusted one
|
||||
// in the command: bound the read, and defang the message before it reaches
|
||||
// the terminal through the error return. An announce reply is a DID, a URL
|
||||
// and a count, so 8 KiB is well past what the shape needs.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("announce failed ({status}): {msg}");
|
||||
if let Some(failure) = remote_announce_failure(status, &raw) {
|
||||
anyhow::bail!("{failure}");
|
||||
}
|
||||
|
||||
let result: Value = serde_json::from_str(&raw).context("invalid JSON response")?;
|
||||
|
||||
let their_did = result["node_did"].as_str().unwrap_or("?");
|
||||
let their_url = result["node_url"].as_str().unwrap_or("?");
|
||||
let peer_count = result["peer_count"].as_u64().unwrap_or(0);
|
||||
|
||||
println!("Announced to peer node:");
|
||||
println!(" DID: {their_did}");
|
||||
println!(" URL: {their_url}");
|
||||
println!(" Their peers: {peer_count}");
|
||||
print!(
|
||||
"{}",
|
||||
announced_peer_summary(their_did, their_url, peer_count)
|
||||
);
|
||||
|
||||
// Also add their info to our local node's peer list
|
||||
// (the peer's /announce response includes their did + url)
|
||||
@@ -150,9 +223,31 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
"did": their_did,
|
||||
"http_url": their_url,
|
||||
}))?;
|
||||
// This requires the local node to be running; ignore errors here
|
||||
let _ = local_client.post("/api/v1/peers/announce", &add_body).await;
|
||||
println!(" Added to local peer list.");
|
||||
// This requires the local node to be running, so a transport failure
|
||||
// stays a warning rather than failing the command. A reply that
|
||||
// ARRIVED and refused is different: the announce route answers 403
|
||||
// when an unproven caller tries to repoint an existing peer, and
|
||||
// printing the success line over that reports a completed add that
|
||||
// never happened. Same status handling as the remote announce above,
|
||||
// except best effort.
|
||||
match local_client.post("/api/v1/peers/announce", &add_body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// Same bound as the remote announce above: a compromised local
|
||||
// node (or a MITM on plain http) must not force an unbounded
|
||||
// read. A body that does not parse stays `Null`, which still
|
||||
// routes a non-success status to the warning.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
let result: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
|
||||
let (is_warning, line) = local_add_report(status, &result);
|
||||
if is_warning {
|
||||
eprintln!("{line}");
|
||||
} else {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("warning: local peer list not updated: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -210,3 +305,302 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
announced_peer_summary, cmd_add, local_add_refusal, local_add_report,
|
||||
remote_announce_failure,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
/// The node's 403 for repointing an existing peer must surface, not print
|
||||
/// as a completed add. Without the status check the command prints "Added
|
||||
/// to local peer list." over a refusal.
|
||||
///
|
||||
/// The fixture body is the real wire shape, not an invented one: the
|
||||
/// message is the Display of `PeerWriteDenied::UnprovenRepoint`, declared
|
||||
/// in `crates/gitlawb-node/src/db/mod.rs`. The executable detector for a
|
||||
/// wording change is the node-side pin
|
||||
/// `announce_unsigned_repoint_is_a_403_denial` in
|
||||
/// `crates/gitlawb-node/src/api/peers.rs`, which asserts this same
|
||||
/// substring against a response driven through the real router, so
|
||||
/// rewording the Display turns that test red and the grep for the old
|
||||
/// phrase lands here.
|
||||
///
|
||||
/// The assertion bites rather than echoing its own input because the
|
||||
/// substring is reachable only through the helper's `body["message"]`
|
||||
/// extraction; the fixture's `error` field carries a different string, so
|
||||
/// reading `body["error"]` instead fails this test.
|
||||
#[test]
|
||||
fn a_refused_local_add_warns_with_the_node_reason() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({
|
||||
"error": "forbidden",
|
||||
"message": "unproven announce cannot change an existing peer's http_url: did:key:z6MkuMqUm4i228K9qXidJ57zqSWAcQLgrcbMxB8RKVLuqitj"
|
||||
}),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(warning.contains("403"), "must name the status: {warning}");
|
||||
assert!(
|
||||
warning.contains("unproven announce cannot change an existing peer"),
|
||||
"must carry the node's own reason: {warning}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refusal with no message body still warns rather than passing silently.
|
||||
#[test]
|
||||
fn a_refusal_without_a_message_still_warns() {
|
||||
let warning = local_add_refusal(StatusCode::BAD_REQUEST, &serde_json::Value::Null)
|
||||
.expect("a refusal must not render as success");
|
||||
assert!(warning.contains("400"), "must name the status: {warning}");
|
||||
}
|
||||
|
||||
/// The accepted case stays quiet, so the success line still prints.
|
||||
#[test]
|
||||
fn an_accepted_local_add_produces_no_warning() {
|
||||
assert!(local_add_refusal(StatusCode::OK, &json!({ "peer_count": 3 })).is_none());
|
||||
}
|
||||
|
||||
/// The warning is printed straight to a terminal, so a node-supplied message
|
||||
/// carrying an OSC title-rewrite plus a C0 escape must reach it defanged.
|
||||
#[test]
|
||||
fn a_refusal_message_cannot_carry_terminal_escapes() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "\u{1b}]2;pwned\u{7}legit-looking text" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
!warning.chars().any(|c| c.is_control()),
|
||||
"control char leaked to the terminal: {warning:?}"
|
||||
);
|
||||
assert!(
|
||||
warning.contains("legit-looking text"),
|
||||
"printable text dropped: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bidi format chars are not `char::is_control`, yet they reorder the
|
||||
/// displayed line, so a refusal must not carry them either.
|
||||
#[test]
|
||||
fn a_refusal_message_cannot_carry_bidi_overrides() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "peer \u{202e}refused\u{202c} here" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
!warning.chars().any(gitlawb_core::sanitize::is_bidi_format),
|
||||
"bidi format char leaked to the terminal: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hostile node must not be able to dump an arbitrary amount of text into
|
||||
/// the user's scrollback through the warning line.
|
||||
#[test]
|
||||
fn a_refusal_message_is_length_bounded() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "x".repeat(5000) }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
warning.chars().count() < 500,
|
||||
"warning not bounded: {} chars",
|
||||
warning.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
/// The status is the part the user can act on, so it must survive a body
|
||||
/// that does not parse. The read is capped, so a peer answering past the cap
|
||||
/// leaves exactly this: a valid status and truncated JSON.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_keeps_the_status_when_the_body_does_not_parse() {
|
||||
let truncated = r#"{"error":"service_unavailable","message":"backend is do"#;
|
||||
let failure = remote_announce_failure(StatusCode::SERVICE_UNAVAILABLE, truncated)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
failure.contains("503"),
|
||||
"the status must survive an unparseable body: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A parseable error body still reports the peer's own message.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_carries_the_peers_message() {
|
||||
let failure = remote_announce_failure(
|
||||
StatusCode::FORBIDDEN,
|
||||
r#"{"error":"forbidden","message":"unproven announce cannot change an existing peer"}"#,
|
||||
)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
failure.contains("403")
|
||||
&& failure.contains("unproven announce cannot change an existing peer"),
|
||||
"status and reason must both survive: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fallback path prints the raw body, so it is defanged like every other
|
||||
/// peer-supplied string headed for the terminal.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_defangs_an_unparseable_body() {
|
||||
let failure = remote_announce_failure(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"\u{1b}]2;pwned\u{7}<html>gateway error</html>",
|
||||
)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
!failure.chars().any(|c| c.is_control()),
|
||||
"control char leaked to the terminal: {failure:?}"
|
||||
);
|
||||
assert!(failure.contains("502"), "status dropped: {failure:?}");
|
||||
assert!(
|
||||
failure.contains("gateway error"),
|
||||
"the body is all the user gets when there is no message field: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unit tests above bind the helper. This one binds the CALL SITE: the
|
||||
/// failure check has to run before the parse, or a body past the read cap
|
||||
/// turns a 503 into "invalid JSON response" and the status the user needs is
|
||||
/// gone. Reordering the two statements in `cmd_add` compiles and passes every
|
||||
/// other test in this file, so only driving the command catches it.
|
||||
#[tokio::test]
|
||||
async fn a_peer_failure_past_the_read_cap_still_reports_its_status() {
|
||||
let mut peer = mockito::Server::new_async().await;
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _local_info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://me.example"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Valid JSON, but past the 8 KiB read cap, so what survives the read is
|
||||
// truncated and unparseable. This is the shape the cap itself creates.
|
||||
let oversized = format!(r#"{{"message":"{}"}}"#, "x".repeat(16 * 1024));
|
||||
let _peer_refusal = peer
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(oversized)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_add(peer.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.expect_err("a 503 from the peer must fail the command");
|
||||
|
||||
let rendered = format!("{err:#}");
|
||||
assert!(
|
||||
rendered.contains("503"),
|
||||
"the status must survive a body past the read cap: {rendered:?}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("invalid JSON response"),
|
||||
"the parse failure must not stand in for the status: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An accepted announce produces no error, so the caller proceeds to parse.
|
||||
#[test]
|
||||
fn an_accepted_remote_announce_produces_no_failure() {
|
||||
assert!(remote_announce_failure(StatusCode::OK, r#"{"peer_count":3}"#).is_none());
|
||||
}
|
||||
|
||||
/// The selection, not the helper. A refusal must never yield the success
|
||||
/// line, and the success line must never carry the warning prefix. This is
|
||||
/// what stayed green when the entire match was replaced with an
|
||||
/// unconditional "Added to local peer list."
|
||||
#[test]
|
||||
fn a_local_add_refusal_never_selects_the_success_line() {
|
||||
let (is_warning, line) = local_add_report(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "unproven announce cannot change an existing peer's http_url: did:key:zAbc" }),
|
||||
);
|
||||
|
||||
assert!(is_warning, "a 403 must select the warning stream: {line:?}");
|
||||
assert!(
|
||||
!line.contains("Added to local peer list"),
|
||||
"a refusal rendered as the success line: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
line.contains("unproven announce cannot change an existing peer"),
|
||||
"the node's reason must reach the user: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other direction, so the fix cannot be satisfied by always warning.
|
||||
#[test]
|
||||
fn an_accepted_local_add_selects_the_success_line() {
|
||||
let (is_warning, line) = local_add_report(StatusCode::OK, &json!({ "peer_count": 3 }));
|
||||
|
||||
assert!(!is_warning, "a 200 must not select the warning stream");
|
||||
assert!(
|
||||
line.contains("Added to local peer list"),
|
||||
"the success line must still print: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The accepted-announce block prints the remote peer's own DID and URL, so
|
||||
/// a hostile peer answering 200 must not get escapes onto the terminal that
|
||||
/// way either. The refusal path is not the only sink in this command.
|
||||
#[test]
|
||||
fn an_accepted_announce_summary_cannot_carry_terminal_escapes() {
|
||||
let summary = announced_peer_summary(
|
||||
"did:key:z6Mk\u{1b}]2;pwned\u{7}abc",
|
||||
"https://peer.example\u{202e}moc.live//:sptth",
|
||||
3,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!summary.chars().any(|c| c.is_control() && c != '\n'),
|
||||
"control char leaked to the terminal: {summary:?}"
|
||||
);
|
||||
assert!(
|
||||
!summary.chars().any(gitlawb_core::sanitize::is_bidi_format),
|
||||
"bidi format char leaked to the terminal: {summary:?}"
|
||||
);
|
||||
assert!(
|
||||
summary.contains("did:key:z6Mk") && summary.contains("Their peers: 3"),
|
||||
"printable content dropped: {summary:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The counterweight to the stripping tests: a legitimate message with RTL
|
||||
/// script letters and a ZWJ must survive intact, so the sanitizer is not
|
||||
/// proven by over-stripping.
|
||||
#[test]
|
||||
fn a_legitimate_refusal_message_survives_intact() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "peer refused \u{0627}\u{200D}b" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
warning.contains("peer refused \u{0627}\u{200D}b"),
|
||||
"legitimate text mangled: {warning:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-65
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::http::{read_body_capped, sanitize_node_msg, NodeClient};
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -104,39 +104,6 @@ fn trigger_counts(resp: &serde_json::Value) -> (u64, u64) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
|
||||
/// hostile or broken node returning a huge error body — the display is capped
|
||||
/// separately, but the read itself must not be unbounded (INV-6, read half).
|
||||
async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while buf.len() < cap {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let take = (cap - buf.len()).min(chunk.len());
|
||||
buf.extend_from_slice(&chunk[..take]);
|
||||
if take < chunk.len() {
|
||||
break; // hit the cap mid-chunk
|
||||
}
|
||||
}
|
||||
_ => break, // end of body or read error — return what we have
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
/// Strip terminal-dangerous characters from (and cap the length of) a
|
||||
/// node-supplied error string before surfacing it. The node a caller talks to
|
||||
/// could be hostile and embed escape sequences in its error body; those must not
|
||||
/// reach the terminal verbatim (INV-6). We drop the C0/C1 control bytes (which
|
||||
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
fn sanitize_node_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -260,37 +227,6 @@ mod tests {
|
||||
run(args).await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_controls_bidi_and_caps_length() {
|
||||
// C0 (ESC/BEL) and the Cf bidi override (U+202E) are both removed; the
|
||||
// printable text survives. (Note: a stripped ESC leaves any following
|
||||
// "[31m" as inert literal text — that is the point, so the input here
|
||||
// avoids that residue to keep the expectation unambiguous.)
|
||||
let out = sanitize_node_msg("a\u{1b}\u{07}b\u{202e}c");
|
||||
assert!(
|
||||
!out.chars().any(|c| c.is_control()),
|
||||
"control char leaked: {out:?}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains('\u{202e}'),
|
||||
"RLO bidi override leaked: {out:?}"
|
||||
);
|
||||
assert_eq!(out, "abc");
|
||||
// Length is capped at 200 chars regardless of input size.
|
||||
let long = "x".repeat(250);
|
||||
assert_eq!(sanitize_node_msg(&long).chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_legitimate_and_rtl_text() {
|
||||
// Must not over-strip: a plain word, a genuine RTL SCRIPT letter (Arabic
|
||||
// U+0627, category Lo — NOT a format char), and ZWJ (U+200D, a legitimate
|
||||
// Cf char, e.g. emoji sequences) all survive. Guards the shared predicate
|
||||
// against being widened into a blanket Cf stripper.
|
||||
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
|
||||
assert_eq!(out, "ok \u{0627}\u{200D}b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_handles_oversized_error_body_without_unbounded_output() {
|
||||
// A hostile/broken node returns a 2 MB error body. The command must still
|
||||
|
||||
@@ -720,4 +720,45 @@ mod tests {
|
||||
|
||||
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_normalizes_empty_api_key_to_none_on_trusted_origin() {
|
||||
let guard = ICAPTCHA_ENV_LOCK.lock().unwrap();
|
||||
let prev_key = std::env::var_os("GITLAWB_ICAPTCHA_API_KEY");
|
||||
let prev_op = std::env::var_os("GITLAWB_ICAPTCHA_URL");
|
||||
|
||||
// Operator origin configured and resolved as trusted (no advert), so
|
||||
// api_key reaches the env read and the emptiness filter is the only
|
||||
// thing standing between a blank var and an empty bearer token.
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_URL", "https://icap.mynode.example");
|
||||
|
||||
// "Operator set the var but left it blank" must stay keyless: a
|
||||
// Some("") would attach `Authorization: Bearer ` (empty token) to the
|
||||
// challenge and answer requests instead of omitting the header.
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", "");
|
||||
let empty = IcaptchaCfg::new("did:key:zTEST", None, None);
|
||||
|
||||
// Contrast arm: a real key on the same trusted path must survive the
|
||||
// filter, so the filter can't regress to dropping every key.
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", "secret-bearer");
|
||||
let non_empty = IcaptchaCfg::new("did:key:zTEST", None, None);
|
||||
|
||||
// Restore env and release the lock BEFORE asserting, so a failing
|
||||
// assertion can't poison the shared lock or cascade into a sibling.
|
||||
match prev_key {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_API_KEY"),
|
||||
}
|
||||
match prev_op {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
assert_eq!(
|
||||
empty.api_key, None,
|
||||
"empty GITLAWB_ICAPTCHA_API_KEY must normalize to None"
|
||||
);
|
||||
assert_eq!(non_empty.api_key.as_deref(), Some("secret-bearer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Tripwire: a committed negative case that locks the blackhole mechanism.
|
||||
//!
|
||||
//! The guard against non-loopback egress works because `obtain_proof` builds a
|
||||
//! stock `reqwest::blocking::Client` that inherits the process's proxy env vars.
|
||||
//! If the builder later gains `.no_proxy()` or a custom connector, the guard
|
||||
//! disarms silently and every existing test stays green (no live call is made
|
||||
//! during the normal mock-consumed run because the mock is on loopback and
|
||||
//! `NO_PROXY` covers it — the blackhole isn't even exercised).
|
||||
//!
|
||||
//! This test asserts that a destination the blackhole's `NO_PROXY` does NOT
|
||||
//! cover fails *while the blackhole is armed*. The destination is a local HTTP
|
||||
//! server on `[::1]` (IPv6 loopback), which `NO_PROXY` (`127.0.0.1, localhost`)
|
||||
//! does not list. With the blackhole active the proxy intercepts the
|
||||
//! connection and blocks it; without the blackhole the request would reach the
|
||||
//! server directly and succeed, turning this test RED.
|
||||
//!
|
||||
//! A positive control (disarmed blackhole) runs first, proving the fixture is
|
||||
//! valid and `[::1]` is reachable.
|
||||
//!
|
||||
//! Design constraints (see issue #211):
|
||||
//! - An unresolvable host would keep the request failing even when the guard
|
||||
//! is disarmed (DNS failure masquerading as the blackhole), so we must use
|
||||
//! a reachable address.
|
||||
//! - A real external host would make a live network call the moment the guard
|
||||
//! disarms, and on any runner that cannot reach that host the connect error
|
||||
//! again masquerades as the blackhole. A local loopback address avoids both
|
||||
//! problems — it is always reachable when unblocked and never reaches the
|
||||
//! real network.
|
||||
//! - The address must NOT be covered by `NO_PROXY`, yet must exist on every
|
||||
//! platform. An arbitrary `127/8` alias is not portable: macOS only binds
|
||||
//! `127.0.0.1` by default. `[::1]` (IPv6 loopback) is present by default
|
||||
//! on Linux, macOS, and Windows, and `NO_PROXY` only lists `127.0.0.1` and
|
||||
//! `localhost`, so the blackhole still intercepts it.
|
||||
//! - This depends on hyper-util's proxy matcher having no implicit loopback
|
||||
//! bypass — a host is only proxied-around when literally listed in
|
||||
//! `NO_PROXY` (verified in the pinned hyper-util; recheck on upgrade).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
|
||||
|
||||
mod support;
|
||||
|
||||
/// A minimal HTTP server that responds to iCaptcha challenge and answer
|
||||
/// requests on the IPv6 loopback address NOT covered by NO_PROXY.
|
||||
///
|
||||
/// When the blackhole is working, the proxy should intercept these requests
|
||||
/// and `obtain_proof` should fail. When the blackhole is disarmed, the
|
||||
/// requests reach this server and the flow succeeds.
|
||||
fn serve_icaptcha(listener: TcpListener) {
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
// Read until the first \n (end of request line) so TCP
|
||||
// segmentation cannot split the path out of a single read.
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut n = 0usize;
|
||||
while n < buf.len() {
|
||||
match stream.read(&mut buf[n..]) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(read) => {
|
||||
n += read;
|
||||
if buf[..n].contains(&b'\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let body = if request.contains("/v1/answer") {
|
||||
r#"{"status":"passed","proof":"PROOF-TRIP"}"#
|
||||
} else {
|
||||
r#"{"challengeId":"c1","type":"anagram","difficulty":1,"prompt":"listen","token":"tok-1"}"#
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len(),
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn make_cfg(url: &str) -> IcaptchaCfg {
|
||||
IcaptchaCfg {
|
||||
url: url.to_string(),
|
||||
did: "did:key:zTEST".to_string(),
|
||||
level: 1,
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obtain_proof_blackhole_tripwire() {
|
||||
// Bind to [::1], IPv6 loopback, which NO_PROXY (127.0.0.1, localhost)
|
||||
// does not cover. Unlike an arbitrary 127/8 alias, IPv6 loopback is
|
||||
// present by default on Linux, macOS, and Windows, so the fixture is
|
||||
// portable across platforms. Fail loudly if it cannot be bound: a
|
||||
// silent skip would report this tripwire as green while exercising
|
||||
// none of the negative control, recreating the very gap it exists to
|
||||
// close.
|
||||
let listener = TcpListener::bind(("::1", 0)).expect(
|
||||
"cannot bind [::1] for the blackhole tripwire. IPv6 loopback (::1) \
|
||||
is present by default on Linux, macOS, and Windows. This test fails \
|
||||
rather than silently skipping so a platform without it cannot count \
|
||||
the negative control as passed.",
|
||||
);
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let url = format!("http://[::1]:{port}");
|
||||
serve_icaptcha(listener);
|
||||
|
||||
// ── Positive control: disarmed blackhole → obtain_proof succeeds ──────
|
||||
let _disarmed_guard = support::disarm_proxy_env();
|
||||
|
||||
let solve: &dyn Fn(&Challenge) -> Option<String> = &|_c| Some("silent".to_string());
|
||||
let cfg = make_cfg(&url);
|
||||
let proof = obtain_proof(&cfg, Some(solve))
|
||||
.expect("positive control: obtain_proof should succeed when the proxy is disarmed");
|
||||
assert_eq!(
|
||||
proof, "PROOF-TRIP",
|
||||
"positive control: unexpected proof value"
|
||||
);
|
||||
drop(_disarmed_guard);
|
||||
|
||||
// ── Negative control: armed blackhole → obtain_proof fails with connect error ──
|
||||
let _armed_guard = support::arm_blackhole("http://127.0.0.1:1");
|
||||
|
||||
let result = obtain_proof(&cfg, Some(solve));
|
||||
|
||||
let err = result.expect_err(
|
||||
"blackhole tripwire: obtain_proof succeeded against [::1], \
|
||||
meaning the proxy blackhole did not intercept the request; \
|
||||
the no-live-call guard is disarmed",
|
||||
);
|
||||
assert!(
|
||||
err.chain().any(|c| c
|
||||
.downcast_ref::<reqwest::Error>()
|
||||
.is_some_and(|e| e.is_connect())),
|
||||
"expected a connect/proxy error, got: {err:#}",
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! INV-19 guard: the icaptcha flow must actually call the service (consume the
|
||||
//! INV-19 guard: the icaptcha flow must actually call the service (consuming the
|
||||
//! mock), not short-circuit or make a live call. If a future change let
|
||||
//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which
|
||||
//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go
|
||||
@@ -8,50 +8,144 @@
|
||||
//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or
|
||||
//! any other origin (a different host the mock never observes). To make the
|
||||
//! "no live call" half of the guard load-bearing, the test blackholes every
|
||||
//! non-loopback destination: `NO_PROXY` lets the loopback mock through while a
|
||||
//! closed proxy port routes any other host nowhere, so the client contacting
|
||||
//! anything but the injected mock fails the request instead of silently
|
||||
//! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED.
|
||||
//! non-loopback destination via a proxy observer: `NO_PROXY` lets the loopback
|
||||
//! mock through while every proxy variable routes any other host through a TCP
|
||||
//! listener that counts connections. Zero observed connections proves no leak
|
||||
//! reached the network on the exercised path.
|
||||
//!
|
||||
//! The observer design counts every accepted connection regardless of whether
|
||||
//! the caller propagates the transport error, so it catches leaks that a
|
||||
//! closed-port blackhole (which only surfaces propagating errors) would miss.
|
||||
//! To close the timing race inherent in a detached accept loop, the count is
|
||||
//! read only after a bounded drain: the test opens a flush connection and
|
||||
//! blocks until the accept thread has accepted *that specific connection*
|
||||
//! (tagged by its source port), not merely until any next counter tick.
|
||||
//! Because `accept()` hands out connections FIFO, every leak connection that
|
||||
//! completed its TCP handshake before the flush is counted before the flush —
|
||||
//! the assertion therefore covers all egress in flight when `obtain_proof`
|
||||
//! returned. A leak that starts its handshake after the drain is outside the
|
||||
//! window; that is the irreducible limit of observing a detached accept loop,
|
||||
//! and it is the remaining, well-defined gap this guard accepts.
|
||||
//!
|
||||
//! SCOPE: The observer only witnesses leaks issued on the path this test drives
|
||||
//! (a single passing round, 200 on both endpoints, no PoW, no Continue/Failed,
|
||||
//! no non-2xx response). A leak living on an error or retry branch makes no
|
||||
//! connection during the run and the zero-count assertion stays green. The
|
||||
//! guarantee covers only the exercised happy path and proxy-honoring transports;
|
||||
//! a future `.no_proxy()` or raw `TcpStream` bypasses the observer entirely.
|
||||
//!
|
||||
//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific
|
||||
//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so
|
||||
//! a runner with a working `HTTPS_PROXY` in its environment would route an https
|
||||
//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback
|
||||
//! mock is still consumed and this test stayed green. So we blackhole the
|
||||
//! scheme-specific variables too. No restore is needed: this file has a single
|
||||
//! `#[test]`, so it runs in its own process and the override never races a
|
||||
//! sibling test.
|
||||
//! mock is still consumed and this test stayed green. So we blackhole the
|
||||
//! scheme-specific variables too.
|
||||
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
|
||||
|
||||
mod support;
|
||||
|
||||
/// A TCP listener that counts every accepted connection.
|
||||
///
|
||||
/// Acts as the proxy target: any leaked connection to a non-loopback
|
||||
/// destination will be routed through this listener and counted. Every
|
||||
/// accepted connection is recorded by its source port so the test's own
|
||||
/// flush connection ([`ProxyObserver::drain`]) can be recognised and
|
||||
/// excluded from the leak count.
|
||||
struct ProxyObserver {
|
||||
_listener: Arc<TcpListener>,
|
||||
port: u16,
|
||||
/// Source ports of every accepted connection, in accept order.
|
||||
accepted: Arc<Mutex<Vec<u16>>>,
|
||||
/// Source ports of the test's own flush connections (never counted).
|
||||
flush_ports: Arc<Mutex<Vec<u16>>>,
|
||||
/// Signalled after each accept so `drain` can wait on the flush.
|
||||
ack: Arc<Condvar>,
|
||||
}
|
||||
|
||||
impl ProxyObserver {
|
||||
fn bind() -> Self {
|
||||
let listener = Arc::new(TcpListener::bind("127.0.0.1:0").expect("bind proxy observer"));
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let accepted = Arc::new(Mutex::new(Vec::new()));
|
||||
let flush_ports = Arc::new(Mutex::new(Vec::new()));
|
||||
let ack = Arc::new(Condvar::new());
|
||||
let a = Arc::clone(&accepted);
|
||||
let c = Arc::clone(&ack);
|
||||
let l = Arc::clone(&listener);
|
||||
std::thread::spawn(move || {
|
||||
for stream in l.incoming() {
|
||||
let Ok(stream) = stream else { continue };
|
||||
a.lock()
|
||||
.unwrap()
|
||||
.push(stream.peer_addr().map(|a| a.port()).unwrap_or(0));
|
||||
c.notify_all();
|
||||
}
|
||||
});
|
||||
ProxyObserver {
|
||||
_listener: listener,
|
||||
port,
|
||||
accepted,
|
||||
flush_ports,
|
||||
ack,
|
||||
}
|
||||
}
|
||||
|
||||
fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Number of accepted connections that are not the test's own flush
|
||||
/// connections — i.e. the leak count.
|
||||
fn count(&self) -> usize {
|
||||
let flush = self.flush_ports.lock().unwrap();
|
||||
let accepted = self.accepted.lock().unwrap();
|
||||
accepted.iter().filter(|p| !flush.contains(p)).count()
|
||||
}
|
||||
|
||||
/// Bounded acknowledgement barrier for the accept loop.
|
||||
///
|
||||
/// Opens a flush connection and blocks until the accept thread has
|
||||
/// accepted *that connection* (identified by its source port), bounded by
|
||||
/// a timeout. Because `accept()` hands out connections FIFO, once the
|
||||
/// flush is accepted every connection that completed its handshake before
|
||||
/// it has been accepted and recorded too — so counting on afterwards
|
||||
/// cannot miss a leak that was already in flight. The flush connection
|
||||
/// itself is never counted.
|
||||
///
|
||||
/// A counter-tick barrier would be unsound here: sampling "current + 1"
|
||||
/// and waiting for any increment lets a leak accepted after the sample
|
||||
/// satisfy the wait, and the flush is then miscounted as that leak.
|
||||
fn drain(&self) {
|
||||
let flush =
|
||||
TcpStream::connect(("127.0.0.1", self.port)).expect("observer flush connection");
|
||||
let flush_port = flush.local_addr().unwrap().port();
|
||||
self.flush_ports.lock().unwrap().push(flush_port);
|
||||
let mut accepted = self.accepted.lock().unwrap();
|
||||
while !accepted.contains(&flush_port) {
|
||||
let (guard, timed_out) = self
|
||||
.ack
|
||||
.wait_timeout(accepted, std::time::Duration::from_secs(5))
|
||||
.unwrap();
|
||||
accepted = guard;
|
||||
assert!(
|
||||
!timed_out.timed_out(),
|
||||
"proxy observer drain timed out; accept loop stalled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
|
||||
// Blackhole every non-loopback origin: the loopback mock bypasses the proxy
|
||||
// (NO_PROXY); any other host is forced through a closed port and fails.
|
||||
// reqwest reads these at client-build time, and obtain_proof builds its
|
||||
// client fresh, so this bounds the transport for the whole flow.
|
||||
//
|
||||
// Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest
|
||||
// gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so
|
||||
// an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a
|
||||
// runner whose environment already has a working HTTPS_PROXY.
|
||||
let blackhole = "http://127.0.0.1:1";
|
||||
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||
std::env::set_var("no_proxy", "127.0.0.1,localhost");
|
||||
for var in [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
] {
|
||||
std::env::set_var(var, blackhole);
|
||||
}
|
||||
let observer = ProxyObserver::bind();
|
||||
let _guard = support::arm_blackhole(&format!("http://127.0.0.1:{}", observer.port()));
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
|
||||
// The two endpoints the flow hits, each expected exactly once.
|
||||
let challenge = server
|
||||
.mock("POST", "/v1/challenge")
|
||||
.with_status(200)
|
||||
@@ -77,18 +171,29 @@ fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
// "anagram" is not a built-in solvable type, so the flow consults this
|
||||
// solver callback. The answer body itself does not matter: the mocked
|
||||
// /v1/answer returns "passed" regardless of what is submitted.
|
||||
let solve = |_c: &Challenge| Some("silent".to_string());
|
||||
let solver: &dyn Fn(&Challenge) -> Option<String> = &solve;
|
||||
|
||||
let count_before = observer.count();
|
||||
|
||||
let proof = obtain_proof(&cfg, Some(solver))
|
||||
.expect("obtain_proof should complete against the mocked service");
|
||||
assert_eq!(proof, "PROOF-XYZ");
|
||||
|
||||
// INV-19: prove the client actually called the mocked endpoints. Had it made
|
||||
// a live call or skipped the network, these would fail (mock never hit).
|
||||
challenge.assert();
|
||||
answer.assert();
|
||||
|
||||
// Drain the accept loop before asserting: the flush connection is not
|
||||
// counted, and because accept() is FIFO every leak connection that
|
||||
// completed its handshake before the flush is recorded before the flush
|
||||
// is — so the count after drain reflects all egress in flight during the
|
||||
// call.
|
||||
observer.drain();
|
||||
let leak_count = observer.count() - count_before;
|
||||
|
||||
assert_eq!(
|
||||
leak_count, 0,
|
||||
"proxy observer caught leaked connections; \
|
||||
the blackhole failed to contain egress"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Shared helpers for the icaptcha-client integration tests.
|
||||
//!
|
||||
//! CONCURRENCY CONSTRAINT: [`arm_blackhole`] and [`disarm_proxy_env`] mutate
|
||||
//! process-wide proxy env vars and restore them on drop. Each test binary
|
||||
//! that consumes this module currently has exactly one `#[test]`, so there is
|
||||
//! no concurrent use. `cargo test` runs tests within a binary in parallel by
|
||||
//! default, so the NEXT test added to either file MUST be serialized against
|
||||
//! these helpers (e.g. a shared `Mutex` in this module, or the `serial_test`
|
||||
//! crate) or it will race on the proxy vars.
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
const PROXY_VARS: &[&str] = &[
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"REQUEST_METHOD",
|
||||
];
|
||||
|
||||
/// Captures the current values of a set of env vars and restores them on drop.
|
||||
pub struct EnvSnapshot(Vec<(String, Option<OsString>)>);
|
||||
|
||||
impl EnvSnapshot {
|
||||
pub fn capture(vars: &[&str]) -> Self {
|
||||
EnvSnapshot(
|
||||
vars.iter()
|
||||
.map(|v| (v.to_string(), std::env::var_os(v)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvSnapshot {
|
||||
fn drop(&mut self) {
|
||||
for (var, val) in &self.0 {
|
||||
match val {
|
||||
Some(v) => std::env::set_var(var, v),
|
||||
None => std::env::remove_var(var),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard that restores env vars captured at construction time.
|
||||
pub struct EnvGuard {
|
||||
_prev: EnvSnapshot,
|
||||
}
|
||||
|
||||
/// Override every proxy-related env var so that non-loopback destinations are
|
||||
/// forced through `proxy_url`, with NO_PROXY covering loopback. Also clears
|
||||
/// REQUEST_METHOD, which causes hyper-util to ignore all proxy variables when
|
||||
/// set. Restores prior values on drop.
|
||||
///
|
||||
/// Not safe to call from concurrent tests (see module docs).
|
||||
pub fn arm_blackhole(proxy_url: &str) -> EnvGuard {
|
||||
let prev = EnvSnapshot::capture(PROXY_VARS);
|
||||
|
||||
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||
std::env::set_var("no_proxy", "127.0.0.1,localhost");
|
||||
for var in [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
] {
|
||||
std::env::set_var(var, proxy_url);
|
||||
}
|
||||
std::env::remove_var("REQUEST_METHOD");
|
||||
|
||||
EnvGuard { _prev: prev }
|
||||
}
|
||||
|
||||
/// Clear every proxy-related env var so connections go direct. Restores
|
||||
/// prior values on drop.
|
||||
///
|
||||
/// Not safe to call from concurrent tests (see module docs).
|
||||
#[allow(dead_code)]
|
||||
pub fn disarm_proxy_env() -> EnvGuard {
|
||||
let prev = EnvSnapshot::capture(PROXY_VARS);
|
||||
|
||||
for var in PROXY_VARS {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
|
||||
EnvGuard { _prev: prev }
|
||||
}
|
||||
@@ -38,8 +38,8 @@ Owner focus: node reliability.
|
||||
|
||||
Owner focus: protocol/auth.
|
||||
|
||||
- Implement repo write authorization: repo owner checks, protected branches, UCAN capability checks, and clear delegation semantics.
|
||||
- Implement private-read enforcement or remove private-repo affordances until it exists.
|
||||
- Close default-open repo write authorization and wire trusted UCAN delegation into repository permissions.
|
||||
- Close the remaining visibility gaps: restrict or document task, pin, and anchor metadata routes, and publish an irreversible-publication policy.
|
||||
- Add UCAN revocation or blocklisting, with an emergency compromised-key runbook.
|
||||
- Add mutation-aware GraphQL auth before GraphQL becomes a public write API surface.
|
||||
- Harden peer registration and outbound peer calls against SSRF and peer-list poisoning.
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
Date: 2026-05-28
|
||||
Repo state inspected: `main` tracking `origin/main`, starting at `b12c6bc feat: per-DID rate limiting on creation endpoints (10/hour) (#13)`.
|
||||
|
||||
> **Historical snapshot.** This audit describes the repository at the commit above. Its
|
||||
> statements about incomplete UCAN chain validation and absent per-repository read
|
||||
> enforcement have been superseded; see [`SECURITY.md`](../SECURITY.md) for the current
|
||||
> security posture.
|
||||
|
||||
## Commands run
|
||||
|
||||
```sh
|
||||
|
||||
+2
-1
@@ -177,7 +177,8 @@ GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
| Missed heartbeats | After 3 days without one, you're excluded from rewards until you beat again |
|
||||
| Operator key | Dedicated wallet, small ETH balance, not your main treasury |
|
||||
| Monitoring | Watch `lastHeartbeat` on-chain; alert if > 22h since last beat |
|
||||
| Public URL | Must resolve and serve `/health` — peers will ping it |
|
||||
| Public URL | Must resolve and serve `/health` for liveness and DB-aware `/ready` for peer readiness |
|
||||
| Rolling upgrade (advisory-lock algorithm) | The SHA-256 advisory-lock key swap (replacing `DefaultHasher`) re-keys every repo in the first release that carries it. During a shared-Postgres rolling upgrade, old nodes compute a different `i64` key than new nodes for the same repo, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost. **Drain in-flight writes** or cut over through a **single node** before bringing new-binary nodes online. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
{ "type": "generic", "path": "crates/gitlawb-node/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/gl/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/git-remote-gitlawb/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/gitlawb-attest/Cargo.toml" }
|
||||
{ "type": "generic", "path": "crates/gitlawb-attest/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/icaptcha-client/Cargo.toml" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Release-versioning coverage gate for the workspace crates.
|
||||
#
|
||||
# release-please owns every crate's version. It rewrites a version line only
|
||||
# when BOTH halves hold: the line carries an `# x-release-please-version`
|
||||
# annotation, and the crate's manifest is listed under `extra-files` in
|
||||
# release-please-config.json. A crate missing either half is silently skipped
|
||||
# at release time and freezes at whatever version it was created with, which
|
||||
# is exactly what happened to icaptcha-client (added as a workspace member
|
||||
# after the config was last written, so it sat at 0.4.0 while the rest of the
|
||||
# workspace walked to 0.7.1 across four releases).
|
||||
#
|
||||
# Exhaustive-by-construction: the required set is DERIVED from the workspace
|
||||
# member list via `cargo metadata`, never from a hand-maintained list here. A
|
||||
# newly added crate reds CI whether or not anyone remembered this gate exists,
|
||||
# which is the property a hand list cannot have.
|
||||
#
|
||||
# Hard-fail directions, all three release-silent:
|
||||
# 1. a workspace member absent from extra-files
|
||||
# 2. a workspace member whose version line lacks the annotation
|
||||
# 3. an extra-files entry pointing at a manifest that no longer exists
|
||||
#
|
||||
# Runnable from anywhere in the repo.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
CONFIG="$ROOT/release-please-config.json"
|
||||
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "ERROR: release-please config not found at $CONFIG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolving can refresh Cargo.lock as a side effect, so snapshot and restore it:
|
||||
# this check must never leave the working tree dirty when run locally. Same
|
||||
# reasoning as scripts/check-gitlawb-core-deps.sh.
|
||||
lock_backup="$(mktemp)"
|
||||
cp "$ROOT/Cargo.lock" "$lock_backup"
|
||||
restore_lock() { cp "$lock_backup" "$ROOT/Cargo.lock"; rm -f "$lock_backup"; }
|
||||
trap restore_lock EXIT
|
||||
|
||||
# Workspace members as repo-relative manifest paths, one per line. This is the
|
||||
# derived required set: whatever cargo considers a member must be release-managed.
|
||||
members="$(
|
||||
cargo metadata --no-deps --format-version 1 --manifest-path "$ROOT/Cargo.toml" \
|
||||
| jq -r '.packages[].manifest_path' \
|
||||
| sed "s#^$ROOT/##" \
|
||||
| sort -u
|
||||
)"
|
||||
|
||||
# Manifest paths release-please is configured to rewrite.
|
||||
listed="$(jq -r '.packages["."]["extra-files"][] | .path' "$CONFIG" | sort -u)"
|
||||
|
||||
unlisted="$(comm -23 <(printf '%s\n' "$members") <(printf '%s\n' "$listed"))"
|
||||
orphaned="$(comm -13 <(printf '%s\n' "$members") <(printf '%s\n' "$listed"))"
|
||||
|
||||
# Second half of the invariant: being listed does nothing without the marker,
|
||||
# because the generic updater keys on the annotation to find the line.
|
||||
#
|
||||
# Scoped to the [package] table on purpose. A manifest-wide match is satisfied by
|
||||
# an annotated `version` under any other table, so a crate could leave the version
|
||||
# cargo actually reads unannotated, pass this gate, and still freeze at release
|
||||
# time while release-please rewrote the decoy line instead.
|
||||
unmarked=""
|
||||
while IFS= read -r manifest; do
|
||||
[ -n "$manifest" ] || continue
|
||||
if ! awk '
|
||||
/^[[:space:]]*\[/ { in_package = ($0 ~ /^[[:space:]]*\[package\][[:space:]]*$/); next }
|
||||
in_package && /^version = ".*" # x-release-please-version/ { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
' "$ROOT/$manifest"; then
|
||||
unmarked="$unmarked$manifest"$'\n'
|
||||
fi
|
||||
done <<< "$members"
|
||||
|
||||
failed=0
|
||||
|
||||
if [ -n "$unlisted" ]; then
|
||||
{
|
||||
echo "ERROR: workspace members missing from extra-files in release-please-config.json:"
|
||||
printf ' %s\n' $unlisted
|
||||
echo
|
||||
echo "release-please will never bump these crates, so they freeze at their"
|
||||
echo "current version while the rest of the workspace moves. Add each one as"
|
||||
echo ' { "type": "generic", "path": "<manifest path>" }'
|
||||
} >&2
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [ -n "$unmarked" ]; then
|
||||
{
|
||||
echo "ERROR: workspace members whose version line lacks the release-please annotation:"
|
||||
printf ' %s\n' $unmarked
|
||||
echo
|
||||
echo 'The generic updater finds the line by its trailing `# x-release-please-version`'
|
||||
echo "comment. Without it the crate is listed but never rewritten."
|
||||
} >&2
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [ -n "$orphaned" ]; then
|
||||
{
|
||||
echo "ERROR: extra-files entries pointing at manifests that are not workspace members:"
|
||||
printf ' %s\n' $orphaned
|
||||
echo
|
||||
echo "release-please fails the release when an extra-file path does not exist."
|
||||
echo "Drop the stale entry, or restore the crate to the workspace."
|
||||
} >&2
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "release versioning coverage: OK ($(printf '%s\n' "$members" | wc -l | tr -d ' ') workspace members, all annotated and listed)."
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
tag="${1-}"
|
||||
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
|
||||
|
||||
case "$tag" in
|
||||
""|*[!A-Za-z0-9._-]*)
|
||||
printf '%s\n' "::error::release tag is empty or contains invalid characters"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$tag" in
|
||||
v[0-9]*) ;;
|
||||
*)
|
||||
printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
resolver="$repo_root/scripts/resolve-release-tag.sh"
|
||||
test_tmp="$(mktemp -d)"
|
||||
trap 'rm -r -- "$test_tmp"' EXIT
|
||||
|
||||
valid_output="$test_tmp/valid-output"
|
||||
GITHUB_OUTPUT="$valid_output" "$resolver" "v1.2.3"
|
||||
|
||||
expected_output="$test_tmp/expected-output"
|
||||
printf '%s\n' "tag=v1.2.3" "version=1.2.3" > "$expected_output"
|
||||
cmp "$expected_output" "$valid_output"
|
||||
|
||||
newline_output="$test_tmp/newline-output"
|
||||
newline_stdout="$test_tmp/newline-stdout"
|
||||
newline_stderr="$test_tmp/newline-stderr"
|
||||
if GITHUB_OUTPUT="$newline_output" "$resolver" $'v1.2.3\nname=owned' \
|
||||
> "$newline_stdout" 2> "$newline_stderr"
|
||||
then
|
||||
printf '%s\n' "newline-containing release tag unexpectedly passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$newline_output"
|
||||
grep -qxF "::error::release tag is empty or contains invalid characters" "$newline_stdout"
|
||||
test ! -s "$newline_stderr"
|
||||
|
||||
empty_output="$test_tmp/empty-output"
|
||||
if GITHUB_OUTPUT="$empty_output" "$resolver" ""; then
|
||||
printf '%s\n' "empty release tag unexpectedly passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$empty_output"
|
||||
|
||||
invalid_output="$test_tmp/invalid-output"
|
||||
for invalid_tag in \
|
||||
"v1.2.3 tag" \
|
||||
"v1.2.3;name=owned" \
|
||||
"v1.2.3+build.5" \
|
||||
"release-1.2.3" \
|
||||
"v-1" \
|
||||
"vlatest" \
|
||||
"v" \
|
||||
"v1.2.3/../../x" \
|
||||
"V1.2.3"
|
||||
do
|
||||
: > "$invalid_output"
|
||||
if GITHUB_OUTPUT="$invalid_output" "$resolver" "$invalid_tag"; then
|
||||
printf '%s\n' "invalid release tag unexpectedly passed: $invalid_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$invalid_output"
|
||||
done
|
||||
|
||||
release_workflow="$repo_root/.github/workflows/release.yml"
|
||||
actual_resolver_steps="$test_tmp/actual-resolver-steps"
|
||||
expected_resolver_steps="$test_tmp/expected-resolver-steps"
|
||||
|
||||
# Pin each resolver step and the checkout step that supplies its script. Any
|
||||
# change to one of these reviewed blocks must be reflected here deliberately.
|
||||
awk '
|
||||
function emit_step() {
|
||||
if (in_step && (is_rel || is_workflow_scripts_checkout)) {
|
||||
printf "job=%s\n%s", job, step
|
||||
}
|
||||
in_step = 0
|
||||
is_rel = 0
|
||||
is_workflow_scripts_checkout = 0
|
||||
step = ""
|
||||
}
|
||||
|
||||
/^ [A-Za-z0-9_-]+:[[:space:]]*$/ {
|
||||
emit_step()
|
||||
job = $0
|
||||
sub(/^ /, "", job)
|
||||
sub(/:[[:space:]]*$/, "", job)
|
||||
next
|
||||
}
|
||||
|
||||
/^ - / {
|
||||
emit_step()
|
||||
in_step = 1
|
||||
is_workflow_scripts_checkout = ($0 ~ /^ - name:[[:space:]]*Check out workflow scripts[[:space:]]*$/)
|
||||
step = $0 ORS
|
||||
next
|
||||
}
|
||||
|
||||
in_step {
|
||||
step = step $0 ORS
|
||||
if ($0 ~ /^ id:[[:space:]]*rel[[:space:]]*$/) {
|
||||
is_rel = 1
|
||||
}
|
||||
}
|
||||
|
||||
END {
|
||||
emit_step()
|
||||
}
|
||||
' "$release_workflow" > "$actual_resolver_steps"
|
||||
|
||||
cat > "$expected_resolver_steps" <<'EOF'
|
||||
job=docker
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
job=docker
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing — a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
job=docker-manifest
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
job=docker-manifest
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
|
||||
job=npm-publish
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
job=npm-publish
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
|
||||
EOF
|
||||
|
||||
if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then
|
||||
printf '%s\n' \
|
||||
"release workflow resolver steps differ from the three reviewed blocks" >&2
|
||||
diff -u "$expected_resolver_steps" "$actual_resolver_steps" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "release tag validation tests passed"
|
||||
Reference in new issue
Block a user