mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
370a364b0f | ||
|
|
03aee267e6 | ||
|
|
c519d54fc1 | ||
|
|
1c82060c2b | ||
|
|
73429c7a56 | ||
|
|
fccdfc1820 | ||
|
|
8e6e2ad0fe | ||
|
|
9a70d75d13 | ||
|
|
62a99270a9 | ||
|
|
fba3c6fda3 | ||
|
|
45e269b2ae | ||
|
|
b774c435a6 | ||
|
|
0a1618f7fb | ||
|
|
c2fce67bae | ||
|
|
3351e09c08 | ||
|
|
757edf560c | ||
|
|
86828046ea | ||
|
|
d26cf6dd1d | ||
|
|
94ca744269 | ||
|
|
2eea354188 | ||
|
|
c767658b7b | ||
|
|
8afc9eaf97 | ||
|
|
433c8915bf | ||
|
|
413d6cf0bc | ||
|
|
394768711e | ||
|
|
f424ccbbcd | ||
|
|
8d17038e5b | ||
|
|
72e899dae0 | ||
|
|
5749df6e67 | ||
|
|
2a54c159e6 | ||
|
|
12643573f6 | ||
|
|
be0cdd6a9e | ||
|
|
ac59bd7e01 | ||
|
|
3cc01890fb | ||
|
|
a030eefb70 | ||
|
|
cf97405bc1 | ||
|
|
4410b834df | ||
|
|
9412aa9c5b | ||
|
|
7a6bd359eb | ||
|
|
473924dcfe | ||
|
|
f19f2813a2 | ||
|
|
79b9ee5c89 | ||
|
|
454e59ba53 | ||
|
|
c35452ed41 | ||
|
|
e750c3042d | ||
|
|
87a7e40513 | ||
|
|
ab0ea240fc | ||
|
|
71389e621a | ||
|
|
e444bc0ae5 | ||
|
|
4983b854d1 | ||
|
|
362ee3462e | ||
|
|
3b1fa54b73 | ||
|
|
5069cd13ca | ||
|
|
6336657efc | ||
|
|
57c6e01c26 | ||
|
|
2c264e08e3 | ||
|
|
fc4c206c3a | ||
|
|
2434dde42f | ||
|
|
db4de844ad | ||
|
|
65ba1374af | ||
|
|
ad7c2b2b83 | ||
|
|
c98b503f90 | ||
|
|
3ec8cbf1e1 | ||
|
|
6b5e5bc7ae | ||
|
|
dfcaa22b23 | ||
|
|
2109d08e2c | ||
|
|
c5e5099def | ||
|
|
cd67718f49 | ||
|
|
6bafaa6dc5 | ||
|
|
26bc3f6987 | ||
|
|
d00d89ae5b | ||
|
|
67ad2b876c | ||
|
|
6cff5286b4 |
No files matched your search
+100
@@ -21,6 +21,23 @@ GITLAWB_REPOS_DIR=/data/repos
|
||||
# When using the bundled docker-compose, this is wired automatically.
|
||||
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
|
||||
# ── Database pool & startup resilience ────────────────────────────────────
|
||||
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
|
||||
# connections open lazily. Size against the DB server's max_connections,
|
||||
# remembering admin tooling opens its own pool.
|
||||
GITLAWB_DB_MAX_CONNECTIONS=20
|
||||
# Seconds a request waits for a pool connection before failing with 503.
|
||||
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
|
||||
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
|
||||
# generous enough for a peer instance to finish migrating (the migration
|
||||
# advisory lock is held across a deploy); on expiry the attempt is retried.
|
||||
GITLAWB_DB_CONNECT_TIMEOUT_SECS=60
|
||||
# Startup retry backoff while the database is unavailable: first delay and
|
||||
# ceiling, in seconds. The node stays up serving 503s (with a truthful
|
||||
# /ready) instead of crash-looping.
|
||||
GITLAWB_DB_RETRY_INITIAL_SECS=5
|
||||
GITLAWB_DB_RETRY_MAX_SECS=60
|
||||
|
||||
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
|
||||
# Get a JWT at https://app.pinata.cloud/developers/api-keys
|
||||
GITLAWB_PINATA_JWT=
|
||||
@@ -90,11 +107,94 @@ GITLAWB_PUBLIC_READ=true
|
||||
# Maximum git smart-HTTP pack request size, in bytes.
|
||||
GITLAWB_MAX_PACK_BYTES=2147483648
|
||||
|
||||
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
|
||||
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
|
||||
# worker and, on push, the repo write lock. Also bounds both info/refs
|
||||
# advertisements, the withheld-blob pack build, and the push-side candidate
|
||||
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
|
||||
# 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 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
|
||||
|
||||
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
|
||||
# Max receive-pack requests (info/refs advertisement + push POST) per client
|
||||
# IP per hour. 0 disables. Default 600.
|
||||
GITLAWB_PUSH_RATE_LIMIT=600
|
||||
|
||||
# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
|
||||
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
|
||||
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
|
||||
# so it is bounded to keep a permissionless caller from fanning out unbounded
|
||||
# concurrent walks and exhausting blocking-pool threads + PIDs.
|
||||
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
|
||||
# the served-git pools). Over-cap sheds a 503. Default 32.
|
||||
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
|
||||
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
|
||||
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
|
||||
# Default 4.
|
||||
GITLAWB_IPFS_WALK_PER_SOURCE=4
|
||||
# Max legacy (NULL-provenance) repos probed per single /ipfs request, bounding the
|
||||
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
|
||||
# truncated scan sheds a retryable 503, never a false 404. Default 256.
|
||||
GITLAWB_IPFS_MAX_REPOS_WALKED=256
|
||||
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
|
||||
# from the concurrency caps above). 0 disables. Default 600.
|
||||
GITLAWB_IPFS_RATE_LIMIT=600
|
||||
|
||||
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
|
||||
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
|
||||
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
|
||||
# limit alone is bypassed by a did:key farm (one throwaway identity per repo),
|
||||
# which is how spam-repo floods slip past it and the iCaptcha gate; the per-IP
|
||||
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
|
||||
# the client IP. 0 disables. Default 120.
|
||||
GITLAWB_CREATE_RATE_LIMIT=120
|
||||
|
||||
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
|
||||
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
|
||||
# known peers and run at higher frequency, so a generous bucket. Separate from
|
||||
# the trigger bucket so an unsigned notify flood can't drain trigger's quota.
|
||||
# 0 disables. Default 600.
|
||||
GITLAWB_PEER_WRITE_RATE_LIMIT=600
|
||||
# /api/v1/sync/trigger requires a signature and fans out to every peer per call,
|
||||
# so it gets a tight bucket. 0 disables. Default 60.
|
||||
GITLAWB_SYNC_TRIGGER_RATE_LIMIT=60
|
||||
|
||||
# Which forwarded header the edge is trusted to set, used to resolve the real
|
||||
# client IP for the push limiter. One of:
|
||||
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
|
||||
|
||||
@@ -116,10 +116,14 @@ jobs:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
# Native arm64 runner: cross-compiling this target with the glibc
|
||||
# toolchain broke aws-lc-sys, and native lets the smoke test run.
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-24.04-arm
|
||||
# macos-13 (the last plain Intel image) is retired and queues forever;
|
||||
# macos-15-intel is GitHub's supported Intel label.
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-pc-windows-msvc
|
||||
@@ -137,16 +141,13 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# Both musl targets build natively on a matching-arch runner, so the stock
|
||||
# musl-gcc wrapper is all that's needed (no cross toolchain).
|
||||
- name: Install musl tools (linux)
|
||||
if: contains(matrix.target, 'linux-musl')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y musl-tools
|
||||
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||
echo "CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
@@ -171,10 +172,9 @@ jobs:
|
||||
cargo build --release --target ${{ matrix.target }} $args
|
||||
|
||||
# Run each packaged binary's --version so a broken release artifact fails the
|
||||
# build instead of shipping. Skipped for cross-compiled targets that can't run
|
||||
# on the host runner (aarch64 linux on an x86_64 runner).
|
||||
# build instead of shipping. Every target builds on a matching-arch runner,
|
||||
# so all of them can execute here.
|
||||
- name: Smoke test binaries
|
||||
if: ${{ matrix.target != 'aarch64-unknown-linux-musl' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.5.0"
|
||||
".": "0.5.1"
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bounties:** add tests for claim_bounty repo-read gate ([#160](https://github.com/Gitlawb/node/issues/160)) ([#169](https://github.com/Gitlawb/node/issues/169)) ([6bafaa6](https://github.com/Gitlawb/node/commit/6bafaa6dc5f05c0dcd61c708397afddbcf8c2e3f))
|
||||
* **deps:** bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#162](https://github.com/Gitlawb/node/issues/162)) ([#163](https://github.com/Gitlawb/node/issues/163)) ([67ad2b8](https://github.com/Gitlawb/node/commit/67ad2b876c8d9a336219d1016968de7a88fc4e75))
|
||||
* **node,git:** bound a hung served git with a total-duration timeout ([#62](https://github.com/Gitlawb/node/issues/62)) ([#165](https://github.com/Gitlawb/node/issues/165)) ([cd67718](https://github.com/Gitlawb/node/commit/cd67718f49ec38726a40f6bcf36f539ccdb42969))
|
||||
* **node:** bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth ([#147](https://github.com/Gitlawb/node/issues/147)) ([#149](https://github.com/Gitlawb/node/issues/149)) ([6b5e5bc](https://github.com/Gitlawb/node/commit/6b5e5bc7aee00a2d03295d3620df1ee4d8c024a2))
|
||||
* **node:** gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes ([#82](https://github.com/Gitlawb/node/issues/82)) ([#161](https://github.com/Gitlawb/node/issues/161)) ([d00d89a](https://github.com/Gitlawb/node/commit/d00d89ae5be992d1f63e95b714ae1bd3735e8457))
|
||||
* **node:** rate-limit repo/agent creation per client IP to stop DID-farm spam floods ([#180](https://github.com/Gitlawb/node/issues/180)) ([dfcaa22](https://github.com/Gitlawb/node/commit/dfcaa22b23ec91be4c75926956aa994ca89de8d5))
|
||||
* **release:** build aarch64-musl natively on arm64 runners, replace retired macos-13 ([#155](https://github.com/Gitlawb/node/issues/155)) ([6cff528](https://github.com/Gitlawb/node/commit/6cff5286b436fdee44ad881999e2a5f4bdba18f9))
|
||||
* **visibility:** gate repo-scoped read surfaces on visibility ([#120](https://github.com/Gitlawb/node/issues/120)) ([#157](https://github.com/Gitlawb/node/issues/157)) ([26bc3f6](https://github.com/Gitlawb/node/commit/26bc3f69870aa77e43c0d92115a5aa59555b7d88))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
|
||||
|
||||
|
||||
|
||||
Generated
+8
-7
@@ -2293,9 +2293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -3300,7 +3300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
@@ -3312,7 +3312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3329,7 +3329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3356,7 +3356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3412,7 +3412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.4.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3824,6 +3824,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -302,20 +302,21 @@ POST /api/v1/bounties/{id}/...
|
||||
POST /{owner}/{repo}/git-receive-pack
|
||||
```
|
||||
|
||||
Peer write routes support staged rollout:
|
||||
These peer write routes support staged rollout:
|
||||
|
||||
```txt
|
||||
POST /api/v1/peers/announce
|
||||
POST /api/v1/sync/notify
|
||||
POST /api/v1/sync/trigger
|
||||
```
|
||||
|
||||
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted, 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. Once all live peers upgrade, operators can set:
|
||||
|
||||
```bash
|
||||
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
|
||||
```
|
||||
|
||||
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -342,12 +343,20 @@ 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, 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. |
|
||||
| `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_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 legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
|
||||
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
|
||||
|
||||
Production note: change the default Postgres password before exposing a node publicly.
|
||||
|
||||
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
|
||||
|
||||
---
|
||||
|
||||
## Optional node staking
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.5.0" # x-release-please-version
|
||||
version = "0.5.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.5.0" # x-release-please-version
|
||||
version = "0.5.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.5.0" # x-release-please-version
|
||||
version = "0.5.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -64,6 +64,19 @@ impl Cid {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `s` parses as a CIDv1 with the raw codec — the exact shape
|
||||
/// [`Cid::from_git_object_bytes`] produces and the `/ipfs` resolver looks up.
|
||||
/// A legacy provider CID (Kubo dag-pb, Pinata CIDv0) parses to a different
|
||||
/// version or codec and returns `false`, marking it an opportunistic-repair
|
||||
/// candidate. Decidable from the string alone (no object bytes), so the pin path
|
||||
/// can gate the byte-read/recompute cost on it and leave non-legacy rows at the
|
||||
/// existing DB-only skip cost. An unparseable string is non-canonical (`false`).
|
||||
pub fn is_raw_cidv1(s: &str) -> bool {
|
||||
s.parse::<CidGeneric<64>>()
|
||||
.map(|c| c.version() == cid::Version::V1 && c.codec() == RAW)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
impl fmt::Display for Cid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
@@ -177,6 +190,38 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_raw_cidv1_classifies_codec_from_string() {
|
||||
// The canonical resolver key: CIDv1 + raw codec → not a repair candidate.
|
||||
let raw = Cid::from_git_object_bytes(b"blob 5\0hello");
|
||||
assert!(
|
||||
is_raw_cidv1(raw.as_str()),
|
||||
"from_git_object_bytes output is CIDv1/raw"
|
||||
);
|
||||
|
||||
// A CIDv0 (Pinata dag-pb legacy shape) → repair candidate.
|
||||
assert!(
|
||||
!is_raw_cidv1("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"),
|
||||
"a CIDv0 dag-pb value is a legacy-repair candidate"
|
||||
);
|
||||
|
||||
// A CIDv1 with the dag-pb codec (the Kubo above-block-size root) over the
|
||||
// same multihash → still a repair candidate (codec, not just version).
|
||||
let parsed = raw.as_str().parse::<CidGeneric<64>>().unwrap();
|
||||
const DAG_PB: u64 = 0x70;
|
||||
let dagpb = CidGeneric::<64>::new_v1(DAG_PB, *parsed.hash()).to_string();
|
||||
assert!(
|
||||
!is_raw_cidv1(&dagpb),
|
||||
"a CIDv1 dag-pb value is a legacy-repair candidate"
|
||||
);
|
||||
|
||||
// Garbage is non-canonical.
|
||||
assert!(
|
||||
!is_raw_cidv1("not-a-cid"),
|
||||
"an unparseable string is non-canonical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_of_empty_input_is_well_known() {
|
||||
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.5.0" # x-release-please-version
|
||||
version = "0.5.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -110,19 +110,28 @@ pub async fn create_bounty(
|
||||
Ok((StatusCode::CREATED, Json(bounty)))
|
||||
}
|
||||
|
||||
const MAX_BOUNTY_LIMIT: i64 = 200;
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/bounties
|
||||
pub async fn list_repo_bounties(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Query(q): Query<ListBountiesQuery>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, MAX_BOUNTY_LIMIT);
|
||||
let bounties = state
|
||||
.db
|
||||
.list_bounties(
|
||||
Some(&owner),
|
||||
Some(&repo),
|
||||
q.status.as_deref(),
|
||||
q.limit.unwrap_or(50),
|
||||
limit,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -133,25 +142,92 @@ pub async fn list_repo_bounties(
|
||||
pub async fn list_all_bounties(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListBountiesQuery>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let bounties = state
|
||||
.db
|
||||
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
|
||||
.await?;
|
||||
let raw_limit = q.limit.unwrap_or(50);
|
||||
let limit = raw_limit.clamp(1, MAX_BOUNTY_LIMIT);
|
||||
|
||||
Ok(Json(serde_json::json!({ "bounties": bounties })))
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let mut memo = std::collections::HashMap::new();
|
||||
let mut allowed: Vec<BountyRecord> = Vec::new();
|
||||
let page_size = (limit * 5).clamp(1, 200);
|
||||
let max_scanned = 10_000i64;
|
||||
let mut total_scanned: i64 = 0;
|
||||
let mut cursor: Option<(String, String)> = None;
|
||||
|
||||
while (allowed.len() as i64) < limit {
|
||||
let after = cursor.as_ref().map(|(ts, id)| (ts.as_str(), id.as_str()));
|
||||
let bounties = state
|
||||
.db
|
||||
.list_bounties(
|
||||
None,
|
||||
None,
|
||||
q.status.as_deref(),
|
||||
page_size,
|
||||
after.map(|(ts, _)| ts),
|
||||
after.map(|(_, id)| id),
|
||||
)
|
||||
.await?;
|
||||
if bounties.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
total_scanned += bounties.len() as i64;
|
||||
if total_scanned > max_scanned {
|
||||
return Err(AppError::Incomplete(
|
||||
"too many rows scanned; refine your filters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let last = bounties.last().unwrap();
|
||||
for b in &bounties {
|
||||
if (allowed.len() as i64) >= limit {
|
||||
break;
|
||||
}
|
||||
let key = format!("{}/{}", b.repo_owner, b.repo_name);
|
||||
let admitted = if let Some(&result) = memo.get(&key) {
|
||||
result
|
||||
} else {
|
||||
let result = crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&b.repo_owner,
|
||||
&b.repo_name,
|
||||
caller,
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
memo.insert(key, result);
|
||||
result
|
||||
};
|
||||
if admitted {
|
||||
allowed.push(b.clone());
|
||||
}
|
||||
}
|
||||
cursor = Some((last.created_at.clone(), last.id.clone()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "bounties": allowed })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/bounties/{id}
|
||||
pub async fn get_bounty(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
if crate::api::authorize_repo_read(&state, &bounty.repo_owner, &bounty.repo_name, caller, "/")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
Ok(Json(bounty))
|
||||
}
|
||||
|
||||
@@ -162,11 +238,22 @@ pub async fn claim_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ClaimBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
if bounty.status != "open" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
//! API handlers for ref certificates.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/certs
|
||||
/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50
|
||||
pub async fn list_certs(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.max(1))
|
||||
.unwrap_or(50)
|
||||
.min(200);
|
||||
|
||||
let certs = state.db.list_ref_certificates(&record.id).await?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
// When a prefix is given (short-ID resolution from the CLI) use a
|
||||
// generous limit and delegate to the prefix-matched query so the
|
||||
// caller can resolve IDs regardless of how many certs exist.
|
||||
let prefix = params.get("prefix").filter(|p| !p.is_empty());
|
||||
let certs = if let Some(prefix) = prefix {
|
||||
state
|
||||
.db
|
||||
.list_ref_certificates_by_prefix(&record.id, prefix, 200)
|
||||
.await?
|
||||
} else {
|
||||
state.db.list_ref_certificates(&record.id, limit).await?
|
||||
};
|
||||
let certs_json: Vec<serde_json::Value> = certs
|
||||
.iter()
|
||||
.map(|c| {
|
||||
@@ -35,20 +56,21 @@ pub async fn list_certs(
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "certificates": certs_json })))
|
||||
let count = certs_json.len();
|
||||
Ok(Json(
|
||||
serde_json::json!({ "certificates": certs_json, "count": count }),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/certs/{id}
|
||||
pub async fn get_cert(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, id)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
// Verify the repo exists
|
||||
let _record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let cert = state
|
||||
.db
|
||||
@@ -56,6 +78,10 @@ pub async fn get_cert(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
|
||||
|
||||
if cert.repo_id != record.id {
|
||||
return Err(AppError::NotFound(format!("certificate {id}")));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": cert.id,
|
||||
"repo_id": cert.repo_id,
|
||||
|
||||
@@ -129,6 +129,7 @@ pub async fn list_ref_updates(
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.max(0))
|
||||
.unwrap_or(50)
|
||||
.clamp(0, MAX_VISIBLE_REF_UPDATES);
|
||||
|
||||
@@ -177,6 +178,7 @@ pub async fn list_repo_events(
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.max(0))
|
||||
.unwrap_or(50)
|
||||
.clamp(0, MAX_VISIBLE_REF_UPDATES);
|
||||
|
||||
@@ -209,7 +211,7 @@ pub async fn list_repo_events(
|
||||
// into an empty 200, matching the gossip half below.
|
||||
let cert_events: Vec<serde_json::Value> = state
|
||||
.db
|
||||
.list_ref_certificates(&record.id)
|
||||
.list_ref_certificates(&record.id, limit)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|c| {
|
||||
|
||||
+1518
-133
File diff suppressed because it is too large.
Load diff
@@ -61,11 +61,14 @@ pub async fn create_issue(
|
||||
let json_str = serde_json::to_string(&issue)
|
||||
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). This path holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
@@ -90,12 +93,11 @@ pub async fn create_issue(
|
||||
pub async fn list_issues(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
@@ -120,12 +122,11 @@ pub async fn list_issues(
|
||||
pub async fn get_issue(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
@@ -191,14 +192,31 @@ pub async fn create_issue_comment(
|
||||
pub async fn list_issue_comments(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let _record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let comments = state.db.list_issue_comments(&issue_id).await?;
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
// Resolve the full issue ID (accepts 8-char prefix) so the DB fetch
|
||||
// below uses the same canonical id as the git ref.
|
||||
let full_id = match git_issues::resolve_issue_id(&disk_path, &issue_id)
|
||||
.map_err(|e| AppError::Git(e.to_string()))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Err(AppError::RepoNotFound(format!(
|
||||
"issue {issue_id} not found"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let comments = state.db.list_issue_comments(&full_id).await?;
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
@@ -214,11 +232,13 @@ pub async fn close_issue(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// Same capacity shed as create_issue above (#173 F1): an exhausted write-lock
|
||||
// pool is a 503 + Retry-After, not a 500 git error.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
// Owner OR issue author may close. The author lives in the issue's git-JSON
|
||||
@@ -264,3 +284,168 @@ pub async fn close_issue(
|
||||
|
||||
Ok(Json(issue))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: the two issue write paths reach `acquire_write` holding NO
|
||||
/// admission permit (unlike the push handler, which is capped by the git-push
|
||||
/// semaphore), so they are the callers most likely to meet an exhausted write-lock
|
||||
/// POOL under load. An exhausted pool is a capacity signal, so both must shed
|
||||
/// 503 + Retry-After (`AppError::Overloaded`) the way the push handler does, not
|
||||
/// report the generic 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
let now = Utc::now();
|
||||
crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/tmp/{name}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// State whose repo store draws write locks from a ONE-connection pool with a
|
||||
/// short checkout timeout, so a single held guard exhausts it promptly rather
|
||||
/// than at the pool default.
|
||||
async fn one_connection_lock_pool_state(pool: &PgPool) -> AppState {
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-issues-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(pool, 1, std::time::Duration::from_secs(1)),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The shed must be a real 503 carrying Retry-After, not just an internal enum
|
||||
/// variant: assert on the rendered response so a remapping of `Overloaded` is
|
||||
/// caught here too.
|
||||
fn assert_sheds_503_with_retry_after(err: AppError, what: &str) {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"{what}: an exhausted write-lock pool must shed 503, not a 500 git error"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("1"),
|
||||
"{what}: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `create_issue`. Both directions: the shed while the
|
||||
/// only lock-pool connection is held by a guard on a DIFFERENT repo (so this is
|
||||
/// pool capacity, not advisory-lock contention on this repo), and the must-not
|
||||
/// case once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECREATELOCKPOOLAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = one_connection_lock_pool_state(&pool).await;
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, "lp-create"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-create".to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "create_issue");
|
||||
|
||||
// MUST-NOT: with the pool free again the call is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await;
|
||||
let admitted = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-create".to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(admitted, Err(AppError::Overloaded(_))),
|
||||
"with the lock pool free, create_issue must not be shed as capacity; got {:?}",
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `close_issue`, same two directions.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
|
||||
let state = one_connection_lock_pool_state(&pool).await;
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, "lp-close"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((
|
||||
owner.to_string(),
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "close_issue");
|
||||
|
||||
held.release(false).await;
|
||||
let admitted = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((
|
||||
owner.to_string(),
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(admitted, Err(AppError::Overloaded(_))),
|
||||
"with the lock pool free, close_issue must not be shed as capacity; got {:?}",
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,12 +74,11 @@ pub async fn remove_label(
|
||||
pub async fn list_labels(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let labels = state.db.list_labels(&record.id).await?;
|
||||
Ok(Json(serde_json::json!({ "labels": labels })))
|
||||
|
||||
@@ -114,6 +114,12 @@ mod did_tests {
|
||||
assert!(!did_matches("did:key:zABC", "did:key:zXYZ"));
|
||||
assert!(!did_matches("zABC", "zXYZ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_did_matches() {
|
||||
assert!(did_matches("", ""));
|
||||
assert!(did_matches("", "did:key:"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drift guard (plan 002 §Gate-type table, Step 5). Every in-scope mutation
|
||||
@@ -194,9 +200,11 @@ mod authz_guard {
|
||||
(issues, "create_issue", "authorize_repo_read("),
|
||||
(bounties, "create_bounty", "authorize_repo_read("),
|
||||
(repos, "fork_repo", "authorize_repo_read("),
|
||||
// get_by_cid gates each iterated repo row directly via visibility_check
|
||||
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
|
||||
(ipfs, "get_by_cid", "visibility_check("),
|
||||
// get_by_cid resolves each candidate (provenance path + legacy scan) through
|
||||
// the shared `gate_and_serve` (#173 round 2); the gate markers themselves are
|
||||
// asserted below. This row proves the delegation is real — the gate is
|
||||
// actually reached, not dead code.
|
||||
(ipfs, "get_by_cid", "gate_and_serve("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
@@ -230,6 +238,22 @@ mod authz_guard {
|
||||
"visibility::require_owner must use did_matches for DID-safe owner matching"
|
||||
);
|
||||
|
||||
// The CID read surface (#173) enforces its gate inside the shared
|
||||
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
|
||||
// the markers must live there (the get_by_cid row above only proves delegation).
|
||||
// The repo's own "/" visibility check (KTD2a — never authorize_repo_read's fuzzy
|
||||
// re-resolve) and the quarantine hard-drop BEFORE visibility (INV-11) are both
|
||||
// load-bearing: removing either re-opens a leak on the provenance path.
|
||||
let gate_body = fn_body(ipfs, "gate_and_serve");
|
||||
assert!(
|
||||
gate_body.contains("visibility_check("),
|
||||
"gate_and_serve must gate the CID read surface via visibility_check (KTD2a)"
|
||||
);
|
||||
assert!(
|
||||
gate_body.contains("if quarantined"),
|
||||
"gate_and_serve must hard-drop a quarantined repo before the visibility gate (INV-11)"
|
||||
);
|
||||
|
||||
for (src, func, marker) in rows {
|
||||
let body = fn_body(src, func);
|
||||
assert!(
|
||||
@@ -504,14 +528,6 @@ mod authz_guard {
|
||||
// Repo-scoped reads known to be ungated today, each tracked by an issue.
|
||||
// Remove an entry the moment its gate lands (the staleness assert enforces it).
|
||||
let known_ungated: &[(&str, &str)] = &[
|
||||
("list_certs", "#120"),
|
||||
("get_cert", "#120"),
|
||||
("list_issues", "#120"),
|
||||
("get_issue", "#120"),
|
||||
("list_issue_comments", "#120"),
|
||||
("list_labels", "#120"),
|
||||
("list_repo_bounties", "#120"),
|
||||
("get_star_status", "#120"),
|
||||
("list_webhooks", "#94 (PR #113)"),
|
||||
("list_replicas", "PR #113"),
|
||||
("list_protected_branches", "PR #113"),
|
||||
|
||||
@@ -537,4 +537,458 @@ mod tests {
|
||||
// pins the `o[0] == 0` check against an off-by-one into 1.x.
|
||||
assert!(is_public_http_url("http://1.0.0.0/"));
|
||||
}
|
||||
|
||||
// ── #82: /sync/trigger signature gate + per-IP brake on the peer-sync routes ──
|
||||
//
|
||||
// These drive the FULL production router (crate::server::build_router) so the
|
||||
// route wiring, layer order, and config-mode branching are all under test.
|
||||
// The positive-path and DID-agnostic cases mount the handler directly because
|
||||
// signed_request_as injects only an AuthenticatedDid extension (not a real
|
||||
// RFC-9421 signature), which require_signature rejects on the full router.
|
||||
use crate::rate_limit::{IpRateLimiter, RateLimiter, TrustedProxy};
|
||||
use crate::state::AppState;
|
||||
use crate::test_support::{signed_request_as, test_state};
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{header, Method, Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{middleware, Extension, Router};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use sqlx::PgPool;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn unsigned_post(uri: &str, body: &str, peer: &str) -> Request<Body> {
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
|
||||
req
|
||||
}
|
||||
|
||||
fn require_signed_peer_writes(state: &mut AppState) {
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.require_signed_peer_writes = true;
|
||||
state.config = Arc::new(cfg);
|
||||
}
|
||||
|
||||
const NOTIFY_BODY: &str = r#"{"repo":"demo","ref_name":"refs/heads/main","new_sha":"0000000000000000000000000000000000000000","node_did":"PEER_DID"}"#;
|
||||
|
||||
// ── trigger: mandatory signature ──────────────────────────────────────────
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_rejects_unsigned_in_default_mode(pool: PgPool) {
|
||||
// The hole: with require_signed_peer_writes=false (default), an anonymous
|
||||
// caller reaches the fan-out. Must be 401 regardless of the flag.
|
||||
let state = test_state(pool).await;
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/sync/trigger",
|
||||
"{}",
|
||||
"203.0.113.1:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_rejects_unsigned_in_signed_writes_mode(pool: PgPool) {
|
||||
// The signature requirement must not depend on the flag.
|
||||
let mut state = test_state(pool).await;
|
||||
require_signed_peer_writes(&mut state);
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/sync/trigger",
|
||||
"{}",
|
||||
"203.0.113.2:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_admits_signed_caller(pool: PgPool) {
|
||||
// must-not-over-throttle: a signed caller reaches the handler and (with no
|
||||
// peers seeded) gets 200, not 429. Direct-mount because a real signature
|
||||
// cannot be forged through the full router in tests.
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let app = Router::new()
|
||||
.route("/api/v1/sync/trigger", post(super::trigger_sync))
|
||||
.layer(middleware::from_fn(crate::rate_limit::rate_limit_by_ip))
|
||||
.layer(Extension(IpRateLimiter {
|
||||
limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
trust: TrustedProxy::None,
|
||||
}))
|
||||
.with_state(state);
|
||||
let mut req =
|
||||
signed_request_as(&did, Method::POST, "/api/v1/sync/trigger", Body::from("{}"));
|
||||
req.extensions_mut().insert(ConnectInfo(
|
||||
"203.0.113.3:5000".parse::<SocketAddr>().unwrap(),
|
||||
));
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ── trigger: IP brake ─────────────────────────────────────────────────────
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_ip_flood_is_throttled(pool: PgPool) {
|
||||
// The brake is outermost, so an over-limit request 429s before it ever
|
||||
// reaches require_signature — an unsigned request suffices to prove it.
|
||||
let mut state = test_state(pool).await;
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let peer = "203.0.113.4:5000";
|
||||
// Exhaust the single-request budget up front.
|
||||
assert!(
|
||||
state
|
||||
.sync_trigger_rate_limiter
|
||||
.check(&peer.parse::<SocketAddr>().unwrap().ip().to_string())
|
||||
.await
|
||||
);
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(unsigned_post("/api/v1/sync/trigger", "{}", peer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_brake_is_did_agnostic(pool: PgPool) {
|
||||
// Why per-IP, not per-DID: a DID farm (fresh did:key per request) does not
|
||||
// bypass an IP-keyed brake. Two DISTINCT DIDs from one IP, brake limit 1 →
|
||||
// the second is 429. A per-DID limiter would give each DID its own bucket.
|
||||
let state = test_state(pool).await;
|
||||
let app = Router::new()
|
||||
.route("/api/v1/sync/trigger", post(super::trigger_sync))
|
||||
.layer(middleware::from_fn(crate::rate_limit::rate_limit_by_ip))
|
||||
.layer(Extension(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(3600)),
|
||||
trust: TrustedProxy::None,
|
||||
}))
|
||||
.with_state(state);
|
||||
let peer = "203.0.113.5:5000".parse::<SocketAddr>().unwrap();
|
||||
let mk = |did: &str| {
|
||||
let mut r =
|
||||
signed_request_as(did, Method::POST, "/api/v1/sync/trigger", Body::from("{}"));
|
||||
r.extensions_mut().insert(ConnectInfo(peer));
|
||||
r
|
||||
};
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(mk(&Keypair::generate().did().to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
let second = app
|
||||
.oneshot(mk(&Keypair::generate().did().to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_forged_forwarded_header_cannot_bypass(pool: PgPool) {
|
||||
// TrustedProxy::None keys on the socket peer, so rotating X-Forwarded-For
|
||||
// from one socket peer does not refill the bucket. limit 1: first request
|
||||
// consumes it (then 401 unsigned), second from the same socket → 429
|
||||
// regardless of a different XFF value.
|
||||
let mut state = test_state(pool).await;
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let router = crate::server::build_router(state);
|
||||
let mut a = unsigned_post("/api/v1/sync/trigger", "{}", "203.0.113.6:5000");
|
||||
a.headers_mut()
|
||||
.insert("x-forwarded-for", "1.1.1.1".parse().unwrap());
|
||||
assert_eq!(
|
||||
router.clone().oneshot(a).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let mut b = unsigned_post("/api/v1/sync/trigger", "{}", "203.0.113.6:5000");
|
||||
b.headers_mut()
|
||||
.insert("x-forwarded-for", "2.2.2.2".parse().unwrap());
|
||||
assert_eq!(
|
||||
router.oneshot(b).await.unwrap().status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_rate_limit_zero_disables_but_signature_holds(pool: PgPool) {
|
||||
// 0 disables the brake (RateLimiter::check early-returns), proving the two
|
||||
// halves are independent: no 429 even under a flood, but the signature gate
|
||||
// still 401s every unsigned request.
|
||||
let mut state = test_state(pool).await;
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(0, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let router = crate::server::build_router(state);
|
||||
for _ in 0..3 {
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/sync/trigger",
|
||||
"{}",
|
||||
"203.0.113.7:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
// ── notify: braked, but signature behavior unchanged ──────────────────────
|
||||
|
||||
async fn seed_peer(state: &AppState) -> String {
|
||||
let did = Keypair::generate().did().to_string();
|
||||
state
|
||||
.db
|
||||
.upsert_peer(&did, "https://peer.example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
did
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_notify_unsigned_flood_is_throttled(pool: PgPool) {
|
||||
// notify still accepts an unsigned known-peer notification (rolling-upgrade
|
||||
// compat), but a flood from one IP is now braked. brake limit 1: first
|
||||
// enqueues (200), second from the same IP → 429.
|
||||
let mut state = test_state(pool).await;
|
||||
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let peer_did = seed_peer(&state).await;
|
||||
let body = NOTIFY_BODY.replace("PEER_DID", &peer_did);
|
||||
let router = crate::server::build_router(state);
|
||||
let first = router
|
||||
.clone()
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/sync/notify",
|
||||
&body,
|
||||
"203.0.113.8:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
let second = router
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/sync/notify",
|
||||
&body,
|
||||
"203.0.113.8:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_notify_distinct_ips_are_not_collaterally_throttled(pool: PgPool) {
|
||||
// must-not-over-throttle: per-IP keying means one peer's volume does not
|
||||
// throttle another. brake limit 1, two DIFFERENT source IPs → both 200.
|
||||
let mut state = test_state(pool).await;
|
||||
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let peer_did = seed_peer(&state).await;
|
||||
let body = NOTIFY_BODY.replace("PEER_DID", &peer_did);
|
||||
let router = crate::server::build_router(state);
|
||||
for ip in ["203.0.113.20:5000", "203.0.113.21:5000"] {
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(unsigned_post("/api/v1/sync/notify", &body, ip))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"distinct IP {ip} must not be throttled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn notify_flood_does_not_exhaust_trigger_bucket(pool: PgPool) {
|
||||
// Separate buckets: draining peer_write via an unsigned notify flood must
|
||||
// NOT throttle the signed trigger caller from the same IP. After the notify
|
||||
// bucket is spent, an unsigned trigger from that IP hits its own (unspent)
|
||||
// bucket and is rejected by the SIGNATURE gate (401), not the brake (429).
|
||||
let mut state = test_state(pool).await;
|
||||
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(60, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let peer_did = seed_peer(&state).await;
|
||||
let body = NOTIFY_BODY.replace("PEER_DID", &peer_did);
|
||||
let router = crate::server::build_router(state);
|
||||
let ip = "203.0.113.9:5000";
|
||||
assert_eq!(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(unsigned_post("/api/v1/sync/notify", &body, ip))
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(unsigned_post("/api/v1/sync/notify", &body, ip))
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"peer_write bucket should now be exhausted for this IP"
|
||||
);
|
||||
let trigger = router
|
||||
.oneshot(unsigned_post("/api/v1/sync/trigger", "{}", ip))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
trigger.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"trigger must hit its own bucket (401 from the sig gate), not the drained peer_write bucket (429)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn announce_still_accepts_unsigned_in_default_mode(pool: PgPool) {
|
||||
// must-not-over-reach: adding the brake must not tighten announce's
|
||||
// rolling-upgrade behavior — an unsigned announce with a public URL still
|
||||
// succeeds (it only gains the brake, which a single request is under).
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let body = format!(r#"{{"did":"{did}","http_url":"https://peer.example.com"}}"#);
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(unsigned_post(
|
||||
"/api/v1/peers/announce",
|
||||
&body,
|
||||
"203.0.113.10:5000",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
let kp = Keypair::generate();
|
||||
let body = b"{}";
|
||||
let s = sign_request(&kp, "POST", "/api/v1/sync/trigger", body);
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/sync/trigger")
|
||||
.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_vec()))
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
|
||||
req
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_admits_real_signature_through_full_router(pool: PgPool) {
|
||||
// The positive path through the REAL gate: a validly-signed trigger passes
|
||||
// require_signature on the full router and reaches the handler (no peers
|
||||
// seeded → 200), in BOTH config modes.
|
||||
let state = test_state(pool.clone()).await;
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(real_signed_trigger("203.0.113.30:5000"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a real signature must reach the handler (default mode)"
|
||||
);
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
require_signed_peer_writes(&mut state);
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(real_signed_trigger("203.0.113.30:5001"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a real signature must reach the handler (signed-writes mode)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn announce_flood_is_throttled(pool: PgPool) {
|
||||
// The peer_write brake covers /peers/announce too (co-benefit): limit 1,
|
||||
// first unsigned announce from an IP is 200, the second from that IP 429.
|
||||
let mut state = test_state(pool).await;
|
||||
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let body = format!(r#"{{"did":"{did}","http_url":"https://peer.example.com"}}"#);
|
||||
let router = crate::server::build_router(state);
|
||||
let ip = "203.0.113.31:5000";
|
||||
assert_eq!(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(unsigned_post("/api/v1/peers/announce", &body, ip))
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.oneshot(unsigned_post("/api/v1/peers/announce", &body, ip))
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"announce must be braked by the peer_write limiter"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sync_trigger_429_body_is_generic_not_push(pool: PgPool) {
|
||||
// The shared 429 middleware now serves the sync routes, so its body must
|
||||
// not claim "push" (RED before the message was genericized).
|
||||
let mut state = test_state(pool).await;
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = TrustedProxy::None;
|
||||
let peer = "203.0.113.32:5000";
|
||||
assert!(
|
||||
state
|
||||
.sync_trigger_rate_limiter
|
||||
.check(&peer.parse::<SocketAddr>().unwrap().ip().to_string())
|
||||
.await
|
||||
);
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(unsigned_post("/api/v1/sync/trigger", "{}", peer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let s = String::from_utf8_lossy(&bytes);
|
||||
assert!(s.contains("rate limit exceeded"), "429 body: {s:?}");
|
||||
assert!(
|
||||
!s.contains("push"),
|
||||
"a 429 on a sync route must not say 'push': {s:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ pub async fn set_profile(
|
||||
&state.http_client,
|
||||
&state.config.pinata_upload_url,
|
||||
&state.config.pinata_jwt,
|
||||
&format!("profile-{}", &did),
|
||||
&format!("profile-{}", did),
|
||||
&profile_json,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -209,11 +209,14 @@ pub async fn merge_pr(
|
||||
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
|
||||
}
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
let merger_did = auth.0;
|
||||
let merge_result = store::merge_branch(
|
||||
@@ -424,3 +427,116 @@ pub async fn list_comments(
|
||||
let comments = state.db.list_pr_comments(&pr.id).await?;
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
|
||||
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
|
||||
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
|
||||
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
|
||||
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
|
||||
/// 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
let now = Utc::now();
|
||||
crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/tmp/{name}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
|
||||
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
|
||||
/// capacity, not advisory-lock contention on this repo), and the must-not case
|
||||
/// once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
// One lock-pool connection with a short checkout timeout, so a single held
|
||||
// guard exhausts it promptly rather than at the pool default.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
|
||||
);
|
||||
|
||||
let repo = seed_repo(owner, "lp-merge");
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
state
|
||||
.db
|
||||
.create_pr(&PullRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo_id.clone(),
|
||||
number: 1,
|
||||
title: "lp".to_string(),
|
||||
body: None,
|
||||
author_did: owner.to_string(),
|
||||
source_branch: "feature".to_string(),
|
||||
target_branch: "main".to_string(),
|
||||
status: "open".to_string(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.expect("seed open PR");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-merge".to_string(), 1)),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"merge_pr: an exhausted write-lock pool must shed 503, not a 500 git error"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("1"),
|
||||
"merge_pr: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
|
||||
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await;
|
||||
let admitted = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-merge".to_string(), 1)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(admitted, Err(AppError::Overloaded(_))),
|
||||
"with the lock pool free, merge_pr must not be shed as capacity; got {:?}",
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
+3363
-157
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,14 @@
|
||||
//!
|
||||
//! Any authenticated agent can star or unstar a repo.
|
||||
//! One agent = one star per repo (enforced by UNIQUE constraint in repo_stars).
|
||||
//! Star count is public — no auth required on GET.
|
||||
//! Star count is gated on repo read access.
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// PUT /api/v1/repos/:owner/:repo/star
|
||||
@@ -19,11 +19,8 @@ pub async fn star_repo(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let caller = &auth.0;
|
||||
let inserted = state.db.star_repo(&record.id, caller).await?;
|
||||
@@ -54,11 +51,8 @@ pub async fn unstar_repo(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let caller = &auth.0;
|
||||
state.db.unstar_repo(&record.id, caller).await?;
|
||||
@@ -74,16 +68,15 @@ pub async fn unstar_repo(
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/star
|
||||
/// Returns star count — unauthenticated.
|
||||
/// Returns star count for callers who can read the repo.
|
||||
pub async fn get_star_status(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let count = state.db.count_stars(&record.id).await?;
|
||||
|
||||
|
||||
@@ -514,9 +514,30 @@ mod tests {
|
||||
machine_id: None,
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
|
||||
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
encrypt_inflight: crate::state::EncryptInflight::new(),
|
||||
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
git_push_advert_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_ipfs_walk_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
git_bin: "git".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ pub async fn issue_ref_certificate(
|
||||
issued_at,
|
||||
};
|
||||
|
||||
state.db.insert_ref_certificate(&cert).await?;
|
||||
Ok(cert)
|
||||
// Persist and return the row as it exists in the database (on a
|
||||
// conflict the existing row survives when it is newer).
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
@@ -140,6 +140,20 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)]
|
||||
pub max_pack_bytes: usize,
|
||||
|
||||
/// Per-client-IP rate limit for `POST /api/v1/sync/trigger`, in requests per
|
||||
/// hour. `/sync/trigger` requires a signature and drives an O(peers) outbound
|
||||
/// fan-out per call, so it gets a tight bucket. `0` disables. Default: 60.
|
||||
#[arg(long, env = "GITLAWB_SYNC_TRIGGER_RATE_LIMIT", default_value_t = 60)]
|
||||
pub sync_trigger_rate_limit: usize,
|
||||
|
||||
/// Per-client-IP rate limit for the peer-write routes (`/peers/announce`,
|
||||
/// `/sync/notify`), in requests per hour. These accept unsigned requests from
|
||||
/// known peers and run at higher frequency, so the bucket is generous. Keeping
|
||||
/// it separate from the trigger bucket stops an unsigned notify flood from
|
||||
/// draining the signed trigger caller's quota. `0` disables. Default: 600.
|
||||
#[arg(long, env = "GITLAWB_PEER_WRITE_RATE_LIMIT", default_value_t = 600)]
|
||||
pub peer_write_rate_limit: usize,
|
||||
|
||||
/// Optional address to bind a Prometheus `/metrics` exposition endpoint on.
|
||||
/// Example: `127.0.0.1:9091`. Leave empty (default) to disable.
|
||||
/// Bind to localhost or a private interface — the metrics endpoint is
|
||||
@@ -152,6 +166,287 @@ pub struct Config {
|
||||
/// in flight and exits. Default: 30s.
|
||||
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
|
||||
pub shutdown_grace_secs: u64,
|
||||
|
||||
/// Maximum wall-clock time a single served git operation (upload-pack /
|
||||
/// receive-pack through `run_git_service`) may run before it is aborted and
|
||||
/// its process group torn down, in seconds. Bounds a git that neither
|
||||
/// finishes nor disconnects. Must be positive; set it very large to
|
||||
/// effectively 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..)
|
||||
)]
|
||||
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.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_MAX_CONNECTIONS",
|
||||
default_value_t = 20,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub db_max_connections: u32,
|
||||
|
||||
/// Maximum time a request waits for a pool connection before failing with
|
||||
/// 503, in seconds. Bounds queueing when the database is slow or down.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_ACQUIRE_TIMEOUT_SECS",
|
||||
default_value_t = 5,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_acquire_timeout_secs: u64,
|
||||
|
||||
/// Upper bound on each startup connect-and-migrate attempt, in seconds.
|
||||
/// Migrations wait on a cross-instance advisory lock, so this must be
|
||||
/// generous enough for a peer instance to finish migrating; on expiry the
|
||||
/// attempt is retried (migrations are idempotent).
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_CONNECT_TIMEOUT_SECS",
|
||||
default_value_t = 60,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_connect_timeout_secs: u64,
|
||||
|
||||
/// First retry delay when the database is unavailable at startup, in
|
||||
/// seconds. Doubles each attempt up to --db-retry-max-secs.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_RETRY_INITIAL_SECS",
|
||||
default_value_t = 5,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_retry_initial_secs: u64,
|
||||
|
||||
/// Ceiling on the startup retry delay, in seconds.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_RETRY_MAX_SECS",
|
||||
default_value_t = 60,
|
||||
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).
|
||||
///
|
||||
/// CONNECTION BUDGET. A push holds a Postgres connection from the node's separate
|
||||
/// advisory-lock pool for the whole receive-pack, and that pool is sized from this
|
||||
/// knob (this value + 8, clamped to 64 in `main.rs`). The node's total ceiling is
|
||||
/// therefore `db_max_connections` (default 20) + the lock pool (default 40), i.e.
|
||||
/// 60 by default, and at most `db_max_connections` + 64. Size BOTH against the
|
||||
/// database server's `max_connections`: `db_max_connections`' own doc predates the
|
||||
/// lock pool and no longer covers most of the node's connections. The +8 headroom
|
||||
/// is shared with the three non-push `acquire_write` callers (`api/issues.rs` x2,
|
||||
/// `api/pulls.rs`). Raising this knob past the clamp does NOT buy more lock-pool
|
||||
/// connections; pushes beyond it wait briefly and then shed a 503 + Retry-After.
|
||||
#[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 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,
|
||||
|
||||
/// Per-request ceiling on the number of legacy (NULL-provenance) repos the
|
||||
/// `/ipfs/{cid}` resolver's scan fallback will PROBE (`acquire` + `git cat-file
|
||||
/// -t`) before giving up. The provenance path targets one repo; the legacy scan,
|
||||
/// absent this bound, fans one anonymous request out to O(repos) subprocess spawns
|
||||
/// and cold-cache fetches for a CID enumerable from the public pins index (#173,
|
||||
/// INV-10). A truncated scan surfaces as a retryable 503, never a false 404. Wired
|
||||
/// into `AppState::ipfs_max_legacy_probes` at construction; the history-walk ceiling
|
||||
/// stays constant (`MAX_HISTORY_WALKS_PER_REQUEST`) and is NOT governed by this knob
|
||||
/// (a smaller value would falsely 503 a provenanced request). Must be between 1 and
|
||||
/// 1_048_576. Default: 256.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_MAX_REPOS_WALKED",
|
||||
default_value_t = crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST as usize,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub ipfs_max_repos_walked: usize,
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// This is the pure once-per-request ROUTE brake. The resolver's internal
|
||||
/// per-probe/per-walk WORK budget is a SEPARATE bucket whose capacity is DERIVED
|
||||
/// from this value (`AppState::ipfs_work_budget`), not a knob of its own; `0` here
|
||||
/// disables that derived bucket too.
|
||||
#[arg(long, env = "GITLAWB_IPFS_RATE_LIMIT", default_value_t = 600)]
|
||||
pub ipfs_rate_limit: usize,
|
||||
|
||||
/// Rows the legacy provider-CID repair sweep reads per batch (U4, #173).
|
||||
///
|
||||
/// The sweep walks every `pinned_cids` row on the node once, repairing rows that
|
||||
/// releases before this branch keyed on a PROVIDER CID (Kubo dag-pb / Pinata CIDv0)
|
||||
/// instead of the raw-content resolver key. This bounds one batch, so the sweep can
|
||||
/// never turn into a single unbounded table scan competing with request traffic.
|
||||
/// Conservative on purpose: paired with the inter-batch delay below the default is
|
||||
/// ~64 rows per minute, which finishes a large pin set in hours of idle background
|
||||
/// work rather than one expensive burst. Must be between 1 and 100_000.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_PIN_REPAIR_SWEEP_BATCH",
|
||||
default_value_t = 64,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<i64>::new().range(1..=100_000)
|
||||
)]
|
||||
pub pin_repair_sweep_batch: i64,
|
||||
|
||||
/// Seconds the legacy provider-CID repair sweep sleeps between batches (U4, #173).
|
||||
///
|
||||
/// Each batch costs an indexed range scan plus, for the legacy rows in it, a
|
||||
/// `git cat-file` per row. The delay is what keeps that off the DB's and the disk's
|
||||
/// critical path: the sweep is repairing rows that have been unresolvable since the
|
||||
/// upgrade, so finishing slowly is fine and finishing fast at the cost of live
|
||||
/// traffic is not. `0` disables the pause (test and one-off operational use only).
|
||||
/// Must be between 0 and 86_400.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_PIN_REPAIR_SWEEP_DELAY_SECS",
|
||||
default_value_t = 60,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<u64>::new().range(0..=86_400)
|
||||
)]
|
||||
pub pin_repair_sweep_delay_secs: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -169,3 +464,311 @@ impl Config {
|
||||
PathBuf::from(&self.key_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).git_service_timeout_secs,
|
||||
600
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--git-service-timeout-secs", "30"])
|
||||
.git_service_timeout_secs,
|
||||
30
|
||||
);
|
||||
// 0 is a footgun (immediate-504 on every request); clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).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
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 (#173): the repair sweep's bounds are conservative by default and a batch of
|
||||
/// 0 (a sweep that walks nothing and never terminates) is a CLI error, not a
|
||||
/// runtime hang. The delay does accept 0, for tests and one-off operational runs.
|
||||
#[test]
|
||||
fn pin_repair_sweep_knobs_default_conservatively() {
|
||||
let c = Config::parse_from(["gitlawb-node"]);
|
||||
assert_eq!(c.pin_repair_sweep_batch, 64);
|
||||
assert_eq!(c.pin_repair_sweep_delay_secs, 60);
|
||||
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--pin-repair-sweep-batch", "0"]).is_err());
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--pin-repair-sweep-batch", "100001"]).is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--pin-repair-sweep-batch", "8"])
|
||||
.pin_repair_sweep_batch,
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--pin-repair-sweep-delay-secs", "0"])
|
||||
.pin_repair_sweep_delay_secs,
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--pin-repair-sweep-delay-secs", "86401"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_walk_per_source_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(Config::parse_from(["gitlawb-node"]).ipfs_walk_per_source, 4);
|
||||
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,
|
||||
256
|
||||
);
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
/// The `GITLAWB_IPFS_MAX_REPOS_WALKED` knob must actually reach the legacy-probe
|
||||
/// budget it advertises (R5, KTD5): production seeds `ipfs_max_legacy_probes` from
|
||||
/// this helper, so the knob is a no-op unless the helper reflects it. RED while the
|
||||
/// helper returns the hardcoded `MAX_LEGACY_PROBES_PER_REQUEST` (256 regardless of
|
||||
/// the knob), GREEN once it reads the knob.
|
||||
#[test]
|
||||
fn ipfs_max_repos_walked_wires_the_legacy_probe_budget() {
|
||||
use crate::state::AppState;
|
||||
// Knob set to 1 → a one-probe legacy budget.
|
||||
let one = Config::parse_from(["gitlawb-node", "--ipfs-max-repos-walked", "1"]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_legacy_probe_budget(&one),
|
||||
1,
|
||||
"the knob must control the legacy-probe budget, not be ignored"
|
||||
);
|
||||
// Unset knob preserves the shipped 256-probe behaviour.
|
||||
let default = Config::parse_from(["gitlawb-node"]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_legacy_probe_budget(&default),
|
||||
256,
|
||||
"the default knob keeps the shipped 256-probe budget"
|
||||
);
|
||||
assert_eq!(
|
||||
AppState::ipfs_legacy_probe_budget(&default),
|
||||
crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
|
||||
"the default budget equals the constant it replaced"
|
||||
);
|
||||
// Ceiling guard: the knob never governs the history-walk ceiling, which must
|
||||
// stay at MAX_PIN_SOURCES + 1 or a provenanced full source set false-503s.
|
||||
assert!(
|
||||
crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST > crate::db::MAX_PIN_SOURCES as u32,
|
||||
"the history-walk ceiling is independent of the repos-walked knob"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `/ipfs` work-budget capacity is DERIVED from the route limit (R6, KTD6), with
|
||||
/// a hard floor of one full legacy search per window (the effective
|
||||
/// `ipfs_max_legacy_probes`). This guards the derived default so a single
|
||||
/// default-config deep search never self-throttles mid-scan and recreates the F6
|
||||
/// admit-then-429 for a legitimate caller. A `RateLimiter` sized to the derived
|
||||
/// budget must admit the whole probe budget back to back.
|
||||
#[test]
|
||||
fn ipfs_work_budget_derives_from_route_limit_and_clears_the_probe_floor() {
|
||||
use crate::state::AppState;
|
||||
|
||||
// Default config: derived work budget = max(route 600, probe budget 256) = 600,
|
||||
// comfortably above the 256-probe floor.
|
||||
let default = Config::parse_from(["gitlawb-node"]);
|
||||
let budget = AppState::ipfs_work_budget(&default);
|
||||
assert_eq!(budget, 600, "default derives max(route 600, probe 256)");
|
||||
assert!(
|
||||
budget >= AppState::ipfs_legacy_probe_budget(&default) as usize,
|
||||
"the work budget must clear one full legacy search per window"
|
||||
);
|
||||
|
||||
// Tight route limit (1): the floor lifts the work budget to the probe budget
|
||||
// (256), NOT down to 1 — a single deep search still completes its full scan.
|
||||
let tight = Config::parse_from(["gitlawb-node", "--ipfs-rate-limit", "1"]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_work_budget(&tight),
|
||||
256,
|
||||
"a tight route limit is floored at the 256-probe budget, not clamped to 1"
|
||||
);
|
||||
|
||||
// Raised probe budget lifts the floor with it (the work budget tracks the
|
||||
// effective probe budget, not the constant).
|
||||
let raised = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-rate-limit",
|
||||
"10",
|
||||
"--ipfs-max-repos-walked",
|
||||
"1000",
|
||||
]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_work_budget(&raised),
|
||||
1000,
|
||||
"the floor tracks the operator-raised legacy-probe budget"
|
||||
);
|
||||
|
||||
// 0 route limit disables the derived bucket too (a 0-capacity limiter admits all).
|
||||
let disabled = Config::parse_from(["gitlawb-node", "--ipfs-rate-limit", "0"]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_work_budget(&disabled),
|
||||
0,
|
||||
"route limit 0 disables the derived work bucket alongside the route brake"
|
||||
);
|
||||
|
||||
// Behavioral floor: a limiter sized to the derived (tight-route) budget admits
|
||||
// the whole probe budget back to back for one source, then sheds the next.
|
||||
let budget = AppState::ipfs_work_budget(&tight);
|
||||
let limiter =
|
||||
crate::rate_limit::RateLimiter::new(budget, std::time::Duration::from_secs(3600));
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
for i in 0..budget {
|
||||
assert!(
|
||||
limiter.check("1.2.3.4").await,
|
||||
"probe {i} of one full default-config scan must be admitted (no mid-scan throttle)"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!limiter.check("1.2.3.4").await,
|
||||
"the probe past the derived budget is shed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
+1251
-22
File diff suppressed because it is too large.
Load diff
@@ -38,14 +38,57 @@ pub enum AppError {
|
||||
#[error("too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
#[error("incomplete: {0}")]
|
||||
Incomplete(String),
|
||||
|
||||
#[error("search incomplete: {0}")]
|
||||
SearchIncomplete(String),
|
||||
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
|
||||
#[error("git service timed out: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("server overloaded: {0}")]
|
||||
Overloaded(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
/// Shared error code/message for "the database is unreachable", also used by
|
||||
/// the degraded startup server (main.rs) and the readiness probe (server.rs)
|
||||
/// so clients see one vocabulary for the condition.
|
||||
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
|
||||
pub const DB_UNAVAILABLE_MESSAGE: &str = "database is temporarily unavailable";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
sqlx::Error::PoolTimedOut
|
||||
| sqlx::Error::PoolClosed
|
||||
| sqlx::Error::Io(_)
|
||||
| sqlx::Error::Tls(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// The db layer returns `anyhow::Result`, so sqlx errors reach handlers inside
|
||||
/// anyhow chains. Downcast them back out so the status mapping below can see
|
||||
/// them — without this, every database outage surfaces as a 500 instead of a
|
||||
/// 503. anyhow preserves downcastability through `.context()` layers.
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
match err.downcast::<sqlx::Error>() {
|
||||
Ok(sql) => AppError::Db(sql),
|
||||
Err(err) => AppError::Internal(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
@@ -98,7 +141,33 @@ impl IntoResponse for AppError {
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
|
||||
}
|
||||
AppError::Incomplete(msg) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
|
||||
}
|
||||
// A bounded search that could not complete (the CID resolver hit its
|
||||
// legacy-probe or walk ceiling), distinct from the 404 that asserts a
|
||||
// definitive not-found: absence was NOT proven, so the caller should
|
||||
// retry rather than treat it as gone (#173, F2). 503, retryable.
|
||||
AppError::SearchIncomplete(msg) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"search_incomplete",
|
||||
msg.clone(),
|
||||
),
|
||||
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
|
||||
// 504, distinct from the 500 git_error and from the read-gate's 404 /
|
||||
// the auth 401, so the client can tell a deadline from a failure.
|
||||
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
|
||||
AppError::Db(e) if db_unavailable(e) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
DB_UNAVAILABLE_CODE,
|
||||
DB_UNAVAILABLE_MESSAGE.into(),
|
||||
),
|
||||
// 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())
|
||||
}
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
AppError::Internal(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -112,8 +181,50 @@ impl IntoResponse for AppError {
|
||||
"message": message,
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
let mut resp = (status, body).into_response();
|
||||
// Both retryable 503s advertise when to retry: Overloaded (capacity shed) and
|
||||
// SearchIncomplete (a bounded CID search cut short by a cap — retry may complete
|
||||
// it). They ride the shared tail above for body/status, so the header is attached
|
||||
// here rather than in bespoke early returns, keeping each variant handled once.
|
||||
if matches!(
|
||||
self,
|
||||
AppError::Overloaded(_) | AppError::SearchIncomplete(_)
|
||||
) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
axum::http::HeaderValue::from_static("1"),
|
||||
);
|
||||
}
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504_distinct_from_git_500() {
|
||||
assert_eq!(
|
||||
AppError::Timeout("x".into()).into_response().status(),
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
);
|
||||
// Guard against a swap with the generic git failure (500).
|
||||
assert_eq!(
|
||||
AppError::Git("x".into()).into_response().status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -272,18 +286,22 @@ pub async fn resolve_candidates_for_push(
|
||||
repo_path: PathBuf,
|
||||
new_tips: Vec<String>,
|
||||
old_tips: Vec<String>,
|
||||
git_bin: String,
|
||||
timeout: Duration,
|
||||
) -> PinCandidateSet {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// 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) {
|
||||
match resolve_push_delta(&repo_path, &new_refs, &old_refs, &git_bin, deadline) {
|
||||
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 +322,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 +386,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 +407,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 +423,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 +443,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 +454,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 +466,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 +479,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 +495,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 +510,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 +531,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 +546,14 @@ 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(
|
||||
repo.path.clone(),
|
||||
vec![c2.clone()],
|
||||
vec![c1.clone()],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
)
|
||||
.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,8 +571,18 @@ 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(
|
||||
repo.path.clone(),
|
||||
vec![blob],
|
||||
vec![],
|
||||
"git".to_string(),
|
||||
std::time::Duration::from_secs(600),
|
||||
)
|
||||
.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");
|
||||
@@ -549,7 +594,7 @@ mod tests {
|
||||
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 +609,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 +628,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 +645,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 +659,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 +674,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 +692,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 +702,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 +710,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,12 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Postgres};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -25,30 +28,79 @@ use super::tigris::TigrisClient;
|
||||
pub struct RepoStore {
|
||||
repos_dir: PathBuf,
|
||||
tigris: Option<TigrisClient>,
|
||||
/// Shared Postgres pool for advisory locks.
|
||||
pool: PgPool,
|
||||
/// Dedicated Postgres pool for repo write advisory locks, built by
|
||||
/// `build_lock_pool` (see there for why it is separate and why it carries an
|
||||
/// `after_release` hook). Never use this for ordinary queries.
|
||||
lock_pool: PgPool,
|
||||
/// Tracks repos already confirmed to exist in Tigris — avoids redundant
|
||||
/// HEAD checks and background uploads for repos we've already migrated.
|
||||
migrated: Arc<Mutex<HashSet<String>>>,
|
||||
/// Test-only stall injected at the head of `acquire_write`'s Tigris phase,
|
||||
/// i.e. AFTER the advisory lock is taken and BEFORE the guard exists. That
|
||||
/// window is exactly where the outer `tokio::time::timeout` in
|
||||
/// `api/repos.rs` can drop the future (#173). `TigrisClient` takes its
|
||||
/// endpoint from process-wide AWS env vars and has no injectable seam, so
|
||||
/// this flag is the smallest way to hold a real `acquire_write` open in that
|
||||
/// window and cancel it there.
|
||||
#[cfg(test)]
|
||||
tigris_stall: Option<std::time::Duration>,
|
||||
/// Test-only counter of how many times a write guard from this store REACHED the
|
||||
/// Tigris upload site in `release` (the point past the `success` check, where a
|
||||
/// configured client would be uploaded to). It counts the decision, not a network
|
||||
/// call: `TigrisClient` takes its endpoint from process-wide AWS env vars and has no
|
||||
/// injectable seam, so every test runs with `tigris: None` and a counter inside the
|
||||
/// `Some` arm could never move. Reaching the site is the property under test anyway:
|
||||
/// an interrupted push must not publish a half-applied repo, and the disconnect path
|
||||
/// must therefore never get here (#173 F2).
|
||||
///
|
||||
/// Per store rather than a process global, so cases running in parallel do not see
|
||||
/// each other's uploads, and an `Arc` rather than a `thread_local` because the guard
|
||||
/// is released from a detached task on another worker thread. Same test-only counter
|
||||
/// idiom as `ipfs_pin::note_legacy_repair_read`.
|
||||
#[cfg(test)]
|
||||
upload_site_reached: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl RepoStore {
|
||||
/// Derives its own lock pool from `pool`, so callers that only have the main
|
||||
/// pool (tests, `for_testing` sites in other modules) still get the
|
||||
/// `after_release` semantics `acquire_write` depends on.
|
||||
#[cfg(test)]
|
||||
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
|
||||
Self {
|
||||
Self::new(
|
||||
repos_dir,
|
||||
tigris: None,
|
||||
pool,
|
||||
migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
}
|
||||
None,
|
||||
build_lock_pool(&pool, 8, Duration::from_secs(5)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, pool: PgPool) -> Self {
|
||||
/// Test-only: see `tigris_stall`.
|
||||
#[cfg(test)]
|
||||
pub fn with_tigris_stall(mut self, stall: Duration) -> Self {
|
||||
self.tigris_stall = Some(stall);
|
||||
self
|
||||
}
|
||||
|
||||
/// Test-only: how many write guards from this store have reached the Tigris upload
|
||||
/// site. See [`RepoStore::upload_site_reached`].
|
||||
#[cfg(test)]
|
||||
pub fn tigris_upload_site_reached(&self) -> usize {
|
||||
self.upload_site_reached
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// `lock_pool` must come from `build_lock_pool`; a plain pool leaks advisory
|
||||
/// locks on cancellation.
|
||||
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, lock_pool: PgPool) -> Self {
|
||||
Self {
|
||||
repos_dir,
|
||||
tigris,
|
||||
pool,
|
||||
lock_pool,
|
||||
migrated: Arc::new(Mutex::new(HashSet::new())),
|
||||
#[cfg(test)]
|
||||
tigris_stall: None,
|
||||
#[cfg(test)]
|
||||
upload_site_reached: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,25 +209,64 @@ impl RepoStore {
|
||||
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
|
||||
let lock_key = advisory_lock_key(&owner_slug, repo_name);
|
||||
|
||||
// Acquire Postgres advisory lock with retry using pg_try_advisory_lock
|
||||
// to avoid blocking indefinitely on stale locks from crashed connections.
|
||||
let mut acquired = false;
|
||||
// Acquire the Postgres advisory lock with retry, using pg_try_advisory_lock so a
|
||||
// stale lock from a crashed connection can't block us indefinitely.
|
||||
//
|
||||
// The connection is checked out INSIDE the loop and RETURNED before each sleep.
|
||||
// Only the connection that actually took the lock is retained. Two constraints
|
||||
// pull in opposite directions here, and this is what satisfies both:
|
||||
//
|
||||
// * Session ownership. A session-level advisory lock belongs to the CONNECTION
|
||||
// that took it, so the lock and its `pg_advisory_unlock` must run on the same
|
||||
// one. Running them through the pool (`fetch_one(&self.pool)`) lets them land
|
||||
// on different connections: the unlock silently returns false and the lock
|
||||
// leaks, while a competing acquire that happens to draw the holding
|
||||
// connection re-enters the lock and two pushes to one repo run concurrently.
|
||||
// Hence: keep the connection that WON.
|
||||
// * Occupancy. Holding a connection across the ~60 one-second sleeps would let
|
||||
// one spinning acquire park a lock-pool connection for a minute. That is not
|
||||
// just a push-path concern: `api/issues.rs` and `api/pulls.rs` reach
|
||||
// acquire_write holding no concurrency permit at all, so a caller could park
|
||||
// the whole pool and starve authenticated pushes on every repo (#173 F1).
|
||||
// Hence: return the connection when we LOSE, before sleeping.
|
||||
//
|
||||
// Returning a losing connection is safe with respect to the cancellation design:
|
||||
// `after_release` runs `pg_advisory_unlock_all()`, a no-op on a connection that
|
||||
// took nothing, so it cannot disturb a lock held by any other connection
|
||||
// (proven by `returning_an_unlocked_connection_does_not_clear_another_connections_lock`).
|
||||
//
|
||||
// Cancellation safety is unchanged: the future can only be dropped while a
|
||||
// connection is checked out, and dropping it runs the same `after_release` hook,
|
||||
// which clears whatever lock it had just taken (#173 U1).
|
||||
let mut lock_conn = None;
|
||||
for attempt in 0..60 {
|
||||
let mut conn = self.lock_pool.acquire().await.map_err(|e| {
|
||||
anyhow::Error::new(LockPoolBusy)
|
||||
.context(format!("checking out a lock-pool connection: {e}"))
|
||||
})?;
|
||||
let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(lock_key)
|
||||
.fetch_one(&self.pool)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.context("trying advisory lock")?;
|
||||
if row.0 {
|
||||
acquired = true;
|
||||
lock_conn = Some(conn);
|
||||
break;
|
||||
}
|
||||
// Lost the race: give the connection back so a spinning acquire occupies
|
||||
// nothing while it waits.
|
||||
drop(conn);
|
||||
if attempt < 59 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
if !acquired {
|
||||
let Some(lock_conn) = lock_conn else {
|
||||
anyhow::bail!("could not acquire advisory lock after 60s — possible stale lock for {owner_slug}/{repo_name}");
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(stall) = self.tigris_stall {
|
||||
tokio::time::sleep(stall).await;
|
||||
}
|
||||
|
||||
// Always download the latest from Tigris before writing.
|
||||
@@ -202,8 +293,10 @@ impl RepoStore {
|
||||
repo_name: repo_name.to_string(),
|
||||
local_path,
|
||||
lock_key,
|
||||
pool: self.pool.clone(),
|
||||
lock_conn,
|
||||
tigris: self.tigris.clone(),
|
||||
#[cfg(test)]
|
||||
upload_site_reached: Arc::clone(&self.upload_site_reached),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,42 +350,63 @@ impl RepoStore {
|
||||
/// segment is rejected. This is the CodeQL-recognised barrier
|
||||
/// pattern for `rust/path-injection`.
|
||||
fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> {
|
||||
validate_path_components(owner_did, repo_name)?;
|
||||
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
let local_path = self
|
||||
.repos_dir
|
||||
.join(&owner_slug)
|
||||
.join(format!("{repo_name}.git"));
|
||||
|
||||
if !local_path.starts_with(&self.repos_dir) {
|
||||
anyhow::bail!(
|
||||
"computed repo path escaped repos_dir: {}",
|
||||
local_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit component walk — sanitisation barrier that static analysers
|
||||
// (CodeQL `rust/path-injection`) recognise. The path must be composed
|
||||
// entirely of Normal segments after the root prefix; any ParentDir or
|
||||
// CurDir component is a traversal attempt.
|
||||
for component in local_path.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
|
||||
Component::ParentDir => {
|
||||
anyhow::bail!("path contains parent-directory component");
|
||||
}
|
||||
Component::CurDir => {
|
||||
anyhow::bail!("path contains current-directory component");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let local_path = validated_repo_disk_path(&self.repos_dir, owner_did, repo_name)?;
|
||||
Ok((owner_slug, local_path))
|
||||
}
|
||||
}
|
||||
|
||||
/// The three-layer validated form of `store::repo_disk_path`, with NO Tigris fetch and
|
||||
/// no `RepoStore` (#173 round 11, F3). Extracted from `RepoStore::local_path` so a
|
||||
/// second caller that must not pull a cold repo, the U4 legacy provider-CID sweep, gets
|
||||
/// the same barrier instead of the raw join. `local_path` is now a thin wrapper over
|
||||
/// this, so the two cannot drift.
|
||||
///
|
||||
/// Three-layer defence against path traversal:
|
||||
/// 1. Strict allowlist on `owner_did` and `repo_name` (no `..`, slashes,
|
||||
/// null bytes, leading dots; length-bounded).
|
||||
/// 2. The joined path must remain rooted at `repos_dir`.
|
||||
/// 3. Every component of the joined path must be `Component::Normal`
|
||||
/// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir`
|
||||
/// segment is rejected. This is the CodeQL-recognised barrier
|
||||
/// pattern for `rust/path-injection`.
|
||||
pub(crate) fn validated_repo_disk_path(
|
||||
repos_dir: &Path,
|
||||
owner_did: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<PathBuf> {
|
||||
validate_path_components(owner_did, repo_name)?;
|
||||
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
let local_path = repos_dir.join(&owner_slug).join(format!("{repo_name}.git"));
|
||||
|
||||
if !local_path.starts_with(repos_dir) {
|
||||
anyhow::bail!(
|
||||
"computed repo path escaped repos_dir: {}",
|
||||
local_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit component walk — sanitisation barrier that static analysers
|
||||
// (CodeQL `rust/path-injection`) recognise. The path must be composed
|
||||
// entirely of Normal segments after the root prefix; any ParentDir or
|
||||
// CurDir component is a traversal attempt.
|
||||
for component in local_path.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
|
||||
Component::ParentDir => {
|
||||
anyhow::bail!("path contains parent-directory component");
|
||||
}
|
||||
Component::CurDir => {
|
||||
anyhow::bail!("path contains current-directory component");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(local_path)
|
||||
}
|
||||
|
||||
/// Strict allowlist validator for `owner_did` and `repo_name`.
|
||||
///
|
||||
/// Rejects any character that isn't explicitly safe, plus length and
|
||||
@@ -347,6 +461,19 @@ fn validate_repo_name(repo_name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Error marker for "no lock-pool connection was available in time".
|
||||
///
|
||||
/// Carried through the `anyhow` chain (like [`smart_http::GitServiceTimeout`]) so the
|
||||
/// HTTP handler can `downcast_ref` it and shed a 503 + Retry-After instead of the
|
||||
/// generic 500 a git error maps to: an exhausted lock pool is a CAPACITY signal, and
|
||||
/// telling the client to retry shortly is the same shed semantics the surrounding
|
||||
/// admission code already uses (#173 F1).
|
||||
///
|
||||
/// [`smart_http::GitServiceTimeout`]: crate::git::smart_http::GitServiceTimeout
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("no lock-pool connection available")]
|
||||
pub struct LockPoolBusy;
|
||||
|
||||
/// Guard returned by `acquire_write()`. Holds the Postgres advisory lock and
|
||||
/// uploads to Tigris + releases the lock on `release()`.
|
||||
pub struct RepoWriteGuard {
|
||||
@@ -354,8 +481,17 @@ pub struct RepoWriteGuard {
|
||||
repo_name: String,
|
||||
pub local_path: PathBuf,
|
||||
lock_key: i64,
|
||||
pool: PgPool,
|
||||
/// The lock-pool connection that TOOK the advisory lock. It must be the one
|
||||
/// that releases it (session locks are owned by their connection), and
|
||||
/// holding it here is also what makes a guard dropped without `release`
|
||||
/// safe: the drop returns the connection through `after_release`, which
|
||||
/// clears the lock.
|
||||
lock_conn: PoolConnection<Postgres>,
|
||||
tigris: Option<TigrisClient>,
|
||||
/// Shared with the store that handed this guard out; see
|
||||
/// [`RepoStore::upload_site_reached`].
|
||||
#[cfg(test)]
|
||||
upload_site_reached: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl RepoWriteGuard {
|
||||
@@ -369,9 +505,16 @@ impl RepoWriteGuard {
|
||||
/// half-applied or otherwise inconsistent repo would propagate corruption to
|
||||
/// Tigris (and to every node that later downloads it). The lock is always
|
||||
/// released regardless, to avoid stale locks blocking future writes.
|
||||
pub async fn release(self, success: bool) {
|
||||
pub async fn release(mut self, success: bool) {
|
||||
// Upload to Tigris only on success.
|
||||
if success {
|
||||
// The upload site, recorded for tests before the client is consulted: with
|
||||
// no injectable seam on `TigrisClient` a counter inside the arm below could
|
||||
// never move, and it is reaching this point at all that an interrupted push
|
||||
// must not do (#173 F2).
|
||||
#[cfg(test)]
|
||||
self.upload_site_reached
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(ref tigris) = self.tigris {
|
||||
if let Err(e) = tigris
|
||||
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
|
||||
@@ -384,14 +527,56 @@ impl RepoWriteGuard {
|
||||
warn!(repo = %self.repo_name, "write failed — skipping tigris upload to avoid propagating an inconsistent repo");
|
||||
}
|
||||
|
||||
// Release advisory lock
|
||||
// Release the advisory lock on the connection that took it. Anything else
|
||||
// (a fresh `&pool` checkout) is a no-op that returns false: Postgres
|
||||
// scopes a session lock to its owning connection.
|
||||
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
.bind(self.lock_key)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *self.lock_conn)
|
||||
.await;
|
||||
// Dropping `self` returns the connection to the lock pool, where
|
||||
// `after_release` sweeps anything the unlock above missed.
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the dedicated advisory-lock pool a `RepoStore` runs its write locks on.
|
||||
/// Connect options are cloned off an existing pool so callers need not re-parse
|
||||
/// the database URL; the pool is lazy, so no connection is opened here.
|
||||
///
|
||||
/// Two properties, both load-bearing:
|
||||
///
|
||||
/// * The `after_release` hook runs `pg_advisory_unlock_all()` before a
|
||||
/// connection goes back into the pool. sqlx's `PoolConnection::drop` spawns
|
||||
/// `return_to_pool()`, which invokes this hook, so a connection dropped by
|
||||
/// CANCELLATION still clears its locks. That is what keeps an `acquire_write`
|
||||
/// killed mid-Tigris by the caller's `tokio::time::timeout` from leaking a
|
||||
/// lock and wedging every later push to that repo (#173). Note the hook runs
|
||||
/// from that spawned task, so the unlock is asynchronous with respect to the
|
||||
/// drop: the lock clears shortly after the connection goes away, not
|
||||
/// synchronously with it.
|
||||
/// * It is a SEPARATE pool from the main query pool, not a slice of it. A push
|
||||
/// holds its lock connection for the whole receive-pack, and
|
||||
/// `db_max_connections` (default 20) is well below
|
||||
/// `max_concurrent_git_pushes` (default 32), so drawing these from the main
|
||||
/// pool would starve every other query during a push burst.
|
||||
///
|
||||
/// `acquire_timeout` bounds the wait when every lock-pool connection is busy, so
|
||||
/// exhaustion surfaces as a clean error rather than an unbounded hang.
|
||||
pub fn build_lock_pool(source: &PgPool, max_connections: u32, acquire_timeout: Duration) -> PgPool {
|
||||
PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.acquire_timeout(acquire_timeout)
|
||||
.after_release(|conn, _meta| {
|
||||
Box::pin(async move {
|
||||
sqlx::query("SELECT pg_advisory_unlock_all()")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(true)
|
||||
})
|
||||
})
|
||||
.connect_lazy_with((*source.connect_options()).clone())
|
||||
}
|
||||
|
||||
/// Compute a stable i64 hash for a Postgres advisory lock key.
|
||||
fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -404,6 +589,503 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── advisory-lock test helpers (#173 U1) ───────────────────────────────
|
||||
|
||||
/// Postgres advisory locks live in a CLUSTER-wide space, not a per-database
|
||||
/// one, so two `#[sqlx::test]` cases running against their own temporary
|
||||
/// databases still share the key space. Every lock test therefore mints its
|
||||
/// own key instead of reusing a fixed constant.
|
||||
fn unique_lock_key() -> i64 {
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
static NEXT: AtomicI64 = AtomicI64::new(0);
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
((std::process::id() as i64) << 24) | (n & 0xff_ffff)
|
||||
}
|
||||
|
||||
/// A plain pool (no `after_release` hook, no idle timeout) at the same
|
||||
/// database as the `#[sqlx::test]` pool. Two separate reasons these tests
|
||||
/// cannot just use the pool the harness hands them:
|
||||
///
|
||||
/// 1. Observing lock state has to happen from a session that is definitely
|
||||
/// not the one under test. Session advisory locks are re-entrant, so
|
||||
/// `pg_try_advisory_lock` on the very connection that already holds the key
|
||||
/// returns true, and a same-pool probe silently reports a leaked lock free.
|
||||
/// 2. The harness pool sets `idle_timeout(1s)`, so a connection returned to it
|
||||
/// is closed about a second later and Postgres drops every lock that
|
||||
/// session held. That would mask exactly the leak these tests exist to
|
||||
/// catch, so the store under test runs on one of these too.
|
||||
fn sibling_pool(pool: &PgPool, max_connections: u32) -> PgPool {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect_lazy_with((*pool.connect_options()).clone())
|
||||
}
|
||||
|
||||
/// Probe the lock from a connection that is NOT the one under test. Session
|
||||
/// advisory locks are re-entrant within their own session, so a check from the
|
||||
/// holding connection would pass vacuously and prove nothing.
|
||||
async fn lock_is_free_elsewhere(pool: &PgPool, key: i64) -> bool {
|
||||
let mut probe = pool.acquire().await.expect("probe connection");
|
||||
let taken: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(key)
|
||||
.fetch_one(&mut *probe)
|
||||
.await
|
||||
.expect("probe try-lock");
|
||||
if taken.0 {
|
||||
sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
.bind(key)
|
||||
.execute(&mut *probe)
|
||||
.await
|
||||
.expect("probe unlock");
|
||||
}
|
||||
taken.0
|
||||
}
|
||||
|
||||
/// `after_release` runs from the task sqlx spawns in `PoolConnection::drop`,
|
||||
/// so the unlock is ASYNCHRONOUS with respect to the drop. Callers must poll
|
||||
/// rather than assume the lock is gone the instant the connection goes away.
|
||||
async fn wait_until_free(pool: &PgPool, key: i64, within: Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + within;
|
||||
loop {
|
||||
if lock_is_free_elsewhere(pool, key).await {
|
||||
return true;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DESIGN GATE ────────────────────────────────────────────────────────
|
||||
// The whole cancellation-safety design rests on one sqlx behaviour:
|
||||
// `PoolConnection::drop` spawns `return_to_pool()`, which invokes the pool's
|
||||
// `after_release` hook before the connection is reused. If that holds, a
|
||||
// connection dropped by cancellation still runs `pg_advisory_unlock_all()`
|
||||
// and the lock cannot leak. This test proves it by execution, through the
|
||||
// production `build_lock_pool` so that stripping the hook there turns it red.
|
||||
|
||||
#[sqlx::test]
|
||||
async fn dropped_pool_connection_runs_after_release_and_clears_locks(pool: PgPool) {
|
||||
let key = unique_lock_key();
|
||||
let lock_pool = build_lock_pool(&pool, 4, Duration::from_secs(5));
|
||||
|
||||
{
|
||||
let mut conn = lock_pool.acquire().await.expect("lock-pool connection");
|
||||
let taken: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(key)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.expect("try-lock");
|
||||
assert!(taken.0, "first try-lock must succeed");
|
||||
assert!(
|
||||
!lock_is_free_elsewhere(&pool, key).await,
|
||||
"lock must be observably HELD from another session while the connection lives"
|
||||
);
|
||||
// Drop WITHOUT calling pg_advisory_unlock: this models cancellation.
|
||||
}
|
||||
|
||||
assert!(
|
||||
wait_until_free(&pool, key, Duration::from_secs(5)).await,
|
||||
"after_release must clear the advisory lock of a dropped connection"
|
||||
);
|
||||
}
|
||||
|
||||
// ── acquire_write cancellation safety (#173 U1) ────────────────────────
|
||||
|
||||
/// The reviewer's named regression. `api/repos.rs` wraps `acquire_write` in a
|
||||
/// `tokio::time::timeout`; when that fires during the Tigris phase the future
|
||||
/// is dropped after the advisory lock was taken and before `RepoWriteGuard`
|
||||
/// (the only thing that unlocks) exists. The lock then leaks and every later
|
||||
/// push to the same repo spins the 60-attempt / 60s ceiling and fails.
|
||||
#[sqlx::test]
|
||||
async fn cancelled_acquire_write_mid_tigris_does_not_leak_the_lock(pool: PgPool) {
|
||||
let repos_dir = PathBuf::from("/tmp/gitlawb-test-repos");
|
||||
let owner = "did:key:z6MkCancelMidTigris";
|
||||
let repo = "cancel-mid-tigris";
|
||||
|
||||
let store_pool = sibling_pool(&pool, 8);
|
||||
let stalling = RepoStore::for_testing(repos_dir.clone(), store_pool.clone())
|
||||
.with_tigris_stall(Duration::from_secs(30));
|
||||
let cancelled = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
stalling.acquire_write(owner, repo),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
cancelled.is_err(),
|
||||
"the acquire must still be inside the Tigris phase when the timeout fires"
|
||||
);
|
||||
|
||||
// Observed from an independent session, so the check cannot be satisfied
|
||||
// by re-entrancy on whichever pooled connection happens to be handed back.
|
||||
let probe = sibling_pool(&pool, 2);
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
|
||||
assert!(
|
||||
wait_until_free(&probe, key, Duration::from_secs(5)).await,
|
||||
"a cancelled acquire_write must leave no advisory lock held"
|
||||
);
|
||||
|
||||
// A subsequent acquire for the SAME repo must succeed promptly. Before the
|
||||
// fix it blocks on the leaked lock until the 60-attempt ceiling.
|
||||
let store = RepoStore::for_testing(repos_dir, store_pool);
|
||||
let guard = tokio::time::timeout(Duration::from_secs(5), store.acquire_write(owner, repo))
|
||||
.await
|
||||
.expect("second acquire_write must not block on a leaked lock")
|
||||
.expect("second acquire_write must succeed");
|
||||
guard.release(false).await;
|
||||
}
|
||||
|
||||
/// Cancellation BEFORE the lock is taken must leave nothing behind: no lock,
|
||||
/// and no lock-pool connection stranded. The lock pool here holds exactly one
|
||||
/// connection, so a stranded one would make the follow-up acquire time out
|
||||
/// waiting for a checkout.
|
||||
#[sqlx::test]
|
||||
async fn cancelled_acquire_write_before_the_lock_leaves_nothing_held(pool: PgPool) {
|
||||
let probe = sibling_pool(&pool, 2);
|
||||
let owner = "did:key:z6MkCancelEarly";
|
||||
let repo = "cancel-early";
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
|
||||
|
||||
let store = RepoStore::new(
|
||||
PathBuf::from("/tmp/gitlawb-test-repos"),
|
||||
None,
|
||||
build_lock_pool(&pool, 1, Duration::from_secs(3)),
|
||||
);
|
||||
|
||||
// A zero deadline polls the future once, which gets it no further than the
|
||||
// first await (the pool checkout / the first try-lock round trip), so it is
|
||||
// cancelled before any lock can be taken.
|
||||
let cancelled =
|
||||
tokio::time::timeout(Duration::ZERO, store.acquire_write(owner, repo)).await;
|
||||
assert!(cancelled.is_err(), "the acquire must be cancelled");
|
||||
|
||||
assert!(
|
||||
lock_is_free_elsewhere(&probe, key).await,
|
||||
"no lock may be held when the acquire never got that far"
|
||||
);
|
||||
|
||||
// The single lock-pool connection must be back: if cancellation stranded
|
||||
// it, this checkout blocks until the 3s acquire timeout and fails.
|
||||
let guard = tokio::time::timeout(Duration::from_secs(2), store.acquire_write(owner, repo))
|
||||
.await
|
||||
.expect("the lock-pool connection must have been returned")
|
||||
.expect("acquire after cancellation");
|
||||
guard.release(false).await;
|
||||
}
|
||||
|
||||
/// Lock-pool exhaustion is a bounded wait and a clean error, never a panic and
|
||||
/// never an unbounded hang.
|
||||
#[sqlx::test]
|
||||
async fn lock_pool_exhaustion_is_a_bounded_error(pool: PgPool) {
|
||||
let owner = "did:key:z6MkExhaustion";
|
||||
let store = RepoStore::new(
|
||||
PathBuf::from("/tmp/gitlawb-test-repos"),
|
||||
None,
|
||||
build_lock_pool(&pool, 1, Duration::from_secs(2)),
|
||||
);
|
||||
|
||||
let held = store
|
||||
.acquire_write(owner, "exhaust-a")
|
||||
.await
|
||||
.expect("first acquire");
|
||||
|
||||
// Different repo, so this is not the advisory lock queueing: the only
|
||||
// connection in the lock pool is checked out by `held`.
|
||||
let started = std::time::Instant::now();
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.acquire_write(owner, "exhaust-b"),
|
||||
)
|
||||
.await
|
||||
.expect("the wait must be bounded by the pool acquire timeout");
|
||||
let err = match err {
|
||||
Ok(_) => panic!("an exhausted lock pool must surface an error, not a guard"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(6),
|
||||
"the error must arrive on the acquire timeout, not after a long hang"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("lock-pool connection"),
|
||||
"the error must name the lock-pool checkout, got: {err}"
|
||||
);
|
||||
|
||||
held.release(false).await;
|
||||
}
|
||||
|
||||
/// #173 F1 (RED-before/GREEN-after). A contended `acquire_write` spins for up to
|
||||
/// 60 one-second attempts. It must not OCCUPY a lock-pool connection for that whole
|
||||
/// spin: `acquire_write` has non-push callers (`api/issues.rs`, `api/pulls.rs`) that
|
||||
/// hold no concurrency permit, so any self-minted did:key could otherwise park a
|
||||
/// connection per call and starve authenticated pushes on EVERY repo.
|
||||
///
|
||||
/// Lock pool of exactly 2, two spinners. Pre-fix (checkout hoisted above the retry
|
||||
/// loop) they pin both connections for the full spin and an UNCONTENDED acquire on a
|
||||
/// third repo dies on the pool acquire timeout. Post-fix each spinner returns its
|
||||
/// connection before sleeping, so it occupies ~0 and the uncontended acquire sails
|
||||
/// through.
|
||||
#[sqlx::test]
|
||||
async fn a_spinning_acquire_write_does_not_occupy_a_lock_pool_connection(pool: PgPool) {
|
||||
let owner = "did:key:z6MkSpinOccupancy";
|
||||
let owner_slug = owner.replace([':', '/'], "_");
|
||||
let store = RepoStore::new(
|
||||
PathBuf::from("/tmp/gitlawb-test-repos"),
|
||||
None,
|
||||
build_lock_pool(&pool, 2, Duration::from_secs(2)),
|
||||
);
|
||||
|
||||
// An independent session holds both contended keys, so the spinners' try-locks
|
||||
// return false on every iteration and they stay in the retry loop.
|
||||
let holder = sibling_pool(&pool, 2);
|
||||
let mut held_conn = holder.acquire().await.expect("holder connection");
|
||||
for repo in ["spin-a", "spin-b"] {
|
||||
let taken: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(advisory_lock_key(&owner_slug, repo))
|
||||
.fetch_one(&mut *held_conn)
|
||||
.await
|
||||
.expect("holder try-lock");
|
||||
assert!(taken.0, "the holder must own {repo}'s key");
|
||||
}
|
||||
|
||||
let mut spinners = Vec::new();
|
||||
for repo in ["spin-a", "spin-b"] {
|
||||
let store = store.clone();
|
||||
spinners.push(tokio::spawn(async move {
|
||||
store.acquire_write(owner, repo).await
|
||||
}));
|
||||
}
|
||||
// Let both reach the spin (each has done at least one failed try-lock by now).
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let uncontended = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.acquire_write(owner, "spin-free"),
|
||||
)
|
||||
.await
|
||||
.expect("the uncontended acquire must return, not hang");
|
||||
let elapsed = started.elapsed();
|
||||
let free_guard = uncontended.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"an UNCONTENDED acquire_write on a DIFFERENT repo must not be starved by \
|
||||
spinners holding the lock pool; got: {e}"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"the uncontended acquire must not queue behind the spinners for the pool \
|
||||
acquire timeout; took {elapsed:?}"
|
||||
);
|
||||
free_guard.release(false).await;
|
||||
|
||||
// The drop-and-retake cycle must still END in a real, exclusive lock: free
|
||||
// spin-a's key and the spinner that was cycling connections must take it.
|
||||
sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
.bind(advisory_lock_key(&owner_slug, "spin-a"))
|
||||
.execute(&mut *held_conn)
|
||||
.await
|
||||
.expect("release spin-a");
|
||||
let winner = tokio::time::timeout(Duration::from_secs(15), spinners.remove(0))
|
||||
.await
|
||||
.expect("the spinner must finish once its key frees")
|
||||
.expect("spinner task")
|
||||
.expect("the spinner must acquire once the key frees");
|
||||
let probe = sibling_pool(&pool, 2);
|
||||
assert!(
|
||||
!lock_is_free_elsewhere(&probe, advisory_lock_key(&owner_slug, "spin-a")).await,
|
||||
"the lock a spinner finally took must be observably held from another session"
|
||||
);
|
||||
winner.release(false).await;
|
||||
|
||||
for s in spinners {
|
||||
s.abort();
|
||||
}
|
||||
sqlx::query("SELECT pg_advisory_unlock_all()")
|
||||
.execute(&mut *held_conn)
|
||||
.await
|
||||
.expect("release the remaining holder lock");
|
||||
}
|
||||
|
||||
/// #173 F1, the property the fix rests on: returning a lock-pool connection that
|
||||
/// holds NOTHING runs `after_release`'s `pg_advisory_unlock_all()`, which is a no-op
|
||||
/// and must not disturb a lock held on a DIFFERENT connection of the same pool.
|
||||
/// Session advisory locks are per connection, so this is by construction, but the
|
||||
/// spin fix depends on it, so it is proven by execution rather than assumed.
|
||||
#[sqlx::test]
|
||||
async fn returning_an_unlocked_connection_does_not_clear_another_connections_lock(
|
||||
pool: PgPool,
|
||||
) {
|
||||
let owner = "did:key:z6MkNoOpUnlockAll";
|
||||
let repo = "noop-unlock";
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
|
||||
let probe = sibling_pool(&pool, 2);
|
||||
let lock_pool = build_lock_pool(&pool, 4, Duration::from_secs(5));
|
||||
let store = RepoStore::new(
|
||||
PathBuf::from("/tmp/gitlawb-test-repos"),
|
||||
None,
|
||||
lock_pool.clone(),
|
||||
);
|
||||
|
||||
let guard = store.acquire_write(owner, repo).await.expect("acquire");
|
||||
|
||||
// Churn the pool: check out and drop connections that hold no lock, exactly what
|
||||
// a spinning acquire now does between attempts. Each return fires
|
||||
// pg_advisory_unlock_all() on that connection.
|
||||
for _ in 0..10 {
|
||||
let mut conn = lock_pool.acquire().await.expect("churn checkout");
|
||||
let _: (i32,) = sqlx::query_as("SELECT 1")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.expect("churn query");
|
||||
drop(conn);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!lock_is_free_elsewhere(&probe, key).await,
|
||||
"a held write lock must survive other lock-pool connections being returned"
|
||||
);
|
||||
guard.release(true).await;
|
||||
assert!(
|
||||
lock_is_free_elsewhere(&probe, key).await,
|
||||
"release must still free the lock after the churn"
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 F1: lock-pool exhaustion is a DISTINCT error the handler can shed as a 503,
|
||||
/// not a generic git 500. Both directions: an exhausted pool downcasts to
|
||||
/// [`LockPoolBusy`], and an unrelated failure (a rejected repo name) does not.
|
||||
#[sqlx::test]
|
||||
async fn lock_pool_exhaustion_is_a_distinct_downcastable_error(pool: PgPool) {
|
||||
let owner = "did:key:z6MkBusyDowncast";
|
||||
let store = RepoStore::new(
|
||||
PathBuf::from("/tmp/gitlawb-test-repos"),
|
||||
None,
|
||||
build_lock_pool(&pool, 1, Duration::from_secs(1)),
|
||||
);
|
||||
let held = store
|
||||
.acquire_write(owner, "busy-a")
|
||||
.await
|
||||
.expect("first acquire");
|
||||
|
||||
let err = match store.acquire_write(owner, "busy-b").await {
|
||||
Ok(_) => panic!("an exhausted lock pool must error, not hand back a guard"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(
|
||||
err.downcast_ref::<LockPoolBusy>().is_some(),
|
||||
"lock-pool exhaustion must be downcastable so the handler sheds 503, got: {err}"
|
||||
);
|
||||
|
||||
// MUST-NOT: an ordinary rejection is not a capacity signal.
|
||||
let other = match store.acquire_write(owner, "../escape").await {
|
||||
Ok(_) => panic!("a traversal repo name must be rejected"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(
|
||||
other.downcast_ref::<LockPoolBusy>().is_none(),
|
||||
"a validation failure must not masquerade as lock-pool capacity, got: {other}"
|
||||
);
|
||||
|
||||
held.release(false).await;
|
||||
}
|
||||
|
||||
/// Round trip: the lock is observably HELD between acquire and release, and
|
||||
/// observably FREE after. Both checks run from an independent session; from
|
||||
/// the holding session they would pass vacuously (session locks are
|
||||
/// re-entrant) and would not notice an unlock that landed on the wrong
|
||||
/// connection.
|
||||
#[sqlx::test]
|
||||
async fn acquire_write_holds_the_lock_until_release(pool: PgPool) {
|
||||
let probe = sibling_pool(&pool, 2);
|
||||
let owner = "did:key:z6MkRoundTrip";
|
||||
let repo = "round-trip";
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
|
||||
|
||||
let store = RepoStore::for_testing(PathBuf::from("/tmp/gitlawb-test-repos"), pool.clone());
|
||||
let guard = store.acquire_write(owner, repo).await.expect("acquire");
|
||||
assert!(
|
||||
!lock_is_free_elsewhere(&probe, key).await,
|
||||
"the lock must be held while the guard is alive"
|
||||
);
|
||||
|
||||
// No polling here, deliberately. `release` must free the lock SYNCHRONOUSLY,
|
||||
// which it can only do by unlocking on the connection that took it; a
|
||||
// `pg_advisory_unlock` sent through the pool would land on some other
|
||||
// session and return false. The `after_release` hook is a net for the
|
||||
// cancellation path and fires from a spawned task well after this point, so
|
||||
// it must not be what makes this assertion pass.
|
||||
guard.release(true).await;
|
||||
assert!(
|
||||
lock_is_free_elsewhere(&probe, key).await,
|
||||
"release must free the lock as seen from another session"
|
||||
);
|
||||
}
|
||||
|
||||
/// `release(false)` skips the Tigris upload but must still free the lock; a
|
||||
/// failed write that kept the lock would wedge the repo.
|
||||
#[sqlx::test]
|
||||
async fn release_after_failed_write_still_frees_the_lock(pool: PgPool) {
|
||||
let probe = sibling_pool(&pool, 2);
|
||||
let owner = "did:key:z6MkFailedWrite";
|
||||
let repo = "failed-write";
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), repo);
|
||||
|
||||
let store = RepoStore::for_testing(PathBuf::from("/tmp/gitlawb-test-repos"), pool.clone());
|
||||
let guard = store.acquire_write(owner, repo).await.expect("acquire");
|
||||
guard.release(false).await;
|
||||
|
||||
assert!(
|
||||
wait_until_free(&probe, key, Duration::from_secs(5)).await,
|
||||
"release(success = false) must still free the lock"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lock is per repo: a second acquire for the SAME repo waits for the
|
||||
/// first to release, while a different repo proceeds straight through.
|
||||
#[sqlx::test]
|
||||
async fn same_repo_acquires_serialize_and_different_repos_do_not(pool: PgPool) {
|
||||
let repos_dir = PathBuf::from("/tmp/gitlawb-test-repos");
|
||||
let owner = "did:key:z6MkSerialize";
|
||||
let store = RepoStore::for_testing(repos_dir, pool.clone());
|
||||
|
||||
let first = store
|
||||
.acquire_write(owner, "serialize-a")
|
||||
.await
|
||||
.expect("first acquire");
|
||||
|
||||
// Different repo: unaffected by the held lock.
|
||||
let other = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
store.acquire_write(owner, "serialize-b"),
|
||||
)
|
||||
.await
|
||||
.expect("a different repo must not wait on this lock")
|
||||
.expect("acquire other repo");
|
||||
other.release(false).await;
|
||||
|
||||
// Same repo: must not acquire while `first` is alive.
|
||||
let contender = tokio::spawn({
|
||||
let store = store.clone();
|
||||
async move { store.acquire_write(owner, "serialize-a").await }
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
assert!(
|
||||
!contender.is_finished(),
|
||||
"a second acquire for the same repo must block while the first guard lives"
|
||||
);
|
||||
|
||||
first.release(false).await;
|
||||
let second = tokio::time::timeout(Duration::from_secs(10), contender)
|
||||
.await
|
||||
.expect("contender must finish once the lock is free")
|
||||
.expect("contender task")
|
||||
.expect("contender acquire");
|
||||
second.release(false).await;
|
||||
}
|
||||
|
||||
// ── repo_name validation ───────────────────────────────────────────────
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -306,6 +306,90 @@ pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -
|
||||
Ok(content_output.stdout)
|
||||
}
|
||||
|
||||
/// Bounded twin of [`object_type`] for the `GET /ipfs/{cid}` serve path (#173
|
||||
/// round-10, R1/KTD2). Runs `git cat-file -t` under
|
||||
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so the child runs
|
||||
/// in its own process group and a watchdog reaps it (SIGTERM -> grace -> SIGKILL) at
|
||||
/// `timeout`. The bare [`object_type`] is a `spawn_blocking` `Command::output` that an
|
||||
/// async timeout cannot cancel, so a wedged `cat-file` there pins the caller's held
|
||||
/// /ipfs walk admission for the whole hang; this twin cannot. Semantics mirror
|
||||
/// [`object_type`]: `Ok(Some(t))` for an existing object, `Ok(None)` when git exits
|
||||
/// non-zero without timing out (the object is absent), and
|
||||
/// `Err(`[`GitServiceTimeout`](crate::git::smart_http::GitServiceTimeout)`)` on the
|
||||
/// deadline so the handler can mark the search truncated (retryable 503) rather than a
|
||||
/// false not-found. Callers off the /ipfs path keep the bare helper.
|
||||
pub fn object_type_bounded(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<String>> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
match crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "-t", sha256_hex],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
) {
|
||||
Ok(out) => Ok(Some(String::from_utf8_lossy(&out).trim().to_string())),
|
||||
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => Err(e),
|
||||
// A non-timeout failure is git reporting no such object (a non-zero exit),
|
||||
// matching `object_type`'s `Ok(None)`.
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded `git cat-file -s` size read for the `GET /ipfs/{cid}` serve path (#173
|
||||
/// round-10, R1/KTD2): reads the object size WITHOUT its content (so an oversized object
|
||||
/// is rejected before it is buffered, #173 F6), under
|
||||
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so a wedged size
|
||||
/// read is reaped at `timeout` instead of pinning the held /ipfs walk admission.
|
||||
/// `Ok(Some(n))` on success, `Ok(None)` when the object is absent (a non-timeout
|
||||
/// non-zero exit), `Err(GitServiceTimeout)` on the deadline.
|
||||
pub fn object_size_bounded(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<u64>> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
match crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "-s", sha256_hex],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
) {
|
||||
Ok(out) => Ok(String::from_utf8_lossy(&out).trim().parse::<u64>().ok()),
|
||||
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => Err(e),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded twin of [`read_object_content`] for the `GET /ipfs/{cid}` serve path (#173
|
||||
/// round-10, R1/KTD2): `git cat-file <type>` under
|
||||
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so a wedged content
|
||||
/// read is reaped at `timeout` instead of pinning the held /ipfs walk admission. Returns
|
||||
/// the raw object bytes on success and an error (including `GitServiceTimeout` on the
|
||||
/// deadline) otherwise, mirroring [`read_object_content`].
|
||||
pub fn read_object_content_bounded(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
obj_type: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", obj_type, sha256_hex],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)
|
||||
}
|
||||
|
||||
/// Read a git object by its SHA-256 hex object ID.
|
||||
///
|
||||
/// Returns `(object_type, content_bytes)` where `content_bytes` is the raw
|
||||
@@ -323,6 +407,42 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
|
||||
Ok(Some((obj_type, content)))
|
||||
}
|
||||
|
||||
/// Bounded twin of [`read_object`] for write-side callers that must not hang on a
|
||||
/// wedged `git cat-file` (the post-push pin path, #173): composes
|
||||
/// [`object_type_bounded`] and [`read_object_content_bounded`] under ONE shared
|
||||
/// deadline (mirroring `get_by_cid`'s read stage and `build_filtered_pack`), so a
|
||||
/// single object read holds for at most `timeout` total, not one full timeout per
|
||||
/// stage. `Ok(Some((type, bytes)))` on success, `Ok(None)` when the object is absent,
|
||||
/// and `Err(`[`GitServiceTimeout`](crate::git::smart_http::GitServiceTimeout)`)` when
|
||||
/// either stage overruns the deadline. The bare [`read_object`] is an unbounded
|
||||
/// `Command::output` an async timeout cannot cancel, so a wedged `cat-file` there pins
|
||||
/// the post-push coalescing key until process death; this twin cannot.
|
||||
pub fn read_object_bounded(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let obj_type = match object_type_bounded(
|
||||
git_bin,
|
||||
repo_path,
|
||||
sha256_hex,
|
||||
deadline.saturating_duration_since(std::time::Instant::now()),
|
||||
)? {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let content = read_object_content_bounded(
|
||||
git_bin,
|
||||
repo_path,
|
||||
sha256_hex,
|
||||
&obj_type,
|
||||
deadline.saturating_duration_since(std::time::Instant::now()),
|
||||
)?;
|
||||
Ok(Some((obj_type, content)))
|
||||
}
|
||||
|
||||
/// Get the diff between two branches: changes on source_branch not in target_branch.
|
||||
pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
@@ -501,4 +621,84 @@ mod tests {
|
||||
"unchanged file must not appear: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 round-10 (KTD2): `object_type_bounded` reaps a wedged `cat-file` child at its
|
||||
/// deadline instead of blocking on it to natural exit, so a hung probe cannot pin the
|
||||
/// /ipfs walk admission the owning task holds. A fake `git` records its pid and sleeps
|
||||
/// far past the 1s deadline; the `run_bounded_git` watchdog (SIGTERM -> grace ->
|
||||
/// SIGKILL of the process group) must kill it well before that natural exit, and the
|
||||
/// call must surface `GitServiceTimeout`. REVERT PROOF (RED): swap the twin's
|
||||
/// `run_bounded_git` for the bare `Command::output()` and the wedged child stays alive
|
||||
/// past the deadline — the mid-flight liveness poll below reads it still running.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn object_type_bounded_reaps_wedged_child_at_deadline() {
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pidfile = tmp.path().join("catfile.pid");
|
||||
// `cat-file` records its own pid then sleeps 8s (>> the 1s deadline) so the probe
|
||||
// is genuinely wedged; the watchdog is what must end it.
|
||||
let body = format!(
|
||||
"#!/bin/sh\n\
|
||||
case \"$1\" in\n\
|
||||
cat-file) echo $$ > \"{}\"; sleep 8 ;;\n\
|
||||
*) : ;;\n\
|
||||
esac\n\
|
||||
exit 0\n",
|
||||
pidfile.display()
|
||||
);
|
||||
let git_path = tmp.path().join("fakegit");
|
||||
std::fs::write(&git_path, &body).unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perm = std::fs::metadata(&git_path).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&git_path, perm).unwrap();
|
||||
}
|
||||
let repo = tmp.path().to_path_buf();
|
||||
let git = git_path.to_str().unwrap().to_string();
|
||||
|
||||
let alive = |pid: i32| unsafe { libc::kill(pid, 0) == 0 };
|
||||
|
||||
// The bounded probe blocks until the watchdog tears the child down, so run it on
|
||||
// a worker thread and poll for the reap from here.
|
||||
let handle = std::thread::spawn(move || {
|
||||
super::object_type_bounded(&git, &repo, "deadbeef", Duration::from_secs(1))
|
||||
});
|
||||
|
||||
let mut pid = None;
|
||||
for _ in 0..500 {
|
||||
if let Some(p) = std::fs::read_to_string(&pidfile)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<i32>().ok())
|
||||
{
|
||||
pid = Some(p);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let pid = pid.expect("the fake cat-file must have spawned and recorded its pid");
|
||||
|
||||
// Past the 1s deadline + SIGTERM grace but well before the 8s natural exit: the
|
||||
// watchdog must already have reaped the wedged group. A bare, unbounded read would
|
||||
// leave it running here — the load-bearing RED.
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
let reaped = !alive(pid);
|
||||
// Defensive reap so a RED run leaks no orphan.
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
assert!(
|
||||
reaped,
|
||||
"object_type_bounded must reap the wedged cat-file child at the deadline, \
|
||||
not leave it running to its natural exit"
|
||||
);
|
||||
|
||||
let res = handle.join().expect("probe thread joins");
|
||||
let err = res.expect_err("a deadline overrun must be an error, not a value");
|
||||
assert!(
|
||||
err.is::<crate::git::smart_http::GitServiceTimeout>(),
|
||||
"a deadline overrun must surface GitServiceTimeout, got: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,372 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use gitlawb_core::cid::Cid;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Attempts (including the first) for a transient DB-record retry.
|
||||
const PIN_RECORD_ATTEMPTS: u32 = 3;
|
||||
/// Backoff between DB-record retry attempts.
|
||||
const PIN_RECORD_BACKOFF: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Run an idempotent DB-record operation with a bounded retry so a sub-second
|
||||
/// transient error does not silently leave the pin-source set permanently
|
||||
/// incomplete. The resolver treats a nonempty below-cap source set as complete,
|
||||
/// so a dropped `record_pin_source`/`record_pinned_cid` makes `GET /ipfs/{cid}`
|
||||
/// 404 a valid public copy. Every wrapped insert is idempotent (`ON CONFLICT DO
|
||||
/// NOTHING` / provenance-preserving upsert), so re-running is safe. On exhausted
|
||||
/// attempts the last error is returned and the caller records the durable
|
||||
/// `pin_sources_incomplete` marker (U3, #173), which is what keeps the resolver's
|
||||
/// bounded scan fallback available for that object instead of 404ing a public copy.
|
||||
/// Shared with the `pinata.rs` twin so both pin paths retry identically. Runs
|
||||
/// inside the already-detached post-push task, so the backoff adds no push latency.
|
||||
pub(crate) async fn retry_db_record<F, Fut>(mut op: F) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let mut attempt = 1;
|
||||
loop {
|
||||
match op().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
if attempt >= PIN_RECORD_ATTEMPTS {
|
||||
return Err(e);
|
||||
}
|
||||
tokio::time::sleep(PIN_RECORD_BACKOFF).await;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opportunistically repair a legacy provider-CID row on the already-pinned skip
|
||||
/// path (#173 R8, KTD8). Releases before this branch stored the PROVIDER CID
|
||||
/// (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`; the `/ipfs` resolver
|
||||
/// recomputes the raw CID from object bytes and 404s any row whose key does not
|
||||
/// match, yet `list_pinned_cids` still advertises the stored key — so a client
|
||||
/// gets a CID the resolver deliberately withholds. When a re-push carries the
|
||||
/// object again, rewrite the key to the raw CID and stash the old provider value
|
||||
/// in `legacy_provider_cid`.
|
||||
///
|
||||
/// COST GATE: candidacy is decided from the stored key's codec alone — a
|
||||
/// CIDv1/raw key is already the resolver key and reads NO bytes, keeping the
|
||||
/// steady-state skip cost DB-only. Only a legacy-codec row reads the object to
|
||||
/// recompute. A row whose bytes are gone stays withheld (no destructive rewrite).
|
||||
async fn repair_legacy_provider_cid(
|
||||
repo_path: &std::path::Path,
|
||||
git_bin: &str,
|
||||
git_timeout: Duration,
|
||||
sha: &str,
|
||||
db: &crate::db::Db,
|
||||
) -> Result<RepairOutcome> {
|
||||
let stored = match db.cid_for_oid(sha).await? {
|
||||
Some(c) => c,
|
||||
None => return Ok(RepairOutcome::Settled),
|
||||
};
|
||||
// Cost gate: a canonical raw CIDv1 key is already correct — never read bytes.
|
||||
if gitlawb_core::cid::is_raw_cidv1(&stored) {
|
||||
return Ok(RepairOutcome::Settled);
|
||||
}
|
||||
// Legacy-codec row: read the object to recompute. Counted so a test can prove
|
||||
// the gate above spares non-legacy rows this read.
|
||||
#[cfg(test)]
|
||||
note_legacy_repair_read();
|
||||
// `read_object_bounded` is SYNCHRONOUS `git cat-file`, and its budget is
|
||||
// `git_service_timeout_secs` (600 by default), so running it inline parks a tokio
|
||||
// worker for as long as git takes: one wedged read on the sweep's first pass at boot
|
||||
// holds a worker for ten minutes, per legacy row. Push it to the blocking pool, the
|
||||
// same shape `replication_withheld_set` uses in api/repos.rs (#173 round 11, F4).
|
||||
// Both callers of this function are async, so neither changes shape. The read-counter
|
||||
// increment above stays on THIS thread so the thread_local cost-gate assertion holds.
|
||||
let read = {
|
||||
let repo_path = repo_path.to_path_buf();
|
||||
let git_bin = git_bin.to_string();
|
||||
let sha = sha.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::store::read_object_bounded(&git_bin, &repo_path, &sha, git_timeout)
|
||||
})
|
||||
.await
|
||||
};
|
||||
let data = match read {
|
||||
Ok(Ok(Some((_ty, bytes)))) => bytes,
|
||||
// Bytes gone: the row stays withheld, never destructively rewritten. Nothing a
|
||||
// later pass changes, so this is a TERMINAL outcome for the sweep's re-walk gate.
|
||||
Ok(Ok(None)) => return Ok(RepairOutcome::Settled),
|
||||
// A wedged/D-state `git cat-file` (timeout/infra): the repair is opportunistic
|
||||
// and best-effort, so skip it and return Ok so the pin task PROCEEDS to
|
||||
// requeue_or_release rather than hanging the coalescing key until process death
|
||||
// (grok F2, #173). A later re-push or the deferred sweep retries the repair.
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "skipping legacy provider-CID repair: bounded object read failed");
|
||||
return Ok(RepairOutcome::Retryable);
|
||||
}
|
||||
// The blocking task panicked or was cancelled: same best-effort treatment, and
|
||||
// worth another walk because it says nothing about the row itself.
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "skipping legacy provider-CID repair: object read task failed");
|
||||
return Ok(RepairOutcome::Retryable);
|
||||
}
|
||||
};
|
||||
let raw = Cid::from_git_object_bytes(&data).to_string();
|
||||
if raw == stored {
|
||||
return Ok(RepairOutcome::Settled);
|
||||
}
|
||||
db.repair_legacy_provider_cid(sha, &raw, &stored).await?;
|
||||
Ok(RepairOutcome::Repaired)
|
||||
}
|
||||
|
||||
/// What one opportunistic repair did with a row, so the sweep can tell a skip a later
|
||||
/// run could fix from one nothing will (U4 re-walk, #173 round 11). The push skip path
|
||||
/// ignores the value: it repairs whatever the push happens to carry and a failure there
|
||||
/// is already warn-only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RepairOutcome {
|
||||
/// Nothing to do, or nothing a re-walk would change: the stored key is already the
|
||||
/// raw resolver key, the recomputed key matches it, or the object's bytes are gone.
|
||||
Settled,
|
||||
/// The bounded object read failed (a wedged `git cat-file`, an unreadable repo).
|
||||
/// The bytes may be readable later, so the row is worth walking again.
|
||||
Retryable,
|
||||
/// The row's key was rewritten to the raw-content CID.
|
||||
Repaired,
|
||||
}
|
||||
|
||||
/// What one sweep pass (or a whole sweep run) did. `scanned` counts `pinned_cids`
|
||||
/// rows READ, which is the quantity the batch size bounds; `repaired` counts rows
|
||||
/// whose key was actually rewritten to the raw CID.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SweepStats {
|
||||
pub scanned: usize,
|
||||
pub repaired: usize,
|
||||
pub passes: usize,
|
||||
/// Rows left unrepaired for a reason a LATER run could fix (the source repo is not
|
||||
/// on this node's local disk, a DB read failed, a bounded object read failed). A
|
||||
/// nonzero count is what makes the run rewind its cursor instead of parking it at
|
||||
/// the end of the table forever. Rows that are unrepairable in principle (no
|
||||
/// provenance, the repo row is gone, the bytes are gone) are NOT counted here.
|
||||
pub retryable_skips: usize,
|
||||
}
|
||||
|
||||
/// One bounded pass of the U4 sweep: read at most `batch` `pinned_cids` rows after
|
||||
/// the persisted cursor, repair the legacy ones, and persist the new cursor.
|
||||
///
|
||||
/// The batch is what bounds the pass. It caps rows READ, not rows repaired, because
|
||||
/// the legacy predicate is a codec decode SQL cannot express; a table of raw rows
|
||||
/// therefore costs one indexed range scan per pass and nothing else.
|
||||
///
|
||||
/// The cursor advances to the LAST row read whatever happened to each row, including
|
||||
/// rows that were skipped as unrepairable. A cursor that only advanced on success
|
||||
/// would re-read the same unrepairable row on every pass and the sweep would never
|
||||
/// reach the rows behind it.
|
||||
async fn sweep_pass(
|
||||
repos_dir: &std::path::Path,
|
||||
git_bin: &str,
|
||||
git_timeout: Duration,
|
||||
batch: i64,
|
||||
db: &crate::db::Db,
|
||||
) -> Result<SweepStats> {
|
||||
let cursor = db.pin_repair_cursor().await?;
|
||||
let rows = db.pinned_cids_after(&cursor, batch).await?;
|
||||
let scanned = rows.len();
|
||||
let mut repaired = 0usize;
|
||||
let mut retryable_skips = 0usize;
|
||||
let mut last = cursor;
|
||||
|
||||
for (sha, stored) in rows {
|
||||
// Advance FIRST: every path below this line may skip the row, and none of them
|
||||
// may wedge the walk (scenario 7).
|
||||
last = sha.clone();
|
||||
// Same cost gate as the skip-path repair: a canonical raw CIDv1 key is already
|
||||
// the resolver key, so it reads no bytes and resolves no repo.
|
||||
if gitlawb_core::cid::is_raw_cidv1(&stored) {
|
||||
continue;
|
||||
}
|
||||
// Resolve the row's repo from its recorded provenance (first-pinner plus the
|
||||
// bounded additional source set). An empty set is a pin recorded before
|
||||
// provenance existed: nothing to read the bytes from, so skip it.
|
||||
let sources = match db.pin_sources_for_oid(&sha).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "sweep: failed to read pin sources");
|
||||
// A DB read error says nothing about the row, so a later run retries it.
|
||||
retryable_skips += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Whether this row ended the source walk repaired, and whether anything it hit
|
||||
// along the way was a transient obstacle rather than a permanent one.
|
||||
let mut row_repaired = false;
|
||||
let mut row_retryable = false;
|
||||
for repo_id in sources {
|
||||
let repo = match db.get_repo_by_id(&repo_id).await {
|
||||
Ok(Some(r)) => r,
|
||||
// The repo row is gone: a later source may still hold the bytes. A
|
||||
// deleted repo does not come back, so this is not a retryable skip.
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_id = %repo_id, err = %e, "sweep: failed to read repo");
|
||||
row_retryable = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Derive the LOCAL disk path rather than going through `repo_store.acquire`.
|
||||
// The sweep is opportunistic background maintenance over every pinned row on
|
||||
// the node, so it must never pull a cold repo back from remote storage: that
|
||||
// would turn a repair pass into a bulk restore. A repo that is not on local
|
||||
// disk simply reads no bytes here and stays withheld, but it IS a retryable
|
||||
// skip: on a Tigris-backed node the repo is cold now and warm later, and
|
||||
// without the re-walk that row would never be repaired by anything.
|
||||
// The path goes through the repo store's VALIDATED resolver (allowlisted
|
||||
// components, rooted at repos_dir, no ParentDir/CurDir segment), not the raw
|
||||
// join: the sweep is a second caller of that path logic and gets the same
|
||||
// barrier the acquire path has (#173 round 11, F3). It is the non-fetching
|
||||
// variant, so the no-cold-pull property above is untouched.
|
||||
let repo_path = match crate::git::repo_store::validated_repo_disk_path(
|
||||
repos_dir,
|
||||
&repo.owner_did,
|
||||
&repo.name,
|
||||
) {
|
||||
Ok(p) => p,
|
||||
// An unsafe name is not something a later run fixes, so it is terminal.
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_id = %repo_id, err = %e, "sweep: rejected unsafe repo path");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !repo_path.is_dir() {
|
||||
row_retryable = true;
|
||||
continue;
|
||||
}
|
||||
match repair_legacy_provider_cid(&repo_path, git_bin, git_timeout, &sha, db).await {
|
||||
Ok(RepairOutcome::Repaired) => {
|
||||
repaired += 1;
|
||||
row_repaired = true;
|
||||
break;
|
||||
}
|
||||
// The bytes could not be read from this source right now: try the next
|
||||
// source, and if none of them works, walk the row again on a later run.
|
||||
Ok(RepairOutcome::Retryable) => row_retryable = true,
|
||||
// Nothing to repair from this source and nothing a re-walk changes.
|
||||
Ok(RepairOutcome::Settled) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "sweep: legacy provider-CID repair failed");
|
||||
row_retryable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !row_repaired && row_retryable {
|
||||
retryable_skips += 1;
|
||||
}
|
||||
}
|
||||
|
||||
db.set_pin_repair_cursor(&last).await?;
|
||||
Ok(SweepStats {
|
||||
scanned,
|
||||
repaired,
|
||||
passes: 1,
|
||||
retryable_skips,
|
||||
})
|
||||
}
|
||||
|
||||
/// Test seam for a single bounded pass (scenarios 4 and 5 drive passes by hand to
|
||||
/// observe the batch bound and the restart-resumes-from-cursor behavior).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) async fn sweep_legacy_provider_cids_once(
|
||||
repos_dir: &std::path::Path,
|
||||
git_bin: &str,
|
||||
git_timeout: Duration,
|
||||
batch: i64,
|
||||
db: &crate::db::Db,
|
||||
) -> Result<SweepStats> {
|
||||
sweep_pass(repos_dir, git_bin, git_timeout, batch, db).await
|
||||
}
|
||||
|
||||
/// U4 (#173): the one-shot legacy provider-CID migration sweep.
|
||||
///
|
||||
/// Releases before this branch stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0)
|
||||
/// in `pinned_cids.cid`. This branch's `/ipfs/{cid}` resolver recomputes the raw
|
||||
/// content CID and withholds any row whose key does not match, so those rows are
|
||||
/// unresolvable. The opportunistic repair on the already-pinned skip path only fires
|
||||
/// when a later push re-carries the object, and normal git negotiation omits objects
|
||||
/// the node already has, so on an upgraded node that push generally never comes. This
|
||||
/// walks the table instead.
|
||||
///
|
||||
/// Runs until a pass comes back short of a full batch, which is the end of the table.
|
||||
/// Sleeps `delay` between full batches so it cannot monopolize the DB, and persists
|
||||
/// its cursor every pass so a restart continues instead of rewinding. Errors reading
|
||||
/// or repairing an individual row are warn-and-skip; only a failure of the batch query
|
||||
/// or the cursor write ends the run, and a later run picks up from the stored cursor.
|
||||
///
|
||||
/// A run that skipped at least one RETRYABLE row rewinds the cursor to the start of the
|
||||
/// table on its way out (#173 round 11). Without that the cursor parked at the maximum
|
||||
/// `sha256_hex` for good: every later boot read zero rows, so a row skipped for a
|
||||
/// transient reason (its repo cold on a Tigris-backed node, a DB or object read error)
|
||||
/// was skipped permanently, unadvertised and unresolvable with nothing left to fix it.
|
||||
/// The rewind is a per-RUN decision made after the walk has already finished, never
|
||||
/// mid-walk, so it cannot spin: the cost is one extra ordered scan on the next run, and
|
||||
/// a row that is unrepairable in principle (bytes gone, provenance gone) does not count
|
||||
/// as retryable, so a node holding one does not re-walk on every boot forever.
|
||||
pub(crate) async fn sweep_legacy_provider_cids(
|
||||
repos_dir: &std::path::Path,
|
||||
git_bin: &str,
|
||||
git_timeout: Duration,
|
||||
batch: i64,
|
||||
delay: Duration,
|
||||
db: &crate::db::Db,
|
||||
) -> SweepStats {
|
||||
let mut totals = SweepStats::default();
|
||||
loop {
|
||||
let pass = match sweep_pass(repos_dir, git_bin, git_timeout, batch, db).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, "legacy provider-CID sweep pass failed; stopping");
|
||||
break;
|
||||
}
|
||||
};
|
||||
totals.scanned += pass.scanned;
|
||||
totals.repaired += pass.repaired;
|
||||
totals.retryable_skips += pass.retryable_skips;
|
||||
totals.passes += 1;
|
||||
// A short batch means the ordered walk reached the end of the table. Stop here
|
||||
// rather than after an extra empty pass, and do NOT sleep on the way out.
|
||||
if (pass.scanned as i64) < batch {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
if totals.retryable_skips > 0 {
|
||||
if let Err(e) = db.set_pin_repair_cursor("").await {
|
||||
tracing::warn!(err = %e, "failed to rewind the legacy provider-CID sweep cursor");
|
||||
}
|
||||
}
|
||||
totals
|
||||
}
|
||||
|
||||
// Test-only cost-gate counter (R8, U7): how many times the opportunistic repair
|
||||
// read an object's bytes on the skip path. The codec gate must spare a CIDv1/raw
|
||||
// row this read; the counter is the both-ways guard (removing the gate reads the
|
||||
// raw row and increments it). Same thread_local discipline as the serve-path
|
||||
// oversize counter — the pin tests await `pin_new_objects` on a current-thread
|
||||
// runtime, so the increment and the assertion share one thread.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static LEGACY_REPAIR_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_legacy_repair_reads() {
|
||||
LEGACY_REPAIR_READS.with(|c| c.set(0));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn legacy_repair_reads() -> usize {
|
||||
LEGACY_REPAIR_READS.with(|c| c.get())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn note_legacy_repair_read() {
|
||||
LEGACY_REPAIR_READS.with(|c| c.set(c.get() + 1));
|
||||
}
|
||||
|
||||
/// Pin a single git object to the local IPFS/Kubo node.
|
||||
///
|
||||
@@ -91,14 +457,18 @@ pub async fn cat(ipfs_api: &str, cid: &str) -> Result<Vec<u8>> {
|
||||
/// `..._fail_closed` filter on the full-scan path before calling, so this
|
||||
/// function never sees a withheld blob. `repo_path` is still needed to read each
|
||||
/// object's bytes. The twin in `pinata.rs` mirrors this shape — change both in
|
||||
/// lockstep.
|
||||
/// lockstep. `repo_id` records the pin's provenance so `GET /ipfs/{cid}` resolves
|
||||
/// straight to this repo instead of scanning every repo (#173).
|
||||
///
|
||||
/// Returns a list of `(sha256_hex, cid)` pairs for objects pinned this call.
|
||||
pub async fn pin_new_objects(
|
||||
ipfs_api: &str,
|
||||
repo_path: &std::path::Path,
|
||||
git_bin: &str,
|
||||
git_timeout: Duration,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
if ipfs_api.is_empty() {
|
||||
return vec![];
|
||||
@@ -107,9 +477,54 @@ pub async fn pin_new_objects(
|
||||
let mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
// Skip if already pinned
|
||||
// Skip if already pinned — but first backfill provenance if the existing
|
||||
// pin has none. A legacy pin (recorded before repo_id existed, #173, jatmn)
|
||||
// is skipped here before record_pinned_cid ever runs, so its NULL provenance
|
||||
// would never resolve to one repo and known CIDs keep hitting the scan. The
|
||||
// backfill only sets repo_id (AND repo_id IS NULL guard preserves
|
||||
// first-pinner-owns) and never re-pins the bytes — the object is already on IPFS.
|
||||
match db.is_pinned(&sha).await {
|
||||
Ok(true) => continue,
|
||||
Ok(true) => {
|
||||
match db.provenance_for_oid(&sha).await {
|
||||
Ok(None) => {
|
||||
if let Err(e) = db.backfill_pin_provenance(&sha, repo_id).await {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to backfill pin provenance");
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error reading pin provenance");
|
||||
}
|
||||
}
|
||||
// F1 (#173 round 8): record this repo as an ADDITIONAL source for the
|
||||
// already-pinned object. This is the load-bearing skip-branch insert —
|
||||
// a later repo pushing a shared object hits this path (already pinned),
|
||||
// and without it `GET /ipfs/{cid}` only ever knows the first pinner, so a
|
||||
// shared object first pinned from a private/quarantined repo 404s even
|
||||
// when this repo would serve it. Bounded per object (MAX_PIN_SOURCES).
|
||||
if let Err(e) = retry_db_record(|| db.record_pin_source(&sha, repo_id)).await {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
// U3 (#173): the retries are spent and this repo is NOT in the source
|
||||
// set, so the set is known incomplete. Persist that, or the resolver
|
||||
// reads a non-empty below-cap set as COMPLETE and 404s an object this
|
||||
// repo would serve. Warn-only in turn: if the marker write also fails
|
||||
// the object degrades to the pre-U3 behavior, never worse.
|
||||
if let Err(e) = db.mark_pin_sources_incomplete(&sha).await {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
// R8 (#173 round 10): opportunistically repair a legacy provider-CID
|
||||
// row (Kubo dag-pb / Pinata) to the raw-content resolver key on this
|
||||
// re-push. Cost-gated on the stored key's codec — a non-legacy row
|
||||
// reads no bytes. Warn-only: a failure leaves the row as-is for a
|
||||
// later re-push or the deferred one-shot sweep.
|
||||
if let Err(e) =
|
||||
repair_legacy_provider_cid(repo_path, git_bin, git_timeout, &sha, db).await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to repair legacy provider CID");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking pinned status");
|
||||
@@ -117,22 +532,49 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw object content
|
||||
let data = match crate::git::store::read_object(repo_path, &sha) {
|
||||
Ok(Some((_obj_type, bytes))) => bytes,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to read git object for pinning");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Read raw object content under a bounded read so a wedged/D-state `git
|
||||
// cat-file` (stuck NFS/Tigris backend) is reaped at `git_timeout` instead of
|
||||
// hanging pin_new_objects forever — which would pin the post-push coalescing
|
||||
// key until process death (grok F2, #173). On Err the object is simply not
|
||||
// pinned this pass; a later pass/push retries.
|
||||
let data =
|
||||
match crate::git::store::read_object_bounded(git_bin, repo_path, &sha, git_timeout) {
|
||||
Ok(Some((_obj_type, bytes))) => bytes,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to read git object for pinning");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Pin to IPFS
|
||||
match pin_git_object(ipfs_api, &sha, &data).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
if let Err(e) = db.record_pinned_cid(&sha, &cid).await {
|
||||
// The resolver key (`pinned_cids.cid`) must be the locally-computed
|
||||
// raw-content CID, never the provider Hash: Kubo returns a dag-pb/UnixFS
|
||||
// root for objects above its block size, which does not hash the raw
|
||||
// content, so `GET /ipfs/{provider_cid}` would resolve then fail the F2
|
||||
// integrity check (list-then-404). The serve path reads bytes from git and
|
||||
// verifies them against the requested CID, so the raw CID is the correct
|
||||
// key. Mirrors the pinata twin, which already records the raw CID.
|
||||
let raw_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&data).to_string();
|
||||
// F1 (#173 round 8): the first pinner is recorded in pin_repo_sources too,
|
||||
// so every source (first and subsequent) is tried uniformly by the
|
||||
// resolver. U3 (#173): the pin and its source go down in ONE transaction.
|
||||
// As two independent best-effort calls this path could land the pin while
|
||||
// dropping its own source, producing a source set silently missing its
|
||||
// first pinner; atomically there is no such window, and a total failure
|
||||
// leaves the object unpinned so the next push retries the whole thing.
|
||||
if let Err(e) =
|
||||
retry_db_record(|| db.record_pinned_cid_with_source(&sha, &raw_cid, repo_id))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pinned CID in DB");
|
||||
}
|
||||
// Return the provider Hash (not the resolver key), mirroring the pinata
|
||||
// twin's contract: the DB `cid` is the raw resolver key (recorded above),
|
||||
// the returned value is the provider CID. Here the return is consumed only
|
||||
// for logging, but keeping the twins structurally identical avoids drift.
|
||||
pinned.push((sha, cid));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -144,3 +586,92 @@ pub async fn pin_new_objects(
|
||||
|
||||
pinned
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
// The retry helper is the load-bearing unit: it converts a sub-second
|
||||
// transient DB error at the three warn-only record sites into a landed row,
|
||||
// instead of a permanently incomplete pin-source set. These drive the helper
|
||||
// directly against a controlled closure (the record sites take a concrete
|
||||
// `&Db` over a `PgPool`, so a failing-first wrapper cannot slot in without
|
||||
// changing signatures — see U6 seam note).
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_lands_after_transient_failures() {
|
||||
let calls = Cell::new(0u32);
|
||||
let result = retry_db_record(|| {
|
||||
let n = calls.get() + 1;
|
||||
calls.set(n);
|
||||
async move {
|
||||
if n < PIN_RECORD_ATTEMPTS {
|
||||
Err(anyhow::anyhow!("transient failure on attempt {n}"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"retry lands the row after transient failures"
|
||||
);
|
||||
assert_eq!(
|
||||
calls.get(),
|
||||
PIN_RECORD_ATTEMPTS,
|
||||
"op is retried until it succeeds"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_returns_last_err_after_exhaustion() {
|
||||
let calls = Cell::new(0u32);
|
||||
let result = retry_db_record(|| {
|
||||
let n = calls.get() + 1;
|
||||
calls.set(n);
|
||||
async move { Err::<(), _>(anyhow::anyhow!("attempt {n} failed")) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let err = result.expect_err("all attempts fail so the last error surfaces");
|
||||
assert_eq!(
|
||||
calls.get(),
|
||||
PIN_RECORD_ATTEMPTS,
|
||||
"attempts are bounded to the cap"
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"attempt 3 failed",
|
||||
"the LAST error is returned, not the first"
|
||||
);
|
||||
}
|
||||
|
||||
// Happy path against a real DB: a single-attempt success lands the row, and a
|
||||
// redundant call is idempotent (`ON CONFLICT DO NOTHING`), so the source set
|
||||
// holds exactly one row.
|
||||
#[sqlx::test]
|
||||
async fn retry_records_pin_source_once(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
|
||||
let sha = "a".repeat(64);
|
||||
let repo_id = "repo-retry-1";
|
||||
|
||||
retry_db_record(|| db.record_pin_source(&sha, repo_id))
|
||||
.await
|
||||
.expect("happy-path record succeeds in one attempt");
|
||||
retry_db_record(|| db.record_pin_source(&sha, repo_id))
|
||||
.await
|
||||
.expect("a redundant record is idempotent");
|
||||
|
||||
let sources = db.pin_sources_for_oid(&sha).await.unwrap();
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec![repo_id.to_string()],
|
||||
"exactly one source row lands under ON CONFLICT DO NOTHING"
|
||||
);
|
||||
}
|
||||
}
|
||||
+665
-66
@@ -24,8 +24,14 @@ mod test_support;
|
||||
mod visibility;
|
||||
mod webhooks;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use clap::Parser;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
@@ -38,6 +44,46 @@ use config::Config;
|
||||
use db::Db;
|
||||
use state::AppState;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DegradedState {
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
}
|
||||
|
||||
/// Two independent counters with no cross-field invariant — atomics, not a
|
||||
/// lock, so the retry loop and the degraded handlers never contend.
|
||||
#[derive(Default)]
|
||||
struct DbStartupStatus {
|
||||
attempts: AtomicU64,
|
||||
next_retry_secs: AtomicU64,
|
||||
}
|
||||
|
||||
/// Hard ceiling on the advisory-lock pool's `max_connections`.
|
||||
///
|
||||
/// `max_concurrent_git_pushes` is validated all the way up to 1_048_576, and the lock
|
||||
/// pool used to derive its size straight from that knob, so raising the push cap
|
||||
/// silently raised the node's Postgres connection ceiling with no CLI error and no
|
||||
/// relation to the server's own `max_connections` (#173 F4). The node's total budget is
|
||||
/// now bounded: `db_max_connections` (default 20) + at most this.
|
||||
const LOCK_POOL_MAX_CONNECTIONS: u32 = 64;
|
||||
|
||||
/// Connections the lock pool keeps above the push cap. Covers the three non-push
|
||||
/// `acquire_write` callers (`api/issues.rs` x2, `api/pulls.rs`), which hold no
|
||||
/// concurrency permit, so a push never queues here for a connection where it did not
|
||||
/// before.
|
||||
const LOCK_POOL_PUSH_HEADROOM: u8 = 8;
|
||||
|
||||
/// Size the advisory-lock pool for a given push cap: the cap plus
|
||||
/// [`LOCK_POOL_PUSH_HEADROOM`], clamped to [`LOCK_POOL_MAX_CONNECTIONS`]. Past the
|
||||
/// clamp a push may wait for a lock-pool connection, which is a bounded wait that sheds
|
||||
/// a clean 503 (see `LockPoolBusy`), not an unbounded hang.
|
||||
fn lock_pool_size(max_concurrent_git_pushes: usize) -> u32 {
|
||||
u32::try_from(max_concurrent_git_pushes)
|
||||
.unwrap_or(u32::MAX)
|
||||
.saturating_add(u32::from(LOCK_POOL_PUSH_HEADROOM))
|
||||
.min(LOCK_POOL_MAX_CONNECTIONS)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -76,21 +122,101 @@ async fn main() -> Result<()> {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
info!("╚══════════════════════════════════════════╝");
|
||||
info!(did = %node_did, "node identity");
|
||||
info!(addr = %config.bind_addr(), "listening");
|
||||
|
||||
// Process-wide shutdown signal. One sender lives in AppState (cloned
|
||||
// into every handler); main() keeps a clone and flips it on SIGINT
|
||||
// or SIGTERM. Tasks that hold a watch::Receiver get notified at
|
||||
// their next await point.
|
||||
let (shutdown_tx, _shutdown_rx_for_main) = watch::channel(false);
|
||||
spawn_shutdown_signal(shutdown_tx.clone());
|
||||
|
||||
// Connect to PostgreSQL database
|
||||
let db = Arc::new(
|
||||
Db::connect(&config.database_url)
|
||||
.await
|
||||
.context("failed to connect to database")?,
|
||||
);
|
||||
info!(did = %node_did, "node identity");
|
||||
info!(addr = %config.bind_addr(), "binding HTTP listener");
|
||||
|
||||
// Bind HTTP once, before dependency initialization, and keep this socket
|
||||
// for the life of the process. The degraded server accepts on a dup of the
|
||||
// same socket, so the degraded→full handoff never closes the port: while
|
||||
// the full server initializes, connections queue in the shared backlog
|
||||
// instead of being refused.
|
||||
let listener = TcpListener::bind(config.bind_addr())
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
|
||||
let full_std_listener = listener.into_std()?;
|
||||
let degraded_listener = TcpListener::from_std(
|
||||
full_std_listener
|
||||
.try_clone()
|
||||
.context("failed to clone HTTP listener for degraded server")?,
|
||||
)?;
|
||||
|
||||
// Metrics must stay observable during a database outage — the degraded
|
||||
// window is exactly when dashboards need data — so this listener starts
|
||||
// before the DB connects.
|
||||
let metrics_handle = if !config.metrics_addr.is_empty() {
|
||||
match spawn_metrics_server(&config.metrics_addr, shutdown_tx.subscribe()).await {
|
||||
Ok(handle) => {
|
||||
info!(addr = %config.metrics_addr, "metrics endpoint listening");
|
||||
Some(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(err = %e, addr = %config.metrics_addr, "failed to start metrics endpoint — continuing without");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("metrics endpoint disabled (GITLAWB_METRICS_ADDR not set)");
|
||||
None
|
||||
};
|
||||
|
||||
let db_startup = Arc::new(DbStartupStatus::default());
|
||||
let (db_ready_tx, db_ready_rx) = watch::channel(false);
|
||||
let mut degraded_handle = tokio::spawn(run_degraded_server(
|
||||
degraded_listener,
|
||||
node_did.to_string(),
|
||||
Arc::clone(&db_startup),
|
||||
db_ready_rx,
|
||||
shutdown_tx.subscribe(),
|
||||
));
|
||||
|
||||
// Connect to PostgreSQL database. A transient outage or bad secret should
|
||||
// not crash-loop the process and hammer the database provider; permanent
|
||||
// misconfiguration surfaces through error-level logs and the /ready check.
|
||||
let db = tokio::select! {
|
||||
db = connect_db_with_retry(&config, Arc::clone(&db_startup), shutdown_tx.subscribe()) => {
|
||||
match db {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
// Shutdown requested while waiting for the database. The
|
||||
// degraded server only serves one-shot 503s — abort it
|
||||
// rather than drain, so a slow client can't stall exit.
|
||||
degraded_handle.abort();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
degraded = &mut degraded_handle => {
|
||||
if *shutdown_tx.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
return match degraded {
|
||||
Ok(Ok(())) => Err(anyhow!("degraded HTTP server stopped before database became ready")),
|
||||
Ok(Err(err)) => Err(err.context("degraded HTTP server failed")),
|
||||
Err(err) => Err(anyhow!("degraded HTTP server task failed: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Flip the degraded server into graceful shutdown, but do NOT await the
|
||||
// drain: one slow in-flight request must not delay the full server, and
|
||||
// the shared socket means there is no port gap to cover. The drain
|
||||
// finishes (and logs) in the background.
|
||||
db_ready_tx.send(true).ok();
|
||||
tokio::spawn(async move {
|
||||
match degraded_handle.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => warn!(err = %err, "degraded HTTP server exited with error"),
|
||||
Err(err) => warn!(err = %err, "degraded HTTP server task failed"),
|
||||
}
|
||||
});
|
||||
info!(addr = %config.bind_addr(), "database ready; starting full HTTP server");
|
||||
|
||||
// Prune peer rows that point back at this node (stale self-loop entries)
|
||||
if let Some(public_url) = config.public_url.as_deref() {
|
||||
@@ -179,14 +305,43 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let repo_store =
|
||||
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
|
||||
// Repo write locks run on their own pool, never the main query pool: each
|
||||
// push holds its connection for the whole receive-pack, and
|
||||
// db_max_connections (20) is below max_concurrent_git_pushes (32), so sharing
|
||||
// would starve every other query under a push burst. See build_lock_pool for
|
||||
// the cancellation semantics (#173).
|
||||
let lock_pool = git::repo_store::build_lock_pool(
|
||||
db.pool(),
|
||||
lock_pool_size(config.max_concurrent_git_pushes),
|
||||
std::time::Duration::from_secs(config.db_acquire_timeout_secs),
|
||||
);
|
||||
let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, lock_pool);
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
let rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
|
||||
|
||||
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
|
||||
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
|
||||
// exactly how the recurring spam-repo floods get past both it and the
|
||||
// iCaptcha gate. Keyed on the resolved client IP so a single-source flood is
|
||||
// capped regardless of how many identities it mints. Sized well above any
|
||||
// legitimate per-IP creation rate; GITLAWB_CREATE_RATE_LIMIT overrides, 0
|
||||
// disables. Bounded key set — the key is a client-influenced IP.
|
||||
let create_limit = std::env::var("GITLAWB_CREATE_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(120);
|
||||
let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
create_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if create_limit == 0 {
|
||||
tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
|
||||
}
|
||||
|
||||
// Push-path flood brake: max git-receive-pack requests per client IP per
|
||||
// hour (counts both the info/refs advertisement and the push POST). Sized
|
||||
// for heavy agent automation while still stopping flood traffic (the June
|
||||
@@ -213,6 +368,29 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
|
||||
|
||||
// Peer-sync flood brakes, keyed on the resolved client IP (per-DID is useless
|
||||
// here — a did:key farm self-registers). Two buckets so an unsigned notify
|
||||
// flood can't drain the signed trigger caller's quota (#82). Bounded key sets
|
||||
// (the key is a client-influenced IP); 0 disables each.
|
||||
let sync_trigger_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
config.sync_trigger_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
let peer_write_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
config.peer_write_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if config.sync_trigger_rate_limit == 0 {
|
||||
tracing::warn!(
|
||||
"GITLAWB_SYNC_TRIGGER_RATE_LIMIT=0 — /sync/trigger IP rate limiting disabled"
|
||||
);
|
||||
}
|
||||
if config.peer_write_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_PEER_WRITE_RATE_LIMIT=0 — peer-write IP rate limiting disabled");
|
||||
}
|
||||
|
||||
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
|
||||
icaptcha::init().await;
|
||||
|
||||
@@ -229,10 +407,85 @@ async fn main() -> Result<()> {
|
||||
machine_id,
|
||||
repo_store,
|
||||
rate_limiter,
|
||||
create_ip_rate_limiter,
|
||||
push_rate_limiter,
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
// The legacy-probe budget is operator-tunable via GITLAWB_IPFS_MAX_REPOS_WALKED
|
||||
// (R5); the history-walk ceiling above stays constant (a smaller value false-503s
|
||||
// a provenanced request). Default 256 preserves the shipped behaviour.
|
||||
ipfs_max_legacy_probes: AppState::ipfs_legacy_probe_budget(&config),
|
||||
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
|
||||
push_limiter_trust,
|
||||
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,
|
||||
)),
|
||||
// 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(),
|
||||
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): a single source can hold at most this many write-pool
|
||||
// slots via the anon advertisement, so saturating the pool takes ~8 distinct
|
||||
// source IPs, each also rate-limited (#174).
|
||||
git_push_advert_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
(config.max_concurrent_git_pushes / 8).max(1),
|
||||
),
|
||||
// Per-source cap on the authenticated receive-pack POST, sized like the advert
|
||||
// cap: one source IP can hold at most this many write-pool slots, so
|
||||
// monopolizing the pool takes ~8 distinct source IPs, each also rate-limited
|
||||
// (#174 P1-d).
|
||||
git_write_per_caller: rate_limit::PerCallerConcurrency::with_default_max_keys(
|
||||
(config.max_concurrent_git_pushes / 8).max(1),
|
||||
),
|
||||
// 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,
|
||||
),
|
||||
// Separate WORK-budget bucket for the resolver's per-probe/per-walk charges (R6).
|
||||
// Its capacity is DERIVED from the route limit (no new knob) and floored at the
|
||||
// legacy-probe budget, so one full default-config legacy scan never self-throttles
|
||||
// mid-request while the route brake above stays the pure once-per-request cap.
|
||||
ipfs_work_rate_limiter: rate_limit::RateLimiter::new_bounded(
|
||||
AppState::ipfs_work_budget(&config),
|
||||
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".
|
||||
@@ -260,46 +513,18 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn a task that flips the shutdown signal on SIGINT or SIGTERM.
|
||||
// On Unix, both signals are handled. On Windows, only Ctrl-C is
|
||||
// supported by tokio::signal::ctrl_c.
|
||||
{
|
||||
let tx = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal as unix_signal, SignalKind};
|
||||
let mut sigterm =
|
||||
unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
let mut sigint =
|
||||
unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("SIGTERM received, shutting down"),
|
||||
_ = sigint.recv() => info!("SIGINT received, shutting down"),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
use tokio::signal;
|
||||
let _ = signal::ctrl_c().await;
|
||||
info!("Ctrl-C received, shutting down");
|
||||
}
|
||||
tx.send(true).ok();
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let rl = state.rate_limiter.clone();
|
||||
let push_rl = state.push_rate_limiter.clone();
|
||||
let cleanup_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;
|
||||
push_rl.cleanup().await;
|
||||
// Sweep every per-IP/DID limiter (incl. the ipfs walk brake)
|
||||
// so bounded maps shed stale keys instead of sitting at cap.
|
||||
cleanup_state.sweep_rate_limiters().await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
@@ -318,10 +543,13 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
let _legacy_cid_sweep = spawn_legacy_cid_sweep(&state, &config);
|
||||
|
||||
let router = server::build_router(state.clone());
|
||||
let listener = TcpListener::bind(config.bind_addr())
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
|
||||
// Re-register the socket bound at startup — same fd, so there was never a
|
||||
// moment with the port closed between the degraded and full servers.
|
||||
let listener = TcpListener::from_std(full_std_listener)
|
||||
.context("failed to re-register HTTP listener with the runtime")?;
|
||||
|
||||
info!("✓ node started — did:{}", node_did);
|
||||
info!(" repos dir: {}", config.repos_dir.display());
|
||||
@@ -330,23 +558,6 @@ async fn main() -> Result<()> {
|
||||
&config.database_url.split('@').next_back().unwrap_or("?")
|
||||
);
|
||||
|
||||
// Optional Prometheus metrics listener on a separate port.
|
||||
let metrics_handle = if !config.metrics_addr.is_empty() {
|
||||
match spawn_metrics_server(&config.metrics_addr, state.clone()).await {
|
||||
Ok(handle) => {
|
||||
info!(addr = %config.metrics_addr, "metrics endpoint listening");
|
||||
Some(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(err = %e, addr = %config.metrics_addr, "failed to start metrics endpoint — continuing without");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("metrics endpoint disabled (GITLAWB_METRICS_ADDR not set)");
|
||||
None
|
||||
};
|
||||
|
||||
// Publish our DID record to the Kademlia DHT shortly after startup
|
||||
if let Some(p2p) = &state.p2p {
|
||||
let did_record = p2p::DidRecord {
|
||||
@@ -456,12 +667,267 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// U4 (#173): spawn the one-shot legacy provider-CID repair sweep. Releases before this
|
||||
/// version stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`,
|
||||
/// and this version's `/ipfs/{cid}` resolver withholds any row whose stored key is not
|
||||
/// the raw-content CID. The opportunistic repair on the pin path only fires when a push
|
||||
/// re-carries the object, which normal git negotiation makes it not do, so those rows
|
||||
/// need a walk. DETACHED, never on the boot path: the caller keeps serving while this
|
||||
/// runs, and the sweep's own batch bound plus inter-batch delay keep it off the DB's
|
||||
/// critical path. Its cursor is durable, so a restart mid-walk resumes instead of
|
||||
/// rewinding.
|
||||
///
|
||||
/// A named function rather than an inline block in `main` so the WIRING has a seam a
|
||||
/// test can call: that the task is spawned at all, that it reads its batch and delay
|
||||
/// from the config knobs rather than some other field, that the caller is not blocked
|
||||
/// on it, and that the shutdown watcher actually ends it mid-walk. The sweep's own
|
||||
/// behavior is covered elsewhere; this is the boot-path half.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
stats = ipfs_pin::sweep_legacy_provider_cids(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay, &db,
|
||||
) => {
|
||||
if stats.repaired > 0 {
|
||||
tracing::info!(
|
||||
scanned = stats.scanned,
|
||||
repaired = stats.repaired,
|
||||
"legacy provider-CID sweep finished"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Shutdown mid-walk simply drops the run; the persisted cursor means the
|
||||
// next boot picks up where this one stopped.
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal as unix_signal, SignalKind};
|
||||
let mut sigterm =
|
||||
unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
let mut sigint = unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("SIGTERM received, shutting down"),
|
||||
_ = sigint.recv() => info!("SIGINT received, shutting down"),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
use tokio::signal;
|
||||
let _ = signal::ctrl_c().await;
|
||||
info!("Ctrl-C received, shutting down");
|
||||
}
|
||||
tx.send(true).ok();
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_db_with_retry(
|
||||
config: &Config,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Option<Arc<Db>> {
|
||||
let initial_retry_secs = config.db_retry_initial_secs;
|
||||
let max_retry_secs = config.db_retry_max_secs.max(initial_retry_secs);
|
||||
let acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
|
||||
let attempt_timeout = std::time::Duration::from_secs(config.db_connect_timeout_secs);
|
||||
let mut attempts = 0_u64;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
return None;
|
||||
}
|
||||
|
||||
attempts = attempts.saturating_add(1);
|
||||
db_startup.attempts.store(attempts, Ordering::Relaxed);
|
||||
|
||||
// Bound the whole attempt, not just the pool connect: migrations
|
||||
// block on a cross-instance advisory lock, and an unbounded wait
|
||||
// there would wedge this loop — no retries, no logs, no recovery.
|
||||
// Timing out and retrying is safe; migrations are idempotent.
|
||||
let attempt = match tokio::time::timeout(
|
||||
attempt_timeout,
|
||||
Db::connect(
|
||||
&config.database_url,
|
||||
config.db_max_connections,
|
||||
acquire_timeout,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(anyhow!(
|
||||
"connect + migrate attempt exceeded {}s (GITLAWB_DB_CONNECT_TIMEOUT_SECS); \
|
||||
is another instance holding the migration lock?",
|
||||
attempt_timeout.as_secs()
|
||||
)),
|
||||
};
|
||||
|
||||
match attempt {
|
||||
Ok(db) => {
|
||||
info!(attempts, "database connection established");
|
||||
return Some(Arc::new(db));
|
||||
}
|
||||
Err(err) => {
|
||||
// A bad DATABASE_URL or rejected credentials won't heal on
|
||||
// their own. Still retry (exiting would crash-loop and hammer
|
||||
// the provider — and take liveness down with it), but log at
|
||||
// error level and skip straight to the maximum backoff; the
|
||||
// /ready health check is what surfaces this to deploys.
|
||||
let permanent = is_likely_permanent_db_error(&err);
|
||||
let retry_secs = if permanent {
|
||||
max_retry_secs
|
||||
} else {
|
||||
database_retry_delay_secs(initial_retry_secs, max_retry_secs, attempts)
|
||||
};
|
||||
db_startup
|
||||
.next_retry_secs
|
||||
.store(retry_secs, Ordering::Relaxed);
|
||||
if permanent {
|
||||
tracing::error!(
|
||||
attempts,
|
||||
retry_secs,
|
||||
err = %err,
|
||||
"database rejected our configuration (bad DATABASE_URL or credentials?) — retrying, but operator action is likely required"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
attempts,
|
||||
retry_secs,
|
||||
err = %err,
|
||||
"database unavailable during startup; retrying"
|
||||
);
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(retry_secs)) => {}
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that indicate misconfiguration rather than a transient outage: a
|
||||
/// malformed DATABASE_URL, or a server that answered and rejected us —
|
||||
/// Postgres error class 28xxx (invalid authorization) or 3D000 (database
|
||||
/// does not exist). Best-effort: an error that anyhow can't downcast back to
|
||||
/// sqlx just counts as transient.
|
||||
fn is_likely_permanent_db_error(err: &anyhow::Error) -> bool {
|
||||
match err.downcast_ref::<sqlx::Error>() {
|
||||
Some(sqlx::Error::Configuration(_)) => true,
|
||||
Some(sqlx::Error::Database(db)) => db
|
||||
.code()
|
||||
.map(|c| c.starts_with("28") || c.starts_with("3D"))
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn database_retry_delay_secs(initial_secs: u64, max_secs: u64, attempts: u64) -> u64 {
|
||||
// The exponent bound only keeps the u32 cast safe — max_secs is the real
|
||||
// (operator-configurable) cap, and saturating math handles overflow.
|
||||
let exponent = attempts.saturating_sub(1).min(63) as u32;
|
||||
initial_secs
|
||||
.saturating_mul(2_u64.saturating_pow(exponent))
|
||||
.min(max_secs)
|
||||
}
|
||||
|
||||
async fn run_degraded_server(
|
||||
listener: TcpListener,
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
mut db_ready_rx: watch::Receiver<bool>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
let addr = listener.local_addr().ok();
|
||||
let router = build_degraded_router(node_did, db_startup);
|
||||
info!(?addr, "degraded HTTP server ready");
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
// wait_for resolves on predicate-true or sender-drop; either way
|
||||
// this phase is over.
|
||||
tokio::select! {
|
||||
_ = db_ready_rx.wait_for(|ready| *ready) => {}
|
||||
_ = shutdown_rx.wait_for(|stop| *stop) => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
|
||||
let state = DegradedState {
|
||||
node_did,
|
||||
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.
|
||||
// `/` additionally carries the node identity for probing peers.
|
||||
Router::new()
|
||||
.route("/", get(degraded_node_info))
|
||||
.fallback(degraded_unavailable)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// One source of truth for the degraded 503 body, sharing the error
|
||||
/// vocabulary with error.rs so clients see the same code/message for
|
||||
/// "database unavailable" regardless of which phase produced it.
|
||||
fn degraded_body(db_startup: &DbStartupStatus) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"status": "degraded",
|
||||
"database": "initializing",
|
||||
"error": error::DB_UNAVAILABLE_CODE,
|
||||
"message": error::DB_UNAVAILABLE_MESSAGE,
|
||||
"db_attempts": db_startup.attempts.load(Ordering::Relaxed),
|
||||
"db_next_retry_secs": db_startup.next_retry_secs.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResponse {
|
||||
let mut body = degraded_body(&state.db_startup);
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert("name".into(), "gitlawb-node".into());
|
||||
obj.insert("version".into(), env!("CARGO_PKG_VERSION").into());
|
||||
obj.insert("did".into(), state.node_did.clone().into());
|
||||
}
|
||||
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
|
||||
}
|
||||
|
||||
async fn degraded_unavailable(State(state): State<DegradedState>) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(degraded_body(&state.db_startup)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a small axum router that exposes only `GET /metrics` on its own
|
||||
/// listener. Returns the JoinHandle so `main()` can abort it on shutdown.
|
||||
/// This is deliberately separate from the main router so the metrics port
|
||||
/// can be firewalled differently from the API port — bind to localhost
|
||||
/// or a private interface only.
|
||||
async fn spawn_metrics_server(addr: &str, state: AppState) -> Result<tokio::task::JoinHandle<()>> {
|
||||
async fn spawn_metrics_server(
|
||||
addr: &str,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<tokio::task::JoinHandle<()>> {
|
||||
use axum::{response::IntoResponse, routing::get, Router};
|
||||
|
||||
async fn metrics_handler() -> impl IntoResponse {
|
||||
@@ -485,7 +951,6 @@ async fn spawn_metrics_server(addr: &str, state: AppState) -> Result<tokio::task
|
||||
}
|
||||
}
|
||||
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind metrics listener to {addr}"))?;
|
||||
@@ -705,6 +1170,140 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod legacy_cid_sweep_wiring_tests {
|
||||
use super::spawn_legacy_cid_sweep;
|
||||
use sqlx::PgPool;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Seed `count` `pinned_cids` rows whose keys are already canonical raw CIDv1, in a
|
||||
/// known `sha256_hex` order. The sweep's own cost gate skips a raw-CIDv1 row without
|
||||
/// reading bytes or resolving a repo, so each row is SCANNED (it advances the cursor)
|
||||
/// and nothing else. That is what makes the cursor a clean readout of how far the
|
||||
/// walk got, with no dependency on repos on disk.
|
||||
async fn seed_scannable_rows(pool: &PgPool, count: usize) -> Vec<String> {
|
||||
let mut shas = Vec::new();
|
||||
for i in 1..=count {
|
||||
let sha = format!("wire{i:02}");
|
||||
let cid = gitlawb_core::cid::Cid::from_git_object_bytes(sha.as_bytes()).to_string();
|
||||
assert!(
|
||||
gitlawb_core::cid::is_raw_cidv1(&cid),
|
||||
"the seeded key must hit the sweep's raw-CIDv1 skip, not a repair attempt"
|
||||
);
|
||||
sqlx::query("INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) VALUES ($1, $2, $3)")
|
||||
.bind(&sha)
|
||||
.bind(&cid)
|
||||
.bind("2020-01-01T00:00:00Z")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
shas.push(sha);
|
||||
}
|
||||
shas
|
||||
}
|
||||
|
||||
/// Poll the persisted sweep cursor until it reaches `want`, or give up.
|
||||
async fn cursor_reaches(db: &crate::db::Db, want: &str, within: Duration) -> String {
|
||||
let deadline = std::time::Instant::now() + within;
|
||||
loop {
|
||||
let c = db.pin_repair_cursor().await.unwrap();
|
||||
if c == want || std::time::Instant::now() >= deadline {
|
||||
return c;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// #173 U4, the BOOT-PATH half. The sweep's own logic (batching, cursor resumption,
|
||||
/// terminal vs retryable skips) is covered in `test_support`; what this covers is the
|
||||
/// wiring `main` performs, which nothing else executes: the task is spawned at all,
|
||||
/// it takes its batch and delay from the two `pin_repair_sweep_*` knobs rather than
|
||||
/// some other config field, the caller is not blocked on the walk, and the shutdown
|
||||
/// watcher ends the run mid-walk.
|
||||
///
|
||||
/// Six scannable rows, batch 2, delay 30s. One pass must land the cursor on exactly
|
||||
/// the second row and the task must then still be alive in its inter-batch sleep,
|
||||
/// which pins both knobs at once: a different batch stops at a different row, and a
|
||||
/// delay that did not come from the knob either finishes the table or leaves the task
|
||||
/// gone. Shutdown must then end it while four rows are still unwalked.
|
||||
#[sqlx::test]
|
||||
async fn the_boot_path_spawns_the_sweep_detached_with_its_configured_knobs(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let shas = seed_scannable_rows(&pool, 6).await;
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let mut config = (*state.config).clone();
|
||||
config.repos_dir = repos_dir.path().to_path_buf();
|
||||
config.pin_repair_sweep_batch = 2;
|
||||
// Far longer than this test runs, so a task still alive after the first pass can
|
||||
// only be one that is honoring the configured inter-batch delay.
|
||||
config.pin_repair_sweep_delay_secs = 30;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let handle = spawn_legacy_cid_sweep(&state, &config);
|
||||
let spawn_cost = started.elapsed();
|
||||
|
||||
let cursor = cursor_reaches(&state.db, &shas[1], Duration::from_secs(10)).await;
|
||||
assert_eq!(
|
||||
cursor, shas[1],
|
||||
"the spawned sweep must run and stop its first pass at the CONFIGURED batch \
|
||||
bound (2), leaving the cursor on the second row"
|
||||
);
|
||||
assert!(
|
||||
spawn_cost < Duration::from_secs(1),
|
||||
"the sweep must be detached, not awaited on the boot path; the spawn took \
|
||||
{spawn_cost:?}"
|
||||
);
|
||||
assert!(
|
||||
!handle.is_finished(),
|
||||
"with a 30s inter-batch delay the task must still be sleeping between passes, \
|
||||
not finished: a finished task means the delay was not the configured one"
|
||||
);
|
||||
|
||||
state.shutdown();
|
||||
tokio::time::timeout(Duration::from_secs(10), handle)
|
||||
.await
|
||||
.expect("the shutdown watcher must end the sweep, and not after its 30s delay")
|
||||
.expect("the sweep task must not panic");
|
||||
|
||||
assert_eq!(
|
||||
state.db.pin_repair_cursor().await.unwrap(),
|
||||
shas[1],
|
||||
"shutdown must have ended the run MID-walk, with the remaining rows unwalked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_pool_sizing_tests {
|
||||
use super::{lock_pool_size, LOCK_POOL_MAX_CONNECTIONS, LOCK_POOL_PUSH_HEADROOM};
|
||||
|
||||
/// The default push cap gets its cap plus headroom, so no push ever queues for a
|
||||
/// lock-pool connection where it did not before.
|
||||
#[test]
|
||||
fn default_push_cap_gets_headroom_over_the_cap() {
|
||||
assert_eq!(lock_pool_size(32), 32 + u32::from(LOCK_POOL_PUSH_HEADROOM));
|
||||
assert_eq!(lock_pool_size(1), 1 + u32::from(LOCK_POOL_PUSH_HEADROOM));
|
||||
}
|
||||
|
||||
/// #173 F4: `max_concurrent_git_pushes` is validated all the way to 1_048_576, so an
|
||||
/// operator raising it used to raise the node's Postgres connection ceiling with it,
|
||||
/// silently and without bound. The lock pool is CLAMPED instead.
|
||||
#[test]
|
||||
fn an_oversized_push_cap_is_clamped_not_propagated() {
|
||||
assert_eq!(lock_pool_size(1_048_576), LOCK_POOL_MAX_CONNECTIONS);
|
||||
assert_eq!(lock_pool_size(usize::MAX), LOCK_POOL_MAX_CONNECTIONS);
|
||||
// The largest cap that still fits under the clamp keeps its full headroom.
|
||||
let widest = (LOCK_POOL_MAX_CONNECTIONS - u32::from(LOCK_POOL_PUSH_HEADROOM)) as usize;
|
||||
assert_eq!(lock_pool_size(widest), LOCK_POOL_MAX_CONNECTIONS);
|
||||
assert_eq!(
|
||||
lock_pool_size(widest - 1),
|
||||
LOCK_POOL_MAX_CONNECTIONS - 1,
|
||||
"values below the clamp must not be rounded up to it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::ping_peer_health;
|
||||
|
||||
@@ -74,8 +74,10 @@ pub async fn pin_object(
|
||||
/// `..._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.
|
||||
/// `pinata_cid` are skipped. `repo_id` records the pin's provenance (#173).
|
||||
/// Returns `(sha_hex, provider_cid)` pairs for each newly pinned object: the
|
||||
/// provider CID is the Pinata gateway CID (used for branch→CID recording and
|
||||
/// ref-update gossip), NOT the raw resolver-key CID stored in `pinned_cids.cid`.
|
||||
pub async fn pin_new_objects(
|
||||
client: &reqwest::Client,
|
||||
upload_url: &str,
|
||||
@@ -83,6 +85,7 @@ pub async fn pin_new_objects(
|
||||
repo_path: &std::path::Path,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
if jwt.is_empty() {
|
||||
return vec![];
|
||||
@@ -92,7 +95,39 @@ pub async fn pin_new_objects(
|
||||
|
||||
for sha in object_list {
|
||||
match db.has_pinata_cid(&sha).await {
|
||||
Ok(true) => continue,
|
||||
Ok(true) => {
|
||||
// Backfill NULL first-pinner provenance from a known source, in lockstep
|
||||
// with the ipfs_pin skip branch: a pinata-only node otherwise leaves
|
||||
// pre-provenance rows' `pinned_cids.repo_id` NULL forever (grok P2-D). The
|
||||
// resolver still finds the object via the pin_repo_sources union below, so
|
||||
// this is a consistency backfill, not a correctness fix.
|
||||
match db.provenance_for_oid(&sha).await {
|
||||
Ok(None) => {
|
||||
if let Err(e) = db.backfill_pin_provenance(&sha, repo_id).await {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to backfill pin provenance");
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error reading pin provenance");
|
||||
}
|
||||
}
|
||||
// F1 (#173 round 8): record this repo as an additional source for the
|
||||
// already-pinned object (mirrors the ipfs_pin skip-branch insert) so the
|
||||
// resolver can serve a shared object from any pin-path source. U3 (#173):
|
||||
// retried through the SHARED helper (this was a bare call, so a single
|
||||
// transient error dropped the source outright) and, on exhaustion, marked
|
||||
// durably so the resolver keeps the bounded scan fallback for the object.
|
||||
if let Err(e) =
|
||||
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)).await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
if let Err(e) = db.mark_pin_sources_incomplete(&sha).await {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking pinata_cid");
|
||||
@@ -111,9 +146,32 @@ pub async fn pin_new_objects(
|
||||
|
||||
match pin_object(client, upload_url, jwt, &sha, &data).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
if let Err(e) = db.record_pinata_cid(&sha, &cid).await {
|
||||
// The resolver key (`pinned_cids.cid`) must be the locally-computed
|
||||
// raw-content CID, never the provider CID: Pinata wraps the bytes in
|
||||
// dag-pb/UnixFS, so its returned CID does not hash the raw content and
|
||||
// must not become an alias `/ipfs/{cid}` serves raw git bytes for (#173).
|
||||
let raw_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&data).to_string();
|
||||
// U3 (#173): both records go through the shared retry helper, at parity
|
||||
// with the ipfs_pin twin. These were bare calls, so one transient DB error
|
||||
// permanently dropped a pin source.
|
||||
if let Err(e) = crate::ipfs_pin::retry_db_record(|| {
|
||||
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id))
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB");
|
||||
}
|
||||
// F1 (#173 round 8): also record the first pinner in pin_repo_sources.
|
||||
// U3: an exhausted retry marks the set incomplete so the resolver keeps
|
||||
// the scan fallback rather than 404ing a copy it could serve.
|
||||
if let Err(e) =
|
||||
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)).await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
if let Err(e) = db.mark_pin_sources_incomplete(&sha).await {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
}
|
||||
pinned.push((sha, cid));
|
||||
}
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -121,6 +121,28 @@ impl RateLimiter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Non-consuming check: is this key ALREADY at its limit for the current window?
|
||||
/// Unlike [`check`], it records nothing and never inserts a new key — used to shed
|
||||
/// expensive preparatory work (e.g. the `/ipfs/{cid}` legacy scan's O(repos) DB
|
||||
/// preload) BEFORE it runs, without perturbing the per-unit budget the consuming
|
||||
/// `check` maintains (#173, F3). An unknown key or a disabled limiter is not
|
||||
/// throttled. Prunes the key's expired timestamps as a side effect (keeps state
|
||||
/// tidy) but adds none, so it cannot itself fill or grow the map.
|
||||
pub(crate) async fn is_throttled(&self, key: &str) -> bool {
|
||||
if self.max_requests == 0 {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
if let Some(window) = state.get_mut(key) {
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
return window.timestamps.len() >= self.max_requests;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -130,6 +152,103 @@ impl RateLimiter {
|
||||
!w.timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of distinct keys currently tracked. Test-only introspection so a
|
||||
/// cross-module test can assert that a sweep actually evicted expired entries.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -250,12 +369,14 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared 429 response for the push flood brake.
|
||||
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
|
||||
/// middleware now serves the push path AND the peer-sync routes, so the message
|
||||
/// stays generic (the offending path is recorded in the warn log below).
|
||||
pub fn too_many_requests() -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"push rate limit exceeded — try again later",
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -273,7 +394,7 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
if let Some(limiter) = limiter {
|
||||
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
|
||||
if !limiter.limiter.check(&key).await {
|
||||
tracing::warn!(key = %key, path = %request.uri().path(), "push rate limit exceeded");
|
||||
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
|
||||
return too_many_requests();
|
||||
}
|
||||
}
|
||||
@@ -286,6 +407,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));
|
||||
@@ -541,4 +719,66 @@ mod tests {
|
||||
assert_eq!(post_from(&router, b).await, StatusCode::OK); // independent bucket
|
||||
assert_eq!(post_from(&router, a).await, StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
// ── creation-route layering: IP brake runs BEFORE auth ──────────────
|
||||
|
||||
/// A minimal stand-in for the creation route group: an inner "auth" layer
|
||||
/// that rejects every request with 401 (as signature verification would for
|
||||
/// an unsigned caller), wrapped by the per-IP creation limiter as the
|
||||
/// outermost layer — exactly the ordering `server::build_router` applies to
|
||||
/// `/api/v1/repos` et al. Lets us assert the security-relevant property
|
||||
/// (flood traffic is braked before auth burns CPU) without a database, which
|
||||
/// the full `#[sqlx::test]` route test in `api::repos` cannot cover in the
|
||||
/// plain unit-test pass.
|
||||
fn ip_limited_over_auth_router(limiter: IpRateLimiter) -> axum::Router {
|
||||
async fn always_unauthorized(_req: Request, _next: Next) -> Response {
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
axum::Router::new()
|
||||
.route(
|
||||
"/api/v1/repos",
|
||||
axum::routing::post(|| async { StatusCode::CREATED }),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(always_unauthorized))
|
||||
.layer(axum::middleware::from_fn(rate_limit_by_ip))
|
||||
.layer(axum::Extension(limiter))
|
||||
}
|
||||
|
||||
async fn post_repos_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
|
||||
use tower::ServiceExt;
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method(axum::http::Method::POST)
|
||||
.uri("/api/v1/repos")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
router.clone().oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creation_ip_brake_rejects_before_auth() {
|
||||
// A DID farm mints a fresh signature per repo, so the per-DID limiter
|
||||
// never trips; the per-IP brake is what stops the flood. It must run
|
||||
// outermost, ahead of auth, so a flood is rejected with 429 before
|
||||
// signature verification — otherwise every request would reach (and pay
|
||||
// for) auth and merely 401.
|
||||
let router = ip_limited_over_auth_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let peer: SocketAddr = "203.0.113.42:7000".parse().unwrap();
|
||||
|
||||
// First request passes the IP brake and reaches the (failing) auth layer.
|
||||
assert_eq!(
|
||||
post_repos_from(&router, peer).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Budget now exhausted: the IP brake short-circuits with 429 *before*
|
||||
// auth — proving the limiter is the outermost layer.
|
||||
assert_eq!(
|
||||
post_repos_from(&router, peer).await,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,19 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/v1/tasks", get(tasks::list_tasks))
|
||||
.route("/api/v1/tasks/{id}", get(tasks::get_task));
|
||||
|
||||
// ── Rate-limited creation routes — require HTTP Signature + per-DID throttle
|
||||
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
|
||||
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
|
||||
// single identity; the per-IP limiter (outer) caps a DID farm that mints a
|
||||
// fresh throwaway did:key per repo to slip past both the per-DID limit and
|
||||
// the iCaptcha gate — the mechanism behind the recurring spam-repo floods.
|
||||
// The per-IP layer wraps the auth layer (outermost = runs first) so flood
|
||||
// traffic is rejected before signature verification burns CPU, matching the
|
||||
// push path.
|
||||
let limiter = state.rate_limiter.clone();
|
||||
let create_ip_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.create_ip_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let creation_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route("/api/v1/repos", post(repos::create_repo))
|
||||
@@ -96,7 +107,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
|
||||
.layer(axum::Extension(limiter)),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(create_ip_limiter));
|
||||
|
||||
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
|
||||
let write_routes = add_auth_layers(
|
||||
@@ -201,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 ──────────────────────────────────────────
|
||||
@@ -248,7 +272,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route(
|
||||
"/api/v1/agents/{did}/bounties",
|
||||
get(bounties::agent_bounty_stats),
|
||||
);
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// ── Profile routes (write — require HTTP Signature) ─────────────────
|
||||
let profile_write_routes = add_auth_layers(
|
||||
@@ -277,15 +302,41 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/v1/peers", get(peers::list_peers))
|
||||
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer));
|
||||
|
||||
// /sync/trigger drives an O(peers) outbound fan-out + per-repo enqueue, so it
|
||||
// ALWAYS requires a signature (both config modes) and carries a tight per-IP
|
||||
// brake. A signature alone does not cap cost — a did:key farm self-registers
|
||||
// (INV-10) — so the IP brake is a separate, load-bearing half. The brake is
|
||||
// outermost (runs before signature verification burns CPU) and is keyed on the
|
||||
// client IP before any DID is read, so DID rotation cannot bypass it.
|
||||
let sync_trigger_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
|
||||
state.clone(),
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.sync_trigger_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
}));
|
||||
|
||||
// announce + notify keep their rolling-upgrade signature behavior (unsigned
|
||||
// accepted until all peers upgrade), but both reach peer-write side effects —
|
||||
// notify hits the same enqueue_sync sink as trigger — so they carry a per-IP
|
||||
// brake too, on a SEPARATE bucket from trigger's, so an unsigned notify flood
|
||||
// cannot drain the signed trigger caller's quota.
|
||||
let mut peer_write_routes = Router::new()
|
||||
.route("/api/v1/peers/announce", post(peers::announce))
|
||||
.route("/api/v1/sync/trigger", post(peers::trigger_sync))
|
||||
.route("/api/v1/sync/notify", post(peers::notify_sync));
|
||||
peer_write_routes = if state.config.require_signed_peer_writes {
|
||||
add_auth_layers(peer_write_routes, state.clone())
|
||||
} else {
|
||||
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
|
||||
};
|
||||
let peer_write_routes = peer_write_routes
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.peer_write_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
}));
|
||||
|
||||
// ── Read routes — open for public repos ───────────────────────────────
|
||||
let read_routes = Router::new()
|
||||
@@ -415,6 +466,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let meta_routes = Router::new()
|
||||
.route("/", get(node_info))
|
||||
.route("/health", get(health))
|
||||
.route("/ready", get(ready))
|
||||
.route("/api/v1/p2p/info", get(p2p_info))
|
||||
.route("/api/v1/stats", get(stats))
|
||||
.route("/api/v1/contracts", get(contracts_info));
|
||||
@@ -434,6 +486,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(read_routes)
|
||||
.merge(peer_read_routes)
|
||||
.merge(peer_write_routes)
|
||||
.merge(sync_trigger_routes)
|
||||
.merge(ipfs_routes)
|
||||
.merge(arweave_routes)
|
||||
.merge(meta_routes)
|
||||
@@ -456,6 +509,26 @@ async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
/// Readiness = "this node can serve real traffic", and every real endpoint
|
||||
/// depends on the database, so probe it rather than reporting a constant.
|
||||
/// The probe gets its own short bound so a wedged pool can't hang the health
|
||||
/// check for the full acquire timeout.
|
||||
async fn ready(State(state): State<AppState>) -> axum::response::Response {
|
||||
let probe = tokio::time::timeout(std::time::Duration::from_secs(2), state.db.ping()).await;
|
||||
match probe {
|
||||
Ok(Ok(())) => Json(json!({ "status": "ready" })).into_response(),
|
||||
_ => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"status": "degraded",
|
||||
"error": crate::error::DB_UNAVAILABLE_CODE,
|
||||
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let p2p_peer_id = state.p2p.as_ref().map(|h| h.local_peer_id.to_string());
|
||||
Json(json!({
|
||||
|
||||
@@ -51,13 +51,74 @@ pub struct AppState {
|
||||
pub repo_store: RepoStore,
|
||||
/// Per-DID rate limiter for creation endpoints (repos, issues, PRs)
|
||||
pub rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for the same creation endpoints. The per-DID
|
||||
/// limiter above cannot brake a creation flood from a DID farm — one
|
||||
/// throwaway `did:key` per repo means each DID makes a single create call
|
||||
/// and never trips its own bucket. A valid iCaptcha proof does not cap this
|
||||
/// either: the enforced level draws only machine-solvable deterministic
|
||||
/// challenges (and the caller can pin the easy type), so a bot mints a fresh
|
||||
/// DID, solves a proof, and creates a repo unthrottled. Braking on the
|
||||
/// resolved client IP is what actually stops a single-source flood (same
|
||||
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
|
||||
pub create_ip_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
pub push_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP ROUTE brake for `GET /ipfs/{cid}`: charged ONCE per request by the
|
||||
/// `rate_limit_by_ip` middleware (server.rs), never inside the handler. It bounds
|
||||
/// request RATE (the "requests per hour" contract of `GITLAWB_IPFS_RATE_LIMIT`) on
|
||||
/// the non-farmable source IP, so an anonymous flood of the public route is capped.
|
||||
/// The per-probe/per-walk WORK accounting the resolver does WITHIN a request draws
|
||||
/// from the SEPARATE `ipfs_work_rate_limiter` below — the two cannot share one bucket
|
||||
/// or a single request that spends a route token and then its own probe token off the
|
||||
/// same bucket is admitted at the route and falsely shed mid-request (#173 round-10,
|
||||
/// R6). Keyed by `push_limiter_trust`.
|
||||
pub ipfs_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP WORK-budget limiter for the `GET /ipfs/{cid}` resolver's internal
|
||||
/// fan-out: charged per legacy (NULL-provenance) PROBE (`acquire` + `cat-file`) and
|
||||
/// per provenance-path WALK, and peeked non-consuming before the O(repos) legacy
|
||||
/// preload. A legacy CID from the public pins index otherwise lets one request drive
|
||||
/// O(repos) subprocess spawns and cold Tigris fetches, and repeat requests amplify
|
||||
/// that across requests with zero limiter contact (INV-10, F3). Charging the work to
|
||||
/// the non-farmable source IP bounds it. A bucket DISTINCT from the route brake above:
|
||||
/// one request legitimately spends many work tokens (a full legacy scan is up to
|
||||
/// `ipfs_max_legacy_probes` probes), so it must not double as the once-per-request
|
||||
/// route bucket. Capacity is DERIVED from the route limit (`AppState::ipfs_work_budget`,
|
||||
/// no separate operator knob), floored at the legacy-probe budget so a single default-
|
||||
/// config deep search never self-throttles mid-scan. Keyed by `push_limiter_trust`.
|
||||
pub ipfs_work_rate_limiter: RateLimiter,
|
||||
/// Per-request ceiling on full-history reachability walks the CID resolver
|
||||
/// may spawn (default `api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST`). A field,
|
||||
/// not a bare const, so tests can shrink it to exercise the cap cheaply;
|
||||
/// production keeps the const default.
|
||||
pub ipfs_max_history_walks: u32,
|
||||
/// Per-request ceiling on legacy (NULL-provenance) repo probes in the CID
|
||||
/// resolver's scan fallback (default `api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST`).
|
||||
/// Bounds the anonymous `acquire` + `cat-file` fan-out across the node (#173,
|
||||
/// INV-10); a field for the same test-seam reason as `ipfs_max_history_walks`.
|
||||
pub ipfs_max_legacy_probes: u32,
|
||||
/// Hard ceiling on the byte size of an object `GET /ipfs/{cid}` will buffer and
|
||||
/// serve (default `api::ipfs::MAX_SERVED_OBJECT_BYTES`). The serve reads via a
|
||||
/// blocking `git cat-file` and buffers the whole object; without a bound a large
|
||||
/// public blob could exhaust memory or block a runtime worker (#173, F6, INV-10).
|
||||
/// A field for the same test-seam reason as the sibling caps.
|
||||
pub ipfs_max_served_object_bytes: u64,
|
||||
/// Which forwarded header (if any) the edge is trusted to set, for
|
||||
/// resolving the push limiter's client-IP key. See `GITLAWB_TRUSTED_PROXY`.
|
||||
/// Node-wide; also keys the two peer-sync limiters below.
|
||||
pub push_limiter_trust: crate::rate_limit::TrustedProxy,
|
||||
/// Per-client-IP limiter for `POST /api/v1/sync/trigger` (tight). The route
|
||||
/// requires a signature, but a signature does not cap cost (a did:key farm
|
||||
/// self-registers), and its per-call cost is an O(peers) fan-out, so the IP
|
||||
/// brake is a separate, load-bearing half. Its own bucket so an unsigned
|
||||
/// `/sync/notify` flood cannot drain the signed trigger caller's quota.
|
||||
pub sync_trigger_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP limiter for the peer-write routes (`/peers/announce`,
|
||||
/// `/sync/notify`) (generous). `/sync/notify` reaches the same `enqueue_sync`
|
||||
/// sink as trigger and accepts unsigned requests from known peers, so it is
|
||||
/// braked too; each peer's distinct IP gets its own bucket.
|
||||
pub peer_write_rate_limiter: RateLimiter,
|
||||
/// Process-wide graceful-shutdown signal. Sending `true` causes every
|
||||
/// task that holds a `watch::Receiver` to exit at its next await point.
|
||||
/// Used by:
|
||||
@@ -66,6 +127,94 @@ pub struct AppState {
|
||||
/// * the libp2p swarm task
|
||||
/// * the gossip, sync, operator heartbeat, and rate-limit cleanup loops
|
||||
pub shutdown_tx: tokio::sync::watch::Sender<bool>,
|
||||
/// Bounds concurrent served git READ operations (upload-pack + both info/refs
|
||||
/// advertisements). A read handler acquires a permit before spawning git and
|
||||
/// holds it for the op; when none are free the request is shed with a 503.
|
||||
/// Writes draw from `git_write_semaphore` so a read flood cannot shed an
|
||||
/// authenticated push at admission (#174).
|
||||
pub git_read_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Bounds concurrent `git-receive-pack` (push) operations, a pool separate
|
||||
/// from `git_read_semaphore` so an anonymous READ flood can never shed an
|
||||
/// authenticated push (#174). Sized by `max_concurrent_git_pushes`. Drawn from
|
||||
/// by the `git-receive-pack` POST (owner-gated) ONLY. The anon-reachable
|
||||
/// receive-pack `info/refs` advertisement draws from the SEPARATE
|
||||
/// `git_push_advert_semaphore` below, never this pool, so a multi-source flood
|
||||
/// of push-handshake advertisements can never occupy a permit an authenticated
|
||||
/// POST needs at admission (#174).
|
||||
pub git_write_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Bounds concurrent anon-reachable `git-receive-pack` `info/refs`
|
||||
/// advertisements — a pool SEPARATE from `git_write_semaphore` so adverts (which
|
||||
/// hold a permit across `acquire_fresh` + `info/refs`) can never consume a slot
|
||||
/// the authenticated POST relies on. A per-source flood can at worst exhaust this
|
||||
/// advert pool (each source also capped by `git_push_advert_per_caller` and the
|
||||
/// per-IP push rate limiter), and the reserved POST pool is untouched (#174).
|
||||
pub git_push_advert_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Bounds concurrent post-push encrypt-then-pin history walks. Each successful
|
||||
/// path-scoped push releases its handler write permit and then runs a DETACHED
|
||||
/// full-history walk (`withheld_blob_recipients_bounded`) to seal withheld blobs;
|
||||
/// without a cap, N fast pushes spawn N concurrent full-history git walks past
|
||||
/// `max_concurrent_git_pushes` (which only bounds the in-handler phase). The walk
|
||||
/// acquires a permit here and DEFERS (blocks) when the pool is full rather than
|
||||
/// shedding — the work is background and dropping it would lose the recovery copy
|
||||
/// (#174 P1-e). A pool of its own, not `git_write_semaphore`: a long background
|
||||
/// walk must not hold a foreground write slot, and a handler already holding a
|
||||
/// write permit that needed a second would self-deadlock at pool size 1.
|
||||
pub git_encrypt_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Bounds the OUTSTANDING post-push encryption-task set by per-repo coalescing
|
||||
/// (#174 P2-2). `git_encrypt_semaphore` caps *active* walks; this caps how many
|
||||
/// detached tasks *spawn and park* on that semaphore's `acquire_owned().await`.
|
||||
/// Before spawning a per-push encryption task, the receive-pack handler consults
|
||||
/// this set: if the repo already has a task in flight it coalesces (skips the
|
||||
/// duplicate spawn) rather than parking a new unbounded waiter. Coalescing only
|
||||
/// delays a duplicate walk — it never drops the withheld-blob recovery copy, which
|
||||
/// `2a54c15` deliberately kept fail-closed (there is no reconciliation sweep to
|
||||
/// re-derive a dropped copy). See [`EncryptInflight`].
|
||||
pub encrypt_inflight: EncryptInflight,
|
||||
/// Per-caller concurrency sub-cap on the read pool: each caller (keyed on the
|
||||
/// resolved source IP, #174 U1) may hold at most `max_concurrent_reads_per_caller`
|
||||
/// in-flight read ops, so one caller cannot monopolize `git_read_semaphore`
|
||||
/// (#174). Applied by `git_upload_pack` and the upload-pack `info/refs`
|
||||
/// advertisement.
|
||||
pub git_read_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// Per-source concurrency sub-cap on the anon-reachable receive-pack `info/refs`
|
||||
/// advertisement: each source IP may hold at most a small share of the write
|
||||
/// pool, so a multi-source flood of push-handshake advertisements cannot
|
||||
/// saturate `git_write_semaphore` and shed authenticated pushes (#174). Sized as
|
||||
/// a fraction of `max_concurrent_git_pushes`, so filling the write pool takes many
|
||||
/// distinct source IPs (each also braked by the per-IP push rate limiter).
|
||||
pub git_push_advert_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// Per-source concurrency sub-cap on the authenticated `git-receive-pack` POST:
|
||||
/// each source IP may hold at most a small share of `git_write_semaphore`, so one
|
||||
/// host minting disposable `did:key` identities cannot open enough slow pushes to
|
||||
/// monopolize the write pool and 503 every other source's push (#174 P1-d). Keyed
|
||||
/// on the resolved source IP (never the DID — a DID farm defeats a DID key). Sized
|
||||
/// like `git_push_advert_per_caller`, a fraction of `max_concurrent_git_pushes`.
|
||||
pub git_write_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// Bounds concurrent `GET /ipfs/{cid}` visibility-walk requests. The public
|
||||
/// `/ipfs/{cid}` route runs `allowed_blob_set_for_caller_bounded` in
|
||||
/// `spawn_blocking` (a full-history git walk) with NO served-git admission of its
|
||||
/// own; without this a permissionless caller fans out concurrent walks past every
|
||||
/// git pool, exhausting the blocking pool + PIDs (#174 P1-3). A request acquires a
|
||||
/// permit before the repo loop and holds it for the whole request (across every
|
||||
/// `spawn_blocking` walk), so the slot reflects real thread occupancy — a tokio
|
||||
/// walk-timeout cannot free it while the blocking work still runs. A pool of its
|
||||
/// own (`max_concurrent_ipfs_walks`), NOT a git pool: distinct cost center + public
|
||||
/// surface, so anonymous /ipfs traffic can never shed an authenticated git op.
|
||||
pub git_ipfs_walk_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Per-source concurrency sub-cap on the `/ipfs/{cid}` walk pool: each source
|
||||
/// (keyed on the resolved source IP, never the DID — `/ipfs` admits any `did:key`
|
||||
/// unthrottled, so a DID key would be free to mint around) may hold at most
|
||||
/// `ipfs_walk_per_source` in-flight walk slots, so one source cannot monopolize
|
||||
/// `git_ipfs_walk_semaphore` (#174 P1-3). A request with no resolvable key is
|
||||
/// bounded by the global pool only, never this sub-cap. The key map is bounded
|
||||
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
|
||||
/// it (INV-15).
|
||||
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// The `git` executable the served-git withheld-blob walk spawns. Production is
|
||||
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
|
||||
/// process-group teardown in handler tests without mutating the process-global
|
||||
/// PATH (#174).
|
||||
pub git_bin: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -75,6 +224,21 @@ impl AppState {
|
||||
self.shutdown_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Sweep expired entries from every per-IP/DID rate limiter. Driven by the
|
||||
/// periodic cleanup task so a bounded limiter's key map sheds stale entries
|
||||
/// instead of sitting near its cap until an inline capacity sweep reclaims
|
||||
/// them. Every limiter on the state is swept here; adding a new limiter means
|
||||
/// adding it to this list.
|
||||
pub(crate) async fn sweep_rate_limiters(&self) {
|
||||
self.rate_limiter.cleanup().await;
|
||||
self.create_ip_rate_limiter.cleanup().await;
|
||||
self.push_rate_limiter.cleanup().await;
|
||||
self.ipfs_rate_limiter.cleanup().await;
|
||||
self.ipfs_work_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
self.peer_write_rate_limiter.cleanup().await;
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown. Idempotent — calling more than once
|
||||
/// has no effect. Returns `true` if this call was the one that
|
||||
/// flipped the signal.
|
||||
@@ -95,4 +259,173 @@ impl AppState {
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
*self.shutdown_tx.borrow()
|
||||
}
|
||||
|
||||
/// Legacy-probe budget wired from the `GITLAWB_IPFS_MAX_REPOS_WALKED` operator
|
||||
/// knob (R5, KTD5). The knob seeds `ipfs_max_legacy_probes` at construction so it
|
||||
/// controls the per-request legacy (NULL-provenance) probe fan-out it advertises.
|
||||
/// It deliberately does NOT feed `ipfs_max_history_walks`: that ceiling must stay
|
||||
/// at `MAX_HISTORY_WALKS_PER_REQUEST` (`MAX_PIN_SOURCES + 1`) or a provenanced
|
||||
/// request with a full source set is truncated into a false 503, and the knob's
|
||||
/// range starts at 1. The knob is `usize`, the field `u32`; the range cap
|
||||
/// (1_048_576) keeps the cast lossless.
|
||||
pub(crate) fn ipfs_legacy_probe_budget(config: &crate::config::Config) -> u32 {
|
||||
config.ipfs_max_repos_walked as u32
|
||||
}
|
||||
|
||||
/// Work-budget capacity for [`ipfs_work_rate_limiter`](Self#structfield.ipfs_work_rate_limiter)
|
||||
/// (R6, KTD6), DERIVED from the route limit rather than a new operator knob. The route
|
||||
/// limiter (`ipfs_rate_limiter`) charges once per request; this separate bucket absorbs
|
||||
/// the resolver's per-probe/per-walk work charges so both the route "requests per hour"
|
||||
/// contract and the amplification bound hold. Floor: at least one full legacy search per
|
||||
/// window — the effective `ipfs_max_legacy_probes` (the `GITLAWB_IPFS_MAX_REPOS_WALKED`
|
||||
/// knob, U4) — so a single default-config deep search cannot self-throttle mid-scan and
|
||||
/// recreate the F6 admit-then-429 for a legitimate caller. `GITLAWB_IPFS_RATE_LIMIT=0`
|
||||
/// disables the route brake and this derived bucket alike (a 0-capacity limiter admits
|
||||
/// everything).
|
||||
pub(crate) fn ipfs_work_budget(config: &crate::config::Config) -> usize {
|
||||
if config.ipfs_rate_limit == 0 {
|
||||
return 0;
|
||||
}
|
||||
config.ipfs_rate_limit.max(config.ipfs_max_repos_walked)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds the OUTSTANDING post-push encryption-task set by per-repo coalescing
|
||||
/// (#174 P2-2). Each successful path-scoped push `tokio::spawn`s a DETACHED task that
|
||||
/// parks on `git_encrypt_semaphore.acquire_owned().await` (which DEFERS when the pool
|
||||
/// is full rather than shedding — `2a54c15` kept it fail-closed so the withheld-blob
|
||||
/// recovery copy is never dropped). The semaphore caps *active* walks, but nothing
|
||||
/// capped how many detached tasks *spawn and park* on that await: N rapid pushes to a
|
||||
/// repo spawn N parked tasks, each holding cloned object lists/rules/paths/keys — an
|
||||
/// unbounded outstanding set.
|
||||
///
|
||||
/// This tracks the repo keys with an in-flight encryption task, each carrying a
|
||||
/// DIRTY flag. Before spawning, the handler calls [`try_begin`](Self::try_begin): if
|
||||
/// a task for the repo is already in-flight it MARKS THE REPO DIRTY and returns `None`
|
||||
/// so the handler SKIPS spawning a duplicate (coalesce). The in-flight task consults
|
||||
/// the flag at its tail via [`requeue_or_release`](EncryptInflightGuard::requeue_or_release):
|
||||
/// a set flag makes it run ONE MORE pass (re-reading repo state) before it exits, so a
|
||||
/// push coalesced during the in-flight window is REQUEUED, never dropped. This bounds
|
||||
/// the outstanding set to <=1 task per repo without losing work: there is no
|
||||
/// reconciliation sweep, so a *dropped* job would be lost forever.
|
||||
///
|
||||
/// The returned [`EncryptInflightGuard`] is moved into the detached task. The normal
|
||||
/// exit path is `requeue_or_release`, which removes the repo key ATOMICALLY with the
|
||||
/// "clean" decision — no push can land in the gap between "checked clean" and "task
|
||||
/// exits". `Drop` is a panic backstop only: if the task panics (or returns without
|
||||
/// calling `requeue_or_release`) it still removes the key so a crashed walk can never
|
||||
/// permanently lock a repo out of future recovery copies.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct EncryptInflight {
|
||||
// std::sync::Mutex: only ever held for O(1) HashMap ops in a sync context (before
|
||||
// `tokio::spawn`, at the task tail, and in Drop) — never across an await, so a std
|
||||
// Mutex is correct and cheaper than a tokio one. The value is the DIRTY flag: a
|
||||
// coalesced push flips it true so the in-flight task requeues one more pass.
|
||||
repos: Arc<std::sync::Mutex<std::collections::HashMap<String, bool>>>,
|
||||
}
|
||||
|
||||
impl EncryptInflight {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Try to begin an encryption task for `repo_id`. Returns `Some(guard)` if no task
|
||||
/// for the repo was in-flight (the caller should spawn). If one already is, MARKS
|
||||
/// the repo dirty and returns `None` (the caller COALESCES — skips the duplicate
|
||||
/// spawn; the in-flight task requeues one more pass to cover this push).
|
||||
pub fn try_begin(&self, repo_id: &str) -> Option<EncryptInflightGuard> {
|
||||
let mut map = self.repos.lock().expect("encrypt_inflight mutex poisoned");
|
||||
match map.get_mut(repo_id) {
|
||||
Some(dirty) => {
|
||||
*dirty = true;
|
||||
None
|
||||
}
|
||||
None => {
|
||||
map.insert(repo_id.to_string(), false);
|
||||
Some(EncryptInflightGuard {
|
||||
repos: Arc::clone(&self.repos),
|
||||
repo_id: repo_id.to_string(),
|
||||
released: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of repos with an in-flight encryption task. Test/metrics observability;
|
||||
/// the bound under saturation is `len() <= number of distinct repos`, i.e. at most
|
||||
/// one task per repo.
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.repos
|
||||
.lock()
|
||||
.expect("encrypt_inflight mutex poisoned")
|
||||
.len()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Test-only: read the dirty flag for `repo_id` (`None` if no task is in-flight).
|
||||
#[cfg(test)]
|
||||
pub fn dirty(&self, repo_id: &str) -> Option<bool> {
|
||||
self.repos
|
||||
.lock()
|
||||
.expect("encrypt_inflight mutex poisoned")
|
||||
.get(repo_id)
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard for one in-flight encryption task's repo key. The task drives it at its
|
||||
/// tail with [`requeue_or_release`](Self::requeue_or_release); `Drop` is a panic
|
||||
/// backstop. Move-only — cloning would double-release.
|
||||
pub struct EncryptInflightGuard {
|
||||
repos: Arc<std::sync::Mutex<std::collections::HashMap<String, bool>>>,
|
||||
repo_id: String,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
impl EncryptInflightGuard {
|
||||
/// TASK-TAIL check-and-clear, atomic with the release decision. If a push
|
||||
/// coalesced since the last pass (dirty), clear the flag and return `true` (the
|
||||
/// task loops one more pass). Otherwise remove the key and return `false` (the task
|
||||
/// exits). Atomic under the mutex: a concurrent push either sets the flag BEFORE
|
||||
/// this reads it (-> requeue covers it) or arrives AFTER the key is removed (-> a
|
||||
/// fresh `try_begin` spawns a new task) — there is no window where the key is
|
||||
/// present-but-clean while the task exits.
|
||||
pub fn requeue_or_release(&mut self) -> bool {
|
||||
let mut map = self.repos.lock().expect("encrypt_inflight mutex poisoned");
|
||||
match map.get_mut(&self.repo_id) {
|
||||
Some(dirty) if *dirty => {
|
||||
*dirty = false;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
map.remove(&self.repo_id);
|
||||
self.released = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EncryptInflightGuard {
|
||||
fn drop(&mut self) {
|
||||
// Panic backstop ONLY. The normal exit is `requeue_or_release` (which removed
|
||||
// the key atomically and set `released`), so this does nothing then. If the
|
||||
// task panicked or returned without releasing, remove the key so one crashed
|
||||
// walk never permanently locks the repo out.
|
||||
//
|
||||
// Accepted residual: a panic with the dirty flag still set drops the requeued
|
||||
// work — Drop cannot loop — the same loss class as the on-panic behavior before
|
||||
// this change. No reconciliation sweep re-derives it.
|
||||
if self.released {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut map) = self.repos.lock() {
|
||||
map.remove(&self.repo_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,15 +25,12 @@ fn nfc(s: &str) -> String {
|
||||
s.nfc().collect()
|
||||
}
|
||||
|
||||
/// True if `caller` is the repo owner. Matches exactly or against the
|
||||
/// did:key short form — a non-key DID (did:gitlawb:/did:web:) in `owner_did`
|
||||
/// never matches a bare short segment, and a non-key DID in `caller` never
|
||||
/// matches a did:key owner's short form. Uses `normalize_owner_key` (not
|
||||
/// `did_matches`) so the did:key-only rule is stricter: cross-method matching
|
||||
/// would let a non-key canonical row bypass the #124 visibility gate.
|
||||
/// True if `caller` is the repo owner. Uses [`crate::api::did_matches`] so the
|
||||
/// owner check is representation-agnostic within `did:key` (full `did:key:z...`
|
||||
/// matches bare `z...` stored by mirror rows) while still denying cross-method
|
||||
/// collisions (`did:gitlawb:z...` vs bare `z...`).
|
||||
fn is_owner(owner_did: &str, caller: &str) -> bool {
|
||||
let owner_short = crate::db::normalize_owner_key(owner_did);
|
||||
caller == owner_did || caller == owner_short
|
||||
crate::api::did_matches(owner_did, caller)
|
||||
}
|
||||
|
||||
/// The match prefix for a glob: "/" stays "/", "/secret/**" becomes "/secret".
|
||||
@@ -428,6 +425,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// #153 forward guard: is_owner must match across bare-key / full-did:key
|
||||
// representations should someone revert to single-side normalize_owner_key.
|
||||
#[test]
|
||||
fn bare_owner_full_caller_allows_owner() {
|
||||
let rules = [rule("/", VisibilityMode::A, &[])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, false, "z6MkOwner", Some("did:key:z6MkOwner"), "/"),
|
||||
Decision::Allow,
|
||||
"bare owner + full did:key caller must match (guard against single-side revert)"
|
||||
);
|
||||
}
|
||||
|
||||
// #135 T1: a Mode-B rule on `/secret/**` must DENY the withheld directory's
|
||||
// OWN path `/secret` (the `path == prefix` arm), not just strict descendants —
|
||||
// otherwise get_by_cid's tree gate would serve the /secret tree object and leak
|
||||
// its children. Pins parity with get_tree, which denies the /secret path.
|
||||
#[test]
|
||||
fn subtree_rule_denies_the_withheld_directory_itself() {
|
||||
let reader = "did:key:z6MkReader";
|
||||
let rules = [rule("/secret/**", VisibilityMode::B, &[reader])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, "/secret"),
|
||||
Decision::Deny,
|
||||
"anon denied at the withheld directory's OWN path /secret"
|
||||
);
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, "/public"),
|
||||
Decision::Allow,
|
||||
"anon allowed at a sibling path outside the withheld subtree"
|
||||
);
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, Some(reader), "/secret"),
|
||||
Decision::Allow,
|
||||
"listed reader allowed at the withheld directory (caller-aware)"
|
||||
);
|
||||
}
|
||||
|
||||
// #153 regression: cross-method DID must still be denied even when the
|
||||
// trailing segment collides with a bare owner key.
|
||||
#[test]
|
||||
fn cross_method_did_denied_with_bare_owner() {
|
||||
let rules = [rule("/", VisibilityMode::A, &[])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, false, "z6MkFoo", Some("did:gitlawb:z6MkFoo"), "/"),
|
||||
Decision::Deny,
|
||||
"cross-method DID must not match a bare did:key owner"
|
||||
);
|
||||
assert_eq!(
|
||||
visibility_check(&rules, false, "z6MkFoo", Some("did:web:z6MkFoo"), "/"),
|
||||
Decision::Deny,
|
||||
"did:web must not match a bare did:key owner"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_rule_allows_listed_reader() {
|
||||
let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! #174 U6 — INV-22 completeness guard (rung-raising).
|
||||
//!
|
||||
//! INV-22: a permit held per op recovers only if every path that holds it is also
|
||||
//! duration-bounded and reaps the process group before releasing admission, and every
|
||||
//! detached git/blocking task carries its own admission. PR #174 fixed five paths
|
||||
//! (U1-U5) that violated this. Each fix has a per-unit RED/GREEN regression; together
|
||||
//! those form the five-revert matrix. This guard adds the missing piece: a source-scan
|
||||
//! tripwire that fails when a NEW site reintroduces the class, or when one of the five
|
||||
//! gates is removed.
|
||||
//!
|
||||
//! It lives in `tests/` (a separate crate) on purpose: a guard that scanned the same
|
||||
//! file it lives in would match its own identifier literals and pass vacuously. Here
|
||||
//! the scanned `src/` files never contain this file's literals, so each check is
|
||||
//! load-bearing — reverting the named gate turns the assertion red.
|
||||
//!
|
||||
//! These are deliberately coarse structural checks, not a parser. They cannot prove a
|
||||
//! gate is *correct* (the per-unit tests do that); they prove a gate is *present and
|
||||
//! not bypassed*, which is what stops the class from silently regressing.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn src(rel: &str) -> String {
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(rel);
|
||||
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inv22_concurrency_gates_present_and_not_bypassed() {
|
||||
let repos = src("api/repos.rs");
|
||||
let smart_http = src("git/smart_http.rs");
|
||||
let vis = src("git/visibility_pack.rs");
|
||||
let ipfs = src("api/ipfs.rs");
|
||||
|
||||
// U1 / P1-a: run_bounded_git stands the watchdog down only after confirming the
|
||||
// child actually terminated (WNOWAIT), not on the raw stdout-drain EOF — otherwise
|
||||
// a child that closes stdout then hangs pins the permit past the deadline. The
|
||||
// probe is defined and called, so >= 2 occurrences; reverting the fix removes both.
|
||||
assert!(
|
||||
vis.matches("child_terminated_without_reaping").count() >= 2,
|
||||
"U1/P1-a gate missing: run_bounded_git must confirm child exit via \
|
||||
child_terminated_without_reaping before signalling the watchdog"
|
||||
);
|
||||
|
||||
// U2 / P1-c: on client disconnect KillGroupOnDrop must launch a detached reaper
|
||||
// that runs the full TERM/grace/KILL/reap, not a lone SIGTERM. The reaper is spawned
|
||||
// via a runtime handle in Drop; `Handle::try_current` is unique to that launch (the
|
||||
// timeout path already has an async context and never calls it).
|
||||
assert!(
|
||||
smart_http.contains("Handle::try_current"),
|
||||
"U2/P1-c gate missing: KillGroupOnDrop::drop must launch the full \
|
||||
TERM/grace/KILL reaper on disconnect (Handle::try_current), not a lone SIGTERM"
|
||||
);
|
||||
|
||||
// U4 / P1-d: git_receive_pack must acquire the per-source write sub-cap before the
|
||||
// global write permit. The acquire reads `state.git_write_per_caller`; comments name
|
||||
// the field without the `state.` prefix, so this targets the real acquire site.
|
||||
assert!(
|
||||
repos.contains("state.git_write_per_caller"),
|
||||
"U4/P1-d gate missing: git_receive_pack must acquire the per-source write cap \
|
||||
(state.git_write_per_caller) before the global write permit"
|
||||
);
|
||||
|
||||
// U5 / P1-e: the detached post-push encryption walk must run through the
|
||||
// admission-gated helper, which is wired to the shared encrypt pool.
|
||||
assert!(
|
||||
repos.contains("fn withheld_recipients_gated")
|
||||
&& repos.contains("state.git_encrypt_semaphore"),
|
||||
"U5/P1-e gate missing: the encryption walk must run through \
|
||||
withheld_recipients_gated, which acquires git_encrypt_semaphore"
|
||||
);
|
||||
|
||||
// U1 / R2 (#173 round-10): the path-scoped filtered-pack serve must thread the
|
||||
// caller's AdmissionGuard through BOTH git stages so read + per-caller admission is
|
||||
// held until the pack-objects group is reaped on disconnect, closing the cap bypass
|
||||
// the plain upload_pack path already fixed. Two load-bearing markers: rev-list hands
|
||||
// the disarmed guard back (its tuple return type), and build_filtered_pack forwards
|
||||
// that guard into the pack-objects stage (the `admission` arg after the
|
||||
// "pack-objects" label). Reverting either — dropping the guard between stages, or
|
||||
// passing `None` to pack-objects — trips this.
|
||||
assert!(
|
||||
smart_http.contains("Result<(Vec<String>, Option<AdmissionGuard>)>")
|
||||
&& smart_http.contains("\"pack-objects\",\n admission,"),
|
||||
"U1/R2 gate missing: build_filtered_pack must thread the AdmissionGuard through \
|
||||
rev-list -> pack-objects so the permits are held until the pack-objects group \
|
||||
is reaped on disconnect (the path-scoped half of #174 P1-a)"
|
||||
);
|
||||
|
||||
// U2 / R1 (#173 round-10): the `GET /ipfs/{cid}` serve pipeline must run in a
|
||||
// DETACHED tokio task that OWNS the AdmissionGuard built from BOTH walk permits, so a
|
||||
// cancelled or timed-out request releases admission only after the spawned (bounded)
|
||||
// work completes — not the instant the handler future drops (the /ipfs half of #174
|
||||
// P1-a). Two load-bearing markers: the guard is constructed from the two named
|
||||
// permits, and the whole pipeline is moved into a `tokio::spawn`. Reverting to
|
||||
// handler-local permits (dropping the guard/spawn) trips this.
|
||||
assert!(
|
||||
ipfs.contains("AdmissionGuard::new(ipfs_walk_permit, ipfs_caller_permit)")
|
||||
&& ipfs.contains(
|
||||
"let serve: tokio::task::JoinHandle<Result<Response>> = tokio::spawn(async move {"
|
||||
),
|
||||
"U2/R1 gate missing: get_by_cid must move both /ipfs admission permits into an \
|
||||
AdmissionGuard owned by a detached tokio::spawn task so admission is released \
|
||||
only after the spawned serve work completes (the /ipfs half of #174 P1-a)"
|
||||
);
|
||||
|
||||
// U2 / KTD2 (#173 round-10): the probe/read children on the /ipfs path must be the
|
||||
// duration-bounded twins (process-group teardown via run_bounded_git), not the bare
|
||||
// `store::object_type` / `read_object_content` (or an unbounded `cat-file -s`) a tokio
|
||||
// timeout cannot cancel — otherwise a wedged cat-file lingers and pins the held
|
||||
// admission past the deadline. Reverting any twin call site back to a bare read trips
|
||||
// this.
|
||||
assert!(
|
||||
ipfs.contains("object_type_bounded(")
|
||||
&& ipfs.contains("object_size_bounded(")
|
||||
&& ipfs.contains("read_object_content_bounded("),
|
||||
"U2/KTD2 gate missing: the /ipfs probe+read must call the run_bounded_git-backed \
|
||||
*_bounded twins so a wedged cat-file is reaped at the deadline, not left to pin \
|
||||
the held /ipfs walk admission"
|
||||
);
|
||||
|
||||
// P1-e non-bypass tripwire: the bounded recipients walk is spawn_blocking'd nowhere
|
||||
// but inside withheld_recipients_gated. A second call site (count > 1) is a new
|
||||
// detached git walk that skips the admission gate — exactly the class U5 closed.
|
||||
assert_eq!(
|
||||
repos.matches("withheld_blob_recipients_bounded").count(),
|
||||
1,
|
||||
"P1-e bypass: the bounded recipients walk must be invoked only inside \
|
||||
withheld_recipients_gated; a new call site bypasses the encrypt-walk admission cap"
|
||||
);
|
||||
|
||||
// U4 / P2-2: the detached post-push encryption task must be gated by the per-repo
|
||||
// coalescing set (`encrypt_inflight.try_begin`) so the OUTSTANDING parked-task set is
|
||||
// bounded to <=1 per repo. Removing the gate lets N rapid pushes spawn N parked
|
||||
// waiters (the unbounded set U4 closed); the semaphore only caps active walks.
|
||||
assert!(
|
||||
repos.contains("state.encrypt_inflight.try_begin"),
|
||||
"U4/P2-2 gate missing: the detached post-push encryption spawn must consult \
|
||||
encrypt_inflight.try_begin to coalesce per repo (bound the outstanding-task set)"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.5.0" # x-release-please-version
|
||||
version = "0.5.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
+28
-10
@@ -8,6 +8,10 @@ use std::path::PathBuf;
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
fn signed_client(node: &str, dir: Option<&std::path::Path>) -> NodeClient {
|
||||
NodeClient::new(node, load_keypair_from_dir(dir).ok())
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct BountyArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -49,6 +53,8 @@ pub enum BountyCmd {
|
||||
status: Option<String>,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show details of a specific bounty
|
||||
Show {
|
||||
@@ -56,6 +62,8 @@ pub enum BountyCmd {
|
||||
id: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Claim an open bounty
|
||||
Claim {
|
||||
@@ -121,8 +129,13 @@ pub async fn run(args: BountyArgs) -> Result<()> {
|
||||
node,
|
||||
dir,
|
||||
} => cmd_create(repo, title, amount, issue, tx_hash, deadline, node, dir).await,
|
||||
BountyCmd::List { repo, status, node } => cmd_list(repo, status, node).await,
|
||||
BountyCmd::Show { id, node } => cmd_show(id, node).await,
|
||||
BountyCmd::List {
|
||||
repo,
|
||||
status,
|
||||
node,
|
||||
dir,
|
||||
} => cmd_list(repo, status, node, dir).await,
|
||||
BountyCmd::Show { id, node, dir } => cmd_show(id, node, dir).await,
|
||||
BountyCmd::Claim {
|
||||
id,
|
||||
wallet,
|
||||
@@ -192,8 +205,13 @@ async fn cmd_create(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
async fn cmd_list(
|
||||
repo: Option<String>,
|
||||
status: Option<String>,
|
||||
node: String,
|
||||
dir: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
|
||||
let url = if let Some(ref repo) = repo {
|
||||
let (owner, name) = repo
|
||||
@@ -214,7 +232,7 @@ async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) ->
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
@@ -237,9 +255,9 @@ async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_show(id: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let resp = client.get(&format!("/api/v1/bounties/{id}")).await?;
|
||||
async fn cmd_show(id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let resp = client.get_authed(&format!("/api/v1/bounties/{id}")).await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
@@ -462,7 +480,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(None, None, server.url()).await.unwrap();
|
||||
cmd_list(None, None, server.url(), None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -506,7 +524,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show("nonexistent".to_string(), server.url())
|
||||
let err = cmd_show("nonexistent".to_string(), server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"));
|
||||
|
||||
+34
-17
@@ -5,10 +5,15 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
fn signed_client(node: &str, dir: Option<&std::path::Path>) -> NodeClient {
|
||||
NodeClient::new(node, load_keypair_from_dir(dir).ok())
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CertArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -23,6 +28,8 @@ pub enum CertCmd {
|
||||
repo: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show a specific ref certificate and verify its signature
|
||||
Show {
|
||||
@@ -32,28 +39,39 @@ pub enum CertCmd {
|
||||
id: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(args: CertArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
CertCmd::List { repo, node } => cmd_list(repo, node).await,
|
||||
CertCmd::Show { repo, id, node } => cmd_show(repo, id, node).await,
|
||||
CertCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
|
||||
CertCmd::Show {
|
||||
repo,
|
||||
id,
|
||||
node,
|
||||
dir,
|
||||
} => cmd_show(repo, id, node, dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve "repo" into (owner, name) using the caller's DID when no slash is given.
|
||||
async fn resolve_repo(repo: &str, node: &str) -> Result<(String, String)> {
|
||||
async fn resolve_repo(
|
||||
repo: &str,
|
||||
node: &str,
|
||||
dir: Option<&std::path::Path>,
|
||||
) -> Result<(String, String)> {
|
||||
if let Some((owner, name)) = repo.split_once('/') {
|
||||
Ok((owner.to_string(), name.to_string()))
|
||||
} else {
|
||||
let short = if let Ok(kp) = load_keypair_from_dir(None) {
|
||||
let short = if let Ok(kp) = load_keypair_from_dir(dir) {
|
||||
let did = kp.did().to_string();
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(node, None);
|
||||
let client = signed_client(node, dir);
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.get_authed("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
@@ -65,13 +83,13 @@ async fn resolve_repo(repo: &str, node: &str) -> Result<(String, String)> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_list(repo: String, node: String) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node).await?;
|
||||
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs");
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
@@ -96,16 +114,16 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node).await?;
|
||||
async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let id = resolve_cert_id(&client, &owner, &name, &id).await?;
|
||||
|
||||
// Fetch the certificate
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs/{id}");
|
||||
let resp = client
|
||||
.get(&path)
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("certificate not found")?;
|
||||
@@ -168,9 +186,9 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs");
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs?prefix={id}");
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("failed to list certificates")?
|
||||
@@ -182,7 +200,6 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
|
||||
let matches: Vec<String> = certs
|
||||
.iter()
|
||||
.filter_map(|cert| cert["id"].as_str())
|
||||
.filter(|cert_id| cert_id.starts_with(id))
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -45,6 +45,15 @@ impl NodeClient {
|
||||
.with_context(|| format!("GET {url}"))
|
||||
}
|
||||
|
||||
/// GET that signs when a keypair is available; falls back to unsigned for public repos.
|
||||
pub async fn get_authed(&self, path: &str) -> Result<reqwest::Response> {
|
||||
if self.keypair.is_some() {
|
||||
self.get_signed(path).await
|
||||
} else {
|
||||
self.get(path).await
|
||||
}
|
||||
}
|
||||
|
||||
/// GET with RFC 9421 HTTP Signature auth, for owner-only read endpoints.
|
||||
/// Signs over the empty body (same shape the node verifies for signed reads).
|
||||
pub async fn get_signed(&self, path: &str) -> Result<reqwest::Response> {
|
||||
@@ -176,3 +185,389 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
.await
|
||||
.context("iCaptcha solver task panicked")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use mockito::Server;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Serializes the two integration tests that touch the process-global
|
||||
/// `GITLAWB_ICAPTCHA_URL` / `GITLAWB_ICAPTCHA_INSECURE` env vars so they
|
||||
/// never race.
|
||||
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn test_keypair() -> Keypair {
|
||||
Keypair::generate()
|
||||
}
|
||||
|
||||
fn headers_from_pairs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
k.parse::<reqwest::header::HeaderName>().unwrap(),
|
||||
v.parse::<reqwest::header::HeaderValue>().unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
// ── icaptcha_cfg ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_returns_some_when_both_headers_present() {
|
||||
let kp = test_keypair();
|
||||
let client = NodeClient::new("http://localhost", Some(kp.clone()));
|
||||
let headers = headers_from_pairs(&[
|
||||
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
|
||||
("x-icaptcha-level", "3"),
|
||||
]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.did, kp.did().to_string());
|
||||
assert_eq!(cfg.level, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_defaults_level_when_only_url_present() {
|
||||
let kp = test_keypair();
|
||||
let client = NodeClient::new("http://localhost", Some(kp));
|
||||
let headers = headers_from_pairs(&[("x-icaptcha-url", "https://icaptcha.gitlawb.com")]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_defaults_url_when_only_level_present() {
|
||||
let kp = test_keypair();
|
||||
let client = NodeClient::new("http://localhost", Some(kp));
|
||||
let headers = headers_from_pairs(&[("x-icaptcha-level", "5")]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.level, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_returns_none_without_icaptcha_headers() {
|
||||
let client = NodeClient::new("http://localhost", Some(test_keypair()));
|
||||
let headers = reqwest::header::HeaderMap::new();
|
||||
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_returns_none_with_unrelated_headers() {
|
||||
let client = NodeClient::new("http://localhost", Some(test_keypair()));
|
||||
let headers = headers_from_pairs(&[("content-type", "application/json")]);
|
||||
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_errors_when_no_keypair() {
|
||||
let client = NodeClient::new("http://localhost", None);
|
||||
let headers = headers_from_pairs(&[("x-icaptcha-level", "3")]);
|
||||
let err = client.icaptcha_cfg(&headers).unwrap_err();
|
||||
assert!(err.to_string().contains("identity keypair"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_ignores_unparseable_level() {
|
||||
let client = NodeClient::new("http://localhost", Some(test_keypair()));
|
||||
let headers = headers_from_pairs(&[
|
||||
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
|
||||
("x-icaptcha-level", "not-a-number"),
|
||||
]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
|
||||
}
|
||||
|
||||
// ── send_once ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_attaches_proof_header_when_provided() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("x-icaptcha-proof", "test.proof.token")
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", Some("test.proof.token"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_omits_proof_header_when_not_provided() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("x-icaptcha-proof", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_signs_request_when_keypair_present() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("Signature", mockito::Matcher::Any)
|
||||
.match_header("Signature-Input", mockito::Matcher::Any)
|
||||
.match_header("Content-Digest", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_does_not_sign_when_no_keypair() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("Signature", mockito::Matcher::Missing)
|
||||
.match_header("Signature-Input", mockito::Matcher::Missing)
|
||||
.match_header("Content-Digest", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
// ── send_signed ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_returns_non_icaptcha_403_without_retry() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"forbidden"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 403);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_returns_first_response_on_success() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"created"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_handles_405_not_icaptcha() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(405)
|
||||
.with_body(r#"{"error":"method not allowed"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 405);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
// ── send_signed iCaptcha retry (full integration) ────────────────────
|
||||
|
||||
/// Set GITLAWB_ICAPTCHA_URL and GITLAWB_ICAPTCHA_INSECURE so the iCaptcha
|
||||
/// client trusts a local mockito HTTP server, restoring any prior values on
|
||||
/// drop so a test run launched with those variables keeps working.
|
||||
/// Holds [`ICAPTCHA_ENV_LOCK`] for its lifetime so concurrent tests don't
|
||||
/// race on the process-global env vars.
|
||||
struct IcaptchaEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
prev_url: Option<OsString>,
|
||||
prev_insecure: Option<OsString>,
|
||||
}
|
||||
|
||||
impl IcaptchaEnv {
|
||||
fn new(url: &str) -> Self {
|
||||
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
|
||||
let prev_url = std::env::var_os("GITLAWB_ICAPTCHA_URL");
|
||||
let prev_insecure = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_URL", url);
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
|
||||
IcaptchaEnv {
|
||||
_lock: lock,
|
||||
prev_url,
|
||||
prev_insecure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IcaptchaEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.prev_url.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
|
||||
}
|
||||
match self.prev_insecure.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up a mock iCaptcha server that responds to challenge + answer.
|
||||
/// `hits` sets the expected call count for both endpoints so the test can
|
||||
/// verify the solve loop was entered the correct number of times.
|
||||
struct MockIcaptcha {
|
||||
challenge: mockito::Mock,
|
||||
answer: mockito::Mock,
|
||||
_guard: IcaptchaEnv,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl MockIcaptcha {
|
||||
async fn new(server: &mut mockito::ServerGuard, hits: usize) -> Self {
|
||||
let url = server.url();
|
||||
let guard = IcaptchaEnv::new(&url);
|
||||
let challenge = server
|
||||
.mock("POST", "/v1/challenge")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"challengeId":"c1","type":"arithmetic","difficulty":1,"prompt":"What is 1 + 1?","token":"tk1"}"#,
|
||||
)
|
||||
.expect(hits)
|
||||
.create_async()
|
||||
.await;
|
||||
let answer = server
|
||||
.mock("POST", "/v1/answer")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"passed","proof":"mock.proof"}"#)
|
||||
.expect(hits)
|
||||
.create_async()
|
||||
.await;
|
||||
Self {
|
||||
challenge,
|
||||
answer,
|
||||
_guard: guard,
|
||||
url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_solves_icaptcha_and_retries_to_success() {
|
||||
let mut node = Server::new_async().await;
|
||||
let mut icaptcha = Server::new_async().await;
|
||||
let ic = MockIcaptcha::new(&mut icaptcha, 1).await;
|
||||
|
||||
let n1 = node
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("x-icaptcha-url", &ic.url)
|
||||
.with_header("x-icaptcha-level", "3")
|
||||
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let n2 = node
|
||||
.mock("POST", "/api/register")
|
||||
.match_header("x-icaptcha-proof", "mock.proof")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"created"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
n1.assert();
|
||||
n2.assert();
|
||||
ic.challenge.assert();
|
||||
ic.answer.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_returns_403_after_icaptcha_retries_exhausted() {
|
||||
let mut node = Server::new_async().await;
|
||||
let mut icaptcha = Server::new_async().await;
|
||||
// MAX_ICAPTCHA_RETRIES = 2, so with every call returning 403 with
|
||||
// iCaptcha headers the solve loop runs twice (2 challenge + 2 answer).
|
||||
let ic = MockIcaptcha::new(&mut icaptcha, 2).await;
|
||||
|
||||
// The original + 2 retries = 3 node calls before the loop gives up.
|
||||
let n = node
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("x-icaptcha-url", &ic.url)
|
||||
.with_header("x-icaptcha-level", "3")
|
||||
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
|
||||
.expect(3)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 403);
|
||||
n.assert();
|
||||
ic.challenge.assert();
|
||||
ic.answer.assert();
|
||||
}
|
||||
}
|
||||
+197
-5
@@ -33,13 +33,16 @@ pub enum IpfsCmd {
|
||||
cid: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(args: IpfsArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
IpfsCmd::List { node, dir } => cmd_list(node, dir).await,
|
||||
IpfsCmd::Get { cid, node } => cmd_get(cid, node).await,
|
||||
IpfsCmd::Get { cid, node, dir } => cmd_get(cid, node, dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +89,32 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_get(cid: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let path = format!("/ipfs/{cid}");
|
||||
async fn cmd_get(cid: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
// #173 (F5): the resolver now serves path-scoped objects to authorized readers,
|
||||
// so sign with an available identity like `gl ipfs list` — otherwise an owner or
|
||||
// listed reader gets the opaque anonymous 404 for content they can read.
|
||||
// `get_authed` signs when a keypair is present and falls back to unsigned.
|
||||
//
|
||||
// An explicit `--dir` is a request to use THAT identity: propagate a
|
||||
// missing/corrupt-keystore error (like `list`) instead of silently sending an
|
||||
// anonymous request the authorized reader would see as the node's opaque 404
|
||||
// (#173 review). Only the default (no `--dir`) keeps the best-effort unsigned
|
||||
// fallback, so `get` stays usable for genuinely public content.
|
||||
let keypair = match dir.as_deref() {
|
||||
Some(dir) => Some(crate::identity::load_keypair_from_dir(Some(dir))?),
|
||||
None => crate::identity::load_keypair_from_dir(None).ok(),
|
||||
};
|
||||
let client = NodeClient::new(&node, keypair);
|
||||
// #173 review (F1): the node now accepts equivalent multibase spellings,
|
||||
// including base64 CIDs (prefix 'm'), whose alphabet contains '/', '+', '='.
|
||||
// Interpolating the CID raw would make the client request (and sign)
|
||||
// `/ipfs/<prefix>/<suffix>`, which neither matches the single-segment Axum
|
||||
// route nor points at the intended target. Percent-encode the CID as exactly
|
||||
// one path segment so the signed and sent target agree and the server's
|
||||
// `Path` extractor decodes it back to the original CID.
|
||||
let path = format!("/ipfs/{}", encode_cid_segment(&cid));
|
||||
let resp = client
|
||||
.get(&path)
|
||||
.get_authed(&path)
|
||||
.await
|
||||
.with_context(|| format!("failed to fetch CID {cid} from {node}"))?;
|
||||
|
||||
@@ -119,6 +143,16 @@ async fn cmd_get(cid: String, node: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Percent-encode a CID so it occupies exactly one path segment of `/ipfs/<cid>`.
|
||||
/// `urlencoding::encode` escapes every byte outside the RFC 3986 unreserved set
|
||||
/// (ALPHA / DIGIT / `-._~`), so the base64-CID characters that would otherwise
|
||||
/// break the single-segment route — `/`, `+`, `=` — are all escaped, and the
|
||||
/// server's `Path` extractor decodes the result back to the original CID (#173
|
||||
/// review, F1).
|
||||
fn encode_cid_segment(cid: &str) -> String {
|
||||
urlencoding::encode(cid).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -235,4 +269,162 @@ mod tests {
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 (F5): `gl ipfs get` must SIGN with an available identity, like
|
||||
/// `gl ipfs list`, so an owner/reader can retrieve a path-scoped object the node
|
||||
/// now resolves by CID. RED before the fix: cmd_get ignores the identity dir and
|
||||
/// sends an unsigned request, so the signature-matching mock is never hit
|
||||
/// (cmd_get errors on the unmatched 501, and m.assert fails). GREEN after: the
|
||||
/// signed request carries the RFC 9421 headers and is served 200.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_signs_when_identity_present() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let keystore = seed_keystore();
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreitestcid")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/octet-stream")
|
||||
.with_header("x-git-hash", "abc123")
|
||||
.with_body("object bytes")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_get(
|
||||
"bafkreitestcid".to_string(),
|
||||
server.url(),
|
||||
Some(keystore.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.expect("signed get of a resolvable object should succeed");
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 (F5) must-not: a genuine anonymous denial must surface as an error, not
|
||||
/// be masked as success. With no identity dir the request is unsigned; a 404
|
||||
/// from the node must produce an Err mentioning the status.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_anonymous_denial_is_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreidenied")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("no git object found")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_get("bafkreidenied".to_string(), server.url(), None)
|
||||
.await
|
||||
.expect_err("a 404 denial must be an error, not masked success");
|
||||
assert!(
|
||||
err.to_string().contains("404"),
|
||||
"error should mention the status, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 (INV-8) must-not: the node's new 503 "search incomplete" (the legacy CID
|
||||
/// scan hit its bound and could not prove absence) must surface as an actionable
|
||||
/// Err naming the status, NOT be rendered as an empty/"not found" success — a
|
||||
/// retryable outcome the caller has to see. Mirrors the 404 denial case for the
|
||||
/// bounded-search response the resolver now emits.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_search_incomplete_503_is_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreiincomplete")
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"search_incomplete","message":"CID search incomplete — retry"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_get("bafkreiincomplete".to_string(), server.url(), None)
|
||||
.await
|
||||
.expect_err("a 503 incomplete-search must be an error, not masked as not-found");
|
||||
assert!(
|
||||
err.to_string().contains("503"),
|
||||
"error should mention the status, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 review (F1): a base64 CID (multibase prefix 'm') can contain '/', '+',
|
||||
/// and '='. The client must percent-encode it into ONE path segment before
|
||||
/// building and signing `/ipfs/<cid>`; otherwise the '/' splits the target so
|
||||
/// it misses the single-segment Axum route and the signature covers the wrong
|
||||
/// path. Assert the encoded segment carries no raw '/', '+', or '=', and that
|
||||
/// it decodes back to the original CID (the server's `Path` extractor performs
|
||||
/// that same decode). RED with the old raw `format!("/ipfs/{cid}")`: the
|
||||
/// segment still contains '/'.
|
||||
#[test]
|
||||
fn test_encode_cid_segment_escapes_base64_alphabet() {
|
||||
let cid = "mFoo/Bar+baz==";
|
||||
let encoded = encode_cid_segment(cid);
|
||||
|
||||
assert!(
|
||||
!encoded.contains('/'),
|
||||
"encoded CID must be a single path segment (no raw '/'), got: {encoded}"
|
||||
);
|
||||
assert!(
|
||||
!encoded.contains('+'),
|
||||
"encoded CID must escape '+', got: {encoded}"
|
||||
);
|
||||
assert!(
|
||||
!encoded.contains('='),
|
||||
"encoded CID must escape '=', got: {encoded}"
|
||||
);
|
||||
|
||||
let decoded = urlencoding::decode(&encoded).expect("encoded CID must decode");
|
||||
assert_eq!(
|
||||
decoded, cid,
|
||||
"encoding must round-trip back to the original CID"
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 review: `gl ipfs get --dir <path>` must PROPAGATE a missing/corrupt
|
||||
/// identity-load error like `gl ipfs list`, not silently fall back to an anonymous
|
||||
/// request — otherwise an authorized reader pointing `--dir` at a broken keystore
|
||||
/// gets the node's opaque 404 instead of the actionable key-load error. The
|
||||
/// unsigned fallback is preserved only when NO `--dir` is given (covered by
|
||||
/// `test_cmd_get_anonymous_denial_is_error`). RED before the fix (`.ok()` swallows
|
||||
/// the error, an anonymous request is sent, and the `.expect(0)` mock is hit),
|
||||
/// GREEN after.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_explicit_dir_no_identity_errors_without_request() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Empty keystore dir passed explicitly via --dir: no identity.pem present.
|
||||
let empty = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// The endpoint must never be hit when an explicit --dir fails to load.
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreitestcid")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_get(
|
||||
"bafkreitestcid".to_string(),
|
||||
server.url(),
|
||||
Some(empty.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.expect_err("an explicit --dir that fails to load must be an error");
|
||||
assert!(
|
||||
err.to_string().contains("gl identity new")
|
||||
|| err.to_string().contains("no identity found")
|
||||
|| err.to_string().contains("failed to load keypair"),
|
||||
"error should name the key-load failure, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -10,6 +10,10 @@ use uuid::Uuid;
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
fn signed_client(node: &str, dir: Option<&std::path::Path>) -> NodeClient {
|
||||
NodeClient::new(node, load_keypair_from_dir(dir).ok())
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct IssueArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -215,10 +219,10 @@ async fn cmd_create(
|
||||
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues");
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
@@ -254,10 +258,10 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues/{id}");
|
||||
let resp = client
|
||||
.get(&path)
|
||||
.get_authed(&path)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
@@ -347,10 +351,10 @@ async fn cmd_issue_comments(
|
||||
dir: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
|
||||
let resp: Value = client
|
||||
.get(&format!(
|
||||
.get_authed(&format!(
|
||||
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
|
||||
))
|
||||
.await?
|
||||
|
||||
@@ -970,14 +970,14 @@ async fn call_tool(
|
||||
}
|
||||
u
|
||||
};
|
||||
let resp: Value = client.get(&url).await?.json().await?;
|
||||
let resp: Value = client.get_authed(&url).await?.json().await?;
|
||||
Ok(serde_json::to_string_pretty(&resp)?)
|
||||
}
|
||||
|
||||
"bounty_show" => {
|
||||
let id = args["id"].as_str().context("missing 'id'")?;
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/bounties/{id}"))
|
||||
.get_authed(&format!("/api/v1/bounties/{id}"))
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -1165,7 +1165,7 @@ async fn call_tool(
|
||||
(owner, repo.to_string())
|
||||
};
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/issues"))
|
||||
.get_authed(&format!("/api/v1/repos/{owner}/{name}/issues"))
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
@@ -658,10 +658,10 @@ async fn cmd_label_remove(
|
||||
|
||||
async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/labels"))
|
||||
.get_authed(&format!("/api/v1/repos/{owner}/{name}/labels"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
|
||||
+15
-9
@@ -34,12 +34,14 @@ pub enum StarCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show star count for a repository (no auth required)
|
||||
/// Show star count for a repository
|
||||
Count {
|
||||
/// Repository in owner/repo format
|
||||
repo: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -47,7 +49,7 @@ pub async fn run(args: StarArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
StarCmd::Add { repo, node, dir } => cmd_add(repo, node, dir).await,
|
||||
StarCmd::Remove { repo, node, dir } => cmd_remove(repo, node, dir).await,
|
||||
StarCmd::Count { repo, node } => cmd_count(repo, node).await,
|
||||
StarCmd::Count { repo, node, dir } => cmd_count(repo, node, dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,15 +112,15 @@ async fn cmd_remove(repo: String, node: String, dir: Option<PathBuf>) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_count(repo: String, node: String) -> Result<()> {
|
||||
async fn cmd_count(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = repo
|
||||
.split_once('/')
|
||||
.map(|(o, n)| (o.to_string(), n.to_string()))
|
||||
.context("use owner/repo format for count (e.g. alice/myrepo)")?;
|
||||
let client = NodeClient::new(&node, None);
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
|
||||
let resp = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/star"))
|
||||
.get_authed(&format!("/api/v1/repos/{owner}/{name}/star"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
@@ -310,16 +312,20 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_count("alice/myrepo".to_string(), server.url())
|
||||
cmd_count("alice/myrepo".to_string(), server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_count_requires_slash() {
|
||||
let err = cmd_count("noslash".to_string(), "http://127.0.0.1:1".to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = cmd_count(
|
||||
"noslash".to_string(),
|
||||
"http://127.0.0.1:1".to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("owner/repo format"));
|
||||
}
|
||||
|
||||
|
||||
+285
-10
@@ -1,4 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -30,15 +30,39 @@ pub enum SyncCmd {
|
||||
pub async fn run(args: SyncArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
SyncCmd::Trigger => {
|
||||
let keypair = load_keypair_from_dir(args.dir.as_deref()).ok();
|
||||
let client = NodeClient::new(&args.node, keypair);
|
||||
let resp: serde_json::Value = client
|
||||
.post("/api/v1/sync/trigger", b"{}")
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let peers = resp["peers_reached"].as_u64().unwrap_or(0);
|
||||
let enqueued = resp["repos_enqueued"].as_u64().unwrap_or(0);
|
||||
// /api/v1/sync/trigger always requires a signature, so a missing or
|
||||
// unreadable identity must fail here, locally, rather than sending an
|
||||
// unsigned request that can only 401 remotely (matches the other
|
||||
// signed CLI writes).
|
||||
let keypair = load_keypair_from_dir(args.dir.as_deref())
|
||||
.context("identity not found — run `gl identity new` first")?;
|
||||
let client = NodeClient::new(&args.node, Some(keypair));
|
||||
let resp = client.post("/api/v1/sync/trigger", b"{}").await?;
|
||||
// The node now requires a signature on this route and rate-limits it,
|
||||
// so a denial (401/429/…) is expected. Check the status BEFORE parsing:
|
||||
// otherwise a JSON-ish error body deserializes into a zero-count struct
|
||||
// and prints a fabricated "✓ sync triggered / 0 peers" success.
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
// Bound the read: a hostile or broken node must not force an
|
||||
// unbounded allocation just to surface a denial (INV-6, read half).
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("message")
|
||||
.or_else(|| v.get("error"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or(raw);
|
||||
anyhow::bail!(
|
||||
"sync trigger failed ({status}): {}",
|
||||
sanitize_node_msg(&msg)
|
||||
);
|
||||
}
|
||||
let resp: serde_json::Value = resp.json().await?;
|
||||
let (peers, enqueued) = trigger_counts(&resp);
|
||||
println!("✓ sync triggered");
|
||||
println!(" peers reached: {peers}");
|
||||
println!(" repos enqueued: {enqueued}");
|
||||
@@ -69,3 +93,254 @@ pub async fn run(args: SyncArgs) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract `(peers_reached, repos_enqueued)` from a successful sync-trigger
|
||||
/// response. Split out so the extraction is unit-testable (missing or malformed
|
||||
/// fields default to 0 rather than panicking).
|
||||
fn trigger_counts(resp: &serde_json::Value) -> (u64, u64) {
|
||||
(
|
||||
resp["peers_reached"].as_u64().unwrap_or(0),
|
||||
resp["repos_enqueued"].as_u64().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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() && !is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Unicode bidirectional and directional-isolate format characters (category
|
||||
/// `Cf`). These are not `char::is_control()` (that is category `Cc` only), but a
|
||||
/// right-to-left override or isolate can visually reorder a terminal line to
|
||||
/// spoof the error text, so they are stripped alongside the control bytes.
|
||||
fn is_bidi_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
|
||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
|
||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn trigger_args(node: String) -> (SyncArgs, tempfile::TempDir) {
|
||||
// Seed a real identity so `run` gets past the mandatory-keypair check and
|
||||
// reaches the status-handling path. The mocks below return a fixed status
|
||||
// regardless of the signature, so these tests exercise the client's
|
||||
// status-check-before-parse, not signature verification (that is proved
|
||||
// server-side). Return the TempDir so the caller keeps it alive.
|
||||
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 args = SyncArgs {
|
||||
cmd: SyncCmd::Trigger,
|
||||
node,
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
};
|
||||
(args, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_requires_identity_fails_before_request() {
|
||||
// Empty identity dir → no keypair. `sync trigger` must fail locally with
|
||||
// a clear identity error BEFORE issuing any request. The node URL points
|
||||
// at an unreachable port, so a request attempt would surface a different
|
||||
// (connection) error; getting the identity error proves we never dialed.
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let args = SyncArgs {
|
||||
cmd: SyncCmd::Trigger,
|
||||
node: "http://127.0.0.1:1".to_string(),
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("identity not found"),
|
||||
"expected a local identity error before any request, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_surfaces_401_as_error_not_fake_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/sync/trigger")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
// Valid JSON: the parse-without-status-check bug deserializes this
|
||||
// into a zero-count success struct and prints "✓ sync triggered".
|
||||
.with_body(r#"{"message":"unauthorized"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("401"),
|
||||
"expected 401 surfaced, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_surfaces_429_as_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/sync/trigger")
|
||||
.with_status(429)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"slow down"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("429"),
|
||||
"expected 429 surfaced, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_sanitizes_control_chars_in_node_error() {
|
||||
// A hostile node embeds an ANSI color escape (ESC) and a bell (BEL) in
|
||||
// the JSON message field. The surfaced error must contain neither raw
|
||||
// control byte, while keeping the printable text.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/sync/trigger")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
// Valid JSON whose message carries JSON-escaped ESC (\u001b) and
|
||||
// BEL (\u0007); serde decodes them to real control bytes a naive
|
||||
// client would print. (The status-check bug fake-successes here.)
|
||||
.with_body("{\"message\":\"pwned\\u001b[31m\\u0007bad\"}")
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
let err = run(args).await.unwrap_err();
|
||||
let s = err.to_string();
|
||||
assert!(!s.contains('\u{1b}'), "ESC leaked to terminal: {s:?}");
|
||||
assert!(!s.contains('\u{07}'), "BEL leaked to terminal: {s:?}");
|
||||
assert!(
|
||||
s.contains("pwned") && s.contains("bad"),
|
||||
"message text dropped: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_ok_prints_counts() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/sync/trigger")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"peers_reached":2,"repos_enqueued":5}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
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);
|
||||
}
|
||||
|
||||
#[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
|
||||
// surface the denial with a bounded message, not hang or dump the body.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/sync/trigger")
|
||||
.with_status(401)
|
||||
.with_body("A".repeat(2_000_000))
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
let err = run(args).await.unwrap_err();
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("401"), "denial not surfaced: {s:.80?}");
|
||||
assert!(
|
||||
s.len() < 500,
|
||||
"error message not bounded: {} chars",
|
||||
s.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_body_capped_bounds_the_read() {
|
||||
// The read must stop at the cap — a 2 MB body yields at most `cap` bytes,
|
||||
// not the whole thing (which resp.text() would return).
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/big")
|
||||
.with_status(200)
|
||||
.with_body("A".repeat(2_000_000))
|
||||
.create_async()
|
||||
.await;
|
||||
let resp = reqwest::get(format!("{}/big", server.url())).await.unwrap();
|
||||
let out = read_body_capped(resp, 8192).await;
|
||||
assert!(out.len() <= 8192, "read not bounded: {} bytes", out.len());
|
||||
assert!(!out.is_empty(), "expected some body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_counts_extracts_both_values() {
|
||||
let v = serde_json::json!({"peers_reached": 2, "repos_enqueued": 5});
|
||||
assert_eq!(trigger_counts(&v), (2, 5));
|
||||
// Missing/malformed fields default to 0, never panic.
|
||||
assert_eq!(trigger_counts(&serde_json::json!({})), (0, 0));
|
||||
assert_eq!(
|
||||
trigger_counts(&serde_json::json!({"peers_reached": "x"})),
|
||||
(0, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,4 @@ serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -19,6 +19,7 @@ use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub mod pow;
|
||||
pub mod solvers;
|
||||
|
||||
/// Default iCaptcha service base URL (used when the node doesn't advertise one).
|
||||
@@ -81,18 +82,42 @@ fn sanitize_excerpt(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `u` parses as an `https` URL.
|
||||
/// Whether `u` parses as a trusted URL.
|
||||
///
|
||||
/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a
|
||||
/// runtime escape hatch for integration tests) the function also trusts
|
||||
/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path
|
||||
/// can be exercised against a local mockito server.
|
||||
fn is_https(u: &str) -> bool {
|
||||
reqwest::Url::parse(u)
|
||||
.map(|p| p.scheme() == "https")
|
||||
.unwrap_or(false)
|
||||
let Ok(parsed) = reqwest::Url::parse(u) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() == "https" {
|
||||
return true;
|
||||
}
|
||||
if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
|
||||
&& parsed.scheme() == "http"
|
||||
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Lowercased host of a URL, if it parses and has one.
|
||||
fn host_of(u: &str) -> Option<String> {
|
||||
reqwest::Url::parse(u)
|
||||
.ok()
|
||||
.and_then(|p| p.host_str().map(|h| h.to_ascii_lowercase()))
|
||||
/// Lowercased origin string (`scheme://host[:port]`) of a URL.
|
||||
///
|
||||
/// Includes the effective port so that `http://localhost:3000` and
|
||||
/// `http://localhost:9000` are treated as distinct origins — a hostile node
|
||||
/// cannot redirect the iCaptcha solve to a different loopback port.
|
||||
fn origin_key(u: &str) -> Option<String> {
|
||||
let parsed = reqwest::Url::parse(u).ok()?;
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.map(|p| format!(":{p}"))
|
||||
.unwrap_or_default();
|
||||
Some(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
/// Decide which iCaptcha origin to actually talk to, and whether it is trusted
|
||||
@@ -109,12 +134,13 @@ fn host_of(u: &str) -> Option<String> {
|
||||
/// exfiltrated to an origin the operator did not choose.
|
||||
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
|
||||
let operator = operator.filter(|u| is_https(u));
|
||||
let operator_host = operator.and_then(host_of);
|
||||
let default_host = host_of(DEFAULT_URL);
|
||||
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
|
||||
let default_origin = origin_key(DEFAULT_URL);
|
||||
|
||||
let allowed = |host: &Option<String>| -> bool {
|
||||
host.as_ref()
|
||||
.map(|h| Some(h) == default_host.as_ref() || Some(h) == operator_host.as_ref())
|
||||
let allowed = |origin: &Option<String>| -> bool {
|
||||
origin
|
||||
.as_ref()
|
||||
.map(|o| Some(o) == default_origin.as_ref() || Some(o) == operator_origin.as_ref())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
@@ -125,7 +151,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
|
||||
};
|
||||
|
||||
let chosen = match advertised {
|
||||
Some(a) if is_https(a) && allowed(&host_of(a)) => a.to_string(),
|
||||
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
|
||||
Some(a) => {
|
||||
tracing::warn!(
|
||||
advertised = %a,
|
||||
@@ -137,7 +163,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
|
||||
};
|
||||
|
||||
// Key goes only to the operator's own configured origin.
|
||||
let key_trusted = operator_host.is_some() && host_of(&chosen) == operator_host;
|
||||
let key_trusted = operator_origin.is_some() && origin_key(&chosen) == operator_origin;
|
||||
(chosen, key_trusted)
|
||||
}
|
||||
|
||||
@@ -201,6 +227,10 @@ pub struct Challenge {
|
||||
pub difficulty: u32,
|
||||
pub prompt: String,
|
||||
pub token: String,
|
||||
/// Proof-of-work to solve and echo back as `powNonce` on the answer. Present
|
||||
/// only when the service has PoW enabled; absent/None means no PoW required.
|
||||
#[serde(default)]
|
||||
pub pow: Option<pow::PowChallenge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -237,7 +267,28 @@ pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result<String
|
||||
)
|
||||
})?;
|
||||
|
||||
match submit_answer(&client, cfg, &challenge.token, &answer)? {
|
||||
// Solve the proof-of-work bound to this challenge, if the service
|
||||
// requires one. A required-but-unsolvable PoW (unknown algorithm or a
|
||||
// difficulty above our cap) is a hard error — submitting without it
|
||||
// would just be rejected.
|
||||
let pow_nonce = match &challenge.pow {
|
||||
Some(p) => Some(pow::solve(p).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"cannot solve iCaptcha proof-of-work (algorithm '{}', difficulty {})",
|
||||
p.algorithm,
|
||||
p.difficulty
|
||||
)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
match submit_answer(
|
||||
&client,
|
||||
cfg,
|
||||
&challenge.token,
|
||||
&answer,
|
||||
pow_nonce.as_deref(),
|
||||
)? {
|
||||
AnswerResult::Passed { proof } => return Ok(proof),
|
||||
AnswerResult::Continue { challenge: next } => challenge = next,
|
||||
AnswerResult::Failed { reason } => {
|
||||
@@ -273,11 +324,14 @@ fn submit_answer(
|
||||
cfg: &IcaptchaCfg,
|
||||
token: &str,
|
||||
answer: &str,
|
||||
pow_nonce: Option<&str>,
|
||||
) -> Result<AnswerResult> {
|
||||
let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/'));
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.json(&json!({ "token": token, "answer": answer }));
|
||||
let mut body = json!({ "token": token, "answer": answer });
|
||||
if let Some(nonce) = pow_nonce {
|
||||
body["powNonce"] = json!(nonce);
|
||||
}
|
||||
let mut req = client.post(&url).json(&body);
|
||||
if let Some(key) = &cfg.api_key {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
@@ -320,6 +374,37 @@ fn interactive_prompt(challenge: &Challenge) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Serializes tests that touch the process-global
|
||||
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
|
||||
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
|
||||
/// prior value on drop.
|
||||
struct InsecureEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
prev: Option<OsString>,
|
||||
}
|
||||
|
||||
impl InsecureEnv {
|
||||
fn new() -> Self {
|
||||
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
|
||||
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
|
||||
InsecureEnv { _lock: lock, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InsecureEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1: hostile error bodies must not reach the terminal raw ──────────
|
||||
|
||||
@@ -403,4 +488,20 @@ mod tests {
|
||||
assert_eq!(url, DEFAULT_URL);
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_advertised_url_on_different_port() {
|
||||
// With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different
|
||||
// ports must be treated as distinct origins so a hostile node cannot
|
||||
// redirect the bearer key to a different listener on localhost.
|
||||
let _env = InsecureEnv::new();
|
||||
|
||||
let op = "http://localhost:3000";
|
||||
let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op));
|
||||
assert_eq!(
|
||||
url, op,
|
||||
"must fall back to operator origin, not use different port"
|
||||
);
|
||||
assert!(key_trusted, "key stays trusted with operator origin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Proof-of-work solver for the iCaptcha answer step.
|
||||
//!
|
||||
//! iCaptcha requires a small proof-of-work with each answer so that minting a
|
||||
//! proof costs CPU — a per-proof cost a distributed abuser cannot dodge by
|
||||
//! rotating IPs. The work is bound to the per-challenge id: find a `nonce` such
|
||||
//! that `sha256("{challenge}:{nonce}")` has at least `difficulty` leading zero
|
||||
//! bits. This mirrors `icaptcha/src/pow.ts`.
|
||||
//!
|
||||
//! At the service default (~20 bits) this is well under a second for a single
|
||||
//! solve; we still cap the search so a hostile/misconfigured difficulty can
|
||||
//! never hang the client.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// PoW parameters advertised by the service in a challenge (mirrors the
|
||||
/// service's `PowChallenge`).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct PowChallenge {
|
||||
/// Only `sha256-leading-zero-bits` is understood; other algorithms are
|
||||
/// rejected by [`solve`] rather than silently mis-solved.
|
||||
pub algorithm: String,
|
||||
/// The string to hash against (the per-challenge id).
|
||||
pub challenge: String,
|
||||
/// Required leading zero bits.
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
/// Hard cap on nonce search iterations. 2^26 ≈ 67M keeps a ~20-bit target (~1M
|
||||
/// expected hashes) comfortably solvable while bounding worst-case work if the
|
||||
/// service ever advertises an unexpectedly high difficulty.
|
||||
const MAX_ITERS: u64 = 1 << 26;
|
||||
|
||||
const ALGORITHM: &str = "sha256-leading-zero-bits";
|
||||
|
||||
/// Leading zero bits of a byte slice.
|
||||
fn leading_zero_bits(bytes: &[u8]) -> u32 {
|
||||
let mut bits = 0;
|
||||
for &b in bytes {
|
||||
if b == 0 {
|
||||
bits += 8;
|
||||
} else {
|
||||
bits += b.leading_zeros();
|
||||
break;
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// sha256(`{challenge}:{nonce}`).
|
||||
fn pow_hash(challenge: &str, nonce: &str) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(challenge.as_bytes());
|
||||
hasher.update(b":");
|
||||
hasher.update(nonce.as_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Solve the PoW, returning a nonce whose hash meets the difficulty. Returns
|
||||
/// `None` for an unknown algorithm or if no nonce is found within the cap (a
|
||||
/// difficulty far above what the service uses in practice).
|
||||
pub fn solve(pow: &PowChallenge) -> Option<String> {
|
||||
if pow.algorithm != ALGORITHM {
|
||||
tracing::warn!(algorithm = %pow.algorithm, "unknown iCaptcha PoW algorithm; cannot solve");
|
||||
return None;
|
||||
}
|
||||
if pow.difficulty == 0 {
|
||||
return Some("0".to_string());
|
||||
}
|
||||
for i in 0..MAX_ITERS {
|
||||
let nonce = format!("{i:x}");
|
||||
if leading_zero_bits(&pow_hash(&pow.challenge, &nonce)) >= pow.difficulty {
|
||||
return Some(nonce);
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
difficulty = pow.difficulty,
|
||||
max_iters = MAX_ITERS,
|
||||
"iCaptcha PoW not solved within iteration cap"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pow(challenge: &str, difficulty: u32) -> PowChallenge {
|
||||
PowChallenge {
|
||||
algorithm: ALGORITHM.to_string(),
|
||||
challenge: challenge.to_string(),
|
||||
difficulty,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_zero_bits_counts_across_bytes() {
|
||||
assert_eq!(leading_zero_bits(&[0xff]), 0);
|
||||
assert_eq!(leading_zero_bits(&[0x7f]), 1);
|
||||
assert_eq!(leading_zero_bits(&[0x01]), 7);
|
||||
assert_eq!(leading_zero_bits(&[0x00, 0xff]), 8);
|
||||
assert_eq!(leading_zero_bits(&[0x00, 0x01]), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solves_and_the_solution_verifies() {
|
||||
let p = pow("challenge-abc", 12);
|
||||
let nonce = solve(&p).expect("should solve a 12-bit target");
|
||||
assert!(leading_zero_bits(&pow_hash(&p.challenge, &nonce)) >= 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solution_is_bound_to_the_challenge() {
|
||||
// A nonce solving one challenge should (overwhelmingly) not solve another
|
||||
// at the same difficulty — the work is challenge-specific.
|
||||
let nonce = solve(&pow("challenge-A", 12)).unwrap();
|
||||
assert!(leading_zero_bits(&pow_hash("challenge-B", &nonce)) < 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_algorithm() {
|
||||
let mut p = pow("c", 8);
|
||||
p.algorithm = "scrypt".to_string();
|
||||
assert!(solve(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_difficulty_is_trivially_solved() {
|
||||
assert_eq!(solve(&pow("c", 0)).as_deref(), Some("0"));
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ Positive:
|
||||
Risks:
|
||||
|
||||
- Many commands default to the public node; `git-remote-gitlawb` defaults to localhost. This split should be called out in README examples.
|
||||
- `gl sync trigger` signs the request when a local identity is available and falls back to the legacy unsigned request for live compatibility.
|
||||
- `gl sync trigger` requires a local identity and always sends a signed request; the `/api/v1/sync/trigger` route rejects unsigned calls, so the command fails locally when no identity is configured.
|
||||
- Several CLI commands parse dynamic JSON responses permissively; good for compatibility, but error messages can hide response-shape regressions.
|
||||
|
||||
## CI and release readiness
|
||||
|
||||
@@ -38,6 +38,16 @@ primary_region = "iad"
|
||||
# slots (hard_limit) — root factor in the 2026-06-12 prod outage
|
||||
idle_timeout = 120
|
||||
|
||||
[[http_service.checks]]
|
||||
# Gate routing (and deploys) on real readiness: /ready probes the DB, so a
|
||||
# node stuck retrying a dead or misconfigured database is never routed to,
|
||||
# and a deploy with a bad DATABASE_URL fails visibly instead of going green.
|
||||
interval = "15s"
|
||||
timeout = "5s"
|
||||
grace_period = "75s"
|
||||
method = "GET"
|
||||
path = "/ready"
|
||||
|
||||
[[services]]
|
||||
protocol = "udp"
|
||||
internal_port = 7546
|
||||
|
||||
Reference in new issue
Block a user