mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dac1283b | ||
|
|
570a4c75a3 |
No files matched your search
+5
-30
@@ -94,14 +94,9 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
|
||||
|
||||
# Require the authenticated pusher to be the repo owner on git-receive-pack.
|
||||
# A valid did:key signature is authentication, not authorization: anyone can
|
||||
# mint a key, derive its DID and sign, so with this off every signed caller may
|
||||
# push to every repository, private ones included. On by default.
|
||||
#
|
||||
# Set to false only for a rolling upgrade whose pushers are not yet the repo
|
||||
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
|
||||
# verified but not yet honored for authorization, so they cannot push while this
|
||||
# is on. See docs/RUN-A-NODE.md.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
|
||||
# Keep false until the repo owner is ready for owner-only writes.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=false
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
|
||||
@@ -196,9 +191,8 @@ GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
|
||||
# many WRITE-pool slots. This one is load-bearing for push availability, since
|
||||
# it is acquired before the global write permit: without it, one host minting
|
||||
# disposable did:key identities could open enough slow pushes to monopolize the
|
||||
# write pool and 503 every other source (the push rate limiter caps arrival
|
||||
# rate, not in-flight concurrency, and owner enforcement can be turned off for
|
||||
# a rolling upgrade).
|
||||
# write pool and 503 every other source (owner enforcement defaults off, and
|
||||
# the push rate limiter caps arrival rate, not in-flight concurrency).
|
||||
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
|
||||
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
|
||||
|
||||
@@ -223,16 +217,6 @@ GITLAWB_IPFS_WALK_PER_SOURCE=4
|
||||
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
|
||||
# truncated scan sheds a retryable 503, never a false 404. Default 256.
|
||||
GITLAWB_IPFS_MAX_LEGACY_PROBES=256
|
||||
# Max repo ROWS one /ipfs request's legacy scan may read from the database. The probe
|
||||
# ceiling above only counts once a probe runs, and quarantined or private repos are
|
||||
# denied before that, so without this an all-denying inventory paged the whole repo
|
||||
# table for one anonymous request. A truncated scan sheds a retryable 503 carrying a
|
||||
# sealed `continuation` token; echo it as ?scan= to resume, so a holder buried past the
|
||||
# ceiling is reachable in ceil(repos / ceiling) + 1 requests. Raising this also raises
|
||||
# the per-caller /ipfs work allowance, since each page is charged to it. Lowering it
|
||||
# sharpens a coarse oracle: laddering to the end reveals the node's total repo count to
|
||||
# within one ceiling. Default 2048.
|
||||
GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS=2048
|
||||
# Max EXPENSIVE path-scope visibility walks per single /ipfs request (only a
|
||||
# blob in a path-scoped repo costs a full-history walk). Over-cap repos are
|
||||
# skipped without a verdict and the scan continues; if the object is then found
|
||||
@@ -259,15 +243,6 @@ GITLAWB_IPFS_MAX_REPO_VISITS=1024
|
||||
# Must be 1..=3153600000 (100 years): the node derives an Instant deadline from
|
||||
# this value, and a larger one cannot be represented. Default 600.
|
||||
GITLAWB_IPFS_REQUEST_BUDGET_SECS=600
|
||||
# Shorter budget (seconds) for the pre-walk CID resolve: the lookup that maps a
|
||||
# requested CID to its git oid(s), which runs while the scarce walk admission is
|
||||
# already held. A well-formed CID with no pin row does no probe and no walk work,
|
||||
# so without this a stalled lookup could hold a walk slot for the whole request
|
||||
# budget while nothing walked. The effective deadline is the lesser of this and
|
||||
# the remaining request budget; walk and probe work stay on the request budget,
|
||||
# so a slow but progressing scan is never shed by it.
|
||||
# Must be 1..=3153600000 (100 years). Default 10.
|
||||
GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
|
||||
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
|
||||
# from the concurrency caps above). 0 disables. Default 600.
|
||||
GITLAWB_IPFS_RATE_LIMIT=600
|
||||
|
||||
+2
-4
@@ -31,9 +31,7 @@ crates/
|
||||
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN, ref certs)
|
||||
├── gitlawb-node/ axum HTTP server, git smart HTTP, P2P, GraphQL
|
||||
├── gl/ CLI — identity, repos, MCP server, Base L2 names
|
||||
├── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
|
||||
├── gitlawb-attest/ provenance attestations for ref-update certificates
|
||||
└── icaptcha-client/ client for the iCaptcha proof-of-intelligence service
|
||||
└── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
|
||||
docs/ Operator guides
|
||||
scripts/ Build helpers
|
||||
```
|
||||
@@ -108,7 +106,7 @@ cargo test -p gitlawb-node
|
||||
|
||||
- **TypeScript SDK** (`@gitlawb/sdk`) — client library for the HTTP API
|
||||
- **Python SDK** (`gitlawb`) — for ML/agent pipeline integration
|
||||
- **UCAN authorization** — add trusted issuer anchoring, capability checks, and authorization-aware revocation
|
||||
- **UCAN chain validation** — complete the auth middleware
|
||||
- **Filecoin storage tier** — wire up cold storage deals
|
||||
- **Documentation** — guides, tutorials, API examples
|
||||
- **Node operators** — run a public node and report issues
|
||||
|
||||
Generated
+91
-11
@@ -770,6 +770,23 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
|
||||
dependencies = [
|
||||
"ark-ff-asm 0.6.0",
|
||||
"ark-ff-macros 0.6.0",
|
||||
"ark-serialize 0.6.0",
|
||||
"ark-std 0.6.0",
|
||||
"digest 0.10.7",
|
||||
"educe",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-asm"
|
||||
version = "0.3.0"
|
||||
@@ -800,6 +817,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-asm"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-macros"
|
||||
version = "0.3.0"
|
||||
@@ -838,6 +865,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-macros"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize"
|
||||
version = "0.3.0"
|
||||
@@ -871,6 +911,30 @@ dependencies = [
|
||||
"num-bigint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
|
||||
dependencies = [
|
||||
"ark-serialize-derive",
|
||||
"ark-std 0.6.0",
|
||||
"digest 0.10.7",
|
||||
"num-bigint",
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-std"
|
||||
version = "0.3.0"
|
||||
@@ -901,6 +965,16 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-std"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -2556,7 +2630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2928,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3300,19 +3374,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3329,7 +3406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3356,7 +3433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3412,10 +3489,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
@@ -3821,6 +3899,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5739,14 +5818,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruint"
|
||||
version = "1.17.2"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
|
||||
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
|
||||
dependencies = [
|
||||
"alloy-rlp",
|
||||
"ark-ff 0.3.0",
|
||||
"ark-ff 0.4.2",
|
||||
"ark-ff 0.5.0",
|
||||
"ark-ff 0.6.0",
|
||||
"bytes",
|
||||
"fastrlp 0.3.1",
|
||||
"fastrlp 0.4.0",
|
||||
@@ -5820,7 +5900,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6714,7 +6794,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -53,9 +53,6 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# http client
|
||||
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
|
||||
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
|
||||
# take a parsed URL without pulling reqwest into gitlawb-core)
|
||||
url = "2"
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ The mission is simple: once code is pushed to the network, it should not disappe
|
||||
|
||||
## What is in this repository?
|
||||
|
||||
This is a Rust workspace with six crates:
|
||||
This is a Rust workspace with four crates:
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
@@ -27,8 +27,6 @@ This is a Rust workspace with six crates:
|
||||
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
|
||||
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
|
||||
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
|
||||
| `gitlawb-attest` | Pluggable external provenance attestations for ref-update certificates. |
|
||||
| `icaptcha-client` | Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +54,6 @@ Good today:
|
||||
- Bare git repository storage.
|
||||
- Git smart-HTTP clone/fetch/push.
|
||||
- RFC 9421-signed writes.
|
||||
- Repository and path-scoped visibility enforcement for repository and Git content reads, with 404-shaped repository denials.
|
||||
- DID identities.
|
||||
- `gl` CLI workflows.
|
||||
- libp2p peer discovery/gossip foundation.
|
||||
@@ -66,13 +63,11 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Private repository read enforcement is not wired yet. Treat public nodes as public infrastructure unless you restrict access at your proxy/firewall.
|
||||
- UCAN chain validation and revocation are not complete.
|
||||
- Repository write authorization is not capability-complete yet; HTTP signatures prove identity, not full authorization policy.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID) but are limited to the agent-task queue; repo writes are not exposed over GraphQL and remain REST-only.
|
||||
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
|
||||
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
|
||||
|
||||
See:
|
||||
|
||||
@@ -229,53 +224,6 @@ Notes:
|
||||
sends the bearer token **only** to your configured origin, never to a URL a
|
||||
node advertises, so a hostile node can't capture the key or redirect the solve.
|
||||
|
||||
### Fetching an object by CID
|
||||
|
||||
```bash
|
||||
gl ipfs list # CIDs this node has pinned
|
||||
gl ipfs get bafkrei... > object.bin # object bytes on stdout
|
||||
```
|
||||
|
||||
Objects that were pinned before the node started recording which repo they came
|
||||
from are found by scanning its repo inventory, and that scan stops at the
|
||||
per-request ceilings in the [Configuration](#configuration) table. A stopped scan
|
||||
answers 503 with a resume token instead of a false "not found", and `gl ipfs get`
|
||||
follows the token automatically: up to 8 resumes after the first request, so at
|
||||
most 9 calls to the node, waiting between attempts for as long as the node's
|
||||
`Retry-After` asks and never longer than 5 seconds.
|
||||
|
||||
The whole ladder runs under a 60 second wall-clock deadline. The deadline bounds
|
||||
the search, not the download: each attempt gets the time left on it to produce
|
||||
response headers, and once an object is found its bytes stream outside that
|
||||
deadline. They are not unbounded, though. The client's own 30 second HTTP timeout
|
||||
is a total request timeout, running from the start of a request until its body
|
||||
has finished, so a transfer still going 30 seconds after its request began is cut
|
||||
off. Waits between attempts never run past the deadline either, so a single run
|
||||
spends at most around 90 seconds on the network: the deadline plus the 30 second
|
||||
timeout covering the last attempt. Writing the object out sits outside both
|
||||
bounds, so piping into a reader that stops reading can hold the command open
|
||||
longer than that.
|
||||
|
||||
Two node-side brakes end a ladder early and are reported rather than retried
|
||||
around. A 429 is terminal, because the node's rate-limit window is an hour and
|
||||
that wait cannot be honored inside one invocation; a transient overload (a 503
|
||||
carrying no incomplete-scan code) is retried on the token already held, under the
|
||||
same cap, clamp and deadline. The per-IP fanout brake can also stop a ladder well
|
||||
short of the 9 calls, so automatic resumption is not a guarantee of reaching the
|
||||
object.
|
||||
|
||||
When a bound stops the ladder with a usable token in hand, the command prints the
|
||||
token and the invocation that continues from it before exiting nonzero:
|
||||
|
||||
```txt
|
||||
resume from where this stopped: gl ipfs get bafkrei... --scan <token>
|
||||
```
|
||||
|
||||
Run that to carry on from where the scan stopped. Re-running without `--scan`
|
||||
restarts at the first row, reproduces the same truncation and spends the node's
|
||||
per-IP budget again, so the token is the only thing that makes progress. Tokens
|
||||
are valid for an hour.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -392,8 +340,7 @@ Important node settings:
|
||||
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
|
||||
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
|
||||
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
|
||||
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
|
||||
@@ -406,11 +353,9 @@ Important node settings:
|
||||
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
|
||||
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
|
||||
| `GITLAWB_IPFS_MAX_LEGACY_PROBES` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
|
||||
| `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` | Max repo rows one `/ipfs/{cid}` request's legacy scan may read from the database. The probe ceiling above only starts counting once a probe runs, and quarantined or private repos are denied before that, so this is what bounds a scan over an inventory that denies the caller everywhere. A truncated scan sheds a retryable 503 carrying an opaque `continuation` token; echoing it as `?scan=` resumes the scan where it stopped. Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one, so each request advances the ladder by at least the rows it read and a holder buried past a ceiling is reached in a bounded number of requests, `ceil(repos / ceiling) + 1` when this row ceiling is the one that binds. No ceiling ever produces a 404. Every page is charged to the caller's `/ipfs` work allowance, so raising this raises that allowance too. Lowering it sharpens an oracle: because a truncation emits a token and a completed wrap does not, laddering to the end reveals the node's total repo count (private and quarantined included) to within one ceiling. Default 2048. |
|
||||
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks a `/ipfs/{cid}` request may run per phase; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. It is charged per phase: the provenance lookup and the legacy-scan fallback get separate equal budgets, so one request can run up to twice the cap in total. Default 64. |
|
||||
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks per `/ipfs/{cid}` request; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal per-request history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. Default 64. |
|
||||
| `GITLAWB_IPFS_MAX_REPO_VISITS` | Ceiling on repos one `/ipfs/{cid}` request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
|
||||
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
|
||||
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
@@ -524,8 +469,8 @@ Short-term priorities:
|
||||
2. Add Docker and installer smoke tests.
|
||||
3. Improve operator docs and `gl doctor` checks.
|
||||
4. Harden peer writes and publish the signed-peer rollout plan.
|
||||
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
|
||||
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
|
||||
5. Implement repo write authorization: owner checks, protected branches, and UCAN capability checks.
|
||||
6. Implement private-read enforcement or remove private repo affordances until it exists.
|
||||
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
|
||||
|
||||
Product direction:
|
||||
|
||||
+22
-32
@@ -28,11 +28,10 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
- Every git object is content-addressed via CIDv1 (SHA-256)
|
||||
- Tamper-evident by construction — a modified object changes its CID
|
||||
|
||||
**UCAN token validation**
|
||||
- Bootstrap UCAN tokens are issued at registration.
|
||||
- A supplied token's signature, audience, expiry, and proof-chain attenuation are validated.
|
||||
- Tokens use a signed JSON wire format with expiry.
|
||||
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
|
||||
**UCAN capability tokens**
|
||||
- Bootstrap UCAN tokens issued at registration
|
||||
- Capability-scoped: `git:push`, `git:fetch`, `issue:create`, `pr:open`
|
||||
- JWT-format tokens with expiry
|
||||
|
||||
**Smart contracts (Base Sepolia testnet)**
|
||||
- `GitlawbDIDRegistry` — on-chain DID → document registry
|
||||
@@ -49,20 +48,21 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
## Known Limitations (Planned for v0.2)
|
||||
|
||||
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
|
||||
### Repository write authorization defaults
|
||||
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
|
||||
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
|
||||
### UCAN chain validation
|
||||
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
|
||||
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
|
||||
- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### UCAN delegation and revocation
|
||||
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
|
||||
- Agent lifecycle revocation is not checked by HTTP Signature authorization. Removing or revoking an agent record does not itself block a compromised DID from authenticating.
|
||||
- **Impact:** Do not use UCANs for collaborator permissions or rely on agent-record revocation as a key-compromise response.
|
||||
- **Mitigation:** Keep sensitive deployments behind operational network controls and enable owner-only push enforcement where it fits the deployment until trusted delegation and authorization-aware revocation are implemented.
|
||||
### UCAN revocation
|
||||
- Issued UCAN tokens cannot be revoked before expiry.
|
||||
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
|
||||
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Until revocation/blocklisting is implemented, operators should remove compromised DIDs directly from their local database.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### git-receive-pack authentication
|
||||
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
|
||||
@@ -71,20 +71,10 @@ These are documented limitations of the current live release. They should be pri
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### Private repository reads
|
||||
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
|
||||
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
|
||||
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
|
||||
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
|
||||
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
|
||||
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
|
||||
|
||||
### Pull-request review enforcement
|
||||
- Pull-request review comments are not yet threaded or line-anchored, and merges do not enforce required approvals.
|
||||
- **Impact:** Teams must use their own review policy or external controls for merge approval requirements.
|
||||
|
||||
### GraphQL mutation coverage
|
||||
- Existing GraphQL mutations require an authenticated signer, but a mutation-specific source-level guardrail has not yet been added for future mutations.
|
||||
- **Impact:** A new mutation could accidentally omit its signer check without an explicit test fence.
|
||||
- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release.
|
||||
- **Impact:** Do not store private repositories or secrets on public nodes.
|
||||
- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### Peer route hardening rollout
|
||||
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
|
||||
@@ -111,7 +101,7 @@ These are documented limitations of the current live release. They should be pri
|
||||
| Key storage | PKCS#8 PEM, 0600 permissions |
|
||||
| Content hashing | SHA-256 via CIDv1 |
|
||||
| HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) |
|
||||
| UCAN tokens | Signed JSON object (Ed25519 signature) |
|
||||
| UCAN tokens | JWT (Ed25519 signatures) |
|
||||
| On-chain | ECDSA secp256k1 (Base L2 / Ethereum) |
|
||||
|
||||
---
|
||||
@@ -119,7 +109,7 @@ These are documented limitations of the current live release. They should be pri
|
||||
## Threat Model
|
||||
|
||||
gitlawb is designed to be secure against:
|
||||
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
- **Unauthorized writes** — HTTP Signature auth on all write endpoints
|
||||
- **Tampered git objects** — CIDv1 content addressing detects modification
|
||||
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
|
||||
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
|
||||
|
||||
@@ -11,7 +11,7 @@ name = "git-remote-gitlawb"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -199,7 +199,9 @@ fn handle_connect<R: Read>(
|
||||
other => bail!("unsupported git service: {other}"),
|
||||
}
|
||||
|
||||
let client = build_http_client()?;
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
// ── Phase 1: ref advertisement (GET /info/refs?service=<service>) ─────────
|
||||
//
|
||||
@@ -318,58 +320,6 @@ fn handle_connect<R: Read>(
|
||||
)
|
||||
}
|
||||
|
||||
// ── HTTP client ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Total request timeout. A pack transfer can be large and slow, so this is far
|
||||
/// wider than the CLI's.
|
||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
/// The one client both phases use, with the redirect policy the signing surfaces
|
||||
/// need.
|
||||
///
|
||||
/// Every request this client sends can carry RFC 9421 `Signature` and
|
||||
/// `Signature-Input` headers: a push signs from the first request, and a fetch of a
|
||||
/// private repo signs on the retry. reqwest strips only `Authorization`, `Cookie`,
|
||||
/// `Proxy-Authorization` and `WWW-Authenticate` across hosts, so under the default
|
||||
/// `Policy::limited(10)` those signature headers rode a 302 to whatever origin the
|
||||
/// node named, and on a 307/308 the pack body went with them. Scope the follow to the
|
||||
/// origin that issued the redirect AND to an identical request-target, which is the
|
||||
/// same predicate `gl` uses.
|
||||
fn build_http_client() -> Result<reqwest::blocking::Client> {
|
||||
Ok(reqwest::blocking::Client::builder()
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
|
||||
.build()?)
|
||||
}
|
||||
|
||||
/// Refuse any redirect that leaves the issuing origin or rewrites the request-target,
|
||||
/// and bound the chain.
|
||||
///
|
||||
/// Refusal is `stop`, not `error`: the 3xx comes back as an ordinary response and
|
||||
/// the caller reports it through the status path it already has.
|
||||
///
|
||||
/// `Policy::custom` replaces reqwest's built-in limit, so the chain bound is
|
||||
/// restated. It is not what makes the request finite (`HTTP_TIMEOUT` covers the whole
|
||||
/// chain); it is what keeps a node that redirects to itself from costing a request
|
||||
/// per round trip until that timeout. `>` and not `>=` because reqwest pushes the
|
||||
/// redirecting URL onto `previous` before consulting the policy, which is how
|
||||
/// `Policy::limited` reads the same counter.
|
||||
fn same_origin_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
|
||||
let Some(previous) = attempt.previous().last() else {
|
||||
// Unreachable through reqwest, which pushes the redirecting URL first, but
|
||||
// the safe reading of "cannot prove same-origin" is to refuse.
|
||||
return attempt.stop();
|
||||
};
|
||||
if attempt.previous().len() > gitlawb_core::redirect::MAX_REDIRECTS {
|
||||
return attempt.stop();
|
||||
}
|
||||
if gitlawb_core::redirect::may_follow(previous, attempt.url()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Smart-protocol request builders ───────────────────────────────────────────
|
||||
|
||||
const USER_AGENT: &str = "git/2.0 git-remote-gitlawb/0.1.0";
|
||||
@@ -908,438 +858,6 @@ mod tests {
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
/// The signed headers must not survive a redirect off the node's origin, on
|
||||
/// EITHER phase.
|
||||
///
|
||||
/// This is the binary git runs for `clone`, `fetch` and `push`. It built its
|
||||
/// client with a timeout and nothing else, so it ran reqwest's default
|
||||
/// `Policy::limited(10)`, and reqwest's cross-host header stripping covers
|
||||
/// `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` only.
|
||||
/// `Signature` and `Signature-Input` came straight through. A push signs from the
|
||||
/// first request, so a hostile node answering 302 was handed a working credential,
|
||||
/// and a 307/308 would have taken the pack body along.
|
||||
///
|
||||
/// Two mockito servers are two ports on one host, which is the boundary this
|
||||
/// policy draws. The second server answers everything and expects nothing, with a
|
||||
/// second mock matching on the `signature` header also at zero, so a followed
|
||||
/// redirect fails whether or not the signature came with it. Phase 1 is driven
|
||||
/// with a 302 and Phase 2 with a 308, the status that would carry the body.
|
||||
///
|
||||
/// MUTATION (RED): drop the `.redirect(...)` line from `build_http_client`.
|
||||
#[test]
|
||||
fn signed_requests_do_not_follow_a_redirect_off_the_node_origin() {
|
||||
let kp = Keypair::generate();
|
||||
let client = build_http_client().unwrap();
|
||||
|
||||
let mut elsewhere = mockito::Server::new();
|
||||
let never = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body("bytes from the redirect target")
|
||||
.expect(0)
|
||||
.create();
|
||||
let never_post = elsewhere
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create();
|
||||
let signature_seen_get = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create();
|
||||
let signature_seen_post = elsewhere
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let repo_base = format!("{}/zOwner/myrepo", node.url());
|
||||
let bounce_get = node
|
||||
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/info/refs", elsewhere.url()))
|
||||
.expect(1)
|
||||
.create();
|
||||
let bounce_post = node
|
||||
.mock(
|
||||
"POST",
|
||||
mockito::Matcher::Regex(r"/git-receive-pack$".to_string()),
|
||||
)
|
||||
.with_status(308)
|
||||
.with_header("location", &format!("{}/git-receive-pack", elsewhere.url()))
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let refs_url = format!("{repo_base}/info/refs?service=git-receive-pack");
|
||||
let advertisement = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
let body = b"0009done\n".to_vec();
|
||||
let post_url = format!("{repo_base}/git-receive-pack");
|
||||
let pack_post =
|
||||
build_pack_post_request(&client, &post_url, "git-receive-pack", &body, Some(&kp))
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
// The other origin first: it is the assertion that names the leak, and it must
|
||||
// be the one that speaks when a followed redirect makes every one of these
|
||||
// fail at once.
|
||||
never.assert();
|
||||
never_post.assert();
|
||||
signature_seen_get.assert();
|
||||
signature_seen_post.assert();
|
||||
bounce_get.assert();
|
||||
bounce_post.assert();
|
||||
|
||||
assert_eq!(
|
||||
advertisement.status(),
|
||||
302,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx"
|
||||
);
|
||||
assert!(
|
||||
!advertisement
|
||||
.text()
|
||||
.unwrap()
|
||||
.contains("bytes from the redirect target"),
|
||||
"the redirect target's bytes must never reach the caller"
|
||||
);
|
||||
assert_eq!(
|
||||
pack_post.status(),
|
||||
308,
|
||||
"the pack POST stops at the redirect too"
|
||||
);
|
||||
}
|
||||
|
||||
/// A same-origin hop that REWRITES the request-target is refused, even though the
|
||||
/// origin never changes.
|
||||
///
|
||||
/// The signature binds `@path` as the literal path-and-query the helper signed, and
|
||||
/// the node rebuilds it from the URI it received. An `info/refs` to `info/refs/`
|
||||
/// bounce therefore presents a signature over a target the node never saw, so a
|
||||
/// clone or push behind such a proxy 401s. Refusing the hop hands the caller the
|
||||
/// 3xx that names what happened instead.
|
||||
///
|
||||
/// The target mock expects zero hits and is asserted: mockito only checks
|
||||
/// `.expect(N)` when `.assert()` runs, so an unbound or unasserted mock passes
|
||||
/// vacuously.
|
||||
#[test]
|
||||
fn a_same_origin_path_changing_redirect_is_refused() {
|
||||
let kp = Keypair::generate();
|
||||
let client = build_http_client().unwrap();
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let bounce = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs")
|
||||
.with_status(301)
|
||||
.with_header("location", "/zOwner/myrepo/info/refs/")
|
||||
.expect(1)
|
||||
.create();
|
||||
let target = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs/")
|
||||
.with_status(200)
|
||||
.with_body("bytes from the rewritten target")
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let refs_url = format!("{}/zOwner/myrepo/info/refs", node.url());
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx"
|
||||
);
|
||||
assert!(
|
||||
!resp
|
||||
.text()
|
||||
.unwrap()
|
||||
.contains("bytes from the rewritten target"),
|
||||
"the rewritten target's bytes must never reach the caller"
|
||||
);
|
||||
bounce.assert();
|
||||
target.assert();
|
||||
}
|
||||
|
||||
/// The other direction, and the only same-origin hop still followed: a redirect
|
||||
/// that re-issues the IDENTICAL request-target.
|
||||
///
|
||||
/// Without this the policy could be tightened to `Policy::none()` and nothing in
|
||||
/// this crate would notice. It is also the first executed coverage of the chain
|
||||
/// bound in `build_http_client`'s policy closure: the route answers 301 pointing at
|
||||
/// itself, so bounded it is hit once for the original request plus `MAX_REDIRECTS`
|
||||
/// follows and the call returns the 301 (a refused hop stops rather than errors).
|
||||
/// Unbounded it would spin until `HTTP_TIMEOUT`, costing the node a request per
|
||||
/// round trip. Its twin is `a_self_redirect_stops_at_the_chain_bound` in
|
||||
/// `crates/gl/src/http.rs`.
|
||||
#[test]
|
||||
fn an_identical_target_redirect_is_still_followed_up_to_the_chain_bound() {
|
||||
let kp = Keypair::generate();
|
||||
let client = build_http_client().unwrap();
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let loop_route = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs")
|
||||
.with_status(301)
|
||||
.with_header("location", "/zOwner/myrepo/info/refs")
|
||||
.expect(gitlawb_core::redirect::MAX_REDIRECTS + 1)
|
||||
.create();
|
||||
|
||||
let refs_url = format!("{}/zOwner/myrepo/info/refs", node.url());
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.expect("the bound must end the chain, not the timeout");
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"the chain ends by refusing the next hop, so the last 3xx is what comes back"
|
||||
);
|
||||
loop_route.assert();
|
||||
}
|
||||
|
||||
// ── the node's own verification, run over what the helper actually sent ──
|
||||
|
||||
/// What the verifying mock made of a request that reached it.
|
||||
///
|
||||
/// The empty slot (`None`) is its own state and means the mock was never hit at
|
||||
/// all, which is what the refusal test asserts. It must stay distinguishable from
|
||||
/// [`Verdict::WrongIdentity`], because "nobody verified anything" and "something
|
||||
/// verified against the wrong key" are opposite findings.
|
||||
///
|
||||
/// The payloads are read through `Debug` in the assertion messages and nowhere
|
||||
/// else, which the dead-code pass does not count; they carry the detail that makes
|
||||
/// a failure legible, so they stay.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
enum Verdict {
|
||||
/// The chain accepted the signature AND the key it resolved is the test's DID.
|
||||
Accepted,
|
||||
/// The chain refused it. Carries the error so a failure reads as the actual
|
||||
/// rejection rather than a bare hit count.
|
||||
Rejected(String),
|
||||
/// The chain accepted a signature made by somebody else. A key resolved from
|
||||
/// the parsed `key_id` is read out of the artifact under verification, so an
|
||||
/// accept on it alone proves consistency, never authenticity.
|
||||
WrongIdentity { expected: String, got: String },
|
||||
}
|
||||
|
||||
/// The node's `require_signature` verification, over a request this crate did not
|
||||
/// necessarily build: parse the headers, recompute the content-digest from the
|
||||
/// body, rebuild the signing string over `@method`/`@path`/`content-digest`,
|
||||
/// Ed25519-verify. Returns the DID the signature resolved to, so a caller can pin
|
||||
/// the identity.
|
||||
///
|
||||
/// Its twin is the hand-copy in `crates/gl/src/http.rs`, which cannot import this
|
||||
/// module (this is a binary crate's test module). Keep the two textually identical
|
||||
/// apart from the mockito seam around them, so an edit to one is visibly an edit to
|
||||
/// both.
|
||||
///
|
||||
/// The production verifier both copies mirror is `crate::auth::require_signature` in
|
||||
/// `crates/gitlawb-node/src/auth/mod.rs`. This is a re-implementation, not a call, so
|
||||
/// an edit to that middleware has to land here too: otherwise the copies drift and
|
||||
/// this test keeps passing against a rule the node has stopped applying.
|
||||
///
|
||||
/// It asserts internally, which is deliberate but constrains its callers: inside
|
||||
/// `with_body_from_request` those assertions fire on the server thread and reach
|
||||
/// the client as a transport error, not as a recorded verdict. So the identity
|
||||
/// check lives in the caller as a [`Verdict`] variant, never as an assert in here.
|
||||
fn node_verifies(
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
sig_input: &str,
|
||||
sig_header: &str,
|
||||
content_digest: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
|
||||
use gitlawb_core::identity::verify;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let sig = HttpSignature::parse(sig_input, sig_header)?;
|
||||
sig.check_created()?;
|
||||
assert!(
|
||||
sig.missing_components().is_empty(),
|
||||
"signature must cover all required components"
|
||||
);
|
||||
assert_eq!(sig.alg, "ed25519");
|
||||
assert_eq!(
|
||||
content_digest,
|
||||
compute_content_digest(body),
|
||||
"content-digest must match the body"
|
||||
);
|
||||
let vk = sig.key_id.to_verifying_key()?;
|
||||
let mut values = HashMap::new();
|
||||
values.insert("@method".to_string(), method.to_uppercase());
|
||||
values.insert("@path".to_string(), path_and_query.to_string());
|
||||
values.insert("content-digest".to_string(), content_digest.to_string());
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
|
||||
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
|
||||
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
|
||||
verify(&vk, signing_string.as_bytes(), &sig_array)?;
|
||||
Ok(sig.key_id.to_string())
|
||||
}
|
||||
|
||||
/// Pull a header value off a received mockito request, or explain which one the
|
||||
/// helper failed to send.
|
||||
fn received_header(req: &mockito::Request, name: &str) -> String {
|
||||
req.header(name)
|
||||
.first()
|
||||
.unwrap_or_else(|| panic!("the helper sent no {name} header"))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Run [`node_verifies`] over a GET that arrived at the mock and record what the
|
||||
/// node would have made of it, pinned to `expected_did`.
|
||||
fn record_get_verdict(
|
||||
req: &mockito::Request,
|
||||
expected_did: &str,
|
||||
slot: &std::sync::Arc<std::sync::Mutex<Option<Verdict>>>,
|
||||
) {
|
||||
let verdict = match node_verifies(
|
||||
"GET",
|
||||
req.path_and_query(),
|
||||
b"",
|
||||
&received_header(req, "signature-input"),
|
||||
&received_header(req, "signature"),
|
||||
&received_header(req, "content-digest"),
|
||||
) {
|
||||
Ok(did) if did == expected_did => Verdict::Accepted,
|
||||
Ok(did) => Verdict::WrongIdentity {
|
||||
expected: expected_did.to_string(),
|
||||
got: did,
|
||||
},
|
||||
Err(e) => Verdict::Rejected(e.to_string()),
|
||||
};
|
||||
*slot.lock().unwrap() = Some(verdict);
|
||||
}
|
||||
|
||||
/// The finding's repro, now a guard: a rewritten same-origin target must never
|
||||
/// receive the advertisement's signature, and the proof is the node's own
|
||||
/// verification, not a hit count.
|
||||
///
|
||||
/// Post-fix the hop is refused, so the slot stays empty. Pre-fix the hop is
|
||||
/// followed and the slot records the Ed25519 rejection of a signature made over
|
||||
/// `.../info/refs?service=git-upload-pack` and presented at `.../info/refs/...`,
|
||||
/// which is the 401 an operator behind such a proxy actually sees. The verdict is
|
||||
/// asserted first, so a failure speaks about verification rather than about
|
||||
/// reachability.
|
||||
///
|
||||
/// Its paired positive control is
|
||||
/// `a_direct_signed_advertisement_verifies_under_the_node_verifier`: without it, an
|
||||
/// empty slot would be satisfied just as well by a harness that can never record
|
||||
/// anything.
|
||||
#[test]
|
||||
fn a_rewritten_target_never_receives_the_signature() {
|
||||
let kp = Keypair::generate();
|
||||
let expected_did = kp.did().to_string();
|
||||
let client = build_http_client().unwrap();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let bounce = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs?service=git-upload-pack")
|
||||
.with_status(301)
|
||||
.with_header(
|
||||
"location",
|
||||
"/zOwner/myrepo/info/refs/?service=git-upload-pack",
|
||||
)
|
||||
.expect(1)
|
||||
.create();
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let target = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs/?service=git-upload-pack")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let refs_url = format!(
|
||||
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
node.url()
|
||||
);
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
verdict.is_none(),
|
||||
"the node's own verifier must never see this request: the signature covers \
|
||||
/zOwner/myrepo/info/refs?service=git-upload-pack and the rewritten target \
|
||||
adds a trailing slash, so what arrives there is a stale request-target; \
|
||||
recorded verdict: {verdict:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"the caller sees the 3xx, not the rewritten target's answer"
|
||||
);
|
||||
bounce.assert();
|
||||
target.assert();
|
||||
}
|
||||
|
||||
/// The positive control for the test above, and the proof that the helper signs the
|
||||
/// query it sends.
|
||||
///
|
||||
/// A direct signed advertisement GET, no redirect anywhere, through the same
|
||||
/// verifying mock. The verdict must be `Accepted`, which is what makes the refusal
|
||||
/// test's empty slot attributable to the refusal rather than to a harness that
|
||||
/// cannot record. The advertisement URL carries `?service=`, so a helper that
|
||||
/// signed the bare path would land here as `Rejected`.
|
||||
#[test]
|
||||
fn a_direct_signed_advertisement_verifies_under_the_node_verifier() {
|
||||
let kp = Keypair::generate();
|
||||
let expected_did = kp.did().to_string();
|
||||
let client = build_http_client().unwrap();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = mockito::Server::new();
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let route = node
|
||||
.mock("GET", "/zOwner/myrepo/info/refs?service=git-upload-pack")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let refs_url = format!(
|
||||
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
node.url()
|
||||
);
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
matches!(verdict, Some(Verdict::Accepted)),
|
||||
"a direct signed advertisement must verify under the node's own chain and \
|
||||
resolve to {expected_did}, or the refusal test's empty slot proves \
|
||||
nothing; recorded verdict: {verdict:?}"
|
||||
);
|
||||
route.assert();
|
||||
}
|
||||
|
||||
/// The regression that round-1 missed: the Phase-2 `git-upload-pack` POST was
|
||||
/// left unsigned, so an owner's fetch of a private repo cleared the (now signed)
|
||||
/// advertisement and then 404'd on the pack POST. Drive BOTH request builders
|
||||
@@ -1619,13 +1137,49 @@ mod tests {
|
||||
/// to end (sign here, verify with the node's verifier), not reasoned.
|
||||
#[test]
|
||||
fn client_signature_verifies_under_node_verification_for_both_services() {
|
||||
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
|
||||
use gitlawb_core::identity::verify;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let body = b"0009done\n".to_vec();
|
||||
|
||||
// The verification chain is [`node_verifies`], the test-module helper the
|
||||
// redirect verdict tests share. Same primitives the node's require_signature
|
||||
// runs, over the request-target this crate transmits.
|
||||
// Re-implements the node's require_signature verification (auth/mod.rs):
|
||||
// parse headers, recompute content-digest from the body, rebuild the signing
|
||||
// string over @method/@path/content-digest, Ed25519-verify. Ok iff the node
|
||||
// would accept it.
|
||||
let node_verifies = |method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
sig_input: &str,
|
||||
sig_header: &str,
|
||||
content_digest: &str|
|
||||
-> anyhow::Result<()> {
|
||||
let sig = HttpSignature::parse(sig_input, sig_header)?;
|
||||
sig.check_created()?;
|
||||
assert!(
|
||||
sig.missing_components().is_empty(),
|
||||
"signature must cover all required components"
|
||||
);
|
||||
assert_eq!(sig.alg, "ed25519");
|
||||
assert_eq!(
|
||||
content_digest,
|
||||
compute_content_digest(body),
|
||||
"content-digest must match the body"
|
||||
);
|
||||
let vk = sig.key_id.to_verifying_key()?;
|
||||
let mut values = HashMap::new();
|
||||
values.insert("@method".to_string(), method.to_uppercase());
|
||||
values.insert("@path".to_string(), path_and_query.to_string());
|
||||
values.insert("content-digest".to_string(), content_digest.to_string());
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
|
||||
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
|
||||
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
|
||||
verify(&vk, signing_string.as_bytes(), &sig_array)?;
|
||||
Ok(())
|
||||
};
|
||||
// @path exactly as the node reconstructs it from the request it receives.
|
||||
let path_and_query = |req: &reqwest::blocking::Request| match req.url().query() {
|
||||
Some(q) => format!("{}?{}", req.url().path(), q),
|
||||
|
||||
@@ -22,7 +22,6 @@ multihash-codetable = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true, optional = true }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
pkcs8 = { version = "0.10", features = ["pem", "std"] }
|
||||
curve25519-dalek = "4"
|
||||
@@ -31,9 +30,3 @@ chacha20poly1305 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
# Not optional here: the redirect matrix must run under a bare
|
||||
# `cargo test -p gitlawb-core`, with no feature selected.
|
||||
url = { workspace = true }
|
||||
|
||||
[features]
|
||||
redirect = ["dep:url"]
|
||||
@@ -6,8 +6,6 @@
|
||||
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
|
||||
//! Nodes that receive a certificate with an unknown version MUST reject it.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -137,14 +135,10 @@ impl RefUpdateCert {
|
||||
|
||||
/// Check if this certificate satisfies a threshold of valid signatures
|
||||
/// from the provided set of authorized maintainer DIDs.
|
||||
///
|
||||
/// Counts distinct signer DIDs, not signature entries: a repeated
|
||||
/// signature from the same maintainer counts once.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
let valid = self.verify_all()?;
|
||||
let distinct_signers: HashSet<&Did> =
|
||||
valid.iter().filter(|d| maintainers.contains(d)).collect();
|
||||
Ok(distinct_signers.len() >= threshold)
|
||||
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
|
||||
Ok(count >= threshold)
|
||||
}
|
||||
|
||||
/// Validate the certificate structure (not signatures).
|
||||
@@ -350,33 +344,6 @@ mod tests {
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfies_threshold_rejects_duplicated_signature() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let kp3 = Keypair::generate();
|
||||
let repo_did = kp1.did();
|
||||
|
||||
let mut cert = RefUpdateCert::new(
|
||||
repo_did,
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
// Copy-paste the only real signature onto the certificate a second
|
||||
// time. Same signer, same valid signature, still one real signer.
|
||||
let dup = cert.signatures[0].clone();
|
||||
cert.signatures.push(dup);
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did(), kp3.did()];
|
||||
// Two signature entries but a single distinct signer must not
|
||||
// satisfy a 2-of-3 threshold.
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_always_satisfied() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -5,16 +5,7 @@ pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
// `url` is the one dependency here that drags a tail (idna, then the icu
|
||||
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
|
||||
// needs this predicate already parses URLs, so they opt in and nothing else
|
||||
// pays. `test` is in the cfg so `cargo test -p gitlawb-core` still compiles and
|
||||
// runs the matrix below with no feature selected; without it the tests would
|
||||
// silently not run, which is the failure this module exists to prevent.
|
||||
#[cfg(any(feature = "redirect", test))]
|
||||
pub mod redirect;
|
||||
pub mod sanitize;
|
||||
pub mod scan_token;
|
||||
pub mod ucan;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Redirect policy shared by every gitlawb HTTP client that signs its requests.
|
||||
//!
|
||||
//! Both `gl` (async) and `git-remote-gitlawb` (blocking) attach RFC 9421
|
||||
//! `Signature` and `Signature-Input` headers, and reqwest strips only
|
||||
//! `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` when a
|
||||
//! redirect crosses hosts. A signature would survive that hop, and it binds
|
||||
//! `@method`, `@path` and `content-digest` with no authority component, so a node
|
||||
//! answering 302 could hand a working credential to a host of its choosing and read
|
||||
//! as the caller anywhere until the clock-skew window closes. On a 307/308 the
|
||||
//! request body goes along with it, which for the remote helper is the pack.
|
||||
//!
|
||||
//! That same missing authority component is why the predicate also pins the
|
||||
//! request-target: `@path` is signed as the client sent it and verified as the node
|
||||
//! received it, so a same-origin hop that rewrites the path or the query (a
|
||||
//! trailing-slash or query normalization) makes the signature cover a target the
|
||||
//! node never saw and the read 401s. Only a hop that re-issues the identical target,
|
||||
//! an http-to-https upgrade being the one that matters in practice, is followed.
|
||||
//!
|
||||
//! The decision lives here rather than in either client because the two used to
|
||||
//! disagree: `gl` was scoped to the origin while the remote helper, the binary that
|
||||
//! actually runs `git clone gitlawb://`, still ran reqwest's default and followed
|
||||
//! anywhere. One predicate is what keeps a future third client from repeating that.
|
||||
//!
|
||||
//! The type is `url::Url`, which is what `reqwest::Url` re-exports, so both clients
|
||||
//! pass their attempt URLs straight in.
|
||||
|
||||
/// Longest redirect chain followed. `reqwest::redirect::Policy::custom` replaces
|
||||
/// reqwest's built-in limit, so the bound has to be restated by every client that
|
||||
/// installs a custom policy; the value is reqwest's own default.
|
||||
///
|
||||
/// This counts FOLLOWS, matching `Policy::limited`: reqwest pushes the redirecting
|
||||
/// URL onto `previous` before consulting the policy, and `Limit(max)` refuses once
|
||||
/// `previous.len() > max`, so a caller comparing against this constant must use `>`
|
||||
/// too or it permits one hop fewer than it says.
|
||||
pub const MAX_REDIRECTS: usize = 10;
|
||||
|
||||
/// Follow a redirect only when it stays on the origin that issued it AND re-issues
|
||||
/// the identical request-target.
|
||||
///
|
||||
/// `Policy::none()` would have been the simpler answer, but one same-origin redirect
|
||||
/// shape is legitimate here: a node fronted by a proxy that upgrades http to https,
|
||||
/// or otherwise re-issues the same path and query, so the policy is scoped rather
|
||||
/// than switched off.
|
||||
///
|
||||
/// Path and query must match exactly, and that is the request-target clause rather
|
||||
/// than an origin one. `@path` is signed as the client sent it and verified as the
|
||||
/// node received it, so a hop that rewrites either half leaves a signature covering
|
||||
/// a target nobody asked for and the node answers 401. Refusing the hop turns a
|
||||
/// confusing 401 into the 3xx that names what actually happened. One policy covers
|
||||
/// signed and unsigned callers alike, for the same reason the predicate is shared:
|
||||
/// two rules would drift.
|
||||
///
|
||||
/// The clause pins `@path`, and only `@path`. A gitlawb signature also covers
|
||||
/// `@method` and `content-digest`, and both of those are still open on a followed
|
||||
/// hop: on a 301, 302 or 303, reqwest 0.12.28 delegates to tower-http's
|
||||
/// `FollowRedirect`, which rewrites a POST to a GET and empties the body
|
||||
/// (tower-http-0.6.8 `src/follow_redirect/mod.rs:273-285`), while its
|
||||
/// `drop_payload_headers` removes only `Content-Type`, `Content-Length`,
|
||||
/// `Content-Encoding` and `Transfer-Encoding`. So `Signature`, `Signature-Input` and
|
||||
/// `Content-Digest` ride along on a request that no longer has the method or the body
|
||||
/// they were computed over. Only 307 and 308 preserve both. This predicate returning
|
||||
/// true therefore makes a GET-shaped hop safe to replay against the node's verifier
|
||||
/// and says nothing about a bodied one: a signed write must not rely on it alone.
|
||||
///
|
||||
/// `Url::query` is `None` for `/a` and `Some("")` for `/a?`, and those are two
|
||||
/// different request-targets on the node side too, so the comparison is strict and
|
||||
/// needs no special case.
|
||||
///
|
||||
/// Host and port must match exactly. Port is compared as `Url::port`, which is
|
||||
/// `None` for a scheme's default port, so http -> https on the same host compares
|
||||
/// equal while http -> http on a different port does not. A downgrade from https to
|
||||
/// http is refused as well: the target is the same host, but the signature would go
|
||||
/// out in cleartext, which is the same credential leak by a slower route.
|
||||
///
|
||||
/// Host comparison rides on `url`'s parse-time normalization (lowercasing and IDN
|
||||
/// -> punycode), so the spellings an attacker reaches for do not open a gap. That
|
||||
/// is a property of the parsed `Url`, not of this function, which is why the test
|
||||
/// matrix pins it: a move to raw string comparison would silently lose it.
|
||||
pub fn may_follow(previous: &url::Url, next: &url::Url) -> bool {
|
||||
let same_origin = next.host_str() == previous.host_str() && next.port() == previous.port();
|
||||
let same_target = next.path() == previous.path() && next.query() == previous.query();
|
||||
let downgraded = previous.scheme() == "https" && next.scheme() != "https";
|
||||
same_origin && same_target && !downgraded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every branch of the decision, both ways, plus the spellings that would slip
|
||||
/// past a comparison less careful than `Url`'s own normalization.
|
||||
#[test]
|
||||
fn may_follow_covers_each_origin_branch() {
|
||||
let url = |s: &str| url::Url::parse(s).unwrap();
|
||||
let cases: &[(&str, &str, bool, &str)] = &[
|
||||
(
|
||||
"http://node.example/a",
|
||||
"http://node.example/b",
|
||||
false,
|
||||
"same origin but the path changed: @path is signed as sent and verified as received",
|
||||
),
|
||||
(
|
||||
"http://node.example/a",
|
||||
"https://node.example/a",
|
||||
true,
|
||||
"http to https on one host with an identical request-target: both ports are \
|
||||
the scheme default, so the proxy upgrade is still followed",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://node.example/a/",
|
||||
false,
|
||||
"trailing-slash normalization changes the request-target, so it is refused",
|
||||
),
|
||||
(
|
||||
"https://node.example:8443/a",
|
||||
"https://node.example:8443/a",
|
||||
true,
|
||||
"same explicit port",
|
||||
),
|
||||
(
|
||||
"http://node.example/a",
|
||||
"http://attacker.example/a",
|
||||
false,
|
||||
"different host",
|
||||
),
|
||||
(
|
||||
"http://node.example/a",
|
||||
"http://node.example:8080/a",
|
||||
false,
|
||||
"same host, different port",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"http://node.example/a",
|
||||
false,
|
||||
"https downgraded to cleartext on the same host",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"http://node.example:443/a",
|
||||
false,
|
||||
"a downgrade dressed up as the https port",
|
||||
),
|
||||
// The rows below pass today because `Url::parse` normalizes the host, not
|
||||
// because anything here compares case-insensitively or decodes IDN. They
|
||||
// are the variants an attacker reaches for, so they are pinned: swapping
|
||||
// this predicate for a raw string comparison must break the suite.
|
||||
// Each of these pairs an IDENTICAL path on both sides, deliberately. The
|
||||
// request-target clause below would make every one of them false on the
|
||||
// path alone, and a row that is false for two reasons has stopped pinning
|
||||
// either. With the paths equal, the host or port comparison is the only
|
||||
// thing left that can decide them.
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://NODE.EXAMPLE/a",
|
||||
true,
|
||||
"same host in a different case: parse lowercases it",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://node.example./a",
|
||||
false,
|
||||
"a trailing dot is a different host to url, so the redirect is refused",
|
||||
),
|
||||
(
|
||||
"https://exämple.test/a",
|
||||
"https://xn--exmple-cua.test/a",
|
||||
true,
|
||||
"unicode host and its punycode spelling are one host after parse",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://user:[email protected]/a",
|
||||
true,
|
||||
"userinfo is not part of the origin: same host, still followed",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://[email protected]/a",
|
||||
false,
|
||||
"the node's name smuggled into userinfo: the host is the attacker's",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://attacker.example/a#node.example",
|
||||
false,
|
||||
"the node's name pushed into the fragment: the host is the attacker's",
|
||||
),
|
||||
// The request-target clause, both directions. `@path` is the only thing a
|
||||
// gitlawb signature binds the request to, so a hop that rewrites it hands
|
||||
// the node a signature over a target it never received.
|
||||
(
|
||||
"https://node.example/a?x=1",
|
||||
"https://node.example/a?x=1",
|
||||
true,
|
||||
"identical request-target: the same-origin hop that is still followed",
|
||||
),
|
||||
(
|
||||
"https://node.example/a?x=1",
|
||||
"https://node.example/a?x=2",
|
||||
false,
|
||||
"same path but a different query: the request-target covers the query too",
|
||||
),
|
||||
(
|
||||
"https://node.example/a",
|
||||
"https://node.example/a?x=1",
|
||||
false,
|
||||
"a query added where there was none",
|
||||
),
|
||||
(
|
||||
"http://node.example/a",
|
||||
"http://node.example/a?",
|
||||
false,
|
||||
"an empty query added where there was none: a missing query and an empty \
|
||||
one are different request-targets",
|
||||
),
|
||||
(
|
||||
"https://node.example/a?x=1",
|
||||
"https://node.example/a",
|
||||
false,
|
||||
"the query dropped where there was one: the comparison is symmetric, and \
|
||||
nothing else in the matrix pins that direction",
|
||||
),
|
||||
(
|
||||
"https://node.example/a#x",
|
||||
"https://node.example/a#y",
|
||||
true,
|
||||
"a fragment-only difference is still followed: a fragment never reaches \
|
||||
the wire, so it is no part of the request-target the node verifies",
|
||||
),
|
||||
];
|
||||
for (previous, next, expected, why) in cases {
|
||||
assert_eq!(
|
||||
may_follow(&url(previous), &url(next)),
|
||||
*expected,
|
||||
"{previous} -> {next} ({why})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
//! Sealed continuation token for the node's bounded legacy CID scan (INV-13).
|
||||
//!
|
||||
//! The `/ipfs/{cid}` resolver's legacy scan stops at a row ceiling and sheds a
|
||||
//! retryable 503. To let a holder buried past that ceiling still be reached, the
|
||||
//! shed carries the scan position so the caller can echo it back and resume. The
|
||||
//! whole point of the design is that the node keeps NO server-side scan state: the
|
||||
//! position rides in the caller's token.
|
||||
//!
|
||||
//! That makes the token an EMITTED continuation derived from a FETCHED row, and on
|
||||
//! a scan that served nothing every fetched row is by construction a private or
|
||||
//! quarantined repo the caller may not read. The row's `created_at` leaks its
|
||||
//! creation time and its `id` carries the owner's DID, so both halves are withheld
|
||||
//! fields and the token must be CONFIDENTIAL, not merely tamper-evident:
|
||||
//!
|
||||
//! * AEAD-sealed (XChaCha20-Poly1305), never base64-of-plaintext and never
|
||||
//! signed plaintext. Integrity is not confidentiality.
|
||||
//! * A fresh `OsRng` nonce on EVERY seal. Under a stream cipher a repeated nonce
|
||||
//! means repeated keystream, and an attacker who can force the node to seal a
|
||||
//! position whose plaintext they know XORs two tokens and recovers a withheld
|
||||
//! row's fields in full, strictly worse than emitting plaintext.
|
||||
//! * FIXED-WIDTH plaintext. AEAD ciphertext is plaintext-length plus the tag, and
|
||||
//! every field of a scan position varies in length, so a variable encoding would
|
||||
//! make token LENGTH a side channel for the sealed row (a short name under a
|
||||
//! short owner vs a long one) and for the candidate oid's width (40 hex on a
|
||||
//! sha1 repo, 64 on a sha256 one). Every token this module mints is byte-identical
|
||||
//! in length. Each field is padded to its own separate width, which is a per-field
|
||||
//! constant and so still leaks nothing about a given row or candidate.
|
||||
//! * The canonical CID as associated data, so a token minted while scanning for
|
||||
//! one CID does not authenticate when replayed against another.
|
||||
//!
|
||||
//! Every failure to open (wrong key, tampered bytes, wrong CID, expired, malformed)
|
||||
//! returns the same `None`. The caller treats that as "no token" and starts at the
|
||||
//! front, so no failure class is distinguishable and the token is no oracle.
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit, OsRng, Payload},
|
||||
XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
|
||||
/// Keyset position of the last row a truncated scan fetched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScanPosition {
|
||||
/// The row's raw stored `created_at` text, the first half of the keyset cursor.
|
||||
pub created_at_key: String,
|
||||
/// The row's `id`, the tiebreaking half of the keyset cursor.
|
||||
pub id: String,
|
||||
/// The oid hex of the CANDIDATE this position resumes. One CID can map to several
|
||||
/// git oids, so a bare row cursor names a row without naming whose walk it belongs
|
||||
/// to. The candidate is named by IDENTITY rather than by position in the candidate
|
||||
/// list: that list is ordered by hex and mutates between rungs, so an index would
|
||||
/// name a different candidate the moment anything is pinned or unpinned.
|
||||
pub sha256_hex: String,
|
||||
}
|
||||
|
||||
/// Plaintext version byte, so a future layout change is a clean open-failure
|
||||
/// (treated as absent) rather than a misparse.
|
||||
///
|
||||
/// Bumped to 2 when the two halves stopped sharing one width (see [`ID_WIDTH`]), and
|
||||
/// to 3 when the position gained the candidate oid it resumes (see [`OID_WIDTH`]).
|
||||
/// A token minted under an earlier layout is a different length and a different
|
||||
/// framing, so it opens to `None` and the caller restarts at the front, which is the
|
||||
/// safe direction: a misparse would resume at a fabricated row and skip coverage.
|
||||
const VERSION: u8 = 3;
|
||||
|
||||
/// Byte width the `created_at` half is padded to. Every value stored here is a
|
||||
/// serialized timestamp, about 30 bytes, so 64 is roomy for the field's whole domain.
|
||||
/// It is deliberately NOT widened to match [`ID_WIDTH`]: padding both halves to the id
|
||||
/// width would nearly double every token for a field that can never use the space.
|
||||
const CREATED_WIDTH: usize = 64;
|
||||
|
||||
/// Byte width the `id` half is padded to.
|
||||
///
|
||||
/// The bound is set by the WRITERS, not by what a typical id happens to look like.
|
||||
/// `upsert_mirror_repo` builds `repos.id` as `{owner}/{name}`, and the slug validators
|
||||
/// in the node's `repo_store` admit an owner of up to 255 bytes and a name of up to
|
||||
/// 100, so 356 bytes is reachable through the ordinary write path and repo names are
|
||||
/// peer-controllable. 384 clears that with margin.
|
||||
///
|
||||
/// Under-sizing this is not a cosmetic bug. A row at a truncation boundary whose id
|
||||
/// exceeds the width fails the seal, the handler sheds a 503 with no continuation, and
|
||||
/// a tokenless shed is byte-identical to the wrapped-scan response whose contract is
|
||||
/// "the absence of a token means the ladder is over". The boundary row is deterministic
|
||||
/// for a stable inventory, so every retry reproduces it and every row past it becomes
|
||||
/// permanently unreachable. Anything past the width still fails loudly rather than
|
||||
/// silently truncating a cursor into one that resumes at the wrong row.
|
||||
const ID_WIDTH: usize = 384;
|
||||
|
||||
/// Byte width the candidate oid half is padded to.
|
||||
///
|
||||
/// Git mints exactly two oid widths and this field carries BOTH. A production repo is
|
||||
/// created by `store::init_bare` with `git init --bare --object-format=sha1`, so its
|
||||
/// oids are 40 hex; only the sha256 test fixtures mint 64. The field is therefore
|
||||
/// length-prefixed like the two row halves rather than a bare fixed 64: a 64-only
|
||||
/// field would fail every seal on a real deployment, and a failed seal sheds a
|
||||
/// tokenless 503 that is byte-identical to "your ladder is over".
|
||||
///
|
||||
/// The padding to 64 is what keeps the WIDTH off the wire. Without it a 40-hex token
|
||||
/// is 24 bytes shorter than a 64-hex one, and token length would say which object
|
||||
/// format the holder's repo uses.
|
||||
const OID_WIDTH: usize = 64;
|
||||
|
||||
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
|
||||
const PLAINTEXT_LEN: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 2 + OID_WIDTH + 8;
|
||||
|
||||
/// Nonce width for XChaCha20-Poly1305.
|
||||
const NONCE_LEN: usize = 24;
|
||||
|
||||
/// A fresh random 32-byte sealing key from the OS CSPRNG.
|
||||
pub fn new_key() -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
key
|
||||
}
|
||||
|
||||
// The two halves below are deliberately separate and adjacent. The framing pair owns
|
||||
// "every token is the same length"; the AEAD pair owns "the contents are confidential
|
||||
// and CID-bound". Keeping them apart is what lets each property be exercised (and
|
||||
// broken) without disturbing the other.
|
||||
|
||||
/// Encode a position into the FIXED-WIDTH plaintext:
|
||||
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
|
||||
///
|
||||
/// The padding is the point. AEAD ciphertext is plaintext-length plus the tag, and every
|
||||
/// field of a scan position varies in length, so a length-prefixed encoding with no
|
||||
/// padding would make token LENGTH a side channel for the sealed row and for the
|
||||
/// candidate's object format. Each field is padded to its OWN fixed width, which keeps
|
||||
/// every minted token the same length while letting the id half carry the range the
|
||||
/// write path actually admits.
|
||||
fn encode_position(pos: &ScanPosition, expires_at_unix: i64) -> anyhow::Result<Vec<u8>> {
|
||||
let mut out = vec![0u8; PLAINTEXT_LEN];
|
||||
out[0] = VERSION;
|
||||
let mut at = 1;
|
||||
for (field, width) in [
|
||||
(pos.created_at_key.as_bytes(), CREATED_WIDTH),
|
||||
(pos.id.as_bytes(), ID_WIDTH),
|
||||
(pos.sha256_hex.as_bytes(), OID_WIDTH),
|
||||
] {
|
||||
if field.len() > width {
|
||||
// Loud rather than truncating: a clipped cursor resumes at the wrong row and
|
||||
// silently skips coverage, which is the availability half of the bug this
|
||||
// token exists to fix.
|
||||
anyhow::bail!(
|
||||
"scan token field is {} bytes, over the {width}-byte fixed width",
|
||||
field.len()
|
||||
);
|
||||
}
|
||||
out[at..at + 2].copy_from_slice(&(field.len() as u16).to_le_bytes());
|
||||
at += 2;
|
||||
out[at..at + field.len()].copy_from_slice(field);
|
||||
at += width;
|
||||
}
|
||||
out[at..at + 8].copy_from_slice(&expires_at_unix.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode what [`encode_position`] wrote. `None` on any structural mismatch.
|
||||
fn decode_position(bytes: &[u8]) -> Option<(ScanPosition, i64)> {
|
||||
if bytes.len() != PLAINTEXT_LEN || bytes[0] != VERSION {
|
||||
return None;
|
||||
}
|
||||
let mut at = 1;
|
||||
let mut fields = [const { String::new() }; 3];
|
||||
for (slot, width) in fields.iter_mut().zip([CREATED_WIDTH, ID_WIDTH, OID_WIDTH]) {
|
||||
let len = u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize;
|
||||
at += 2;
|
||||
if len > width {
|
||||
return None;
|
||||
}
|
||||
*slot = String::from_utf8(bytes[at..at + len].to_vec()).ok()?;
|
||||
at += width;
|
||||
}
|
||||
let expires_at = i64::from_le_bytes(bytes[at..at + 8].try_into().ok()?);
|
||||
let [created_at_key, id, sha256_hex] = fields;
|
||||
// A zero-length candidate is a third state the encoder never mints. The front-of-table
|
||||
// sentinel is empty ROW halves with a real oid, so an empty oid would hand the resume
|
||||
// path a candidate that names nothing; refuse it like every other malformed frame.
|
||||
if sha256_hex.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
ScanPosition {
|
||||
created_at_key,
|
||||
id,
|
||||
sha256_hex,
|
||||
},
|
||||
expires_at,
|
||||
))
|
||||
}
|
||||
|
||||
/// AEAD-seal `plaintext` under `key`, bound to `cid`, framed as `nonce || ciphertext`.
|
||||
fn seal_bytes(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let cipher = XChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("scan token key: {e}"))?;
|
||||
// A FRESH nonce per seal, from the OS CSPRNG. Under a stream cipher a repeated nonce
|
||||
// repeats the keystream, and two tokens sealed under one nonce XOR to the difference
|
||||
// of their plaintexts, which recovers a withheld row in full when the attacker can
|
||||
// force one of the two positions. This draw is the property the whole confidentiality
|
||||
// claim rests on.
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let sealed = cipher
|
||||
.encrypt(
|
||||
XNonce::from_slice(&nonce),
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
// The canonical CID as associated data: a token minted while scanning for
|
||||
// one CID does not authenticate against another, so it cannot be replayed
|
||||
// to seed a different scan.
|
||||
aad: cid.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("scan token seal: {e}"))?;
|
||||
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&sealed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Open what [`seal_bytes`] framed. `None` on any failure, including a wrong `cid`.
|
||||
fn open_bytes(key: &[u8; 32], cid: &str, raw: &[u8]) -> Option<Vec<u8>> {
|
||||
if raw.len() <= NONCE_LEN + 16 {
|
||||
return None;
|
||||
}
|
||||
let (nonce, sealed) = raw.split_at(NONCE_LEN);
|
||||
let cipher = XChaCha20Poly1305::new_from_slice(key).ok()?;
|
||||
cipher
|
||||
.decrypt(
|
||||
XNonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: sealed,
|
||||
aad: cid.as_bytes(),
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Seal `pos` under `key`, bound to `cid`, expiring at `expires_at_unix`.
|
||||
///
|
||||
/// Returns the base64url (no pad) token. Errors only when a field exceeds
|
||||
/// its own fixed width ([`CREATED_WIDTH`], [`ID_WIDTH`], [`OID_WIDTH`]) or the AEAD
|
||||
/// itself fails, never silently truncates.
|
||||
pub fn seal_scan_token(
|
||||
key: &[u8; 32],
|
||||
cid: &str,
|
||||
pos: &ScanPosition,
|
||||
expires_at_unix: i64,
|
||||
) -> anyhow::Result<String> {
|
||||
let plaintext = encode_position(pos, expires_at_unix)?;
|
||||
Ok(B64URL.encode(seal_bytes(key, cid, &plaintext)?))
|
||||
}
|
||||
|
||||
/// Open a token minted by [`seal_scan_token`] under the same key and CID.
|
||||
///
|
||||
/// `None` for every failure class alike (wrong key, tampered, foreign CID, expired,
|
||||
/// malformed, wrong version), so the caller can treat all of them as "absent" without
|
||||
/// leaking which one occurred.
|
||||
pub fn open_scan_token(
|
||||
key: &[u8; 32],
|
||||
cid: &str,
|
||||
token: &str,
|
||||
now_unix: i64,
|
||||
) -> Option<ScanPosition> {
|
||||
let raw = B64URL.decode(token).ok()?;
|
||||
let plaintext = open_bytes(key, cid, &raw)?;
|
||||
let (pos, expires_at) = decode_position(&plaintext)?;
|
||||
if now_unix >= expires_at {
|
||||
return None;
|
||||
}
|
||||
Some(pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A production-shaped candidate: `git init --bare --object-format=sha1`, so 40 hex.
|
||||
const OID_40: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
|
||||
/// A test-fixture-shaped candidate: the sha256 repos the suite creates mint 64 hex.
|
||||
const OID_64: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn pos(created: &str, id: &str) -> ScanPosition {
|
||||
pos_for(created, id, OID_40)
|
||||
}
|
||||
|
||||
fn pos_for(created: &str, id: &str, sha256_hex: &str) -> ScanPosition {
|
||||
ScanPosition {
|
||||
created_at_key: created.to_string(),
|
||||
id: id.to_string(),
|
||||
sha256_hex: sha256_hex.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seal a hand-built plaintext through the AEAD half, so a test can frame bytes the
|
||||
/// encoder would never mint (an old version byte, a zero-length oid) and still exercise
|
||||
/// the real open path.
|
||||
fn seal_raw(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> String {
|
||||
B64URL.encode(seal_bytes(key, cid, plaintext).unwrap())
|
||||
}
|
||||
|
||||
/// Byte offset of the oid length prefix inside the plaintext, derived from the widths
|
||||
/// rather than hardcoded so a width change moves it with the layout.
|
||||
const OID_LEN_AT: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH;
|
||||
|
||||
/// Scenario 1: both of git's oid widths round trip. The 40-hex case is the PRODUCTION
|
||||
/// shape (`git init --bare --object-format=sha1`), and an all-64 fixture suite would
|
||||
/// never exercise it, which is exactly how a fixed-64 field would ship broken.
|
||||
#[test]
|
||||
fn round_trips_at_both_oid_widths() {
|
||||
let key = new_key();
|
||||
for hex in [OID_40, OID_64] {
|
||||
let p = pos_for("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo", hex);
|
||||
let t =
|
||||
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("both oid widths must seal");
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkcid", &t, 0),
|
||||
Some(p),
|
||||
"a {}-hex candidate must open to the identical position",
|
||||
hex.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scenario 2: a token framed under the OLD version opens to `None`, never a misparse.
|
||||
/// Both legs matter: the version-2 plaintext was a different LENGTH, and a future
|
||||
/// same-length layout would only be caught by the version byte itself.
|
||||
#[test]
|
||||
fn a_prior_layout_version_opens_to_none() {
|
||||
let key = new_key();
|
||||
|
||||
// The version-2 layout verbatim: no oid field, so 461 bytes.
|
||||
let mut old = vec![0u8; 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 8];
|
||||
old[0] = 2;
|
||||
assert_eq!(old.len(), 461, "the version-2 plaintext was 461 bytes");
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &old), 0),
|
||||
None,
|
||||
"a version-2 token must open to None so the caller restarts at the front"
|
||||
);
|
||||
|
||||
// Same length, stale version byte: only the version check can refuse this one.
|
||||
let mut stamped = encode_position(
|
||||
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
|
||||
1 << 40,
|
||||
)
|
||||
.unwrap();
|
||||
stamped[0] = VERSION - 1;
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &stamped), 0),
|
||||
None,
|
||||
"a stale version byte must open to None even at the current width"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 3: token length is invariant across candidate VALUE and candidate WIDTH.
|
||||
/// The padding is what keeps the oid width off the wire: without it a 40-hex token is
|
||||
/// 24 bytes shorter than a 64-hex one and the length says which repo format the
|
||||
/// holder uses.
|
||||
#[test]
|
||||
fn token_length_is_invariant_across_oid_widths() {
|
||||
let key = new_key();
|
||||
let created = "2020-01-01T00:00:00+00:00";
|
||||
let id = "z6MkOwner/private-repo";
|
||||
let short =
|
||||
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_40), 1 << 40).unwrap();
|
||||
let long =
|
||||
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_64), 1 << 40).unwrap();
|
||||
assert_eq!(
|
||||
short.len(),
|
||||
long.len(),
|
||||
"a 40-hex and a 64-hex candidate must mint tokens of identical length, or the \
|
||||
oid width is a side channel"
|
||||
);
|
||||
|
||||
// The absolute width, pinned by execution rather than by arithmetic on paper. The
|
||||
// gl client's mock fixtures hardcode this number (`TOKEN_LEN` in
|
||||
// crates/gl/src/ipfs_cmd.rs), and nothing in that crate seals a real token, so this
|
||||
// assertion is the only executable check that the two agree.
|
||||
assert_eq!(
|
||||
short.len(),
|
||||
756,
|
||||
"24 nonce + {PLAINTEXT_LEN} plaintext + 16 tag, base64url no pad"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 4: the front-of-table sentinel. Empty row halves with a REAL candidate
|
||||
/// round trip, which is what lets a seal say "this candidate, no row cursor yet".
|
||||
#[test]
|
||||
fn empty_row_fields_round_trip_with_a_real_candidate() {
|
||||
let key = new_key();
|
||||
let p = pos_for("", "", OID_40);
|
||||
let t =
|
||||
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("the front sentinel must seal");
|
||||
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
|
||||
}
|
||||
|
||||
/// Scenario 6, third leg: a zero-length oid is a distinguishable third state that the
|
||||
/// encoder never mints, and accepting it would hand the sentinel machinery a candidate
|
||||
/// naming nothing. The decode path refuses it.
|
||||
#[test]
|
||||
fn a_zero_length_candidate_opens_to_none() {
|
||||
let key = new_key();
|
||||
let mut plaintext = encode_position(
|
||||
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
|
||||
1 << 40,
|
||||
)
|
||||
.unwrap();
|
||||
plaintext[OID_LEN_AT..OID_LEN_AT + 2].copy_from_slice(&0u16.to_le_bytes());
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &plaintext), 0),
|
||||
None,
|
||||
"a zero-length candidate must open to None, not to a position naming nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 6, second leg: an oid past the 64-byte width fails the seal loudly rather
|
||||
/// than being clipped into a hex that names a different candidate.
|
||||
#[test]
|
||||
fn an_oid_over_the_fixed_width_fails_loudly() {
|
||||
let key = new_key();
|
||||
let p = pos_for(
|
||||
"2020-01-01T00:00:03+00:00",
|
||||
"z6MkOwner/private-repo",
|
||||
&"a".repeat(OID_WIDTH + 1),
|
||||
);
|
||||
assert!(
|
||||
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
|
||||
"an over-wide candidate must fail the seal, never be truncated into a hex that \
|
||||
resumes the wrong candidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_under_the_same_key_and_cid() {
|
||||
let key = new_key();
|
||||
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
|
||||
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
|
||||
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_failure_class_opens_to_none() {
|
||||
let key = new_key();
|
||||
let other = new_key();
|
||||
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
|
||||
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
|
||||
|
||||
assert_eq!(open_scan_token(&other, "bafkcid", &t, 0), None, "wrong key");
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkOTHER", &t, 0),
|
||||
None,
|
||||
"foreign CID"
|
||||
);
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkcid", &t, 1 << 41),
|
||||
None,
|
||||
"expired"
|
||||
);
|
||||
assert_eq!(open_scan_token(&key, "bafkcid", "!!not b64", 0), None);
|
||||
assert_eq!(open_scan_token(&key, "bafkcid", "", 0), None);
|
||||
let mut flipped: Vec<u8> = t.bytes().collect();
|
||||
let last = flipped.len() - 1;
|
||||
flipped[last] = if flipped[last] == b'A' { b'B' } else { b'A' };
|
||||
assert_eq!(
|
||||
open_scan_token(&key, "bafkcid", &String::from_utf8(flipped).unwrap(), 0),
|
||||
None,
|
||||
"tampered"
|
||||
);
|
||||
}
|
||||
|
||||
/// The id half must clear the LARGEST repo id the node's own write path admits,
|
||||
/// not merely a typical one. `upsert_mirror_repo` builds `repos.id` as
|
||||
/// `{owner}/{name}`, and the slug validators in `repo_store` admit 255 bytes of
|
||||
/// owner and 100 of name, so 356 is reachable. A width under that turns the
|
||||
/// boundary row into a seal failure, which sheds a tokenless 503 that is
|
||||
/// byte-identical to "your ladder is over" and strands every row past it forever.
|
||||
#[test]
|
||||
fn round_trips_a_repo_id_at_the_write_paths_maximum() {
|
||||
let key = new_key();
|
||||
let id = format!("{}/{}", "o".repeat(255), "n".repeat(100));
|
||||
assert_eq!(id.len(), 356, "255 owner + '/' + 100 name");
|
||||
let p = pos("2020-01-01T00:00:03+00:00", &id);
|
||||
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40)
|
||||
.expect("a repo id the write path admits must seal, never fail the width");
|
||||
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_over_the_fixed_width_fails_loudly() {
|
||||
let key = new_key();
|
||||
let p = pos("2020-01-01T00:00:03+00:00", &"x".repeat(ID_WIDTH + 1));
|
||||
assert!(
|
||||
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
|
||||
"an over-wide field must fail the seal, never be truncated into a cursor \
|
||||
that resumes at the wrong row"
|
||||
);
|
||||
}
|
||||
}
|
||||
+191
-5764
File diff suppressed because it is too large.
Load diff
@@ -279,554 +279,6 @@ mod authz_guard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Completeness fence over the GraphQL mutation surface: the mirror of
|
||||
/// `every_in_scope_mutation_has_its_gate` for `graphql/mutation.rs`. Kept a
|
||||
/// SEPARATE guard with its own table (never folded into the REST rows above):
|
||||
/// a guard over a heterogeneous set must classify per member, and these
|
||||
/// same-named task rows belong to the GraphQL surface, not `api/tasks.rs`.
|
||||
///
|
||||
/// COMPLETENESS comes from the engine's own SDL (`schema.sdl()`), parsed as a
|
||||
/// structured AST (#219), NOT introspection and NOT a text parse of source.
|
||||
/// This matters: async-graphql OMITS `#[graphql(visible = false)]` fields from
|
||||
/// introspection while leaving them executable, so an introspection-sourced fence
|
||||
/// could be defeated by a hidden mutation. SDL includes those fields, so no
|
||||
/// visibility flag (and, via the AST parse, no brace-in-description quirk) can hide
|
||||
/// a live mutation. The SDL field set must equal the registered set: a new field with
|
||||
/// no row fails (a repo-write cannot slip in unlisted); a registered name absent from
|
||||
/// the schema fails (renamed or removed). The set is also cross-checked against
|
||||
/// introspection, so any field present in SDL but hidden from introspection fails
|
||||
/// named as a forbidden hidden mutation.
|
||||
///
|
||||
/// The MARKER check is the only source-scraped part, and it is not the
|
||||
/// completeness guarantee. Three honest limits, so a green check is not misread
|
||||
/// as full authorization coverage:
|
||||
/// 1. A present marker is not load-bearingness (the gate may not redden on a
|
||||
/// hostile probe: the #203 present-but-vacuous case). The adversarial tests
|
||||
/// in `graphql/mutation.rs` are the load-bearing layer.
|
||||
/// 2. A present marker is not gate-correctness (it may be the wrong bucket for
|
||||
/// the operation's resource: a repo-write registered as signer-self would
|
||||
/// pass, so INV-1 review remains the defense).
|
||||
/// 3. The marker check scrapes the comment-masked, brace-bounded resolver body;
|
||||
/// a `{`/`}` or a `did_matches(` inside a STRING literal is not masked, so it
|
||||
/// could mis-slice that body or satisfy the marker vacuously (the same
|
||||
/// vacuous-marker class as limit 1). Completeness is unaffected: it comes
|
||||
/// from the SDL AST, not from this scrape.
|
||||
#[test]
|
||||
fn every_graphql_mutation_has_its_gate() {
|
||||
// (Rust snake name, schema field in camelCase, expected gate marker). The
|
||||
// four are Bucket C signer-self (`did_matches(`); a repo-write would be
|
||||
// Bucket A (`require_repo_owner(`). Same-named REST rows live in the guard
|
||||
// above against `api/tasks.rs`.
|
||||
let registered: &[(&str, &str, &str)] = &[
|
||||
("create_task", "createTask", "did_matches("),
|
||||
("claim_task", "claimTask", "did_matches("),
|
||||
("complete_task", "completeTask", "did_matches("),
|
||||
("fail_task", "failTask", "did_matches("),
|
||||
];
|
||||
|
||||
// Completeness + hidden-mutation cross-check against the REAL schema (#219),
|
||||
// delegated to the generic `assert_mutation_fence` so the SDL sourcing and the
|
||||
// cross-check are ALSO exercised by a synthetic hidden schema in
|
||||
// `fence_rejects_a_hidden_mutation` (INV-21). Because the real MutationRoot has no
|
||||
// hidden field today, that synthetic test — not a hypothetical production canary —
|
||||
// is what turns reverting the SDL source or deleting the cross-check RED.
|
||||
{
|
||||
use crate::graphql::mutation::MutationRoot;
|
||||
use crate::graphql::query::QueryRoot;
|
||||
use crate::graphql::subscription::SubscriptionRoot;
|
||||
let schema =
|
||||
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
|
||||
let registered_camel: Vec<&str> = registered.iter().map(|(_, c, _)| *c).collect();
|
||||
assert_mutation_fence(&schema, ®istered_camel);
|
||||
}
|
||||
|
||||
// Marker present (source scrape; the vacuity limit, backstopped by the
|
||||
// adversarial tests): each resolver body carries its bucket marker.
|
||||
let masked = mask_comments(include_str!("../graphql/mutation.rs"));
|
||||
for (snake, _, marker) in registered {
|
||||
let body = resolver_body(&masked, snake);
|
||||
assert!(
|
||||
body.contains(marker),
|
||||
"GraphQL mutation `{snake}` is missing its gate marker `{marker}`: gate removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Version-safety self-check (#219, INV-21): the fence's completeness guarantee rests
|
||||
/// on `schema.sdl()` INCLUDING `visible = false` fields while introspection omits
|
||||
/// them. That is observed async-graphql behavior, not a documented contract, so pin
|
||||
/// it here: a synthetic schema with a hidden mutation must have that field in its
|
||||
/// SDL-derived set and NOT in its introspection-derived set. If a future
|
||||
/// async-graphql upgrade ever filters SDL by visibility, the first assertion flips
|
||||
/// RED — the assumption is load-bearing, not trusted.
|
||||
#[test]
|
||||
fn sdl_source_sees_hidden_mutations_that_introspection_omits() {
|
||||
use async_graphql::{EmptySubscription, Object, Schema};
|
||||
struct Q;
|
||||
#[Object]
|
||||
impl Q {
|
||||
async fn ping(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
struct M;
|
||||
#[Object]
|
||||
impl M {
|
||||
async fn visible_mut(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
#[graphql(visible = false)]
|
||||
async fn hidden_mut(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
let schema = Schema::build(Q, M, EmptySubscription).finish();
|
||||
|
||||
// SDL source (the fix): includes the hidden field.
|
||||
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
|
||||
assert!(
|
||||
sdl_fields.iter().any(|f| f == "hiddenMut"),
|
||||
"SDL-sourced set MUST include the visible=false mutation; if this is RED, \
|
||||
async-graphql started filtering SDL by visibility and the fence's \
|
||||
completeness guarantee is broken: {sdl_fields:?}"
|
||||
);
|
||||
|
||||
// Introspection (the fence's own helper): OMITS the hidden field. This is the
|
||||
// exact gap the fix closes — an introspection-sourced fence never sees `hiddenMut`.
|
||||
let introspected = introspection_mutation_fields(&schema);
|
||||
assert!(
|
||||
!introspected.iter().any(|f| f == "hiddenMut"),
|
||||
"introspection is expected to OMIT the visible=false mutation (that is the \
|
||||
gap the SDL source closes); got {introspected:?}"
|
||||
);
|
||||
|
||||
// The exact diff the fence computes: hidden ∈ (sdl \ introspection).
|
||||
let hidden: Vec<&String> = sdl_fields
|
||||
.iter()
|
||||
.filter(|f| !introspected.iter().any(|i| i == *f))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
hidden,
|
||||
vec!["hiddenMut"],
|
||||
"the SDL-vs-introspection diff must surface exactly the hidden mutation"
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-21 wiring guard (#219): drives the generic `assert_mutation_fence` — the SAME
|
||||
/// helper the real fence calls — against a synthetic schema whose mutation is
|
||||
/// `visible = false` and IS registered, so completeness passes and ONLY the
|
||||
/// hidden-from-introspection cross-check can fail. This is what makes the fence's SDL
|
||||
/// sourcing load-bearing without a production canary: reverting the source to
|
||||
/// introspection (the hidden field then absent from the SDL set) or deleting the
|
||||
/// cross-check removes this panic (or changes its message), turning the
|
||||
/// `should_panic` test RED. The real MutationRoot has no hidden field, so nothing on
|
||||
/// the real schema could catch that regression.
|
||||
#[test]
|
||||
#[should_panic(expected = "hidden from introspection")]
|
||||
fn fence_rejects_a_hidden_mutation() {
|
||||
use async_graphql::{EmptySubscription, Object, Schema};
|
||||
struct Q;
|
||||
#[Object]
|
||||
impl Q {
|
||||
async fn ping(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
struct M;
|
||||
#[Object]
|
||||
impl M {
|
||||
async fn visible_mut(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
#[graphql(visible = false)]
|
||||
async fn hidden_mut(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
let schema = Schema::build(Q, M, EmptySubscription).finish();
|
||||
// Both fields registered, so completeness passes and only the cross-check fires.
|
||||
assert_mutation_fence(&schema, &["visibleMut", "hiddenMut"]);
|
||||
}
|
||||
|
||||
/// Deprecated-mutation guard (#219): a `#[graphql(deprecation = "...")]` mutation is
|
||||
/// executable and present in SDL, but GraphQL introspection omits it by default. The
|
||||
/// fence's introspection helper uses `includeDeprecated: true`, so a deprecated (but
|
||||
/// visible) mutation is NOT mistaken for a hidden one. With it registered, the fence
|
||||
/// must NOT panic. Remove `includeDeprecated: true` and this goes RED — the deprecated
|
||||
/// field would look hidden and false-red as a forbidden visible=false mutation.
|
||||
#[test]
|
||||
fn fence_admits_a_deprecated_mutation() {
|
||||
use async_graphql::{EmptySubscription, Object, Schema};
|
||||
struct Q;
|
||||
#[Object]
|
||||
impl Q {
|
||||
async fn ping(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
struct M;
|
||||
#[Object]
|
||||
impl M {
|
||||
async fn visible_mut(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
#[graphql(deprecation = "use visibleMut")]
|
||||
async fn deprecated_mut(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
let schema = Schema::build(Q, M, EmptySubscription).finish();
|
||||
// Must not panic: the deprecated field is visible (present in introspection with
|
||||
// includeDeprecated) and registered, so it is neither hidden nor unclassified.
|
||||
assert_mutation_fence(&schema, &["visibleMut", "deprecatedMut"]);
|
||||
}
|
||||
|
||||
/// Completeness + hidden-mutation cross-check for a BUILT schema (#219). Generic over
|
||||
/// the schema type so the real fence AND the synthetic `fence_rejects_a_hidden_mutation`
|
||||
/// / `fence_admits_a_deprecated_mutation` tests drive the SAME sourcing and cross-check.
|
||||
/// Completeness is sourced from SDL (which includes `visible = false` fields); the
|
||||
/// cross-check fails any SDL field hidden from introspection. This genericity is the
|
||||
/// INV-21 fix: because the real MutationRoot has no hidden field, only a synthetic
|
||||
/// hidden schema through THIS helper makes reverting the SDL source or removing the
|
||||
/// cross-check RED, rather than leaving the class latent behind a production canary.
|
||||
fn assert_mutation_fence<Q, M, S>(
|
||||
schema: &async_graphql::Schema<Q, M, S>,
|
||||
registered_camel: &[&str],
|
||||
) where
|
||||
Q: async_graphql::ObjectType + 'static,
|
||||
M: async_graphql::ObjectType + 'static,
|
||||
S: async_graphql::SubscriptionType + 'static,
|
||||
{
|
||||
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
|
||||
let introspected = introspection_mutation_fields(schema);
|
||||
// No SDL field may be hidden from introspection (a visible=false mutation).
|
||||
for f in &sdl_fields {
|
||||
assert!(
|
||||
introspected.iter().any(|i| i == f),
|
||||
"GraphQL mutation `{f}` is in the schema but hidden from introspection \
|
||||
(a `#[graphql(visible = false)]` field): hidden mutations are forbidden \
|
||||
by this fence — make it visible so its classification is enforceable, or \
|
||||
remove it"
|
||||
);
|
||||
}
|
||||
// Completeness both directions against the registered (camelCase) set.
|
||||
for f in &sdl_fields {
|
||||
assert!(
|
||||
registered_camel.contains(&f.as_str()),
|
||||
"unclassified GraphQL mutation `{f}`: add it to \
|
||||
every_graphql_mutation_has_its_gate with a deliberate gate \
|
||||
(`require_repo_owner(` for a repo-write, `did_matches(` for a task op)"
|
||||
);
|
||||
}
|
||||
for c in registered_camel {
|
||||
assert!(
|
||||
sdl_fields.iter().any(|f| f == c),
|
||||
"registered GraphQL mutation `{c}` is not in the schema \
|
||||
(renamed or removed); update the table"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutation field names via the engine's introspection, run against a passed
|
||||
/// schema. STRUCTURED (names arrive as data). Uses `fields(includeDeprecated: true)`:
|
||||
/// GraphQL's default omits `@deprecated` fields, but a `#[graphql(deprecation = "...")]`
|
||||
/// mutation is still executable and present in SDL, so without this the cross-check
|
||||
/// above would false-red a deprecated mutation as a hidden (visible=false) one. Only a
|
||||
/// real visibility filter should trip that arm; deprecation is a lifecycle marker.
|
||||
fn introspection_mutation_fields<Q, M, S>(
|
||||
schema: &async_graphql::Schema<Q, M, S>,
|
||||
) -> Vec<String>
|
||||
where
|
||||
Q: async_graphql::ObjectType + 'static,
|
||||
M: async_graphql::ObjectType + 'static,
|
||||
S: async_graphql::SubscriptionType + 'static,
|
||||
{
|
||||
use async_graphql::Value;
|
||||
let resp = tokio::runtime::Runtime::new()
|
||||
.expect("tokio runtime")
|
||||
.block_on(schema.execute(
|
||||
"{ __schema { mutationType { fields(includeDeprecated: true) { name } } } }",
|
||||
));
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"schema introspection failed: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
let mut names = Vec::new();
|
||||
if let Value::Object(root) = &resp.data {
|
||||
if let Some(Value::Object(sc)) = root.get("__schema") {
|
||||
if let Some(Value::Object(mt)) = sc.get("mutationType") {
|
||||
if let Some(Value::List(field_list)) = mt.get("fields") {
|
||||
for field in field_list {
|
||||
if let Value::Object(fo) = field {
|
||||
if let Some(Value::String(n)) = fo.get("name") {
|
||||
names.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The mutation field names sourced from the built schema's SDL — the completeness
|
||||
/// source (#219). Builds the same DB-free schema as the introspection helper and runs
|
||||
/// its `.sdl()` (sync, no runtime needed) through [`mutation_field_names_from_sdl`].
|
||||
/// SDL includes `#[graphql(visible = false)]` fields, so this source cannot be
|
||||
/// defeated by hiding a mutation from introspection.
|
||||
fn graphql_mutation_fields_from_sdl() -> Vec<String> {
|
||||
use crate::graphql::mutation::MutationRoot;
|
||||
use crate::graphql::query::QueryRoot;
|
||||
use crate::graphql::subscription::SubscriptionRoot;
|
||||
let schema =
|
||||
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
|
||||
mutation_field_names_from_sdl(&schema.sdl())
|
||||
}
|
||||
|
||||
/// The mutation type's field names extracted from an async-graphql SDL string via
|
||||
/// the engine's OWN parser AST (`async_graphql::parser::parse_schema`), NOT
|
||||
/// introspection. This is the completeness source (#219): unlike introspection,
|
||||
/// which async-graphql filters by `visible` (a `#[graphql(visible = false)]`
|
||||
/// mutation is executable but ABSENT from introspection), `schema.sdl()` includes
|
||||
/// every registered field, so a hidden mutation still appears here and must carry a
|
||||
/// classified row. Parsing the AST (not a brace/line text scan) means a `}` inside a
|
||||
/// field description cannot mis-slice the type block. camelCase, as async-graphql
|
||||
/// emits. An empty result on a navigation miss is safe: the set-equality in
|
||||
/// `every_graphql_mutation_has_its_gate` turns "no fields" into a loud failure for
|
||||
/// every registered name, never a vacuous pass.
|
||||
fn mutation_field_names_from_sdl(sdl: &str) -> Vec<String> {
|
||||
use async_graphql::parser::parse_schema;
|
||||
use async_graphql::parser::types::{TypeKind, TypeSystemDefinition};
|
||||
|
||||
let doc = parse_schema(sdl).expect("async-graphql SDL must parse");
|
||||
// The mutation root type name: from the `schema { mutation: X }` block if
|
||||
// present, else the GraphQL default "Mutation".
|
||||
let mutation_type = doc
|
||||
.definitions
|
||||
.iter()
|
||||
.find_map(|def| match def {
|
||||
TypeSystemDefinition::Schema(s) => s
|
||||
.node
|
||||
.mutation
|
||||
.as_ref()
|
||||
.map(|m| m.node.as_str().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| "Mutation".to_string());
|
||||
// The fields of that object type.
|
||||
doc.definitions
|
||||
.iter()
|
||||
.find_map(|def| match def {
|
||||
TypeSystemDefinition::Type(t) if t.node.name.node.as_str() == mutation_type => {
|
||||
match &t.node.kind {
|
||||
TypeKind::Object(obj) => Some(
|
||||
obj.fields
|
||||
.iter()
|
||||
.map(|f| f.node.name.node.as_str().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdl_extractor_includes_a_hidden_visible_false_field() {
|
||||
// A field that would be hidden from introspection must still be listed from SDL.
|
||||
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
|
||||
type QueryRoot { ping: Int! }\n\
|
||||
type MutationRoot { createTask: Int! hiddenMut: Int! }\n";
|
||||
let fields = mutation_field_names_from_sdl(sdl);
|
||||
assert!(
|
||||
fields.iter().any(|f| f == "createTask"),
|
||||
"visible field must be listed: {fields:?}"
|
||||
);
|
||||
assert!(
|
||||
fields.iter().any(|f| f == "hiddenMut"),
|
||||
"a visible=false field (present in SDL, hidden from introspection) MUST be \
|
||||
listed by the completeness source: {fields:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdl_extractor_is_not_fooled_by_a_brace_in_a_field_description() {
|
||||
// A `}` inside a field description must not truncate the type block (the AST
|
||||
// parse is immune to the text-parse quirk the old design feared).
|
||||
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
|
||||
type QueryRoot { ping: Int! }\n\
|
||||
type MutationRoot {\n\
|
||||
\"\"\"a description with a } brace and { another\"\"\"\n\
|
||||
createTask: Int!\n\
|
||||
claimTask: Int!\n\
|
||||
}\n";
|
||||
let fields = mutation_field_names_from_sdl(sdl);
|
||||
assert!(
|
||||
fields.iter().any(|f| f == "createTask") && fields.iter().any(|f| f == "claimTask"),
|
||||
"both fields must be extracted despite a brace in a description: {fields:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdl_extractor_returns_empty_for_a_query_only_schema() {
|
||||
let sdl = "schema { query: QueryRoot }\ntype QueryRoot { ping: Int! }\n";
|
||||
assert!(
|
||||
mutation_field_names_from_sdl(sdl).is_empty(),
|
||||
"a schema with no mutation root yields no mutation fields (no panic)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The comment-masked body of `async fn <snake>` in `graphql/mutation.rs`, bounded
|
||||
/// to the resolver's OWN braces (its `{ .. }`), for the marker check. Brace-matching
|
||||
/// on the comment-masked source means a `}` in a trailing helper cannot extend the
|
||||
/// body and a comment marker does not count. A `{`/`}` inside a STRING literal is
|
||||
/// not masked, a documented marker-check-only residual (limit 3); completeness does
|
||||
/// not use this.
|
||||
fn resolver_body(masked_src: &str, snake: &str) -> String {
|
||||
let needle = format!("async fn {snake}(");
|
||||
let start = masked_src.find(&needle).unwrap_or_else(|| {
|
||||
panic!("resolver `async fn {snake}(` not found in graphql/mutation.rs")
|
||||
});
|
||||
let open = start
|
||||
+ masked_src[start..]
|
||||
.find('{')
|
||||
.expect("resolver body opening brace");
|
||||
let mut depth = 0i32;
|
||||
let mut end = masked_src.len();
|
||||
for (i, c) in masked_src[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = open + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
masked_src[open..end].to_string()
|
||||
}
|
||||
|
||||
/// `src` with `//` line comments and `/* */` block comments (nested) blanked to
|
||||
/// spaces, WITHOUT opening a comment inside a `"..."` string (so a `"https://"` does
|
||||
/// not erase the rest of its line). String CONTENTS are left intact, so a brace or
|
||||
/// a gate marker inside a string is not masked; for the marker check that is a
|
||||
/// documented vacuity residual (limit 3), and completeness does not use this at
|
||||
/// all. Char literals and raw/byte strings are not tracked (absent in resolver
|
||||
/// bodies); a `'"'` char or a raw string is the remaining narrow residual.
|
||||
fn mask_comments(src: &str) -> String {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut i = 0;
|
||||
let mut block_depth = 0usize;
|
||||
let mut in_string = false;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
let next = chars.get(i + 1).copied();
|
||||
if block_depth > 0 {
|
||||
if c == '/' && next == Some('*') {
|
||||
block_depth += 1;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
} else if c == '*' && next == Some('/') {
|
||||
block_depth -= 1;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(if c == '\n' { '\n' } else { ' ' });
|
||||
i += 1;
|
||||
}
|
||||
} else if in_string {
|
||||
out.push(c);
|
||||
if c == '\\' {
|
||||
if let Some(n) = next {
|
||||
out.push(n);
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
if c == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '"' {
|
||||
in_string = true;
|
||||
out.push('"');
|
||||
i += 1;
|
||||
} else if c == '/' && next == Some('/') {
|
||||
while i < chars.len() && chars[i] != '\n' {
|
||||
out.push(' ');
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '/' && next == Some('*') {
|
||||
block_depth = 1;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The SDL field set (the completeness source of truth, #219) is exactly the
|
||||
/// registered mutations. If async-graphql adds or renames a field, this reddens
|
||||
/// and forces the `every_graphql_mutation_has_its_gate` table to be updated. Uses
|
||||
/// SDL, not introspection: introspection omits `visible = false` fields, so an
|
||||
/// introspection-based pin here would go green while a hidden mutation existed.
|
||||
#[test]
|
||||
fn schema_lists_exactly_the_registered_mutations() {
|
||||
let mut fields = graphql_mutation_fields_from_sdl();
|
||||
fields.sort();
|
||||
assert_eq!(
|
||||
fields,
|
||||
vec!["claimTask", "completeTask", "createTask", "failTask"],
|
||||
"the SDL mutation field set is the authoritative discovered set"
|
||||
);
|
||||
}
|
||||
|
||||
/// The marker check is bounded to a resolver's OWN body and is comment- and
|
||||
/// string-aware: a `//` inside a `"https://"` string does not erase a real gate on
|
||||
/// that line (no false-red), and a trailing module helper's `did_matches(` does not
|
||||
/// rescue a resolver whose own gate was removed (no false-green).
|
||||
#[test]
|
||||
fn resolver_body_is_bounded_and_comment_and_string_aware() {
|
||||
let src = "\
|
||||
impl MutationRoot {
|
||||
async fn ok(&self) -> Result<u32> {
|
||||
let _ = (\"see https://x/y\", did_matches(a, b)); // URL string then gate, one line
|
||||
Ok(0)
|
||||
}
|
||||
async fn last(&self) -> Result<u32> {
|
||||
// did_matches( only in a comment
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
fn helper() { let _ = did_matches(z, z); }
|
||||
#[cfg(test)]
|
||||
mod tests { async fn t() { let _ = did_matches(x, y); } }
|
||||
";
|
||||
let masked = mask_comments(src);
|
||||
assert!(
|
||||
resolver_body(&masked, "ok").contains("did_matches("),
|
||||
"a `//` inside a string must not erase the real gate on that line"
|
||||
);
|
||||
assert!(
|
||||
!resolver_body(&masked, "last").contains("did_matches("),
|
||||
"the marker check must be bounded to the resolver's own body (a trailing \
|
||||
helper's marker must not rescue a resolver whose gate was removed)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Proves the comment-stripping that GUARD-1 added: a marker that appears only
|
||||
/// in a full-line comment (the real `replicas.rs` false-pass shape) must NOT
|
||||
/// satisfy a row.
|
||||
|
||||
@@ -2045,7 +2045,7 @@ pub async fn git_receive_pack(
|
||||
"parsed ref updates from pack"
|
||||
);
|
||||
|
||||
// ── Owner-only push enforcement (on by default; GITLAWB_ENFORCE_OWNER_PUSH) ──
|
||||
// ── Owner-only push enforcement (opt-in: GITLAWB_ENFORCE_OWNER_PUSH) ──
|
||||
// Runs before branch protection on purpose: when enabled, a non-owner is
|
||||
// rejected here regardless of whether the target branch is protected, so a
|
||||
// single rejection never yields two different error bodies. The identity is
|
||||
@@ -2168,12 +2168,9 @@ pub async fn git_receive_pack(
|
||||
// (INV-10) and a saturated pool sheds 503 before spawning git.
|
||||
//
|
||||
// Per-source sub-cap first (#174 P1-d): one source IP cannot occupy the whole write
|
||||
// pool via many slow pushes. The push rate limiter bounds arrival RATE, not in-flight
|
||||
// concurrency, so this cap is what bounds occupancy. Owner enforcement is on by
|
||||
// default now, but it is not what makes this cap load-bearing: an operator may turn it
|
||||
// off for a rolling upgrade, and even with it on a single owner can open many
|
||||
// concurrent slow pushes to their own repo.
|
||||
// Keyed on the resolved source IP, NEVER the signed DID (a DID farm defeats
|
||||
// pool via many slow pushes. Owner enforcement defaults off, so any valid did:key is
|
||||
// accepted (auth != authz) and the push rate limiter bounds arrival RATE, not in-flight
|
||||
// concurrency. Keyed on the resolved source IP, NEVER the signed DID (a DID farm defeats
|
||||
// a DID key); no resolvable key -> global write pool only. Then the global write permit:
|
||||
// pushes draw from the dedicated WRITE pool, separate from reads, and it is held for the
|
||||
// whole op (moved into the AdmissionGuard below).
|
||||
@@ -5290,13 +5287,6 @@ mod tests {
|
||||
/// task the global slot stays occupied until the walk finishes; on the pre-fix code
|
||||
/// the handler-local permits drop on future-drop and the slot frees instantly (RED),
|
||||
/// letting disconnect-spam exceed the cap while real git work keeps running.
|
||||
///
|
||||
/// Unix-only: the fake git is a `/bin/sh` script made executable through
|
||||
/// `PermissionsExt::set_mode`, and the hung `rev-list` is reaped with
|
||||
/// `libc::kill(SIGKILL)`. Neither exists on Windows, so without this gate the
|
||||
/// whole `gitlawb-node` test target fails to compile there (#228) and no test
|
||||
/// in the crate can run on a Windows checkout.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn upload_pack_permit_held_through_walk_after_disconnect(pool: sqlx::PgPool) {
|
||||
use axum::body::Body;
|
||||
@@ -5892,9 +5882,8 @@ mod tests {
|
||||
|
||||
/// #174 U4 (P1-d, RED-before/GREEN-after): the authenticated receive-pack POST
|
||||
/// carries a per-source WRITE sub-cap so one source IP cannot monopolize the write
|
||||
/// pool with many slow pushes (the cap keys on source IP, not the DID, so it holds
|
||||
/// whether or not owner enforcement is on — and it can be turned off for a rolling
|
||||
/// upgrade). Global write pool has capacity; the source is pre-held at its single
|
||||
/// pool with many slow pushes (owner enforcement defaults off, so disposable DIDs
|
||||
/// are free). Global write pool has capacity; the source is pre-held at its single
|
||||
/// write slot. A push from THAT source sheds (Overloaded/503) — which also proves
|
||||
/// the PeerAddr+HeaderMap extractors resolve a key (without them the key is None and
|
||||
/// the cap is inert, never shedding). A push from a DIFFERENT source is NOT shed by
|
||||
@@ -5920,10 +5909,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Owner of the `rp4` row, so the push reaches the per-source write cap instead
|
||||
// of stopping at the owner-push gate (on by default). `did_matches` collapses
|
||||
// the `did:key:` prefix against the bare stored owner.
|
||||
let did = "did:key:z6rp4wr";
|
||||
let did = "did:key:z6MkReceivePackWriteCapProofDidAAAAAAAAAA";
|
||||
let capped: SocketAddr = "203.0.113.44:5000".parse().unwrap();
|
||||
let other: SocketAddr = "203.0.113.45:5000".parse().unwrap();
|
||||
|
||||
@@ -5991,13 +5977,23 @@ mod tests {
|
||||
/// stays pinned past the deadline. Restore to return GREEN.
|
||||
#[sqlx::test]
|
||||
async fn receive_pack_acquire_deadline_sheds_and_releases_permit(pool: sqlx::PgPool) {
|
||||
use crate::git::repo_store::advisory_lock_key;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Extension;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
// Reproduce acquire_write's session-level advisory-lock key exactly so the
|
||||
// second-connection lock collides with the handler's pg_try_advisory_lock
|
||||
// (repo_store.rs: advisory_lock_key over owner_slug then repo_name).
|
||||
fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
owner_slug.hash(&mut hasher);
|
||||
repo_name.hash(&mut hasher);
|
||||
hasher.finish() as i64
|
||||
}
|
||||
|
||||
let owner = "z6acqdead";
|
||||
let name = "acq1";
|
||||
// owner_slug as local_path() computes it from the record's owner_did. The
|
||||
@@ -6042,10 +6038,7 @@ mod tests {
|
||||
.await
|
||||
.expect("hold the advisory lock on the second connection");
|
||||
|
||||
// Owner of the seeded rows, so the push reaches `acquire_write` instead of
|
||||
// stopping at the owner-push gate (on by default). `did_matches` collapses the
|
||||
// `did:key:` prefix against the bare `owner` above.
|
||||
let did = "did:key:z6acqdead";
|
||||
let did = "did:key:z6MkAcquireDeadlineProofDidAAAAAAAAAAAAAAAA";
|
||||
let peer: SocketAddr = "203.0.113.61:5000".parse().unwrap();
|
||||
|
||||
let sem = state.git_write_semaphore.clone();
|
||||
@@ -6163,6 +6156,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Reproduce `repo_store::advisory_lock_key` (private there) so a test can probe the
|
||||
/// exact key `acquire_write` derives.
|
||||
#[cfg(unix)]
|
||||
fn write_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
owner_slug.hash(&mut hasher);
|
||||
repo_name.hash(&mut hasher);
|
||||
hasher.finish() as i64
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn pid_alive(pid: i32) -> bool {
|
||||
// SAFETY: kill(2) with signal 0 only probes; it takes integers and borrows no
|
||||
@@ -6260,9 +6264,6 @@ mod tests {
|
||||
// Long enough that the git-service timeout is never what ends this push; the
|
||||
// disconnect is.
|
||||
cfg.git_service_timeout_secs = 600;
|
||||
// Lock-path test, not owner-push authorization: pin off so the default-on gate
|
||||
// does not reject the proof DID before receive-pack runs.
|
||||
cfg.enforce_owner_push = false;
|
||||
state.config = std::sync::Arc::new(cfg);
|
||||
state
|
||||
.db
|
||||
@@ -6271,10 +6272,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// The mirror row stores the short owner as owner_did, so the slug is the owner.
|
||||
// Import the production derivation rather than hand-copying it: a local copy
|
||||
// silently diverged when the key moved to SHA-256 (#210).
|
||||
use crate::git::repo_store::advisory_lock_key;
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), name);
|
||||
let key = write_lock_key(&owner.replace([':', '/'], "_"), name);
|
||||
// Probe from a pool that is NOT the store's lock pool and NOT the harness pool.
|
||||
let probe = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
@@ -6433,17 +6431,13 @@ mod tests {
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 4, std::time::Duration::from_secs(5)),
|
||||
);
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = false;
|
||||
state.config = std::sync::Arc::new(cfg);
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner, name, "/tmp/z6succ-sc1", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
use crate::git::repo_store::advisory_lock_key;
|
||||
let key = advisory_lock_key(&owner.replace([':', '/'], "_"), name);
|
||||
let key = write_lock_key(&owner.replace([':', '/'], "_"), name);
|
||||
let probe = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect_lazy_with((*pool.connect_options()).clone());
|
||||
@@ -6522,9 +6516,6 @@ mod tests {
|
||||
let owner = "z6lockpool";
|
||||
let name = "lp1";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = false;
|
||||
state.config = std::sync::Arc::new(cfg);
|
||||
// One lock-pool connection, short checkout timeout so the exhaustion surfaces
|
||||
// promptly rather than at the handler's own acquire deadline.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
@@ -6589,11 +6580,6 @@ mod tests {
|
||||
/// sheds — releasing the permit lets the SAME walk run and pin (durability stays
|
||||
/// fail-closed). Exercises the gating seam directly; the detached push task calls
|
||||
/// this exact helper.
|
||||
///
|
||||
/// Unix-only for the same reason as
|
||||
/// `upload_pack_permit_held_through_walk_after_disconnect`: the fake git is a
|
||||
/// `/bin/sh` script made executable through `PermissionsExt::set_mode` (#228).
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn encrypt_walk_defers_when_pool_exhausted() {
|
||||
use std::sync::Arc;
|
||||
@@ -7207,17 +7193,15 @@ mod tests {
|
||||
// Scan pool of ONE: at most one post-receive walk may run at a time.
|
||||
state.git_encrypt_semaphore = Arc::new(Semaphore::new(1));
|
||||
|
||||
let did = "did:key:z6MkF4BurstPusherAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let new_sha = "1111111111111111111111111111111111111111";
|
||||
// The two repos have different owners, and owner-only push is on by default,
|
||||
// so each push signs as the owner of the repo it targets. The subject here is
|
||||
// the scan pool serializing two concurrent bursts, not authorization.
|
||||
let push = |owner: &'static str, name: &'static str, peer: &'static str| {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
git_receive_pack(
|
||||
State(state),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body(new_sha),
|
||||
@@ -7310,7 +7294,7 @@ mod tests {
|
||||
State(state),
|
||||
Path(("z6f4fast".to_string(), "f1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f4fast".to_string(),
|
||||
"did:key:z6MkF4FastPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
|
||||
)),
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
@@ -7370,7 +7354,7 @@ mod tests {
|
||||
State(state),
|
||||
Path(("z6f4park".to_string(), "p1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f4park".to_string(),
|
||||
"did:key:z6MkF4ParkPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
|
||||
)),
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
@@ -8589,7 +8573,7 @@ mod tests {
|
||||
// + flush-only body -> no post-receive scans to muddy the observation.
|
||||
let state =
|
||||
f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6f3repo", "r1", false).await;
|
||||
let did = "did:key:z6f3repo";
|
||||
let did = "did:key:z6MkF3PusherAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
// Push A: drive its handler future in slices until it reaches receive-pack and the
|
||||
// fake records its descendant pid (A now holds the lease and is hung).
|
||||
@@ -8712,7 +8696,7 @@ mod tests {
|
||||
let git_bin = write_fake_git(tmp.path(), body);
|
||||
let state =
|
||||
f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6f3clean", "c1", false).await;
|
||||
let did = "did:key:z6f3clean";
|
||||
let did = "did:key:z6MkF3CleanPusherAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
let push = |st: AppState, peer: &'static str| async move {
|
||||
tokio::time::timeout(
|
||||
@@ -8806,7 +8790,7 @@ mod tests {
|
||||
// free, and a pool-holding waiter (B, pre-fix) would drain it to zero. Sizing to
|
||||
// 1 would 503 B on the pool before it could block on the lease, hiding the bug.
|
||||
state.git_write_semaphore = Arc::new(Semaphore::new(2));
|
||||
let did = "did:key:z6f3dos";
|
||||
let did = "did:key:z6MkF3DosPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
// Push A: drive its handler future in slices until it reaches receive-pack (it now
|
||||
// holds the lease and one write permit and is hung). available_permits() drops to 1.
|
||||
@@ -9015,7 +8999,7 @@ mod tests {
|
||||
"the identity key must differ from the row id, or this test proves nothing"
|
||||
);
|
||||
|
||||
let did = "did:key:z6u2key";
|
||||
let did = "did:key:z6MkU2KeyPusherAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let peer: SocketAddr = "203.0.113.91:5000".parse().unwrap();
|
||||
let handle = tokio::spawn({
|
||||
let st = state.clone();
|
||||
@@ -9096,7 +9080,7 @@ mod tests {
|
||||
State(state),
|
||||
Path(("z6ovflow".to_string(), "o1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6ovflow".to_string(),
|
||||
"did:key:z6MkOverflowPusherAAAAAAAAAAAAAAAAAAAAAA".to_string(),
|
||||
)),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
@@ -9143,7 +9127,7 @@ mod tests {
|
||||
let r = state.db.get_repo("z6u1cap", "c1").await.unwrap().unwrap();
|
||||
crate::state::repo_identity_key(&r.owner_did, &r.name)
|
||||
};
|
||||
let did = "did:key:z6u1cap";
|
||||
let did = "did:key:z6MkU1CapPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let push = |peer: SocketAddr| {
|
||||
let st = state.clone();
|
||||
let did = did.to_string();
|
||||
@@ -9247,7 +9231,7 @@ mod tests {
|
||||
crate::state::repo_identity_key(&r.owner_did, &r.name)
|
||||
};
|
||||
f1_add_repo(&state, "z6u1two", "r2").await;
|
||||
let did = "did:key:z6u1two";
|
||||
let did = "did:key:z6MkU1TwoPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let src: SocketAddr = "203.0.113.84:5000".parse().unwrap();
|
||||
let push = |repo: &'static str| {
|
||||
let st = state.clone();
|
||||
@@ -9308,11 +9292,8 @@ mod tests {
|
||||
/// the per-source permit above the park, four parked pushes deny every push on the
|
||||
/// node for up to 1260s.
|
||||
///
|
||||
/// Same one source IP for all three pushes (the collapsed-key shape), per-source
|
||||
/// cap 2 so the holder plus a parked push would exhaust it. A and B are the owner
|
||||
/// of `n1`; C is a second tenant, `z6u1nat2`, pushing to its own `n2` — owner-only
|
||||
/// push means a tenant is identified by the repo it may write, not by an arbitrary
|
||||
/// DID, and the cap keys on the resolved peer address rather than either.
|
||||
/// Same one source IP for all three pushes (the collapsed-key shape), distinct pusher
|
||||
/// DIDs, per-source cap 2 so the holder plus a parked push would exhaust it.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn u1_parked_push_does_not_shed_another_pusher_behind_the_same_ip(pool: sqlx::PgPool) {
|
||||
@@ -9331,22 +9312,16 @@ mod tests {
|
||||
let r = state.db.get_repo("z6u1nat", "n1").await.unwrap().unwrap();
|
||||
crate::state::repo_identity_key(&r.owner_did, &r.name)
|
||||
};
|
||||
// n2 belongs to a DIFFERENT owner, so the third push is a genuinely different
|
||||
// tenant — which is what this test is named for. Owner-only push forces each
|
||||
// push to sign as the owner of the repo it targets, so the tenants are
|
||||
// distinguished by which repo they push to rather than by an arbitrary DID.
|
||||
f1_add_repo(&state, "z6u1nat2", "n2").await;
|
||||
// Every pusher arrives from the one edge IP, so they share a source key: the
|
||||
// per-source cap keys on the resolved peer address and never on the DID, which
|
||||
// is exactly the property under test.
|
||||
f1_add_repo(&state, "z6u1nat", "n2").await;
|
||||
// Every pusher arrives from the one edge IP, so they share a source key.
|
||||
let edge: SocketAddr = "203.0.113.85:5000".parse().unwrap();
|
||||
let push = |owner: &'static str, repo: &'static str| {
|
||||
let push = |pusher: &'static str, repo: &'static str| {
|
||||
let st = state.clone();
|
||||
async move {
|
||||
git_receive_pack(
|
||||
State(st),
|
||||
Path((owner.to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
Path(("z6u1nat".to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(pusher.to_string())),
|
||||
crate::rate_limit::PeerAddr(Some(edge)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9355,12 +9330,18 @@ mod tests {
|
||||
}
|
||||
};
|
||||
|
||||
let handle_a = tokio::spawn(push("z6u1nat", "n1"));
|
||||
let handle_a = tokio::spawn(push(
|
||||
"did:key:z6MkU1NatPusherOneAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"n1",
|
||||
));
|
||||
assert!(
|
||||
f1_wait_for(F1_BACKSTOP, || a_inpack.exists()).await,
|
||||
"pusher one never reached receive-pack within {F1_BACKSTOP:?}"
|
||||
);
|
||||
let handle_b = tokio::spawn(push("z6u1nat", "n1"));
|
||||
let handle_b = tokio::spawn(push(
|
||||
"did:key:z6MkU1NatPusherTwoAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"n1",
|
||||
));
|
||||
assert!(
|
||||
f1_wait_for(F1_BACKSTOP, || state.repo_write_leases.waiters_for(&repo1)
|
||||
== 1)
|
||||
@@ -9368,10 +9349,13 @@ mod tests {
|
||||
"pusher two never parked on n1's contended lease within {F1_BACKSTOP:?}"
|
||||
);
|
||||
|
||||
// A third pusher — a different tenant — behind the same edge IP, on its own repo.
|
||||
let c = tokio::time::timeout(F1_BACKSTOP, push("z6u1nat2", "n2"))
|
||||
.await
|
||||
.expect("an unrelated pusher's push to an uncontended repo must not park");
|
||||
// A third, unrelated pusher behind the same edge IP, on a different repo.
|
||||
let c = tokio::time::timeout(
|
||||
F1_BACKSTOP,
|
||||
push("did:key:z6MkU1NatPusherThreeAAAAAAAAAAAAAAAAAA", "n2"),
|
||||
)
|
||||
.await
|
||||
.expect("an unrelated pusher's push to an uncontended repo must not park");
|
||||
let resp = c.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"U1 scenario 3 RED: one repo's parked push shed an UNRELATED pusher's push \
|
||||
@@ -9421,11 +9405,7 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The pusher must be the repos' owner: this test is about the write-cap key's
|
||||
// shape, so the push has to reach the cap rather than stop at the owner-push
|
||||
// gate, which is on by default. `did_matches` collapses the `did:key:` prefix,
|
||||
// so this is the same identity as the bare `z6f1key` the rows are owned by.
|
||||
let did = "did:key:z6f1key";
|
||||
let did = "did:key:z6MkF1KeyPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let capped: SocketAddr = "203.0.113.65:5000".parse().unwrap();
|
||||
let other: SocketAddr = "203.0.113.66:5000".parse().unwrap();
|
||||
let _slot = state
|
||||
@@ -9457,13 +9437,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let r = push(other, "k2").await;
|
||||
// Positive, for the same reason as `f1_write_cap_is_inert_without_a_resolvable
|
||||
// _source_key`: `!matches!(.., Overloaded)` is satisfied by a Forbidden, so a
|
||||
// gate that started rejecting this pusher would leave the test green and inert.
|
||||
assert!(
|
||||
matches!(r, Err(AppError::Git(_))),
|
||||
!matches!(r, Err(AppError::Overloaded(_))),
|
||||
"a different source must not be shed on any repo while the capped source \
|
||||
holds its slot, and must reach git; got {r:?}"
|
||||
holds its slot; got {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9498,22 +9475,17 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f1none".to_string(), "n1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f1none".to_string(),
|
||||
"did:key:z6MkF1NoKeyPusherAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
|
||||
)),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
)
|
||||
.await;
|
||||
// Assert the POSITIVE outcome, not the absence of one error. Reaching git on
|
||||
// the missing on-disk repo is proof the request cleared BOTH the owner gate
|
||||
// and the per-caller cap. The previous shape — `!matches!(r, Overloaded)` —
|
||||
// was satisfied by any other error, so when owner-only push began rejecting
|
||||
// this pusher the test kept reporting ok while measuring nothing.
|
||||
assert!(
|
||||
matches!(r, Err(AppError::Git(_))),
|
||||
!matches!(r, Err(AppError::Overloaded(_))),
|
||||
"a caller with no resolvable source key must fall back to the global write \
|
||||
pool only, never shed on the per-caller cap, and must reach git; got {r:?}"
|
||||
pool only, never shed on the per-caller cap; got {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9534,7 +9506,7 @@ mod tests {
|
||||
let mut state =
|
||||
f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6f1seq", "s1", false).await;
|
||||
state.git_write_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100);
|
||||
let did = "did:key:z6f1seq";
|
||||
let did = "did:key:z6MkF1SeqPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let src: SocketAddr = "203.0.113.68:5000".parse().unwrap();
|
||||
|
||||
for attempt in 1..=2 {
|
||||
@@ -10038,9 +10010,7 @@ mod tests {
|
||||
(state, log)
|
||||
}
|
||||
|
||||
// No shared pusher constant: the two P2 tests own different repos (`z6p2tail`
|
||||
// and `z6p2fail`), and owner-only push is on by default, so the identity has to
|
||||
// follow the repo each push targets rather than being fixed for both.
|
||||
const P2_PUSHER: &str = "did:key:z6MkP2TailPusherAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn p2_push(
|
||||
state: &AppState,
|
||||
@@ -10053,7 +10023,7 @@ mod tests {
|
||||
git_receive_pack(
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
Extension(crate::auth::AuthenticatedDid(P2_PUSHER.to_string())),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
|
||||
@@ -490,7 +490,6 @@ mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
let keypair = Keypair::generate();
|
||||
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
@@ -521,11 +520,6 @@ mod tests {
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
|
||||
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
|
||||
ipfs_max_legacy_scan_rows: crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
|
||||
ipfs_max_legacy_scan_rule_bytes:
|
||||
crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
|
||||
ipfs_scan_token_key: Arc::new(scan_token_key),
|
||||
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Upper bound on `git_service_timeout_secs`, `ipfs_request_budget_secs`, and
|
||||
/// `ipfs_resolve_budget_secs`, in seconds (100 years).
|
||||
/// Upper bound on `git_service_timeout_secs` and `ipfs_request_budget_secs`, in seconds
|
||||
/// (100 years).
|
||||
///
|
||||
/// Three consumers now, so a future tightening moves all of them. `ipfs_request_budget_secs`
|
||||
/// and `ipfs_resolve_budget_secs` derive only the `Instant` addition in `get_by_cid`, not the
|
||||
/// lease-steal multiply below, but they share this ceiling because the defect class and the
|
||||
/// "set it very large to disable" contract are the same.
|
||||
/// Two consumers now, so a future tightening moves both. `ipfs_request_budget_secs`
|
||||
/// derives only the `Instant` addition in `get_by_cid`, not the lease-steal multiply
|
||||
/// below, but it shares this ceiling because the defect class and the "set it very large
|
||||
/// to disable" contract are the same.
|
||||
///
|
||||
/// The knob is not just stored, it is arithmetic input: the write path derives the
|
||||
/// per-repo lease steal bound from it (`* 2 + 60`), and #174 routed it into
|
||||
@@ -78,26 +78,10 @@ pub struct Config {
|
||||
|
||||
/// Require the authenticated pusher to be the repo owner on `git-receive-pack`.
|
||||
/// Authentication (a valid did:key signature) is not authorization on its own:
|
||||
/// any party can mint a did:key and sign as it, so with this off every signed
|
||||
/// caller may push to every repository, private ones included. On by default.
|
||||
///
|
||||
/// Turn it off only for a rolling upgrade whose pushers are not yet the repo
|
||||
/// owner. Both `GITLAWB_ENFORCE_OWNER_PUSH=false` and `--enforce-owner-push
|
||||
/// false` disable it, and the bare `--enforce-owner-push` form still means
|
||||
/// `true`.
|
||||
///
|
||||
/// The value-taking action is what makes the CLI form parse at all: as a
|
||||
/// presence-only flag, `--enforce-owner-push false` is an "unexpected argument"
|
||||
/// error. The env form resolved correctly either way, so it is the CLI escape
|
||||
/// hatch this buys, not the environment one.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_ENFORCE_OWNER_PUSH",
|
||||
action = clap::ArgAction::Set,
|
||||
num_args = 0..=1,
|
||||
default_value_t = true,
|
||||
default_missing_value = "true"
|
||||
)]
|
||||
/// any party can sign as their own DID. When true, pushes whose authenticated
|
||||
/// DID is not the repo owner are rejected. Keep false during rolling upgrades;
|
||||
/// flip it on once owners are ready for owner-only writes.
|
||||
#[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)]
|
||||
pub enforce_owner_push: bool,
|
||||
|
||||
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
|
||||
@@ -496,37 +480,6 @@ pub struct Config {
|
||||
)]
|
||||
pub ipfs_max_legacy_probes: usize,
|
||||
|
||||
/// Per-request ceiling on how many repo ROWS the `/ipfs/{cid}` resolver's legacy
|
||||
/// scan may fetch from the database. The probe ceiling above only starts counting
|
||||
/// once a probe runs, and the two denial classes that dominate a hostile inventory
|
||||
/// (quarantine, and a root-scope visibility deny) return before a probe or a visit
|
||||
/// is spent, so without this an all-denying node paged its ENTIRE repo table for one
|
||||
/// anonymous request while holding a scarce walk permit.
|
||||
///
|
||||
/// Reach bound: a holder buried past the ceiling is servable in
|
||||
/// `ceil(repos / ceiling) + 1` token-echoing retries. A truncated scan sheds a
|
||||
/// retryable 503 carrying a sealed continuation token; the caller echoes it as
|
||||
/// `?scan=` and the scan resumes where it stopped. No server-side scan state.
|
||||
///
|
||||
/// Floor coupling: raising this knob raises every caller's per-window `/ipfs` work
|
||||
/// allowance whenever the route limit sits below the derived floor, because the
|
||||
/// floor must fit one full deep scan's page toll (see `AppState::ipfs_work_budget`).
|
||||
///
|
||||
/// Tuning DOWN trade: token presence is a coarse inventory-size oracle. A ceiling
|
||||
/// truncation emits a token and a wrapped scan does not, so laddering to the
|
||||
/// `scan-wrapped` taint tells an anonymous caller the node's total repo count,
|
||||
/// private and quarantined included, to within one ceiling. Tolled and coarse at
|
||||
/// the 2048 default; it sharpens as the ceiling is lowered.
|
||||
///
|
||||
/// Must be between 1 and 1_048_576. Default: 2048.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS",
|
||||
default_value_t = crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_048_576)
|
||||
)]
|
||||
pub ipfs_max_legacy_scan_rows: usize,
|
||||
|
||||
/// Upper bound on the number of EXPENSIVE visibility walks
|
||||
/// (`allowed_blob_set_for_caller_bounded`, a full-history git walk in a
|
||||
/// blocking thread) a single `/ipfs/{cid}` request may run. Only a blob in a
|
||||
@@ -539,8 +492,8 @@ pub struct Config {
|
||||
/// absent with a 404. The handler still short-circuits the moment it serves.
|
||||
/// Must be between 1 and 1_048_576. Default: 64.
|
||||
///
|
||||
/// The effective ceiling is the TIGHTER of this knob and the node's internal
|
||||
/// history-walk ceiling, `MAX_PIN_SOURCES + 1` = 17 (see
|
||||
/// The effective per-request ceiling is the TIGHTER of this knob and the node's
|
||||
/// internal per-request history-walk ceiling, `MAX_PIN_SOURCES + 1` = 17 (see
|
||||
/// `api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST` and the `min()` that combines the
|
||||
/// two in the resolver). Setting this above 17 changes nothing, because the
|
||||
/// internal ceiling already binds. Setting it below 17 does lower the cap: the
|
||||
@@ -548,12 +501,6 @@ pub struct Config {
|
||||
/// before its whole bounded provenance source set has been tried, so an operator
|
||||
/// who goes under it is choosing a tighter cap that can 503 a provenanced
|
||||
/// request, which is allowed.
|
||||
///
|
||||
/// That combined cap is charged PER PHASE, not per request: the provenance phase and
|
||||
/// the legacy-scan fallback each get their own equal budget, so one request can run
|
||||
/// up to twice it in total (see `MAX_HISTORY_WALKS_PER_REQUEST`, which explains why
|
||||
/// the split is what keeps the fallback from inheriting a provenance phase's spent
|
||||
/// remainder).
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_MAX_REPOS_WALKED",
|
||||
@@ -622,40 +569,6 @@ pub struct Config {
|
||||
)]
|
||||
pub ipfs_request_budget_secs: u64,
|
||||
|
||||
/// Budget for the PRE-WALK CID resolve inside `get_by_cid`, in seconds: the
|
||||
/// `oids_for_cid` lookup that maps the requested CID to its git oid(s), which runs
|
||||
/// while the scarce walk admission (the global pool permit plus the per-source
|
||||
/// sub-permit) is already held.
|
||||
///
|
||||
/// It exists because that one await decides whether the request does any admitted
|
||||
/// work at all. A syntactically valid CID with no `pinned_cids` row runs zero probes
|
||||
/// and zero walks, so under a stalled or saturated pool it would otherwise occupy a
|
||||
/// walk slot for the whole `ipfs_request_budget_secs` window (600s by default) while
|
||||
/// nothing is walking, and enough distinct source keys doing that reject every real
|
||||
/// `/ipfs` retrieval at admission. The other repair, resolving the CID before taking
|
||||
/// admission, was rejected: admission stays FIRST so an anonymous flood sheds before
|
||||
/// touching the database at all, and moving the read ahead of it would let arbitrarily
|
||||
/// many unadmitted permissionless callers stack concurrent DB queries.
|
||||
///
|
||||
/// The effective deadline is the lesser of this and the remaining request budget, so a
|
||||
/// value larger than `ipfs_request_budget_secs` degrades to the request budget rather
|
||||
/// than extending it. Only the resolve is on this clock; every later stage stays on the
|
||||
/// full request budget, because from the second oid candidate on those run after real
|
||||
/// probe and walk work and a short deadline anchored at admission would shed a
|
||||
/// legitimately slow but progressing scan.
|
||||
///
|
||||
/// Must be positive, and no larger than `GIT_SERVICE_TIMEOUT_SECS_MAX`, for the same
|
||||
/// representability reason as the request budget above: `get_by_cid` derives the
|
||||
/// resolve deadline as `Instant::now() + Duration::from_secs(this)`, and that addition
|
||||
/// panics on overflow in release builds too. Default: 10s.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_IPFS_RESOLVE_BUDGET_SECS",
|
||||
default_value_t = 10,
|
||||
value_parser = clap::value_parser!(u64).range(1..=GIT_SERVICE_TIMEOUT_SECS_MAX)
|
||||
)]
|
||||
pub ipfs_resolve_budget_secs: u64,
|
||||
|
||||
/// Per-client-IP rate limit for `GET /ipfs/{cid}`, in requests per hour. The
|
||||
/// route is publicly reachable (`optional_signature`) and each request can drive
|
||||
/// a full-history git walk, so it carries a per-IP flood brake in addition to the
|
||||
@@ -1038,13 +951,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The `/ipfs` work-budget capacity is DERIVED from the route limit (R6, KTD6), with
|
||||
/// a hard floor of one complete COMBINED resolution per window: the provenance
|
||||
/// phase's walk term plus a full legacy search (the effective
|
||||
/// `ipfs_max_legacy_probes` plus the row ceiling's page toll). 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 budget back to
|
||||
/// back.
|
||||
/// 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;
|
||||
@@ -1059,45 +970,20 @@ mod tests {
|
||||
"the work budget must clear one full legacy search per window"
|
||||
);
|
||||
|
||||
// Tight route limit (1): the floor lifts the work budget to one complete
|
||||
// COMBINED resolution, the 256-probe budget PLUS the page toll a 2048-row
|
||||
// ceiling costs at 128 rows per page (16) PLUS the provenance phase's walk term
|
||||
// min(17, 64) = 17, so 289, NOT down to 1. The provenance walks come off the
|
||||
// same bucket before the fallback runs, so a floor without that term hands the
|
||||
// legacy search a bucket the provenance phase already spent from. This case
|
||||
// also carries the walk term's ABOVE-constant direction: the repos-walked knob
|
||||
// is at its default 64, so `MAX_HISTORY_WALKS_PER_REQUEST` (17) is what binds.
|
||||
// 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),
|
||||
289,
|
||||
"a tight route limit is floored at probes + pages + walks \
|
||||
(256 + 16 + min(17, 64) = 17), not clamped to 1"
|
||||
);
|
||||
|
||||
// The walk term's BELOW-constant direction: a repos-walked knob under the
|
||||
// history-walk constant is what the resolver's own `walk_cap` min() selects, so
|
||||
// it is what the floor must carry too. 256 + 16 + min(17, 3) = 275.
|
||||
let narrow_walk = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-rate-limit",
|
||||
"1",
|
||||
"--ipfs-max-repos-walked",
|
||||
"3",
|
||||
]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_work_budget(&narrow_walk),
|
||||
275,
|
||||
"the walk term takes min(17, repos-walked 3) = 3, the resolver's own \
|
||||
walk_cap, so the floor is 256 + 16 + 3"
|
||||
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). The walk cap here is a SECOND
|
||||
// below-constant proof at a different pair of values: min(17, 7) = 7, and the
|
||||
// probe knob is raised at the same time so a floor that folded the two terms
|
||||
// together (they were one field before the split) reads visibly wrong rather
|
||||
// than plausibly right.
|
||||
// effective probe budget, not the constant). The walk cap is set to a DIFFERENT
|
||||
// value in the same config on purpose: the two were one field before the split,
|
||||
// so a floor that silently read the walk cap would return 7 here and still look
|
||||
// plausible. Only the legacy-probe knob may drive this budget.
|
||||
let raised = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-rate-limit",
|
||||
@@ -1109,30 +995,8 @@ mod tests {
|
||||
]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_work_budget(&raised),
|
||||
1023,
|
||||
"the floor tracks the operator-raised legacy-probe budget (1000) plus the \
|
||||
default row ceiling's page toll (16) plus the walk term min(17, 7) = 7"
|
||||
);
|
||||
|
||||
// The scan-rows knob is coupled to the floor too, and this EXECUTES the coupling
|
||||
// rather than describing it: every page the ceiling permits is charged to the
|
||||
// caller's work bucket, so a raised ceiling that did not lift the floor would
|
||||
// 429 an honest caller part-way down their own token ladder. 4096 rows at 128
|
||||
// rows per page is 32 pages, so the floor is 256 + 32 + the default walk term
|
||||
// of 17.
|
||||
let wide_scan = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-rate-limit",
|
||||
"10",
|
||||
"--ipfs-max-legacy-scan-rows",
|
||||
"4096",
|
||||
]);
|
||||
assert_eq!(
|
||||
AppState::ipfs_work_budget(&wide_scan),
|
||||
305,
|
||||
"raising the row ceiling must raise the work floor by the pages it buys \
|
||||
(256 probes + 4096/128 = 32 pages + min(17, 64) = 17 walks), or a full \
|
||||
deep scan self-throttles"
|
||||
1000,
|
||||
"the floor tracks the operator-raised legacy-probe budget, not the walk cap"
|
||||
);
|
||||
|
||||
// 0 route limit disables the derived bucket too (a 0-capacity limiter admits all).
|
||||
@@ -1144,8 +1008,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Behavioral floor: a limiter sized to the derived (tight-route) budget admits
|
||||
// a whole combined resolution's worth of charges back to back for one source,
|
||||
// then sheds the next.
|
||||
// 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));
|
||||
@@ -1157,7 +1020,7 @@ mod tests {
|
||||
for i in 0..budget {
|
||||
assert!(
|
||||
limiter.check("1.2.3.4").await,
|
||||
"charge {i} of one full combined resolution must be admitted (no mid-scan throttle)"
|
||||
"probe {i} of one full default-config scan must be admitted (no mid-scan throttle)"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -1221,52 +1084,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_resolve_budget_secs_defaults_to_10_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).ipfs_resolve_budget_secs,
|
||||
10
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--ipfs-resolve-budget-secs", "3"])
|
||||
.ipfs_resolve_budget_secs,
|
||||
3
|
||||
);
|
||||
// 0 would shed every /ipfs request at the pre-walk resolve (unconditional
|
||||
// 503); clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--ipfs-resolve-budget-secs", "0"]).is_err()
|
||||
);
|
||||
// The ceiling is shared with the request budget: at the max it parses and the
|
||||
// derived deadline is still representable, past it clap rejects.
|
||||
let at_max = Config::try_parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-resolve-budget-secs",
|
||||
&GIT_SERVICE_TIMEOUT_SECS_MAX.to_string(),
|
||||
])
|
||||
.expect("the documented maximum must parse");
|
||||
assert_eq!(
|
||||
at_max.ipfs_resolve_budget_secs,
|
||||
GIT_SERVICE_TIMEOUT_SECS_MAX
|
||||
);
|
||||
assert!(std::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(
|
||||
at_max.ipfs_resolve_budget_secs
|
||||
))
|
||||
.is_some());
|
||||
for over in [GIT_SERVICE_TIMEOUT_SECS_MAX + 1, u64::MAX] {
|
||||
assert!(
|
||||
Config::try_parse_from([
|
||||
"gitlawb-node",
|
||||
"--ipfs-resolve-budget-secs",
|
||||
&over.to_string(),
|
||||
])
|
||||
.is_err(),
|
||||
"{over} is past the representable ceiling and must be rejected at parse time"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #174 (RED-before/GREEN-after): the upper bound is what keeps the deadline derived
|
||||
/// from this knob in range. `get_by_cid` builds the request budget as
|
||||
/// `Instant::now() + Duration::from_secs(this)` (api/ipfs.rs), and that addition is an
|
||||
@@ -1400,51 +1217,4 @@ mod tests {
|
||||
"db_max_connections at the floor (pushes + headroom) must validate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The DECLARED default, read off the parser rather than out of a parse.
|
||||
///
|
||||
/// `Config::parse_from` consults the process environment, so on a host that
|
||||
/// exports `GITLAWB_ENFORCE_OWNER_PUSH=false` a parse-based assertion says
|
||||
/// nothing about what this crate declares — it reports the operator's setting.
|
||||
/// Asserting the declaration is the env-independent form, and it is the one that
|
||||
/// actually fails if someone flips `default_value_t` back.
|
||||
#[test]
|
||||
fn enforce_owner_push_is_declared_true_independent_of_the_environment() {
|
||||
use clap::CommandFactory;
|
||||
let cmd = Config::command();
|
||||
let arg = cmd
|
||||
.get_arguments()
|
||||
.find(|a| a.get_id() == "enforce_owner_push")
|
||||
.expect("the argument must exist");
|
||||
assert_eq!(
|
||||
arg.get_default_values(),
|
||||
["true"],
|
||||
"owner-only push must be the declared default; a node started with no \
|
||||
configuration cannot accept a push from a self-minted key"
|
||||
);
|
||||
}
|
||||
|
||||
/// The flip must not strand an operator mid-upgrade.
|
||||
///
|
||||
/// Turning the gate on is a breaking change for any deployment whose pushers are
|
||||
/// not yet the repo owner, so the escape hatch has to keep working. As a
|
||||
/// presence-only flag `--enforce-owner-push false` is not "false", it is an
|
||||
/// "unexpected argument" error; the value-taking action is what makes that form
|
||||
/// parse.
|
||||
///
|
||||
/// This pins the CLI form only. The environment form resolved to `false` under
|
||||
/// the presence-only declaration too, so it is not what this change fixed, and it
|
||||
/// is not exercised here because the process environment is global and these
|
||||
/// tests run in parallel.
|
||||
#[test]
|
||||
fn enforce_owner_push_stays_disableable_for_rolling_upgrades() {
|
||||
assert!(
|
||||
!Config::parse_from(["gitlawb-node", "--enforce-owner-push", "false"])
|
||||
.enforce_owner_push,
|
||||
"operators must still be able to opt out during a rolling upgrade"
|
||||
);
|
||||
assert!(
|
||||
Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -50,15 +50,8 @@ pub enum AppError {
|
||||
#[error("incomplete: {0}")]
|
||||
Incomplete(String),
|
||||
|
||||
/// A bounded search that could not complete. `continuation`, when present, is the
|
||||
/// sealed scan position the caller echoes as `?scan=` to resume where the search
|
||||
/// stopped (#173 round 13, F2). It is AEAD-sealed at the mint site, never plaintext:
|
||||
/// the row it names is by construction one the caller was denied (INV-13).
|
||||
#[error("search incomplete: {message}")]
|
||||
SearchIncomplete {
|
||||
message: String,
|
||||
continuation: Option<String>,
|
||||
},
|
||||
#[error("search incomplete: {0}")]
|
||||
SearchIncomplete(String),
|
||||
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
@@ -175,10 +168,10 @@ impl IntoResponse for AppError {
|
||||
// legacy-probe or walk ceiling), distinct from the 404 that asserts a
|
||||
// definitive not-found: absence was NOT proven, so the caller should
|
||||
// retry rather than treat it as gone (#173, F2). 503, retryable.
|
||||
AppError::SearchIncomplete { message, .. } => (
|
||||
AppError::SearchIncomplete(msg) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"search_incomplete",
|
||||
message.clone(),
|
||||
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 /
|
||||
@@ -220,31 +213,19 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
};
|
||||
|
||||
let mut body = json!({
|
||||
let body = Json(json!({
|
||||
"error": code,
|
||||
"message": message,
|
||||
});
|
||||
// A truncated CID search may carry the sealed position the caller echoes as
|
||||
// `?scan=` to resume. Rendered as a third body field, present only when the
|
||||
// shed actually left something to resume: a wrapped scan and a throttled
|
||||
// request both omit it, and its ABSENCE is what tells a caller the ladder is
|
||||
// over. It is opaque ciphertext; see `gitlawb_core::scan_token`.
|
||||
if let AppError::SearchIncomplete {
|
||||
continuation: Some(token),
|
||||
..
|
||||
} = &self
|
||||
{
|
||||
body["continuation"] = json!(token);
|
||||
}
|
||||
}));
|
||||
|
||||
let mut resp = (status, Json(body)).into_response();
|
||||
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 { .. }
|
||||
AppError::Overloaded(_) | AppError::SearchIncomplete(_)
|
||||
) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
|
||||
@@ -230,34 +230,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Take a write lock (Postgres advisory lock), ensure repo is local, return guard.
|
||||
///
|
||||
/// # Cross-machine guarantee
|
||||
///
|
||||
/// When multiple nodes share a single Postgres database (the shared-Postgres
|
||||
/// deployment model), this lock prevents concurrent writes to the same repo
|
||||
/// across machines. The lock has no effect across separate Postgres instances
|
||||
/// (federated per-node-DB topology).
|
||||
///
|
||||
/// # Rolling upgrade caveat (SHA-256 re-keying)
|
||||
///
|
||||
/// This function hashes `(owner_slug, repo_name)` with SHA-256 to produce a
|
||||
/// stable `i64` key. Earlier builds used `std::collections::DefaultHasher`,
|
||||
/// whose algorithm is not frozen by the Rust standard and has already
|
||||
/// shifted across toolchain versions. The SHA-256 swap re-keys *every*
|
||||
/// repo: an old-binary node holding the legacy `DefaultHasher` key and a
|
||||
/// new-binary node holding the SHA-256 key for the same repo compute
|
||||
/// *different* i64 keys, so PostgreSQL treats them as independent locks and
|
||||
/// the cross-machine write-exclusion this lock provides is lost for the
|
||||
/// duration of a rolling upgrade.
|
||||
///
|
||||
/// The accepted remediation (see issue #210) is operational: during a
|
||||
/// shared-Postgres rolling upgrade, **drain in-flight writes or cut over
|
||||
/// through a single node** (e.g. stop receive-pack / issue / pull / archive
|
||||
/// writers on the old version) before bringing new-binary nodes online. The
|
||||
/// window is bounded by the operator's rollout cadence. A future
|
||||
/// transition release could acquire both legacy and new keys for one cycle
|
||||
/// and drop the legacy one a release later; that's optional given the
|
||||
/// accepted-window path.
|
||||
/// The lock prevents concurrent writes to the same repo across machines.
|
||||
pub async fn acquire_write(&self, owner_did: &str, repo_name: &str) -> Result<RepoWriteGuard> {
|
||||
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
|
||||
let lock_key = advisory_lock_key(&owner_slug, repo_name);
|
||||
@@ -905,29 +878,14 @@ pub fn build_lock_pool(source: &PgPool, max_connections: u32, acquire_timeout: D
|
||||
}
|
||||
|
||||
/// Compute a stable i64 hash for a Postgres advisory lock key.
|
||||
///
|
||||
/// Uses SHA-256 (not `DefaultHasher`) so the same `(owner_slug, repo_name)`
|
||||
/// produces the same `i64` key across every Rust toolchain version, operating
|
||||
/// system, and machine — the algorithm is frozen by the SHA-2 standard rather
|
||||
/// than by a std-internal implementation detail.
|
||||
///
|
||||
/// Domain separation is `owner_slug + ":" + repo_name` with no length prefix,
|
||||
/// so the mapping is injective only while `owner_slug` contains no `:` (the
|
||||
/// `did:key:`→`did_key_` slug form `local_path` produces). A raw DID would
|
||||
/// collide: `("did:key:abc", "x")` and `("did", "key:abc:x")` hash the same.
|
||||
pub(crate) fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
debug_assert!(
|
||||
!owner_slug.contains(':'),
|
||||
"advisory_lock_key owner_slug must not contain ':' (domain-separation guarantee)"
|
||||
);
|
||||
use sha2::Digest;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(owner_slug.as_bytes());
|
||||
hasher.update(b":");
|
||||
hasher.update(repo_name.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
i64::from_le_bytes(digest[..8].try_into().expect("sha256 output is >= 8 bytes"))
|
||||
fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
owner_slug.hash(&mut hasher);
|
||||
repo_name.hash(&mut hasher);
|
||||
hasher.finish() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1832,48 +1790,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── advisory_lock_key stability ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn advisory_lock_key_is_stable() {
|
||||
// Golden value: SHA-256("did_key_...:<repo_name>")[..8] as i64 little-endian.
|
||||
// If this test fails, the hashing algorithm has changed — the new key
|
||||
// must be backward-compatible or the rollout planned accordingly.
|
||||
let key = advisory_lock_key(
|
||||
"did_key_z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
|
||||
"hello",
|
||||
);
|
||||
assert_eq!(key, -6680856138670956537_i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advisory_lock_key_differs_for_different_inputs() {
|
||||
// Vary one axis at a time so a regression that drops either parameter
|
||||
// from the hash is caught, not just one that drops both. The golden
|
||||
// test above backstops a total algorithm swap.
|
||||
let base = advisory_lock_key("owner_a", "repo_a");
|
||||
|
||||
// Same owner, different repo: a regression that hashes only owner_slug
|
||||
// would make these collide.
|
||||
let same_owner_diff_repo = advisory_lock_key("owner_a", "repo_b");
|
||||
assert_ne!(
|
||||
base, same_owner_diff_repo,
|
||||
"key must depend on repo_name, not just owner_slug"
|
||||
);
|
||||
|
||||
// Same repo, different owner: a regression that hashes only repo_name
|
||||
// would make these collide.
|
||||
let diff_owner_same_repo = advisory_lock_key("owner_b", "repo_a");
|
||||
assert_ne!(
|
||||
base, diff_owner_same_repo,
|
||||
"key must depend on owner_slug, not just repo_name"
|
||||
);
|
||||
|
||||
// Sanity: both axes varying at once still differs (the original shape).
|
||||
let both_differ = advisory_lock_key("owner_b", "repo_b");
|
||||
assert_ne!(base, both_differ);
|
||||
}
|
||||
|
||||
// ── advisory-lock cancellation-safety (#174 F1, RED-before/GREEN-after) ──
|
||||
|
||||
/// F1 (P1): dropping a `RepoWriteGuard` WITHOUT calling `release()` — the
|
||||
|
||||
@@ -606,20 +606,8 @@ pub(crate) fn object_store_readable_store_wide(repo_path: &Path) -> bool {
|
||||
/// is rejected before it is buffered, #173 F6), under
|
||||
/// [`run_bounded_git`](crate::git::visibility_pack::run_bounded_git) so a wedged size
|
||||
/// read is reaped at `deadline` instead of pinning the held /ipfs walk admission.
|
||||
/// `Ok(n)` on success, and otherwise a [`ProbeError`] in the same vocabulary the type and
|
||||
/// content stages use: a reaped child is `Transient` (retryable), and every other failure
|
||||
/// goes through [`classify_store_fault`], so it is `Transient` when the object store is
|
||||
/// not readable and `Deterministic` when it is.
|
||||
///
|
||||
/// There is deliberately NO absence value (#173 round 12). This returned
|
||||
/// `Ok(None)` for every non-timeout failure, which `gate_and_serve` reads as a verified
|
||||
/// absence and does not taint the search for, so a corrupt object, an unreadable pack, or
|
||||
/// a failed spawn handed an authorized caller a definitive 404 instead of the retryable
|
||||
/// 503 tail. Absence is also not this stage's question: the caller has already had a
|
||||
/// `Present` verdict from [`object_type_bounded`], so an object that cannot be sized here
|
||||
/// is a fault, not a not-found, and the one honest exception (a concurrent gc between the
|
||||
/// two stages) is classified by store readability like any other. Making the absence
|
||||
/// unrepresentable is what keeps the next caller from reintroducing the swallow.
|
||||
/// `Ok(Some(n))` on success, `Ok(None)` when the object is absent (a non-timeout
|
||||
/// non-zero exit), `Err(GitServiceTimeout)` on the deadline.
|
||||
///
|
||||
/// Takes the shared `deadline` rather than its own timeout so a caller that pairs this
|
||||
/// size check with a later read spends ONE budget across the pair, not one per stage.
|
||||
@@ -628,7 +616,7 @@ pub fn object_size_bounded(
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> std::result::Result<u64, ProbeError> {
|
||||
) -> Result<Option<u64>> {
|
||||
match crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "-s", sha256_hex],
|
||||
@@ -636,25 +624,9 @@ pub fn object_size_bounded(
|
||||
b"",
|
||||
deadline,
|
||||
) {
|
||||
Ok(out) => {
|
||||
let text = String::from_utf8_lossy(&out);
|
||||
text.trim().parse::<u64>().map_err(|e| {
|
||||
// git exited 0 but did not print a size: the store answered something
|
||||
// this code cannot read, which is a fault and never an absence.
|
||||
classify_store_fault(
|
||||
repo_path,
|
||||
sha256_hex,
|
||||
anyhow::anyhow!("unparseable `cat-file -s` output {:?}: {e}", text.trim()),
|
||||
)
|
||||
})
|
||||
}
|
||||
// The watchdog reaped the child at `deadline`; retryable whatever the store looks
|
||||
// like, so it is routed before readability gets a say (same rule as the content
|
||||
// stage in `read_object_bounded`).
|
||||
Err(e) if e.is::<crate::git::smart_http::GitServiceTimeout>() => {
|
||||
Err(ProbeError::Transient(e))
|
||||
}
|
||||
Err(e) => Err(classify_store_fault(repo_path, sha256_hex, e)),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1516,134 +1488,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 round 12 (jatmn): the SIZE probe must use the same absence-versus-fault
|
||||
/// vocabulary the type probe does. It mapped every non-timeout failure to `Ok(None)`,
|
||||
/// which `gate_and_serve` reads as a verified absence and does not taint, so a
|
||||
/// corrupt object, an unreadable pack, or a failed spawn ended the search cleanly and
|
||||
/// handed an authorized caller a definitive 404 instead of the retryable 503 tail.
|
||||
///
|
||||
/// A corrupt loose object is the same fixture the type stage uses for this, and it is
|
||||
/// the honest case: the object EXISTS, the type probe says so, and only the size read
|
||||
/// fails. RED before the change (`Ok(None)`).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn object_size_bounded_corrupt_loose_object_is_fault_not_absence() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("sizecorrupt");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?}"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q", "--object-format=sha256", "."]);
|
||||
g(&["config", "user.email", "t@t"]);
|
||||
g(&["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("f.txt"), b"loose object content\n").unwrap();
|
||||
g(&["add", "f.txt"]);
|
||||
g(&["commit", "-qm", "c1"]);
|
||||
let blob = String::from_utf8(
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "HEAD:f.txt"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
// Healthy first: the probe reads the real size, so the fault case below is not
|
||||
// passing for the trivial reason that this fixture never worked.
|
||||
let healthy = super::object_size_bounded("git", &work, &blob, deadline);
|
||||
assert!(
|
||||
matches!(healthy, Ok(n) if n > 0),
|
||||
"a readable object reports its size; got {healthy:?}"
|
||||
);
|
||||
|
||||
let obj = work.join(".git/objects").join(&blob[0..2]).join(&blob[2..]);
|
||||
let mut perms = std::fs::metadata(&obj).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
std::fs::set_permissions(&obj, perms).unwrap();
|
||||
std::fs::write(&obj, b"garbage not a zlib stream").unwrap();
|
||||
|
||||
let res = super::object_size_bounded("git", &work, &blob, deadline);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"a corrupt object must surface as a probe fault, never as an absence the \
|
||||
resolver renders as a clean 404; got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 round 12 (jatmn), the transient arm: a store this process cannot read is the
|
||||
/// retryable case, so the size probe classifies it `Transient` exactly as the type
|
||||
/// probe does. Distinguishing the two arms is the whole point of routing through
|
||||
/// `classify_store_fault` rather than returning one undifferentiated error.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn object_size_bounded_unreadable_store_is_transient() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work = td.path().join("sizeunreadable");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let g = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?}"
|
||||
);
|
||||
};
|
||||
g(&["init", "-q", "--object-format=sha256", "."]);
|
||||
g(&["config", "user.email", "t@t"]);
|
||||
g(&["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("f.txt"), b"loose object content\n").unwrap();
|
||||
g(&["add", "f.txt"]);
|
||||
g(&["commit", "-qm", "c1"]);
|
||||
let blob = String::from_utf8(
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "HEAD:f.txt"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Make this oid's loose fan-out unreadable: git fails, and the store cannot
|
||||
// certify absence, so the fault is retryable rather than terminal.
|
||||
let fanout = work.join(".git/objects").join(&blob[0..2]);
|
||||
let mut perms = std::fs::metadata(&fanout).unwrap().permissions();
|
||||
perms.set_mode(0o000);
|
||||
std::fs::set_permissions(&fanout, perms).unwrap();
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let res = super::object_size_bounded("git", &work, &blob, deadline);
|
||||
|
||||
let mut restore = std::fs::metadata(&fanout).unwrap().permissions();
|
||||
restore.set_mode(0o755);
|
||||
std::fs::set_permissions(&fanout, restore).unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(res, Err(super::ProbeError::Transient(_))),
|
||||
"an unreadable object store is the retryable arm; got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F5/U4: a corrupt LOOSE object makes `git cat-file --batch-check` print
|
||||
/// `<oid> missing` on stdout (exit 0) yet emit `error:` diagnostics on stderr. The
|
||||
/// clean-`missing` absence path must NOT fire here — the `error:` line disqualifies
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -116,9 +116,6 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Load or generate the node's identity keypair
|
||||
let keypair = load_or_create_keypair(&config)?;
|
||||
// Sealing key for the legacy-scan continuation tokens, DERIVED from the identity
|
||||
// just loaded so it is the same key after a restart (see `derive_scan_token_key`).
|
||||
let scan_token_key = AppState::derive_scan_token_key(&keypair);
|
||||
let node_did = keypair.did();
|
||||
|
||||
// One-time metrics init. Must run before any handler that calls into
|
||||
@@ -424,20 +421,10 @@ async fn main() -> Result<()> {
|
||||
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_LEGACY_PROBES
|
||||
// (R5), which is what `ipfs_legacy_probe_budget` reads. NOT
|
||||
// GITLAWB_IPFS_MAX_REPOS_WALKED, which this comment used to name: that is the
|
||||
// separate cap on expensive visibility walks, so an operator following the old
|
||||
// text tuned the walk cap and left this fan-out unchanged. The history-walk
|
||||
// ceiling above stays constant (a smaller value false-503s a provenanced
|
||||
// request). Default 256 preserves the shipped behaviour.
|
||||
// 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_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
|
||||
// Operator-tunable via GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS, read through the same
|
||||
// helper shape as the probe budget so the knob cannot be a silent no-op.
|
||||
ipfs_max_legacy_scan_rows: AppState::ipfs_legacy_scan_row_budget(&config),
|
||||
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
|
||||
ipfs_scan_token_key: Arc::new(scan_token_key),
|
||||
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
|
||||
push_limiter_trust,
|
||||
sync_trigger_rate_limiter,
|
||||
@@ -708,7 +695,7 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// U4 (#173): spawn the periodic legacy provider-CID repair sweep. Releases before this
|
||||
/// 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
|
||||
@@ -716,9 +703,7 @@ async fn main() -> Result<()> {
|
||||
/// 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. It re-arms after every run, including a failed one, so it never returns
|
||||
/// and there is no awaited value to log here; the shutdown watcher below is what ends
|
||||
/// it.
|
||||
/// 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
|
||||
@@ -735,10 +720,17 @@ fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::Joi
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
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() => {}
|
||||
|
||||
@@ -262,7 +262,7 @@ pub async fn pin_new_objects(
|
||||
);
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
db.mark_pin_sources_incomplete(&sha),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -279,7 +279,7 @@ pub async fn pin_new_objects(
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
db.mark_pin_sources_incomplete(&sha),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -457,7 +457,7 @@ pub async fn pin_new_objects(
|
||||
);
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
db.mark_pin_sources_incomplete(&sha),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -468,7 +468,7 @@ pub async fn pin_new_objects(
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.mark_pin_sources_incomplete(&sha, repo_id),
|
||||
db.mark_pin_sources_incomplete(&sha),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -8,19 +8,6 @@ use crate::git::repo_store::RepoStore;
|
||||
use crate::p2p::P2pHandle;
|
||||
use crate::rate_limit::RateLimiter;
|
||||
|
||||
/// HKDF salt for [`AppState::derive_scan_token_key`]. A constant, not a secret: HKDF's
|
||||
/// salt is a domain qualifier, and the confidentiality of the derived key rests entirely
|
||||
/// on the node's private seed being the input keying material.
|
||||
const SCAN_TOKEN_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/ipfs-scan-token";
|
||||
|
||||
/// HKDF `info` for [`AppState::derive_scan_token_key`]: the domain separation AND the
|
||||
/// rotation handle in one string. Nothing else in the node derives from this label, so
|
||||
/// the token key is unrelated to the signing key it shares an input with; bumping the
|
||||
/// trailing version rotates every node's token key on its next boot, which invalidates
|
||||
/// outstanding continuations (they simply fail to open and the caller restarts at the
|
||||
/// front) and needs no migration, no config, and no change to this function.
|
||||
const SCAN_TOKEN_KEY_INFO: &[u8] = b"gitlawb/ipfs-scan-token/v1";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefUpdateBroadcast {
|
||||
pub repo: String,
|
||||
@@ -112,59 +99,6 @@ pub struct AppState {
|
||||
/// Bounds the anonymous `acquire` + `cat-file` fan-out across the node (#173,
|
||||
/// INV-10); a field for the same test-seam reason as `ipfs_max_history_walks`.
|
||||
pub ipfs_max_legacy_probes: u32,
|
||||
/// How many repo rows the CID resolver's legacy scan pulls per keyset page
|
||||
/// (default `api::ipfs::LEGACY_SCAN_PAGE_ROWS`). Bounds the DATABASE-facing half
|
||||
/// of the same fan-out `ipfs_max_legacy_probes` bounds on the probe side: without
|
||||
/// it the scan materialized every repo row and every matching visibility rule
|
||||
/// before spending a single probe (#173, INV-10). A field for the same test-seam
|
||||
/// reason as the sibling caps.
|
||||
pub ipfs_legacy_scan_page_rows: usize,
|
||||
/// Per-request ceiling on how many repo ROWS the CID resolver's legacy scan may
|
||||
/// fetch (default `api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST`, operator-tunable via
|
||||
/// `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS`). `ipfs_max_legacy_probes` bounds the PROBE
|
||||
/// fan-out but only starts counting once a probe runs, and quarantine plus a
|
||||
/// root-scope visibility deny both return before a probe or a visit is spent, so an
|
||||
/// all-denying inventory paged the whole repo table at zero probes (#173 round 13, F2).
|
||||
/// Truncating here sheds a retryable 503 carrying a sealed continuation token.
|
||||
pub ipfs_max_legacy_scan_rows: usize,
|
||||
/// Per-request ceiling on the BYTES of visibility rules the CID resolver's legacy
|
||||
/// scan may retain (default `api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST`). The
|
||||
/// row ceiling above bounds the row count but not the memory each row drags in: the
|
||||
/// pager keeps every fetched page's rules for the whole request, and neither the
|
||||
/// number of rules per repo nor the length of a rule's reader list is capped, so a
|
||||
/// rule COUNT would be the wrong unit. Enforced by the rules QUERY
|
||||
/// (`Db::list_visibility_rules_for_repos_bounded`) rather than by a sum taken once the
|
||||
/// page has landed, so the oversized page is never materialized at all. Deliberately
|
||||
/// NOT an operator knob (it is a
|
||||
/// memory guard, not a reach tradeoff); a field only for the same test-seam reason as
|
||||
/// the sibling caps.
|
||||
pub ipfs_max_legacy_scan_rule_bytes: usize,
|
||||
/// Key sealing the legacy scan's continuation tokens (INV-13), derived from the
|
||||
/// node's persistent identity by [`AppState::derive_scan_token_key`].
|
||||
///
|
||||
/// The token is minted from a FETCHED row on a scan that served nothing, so by
|
||||
/// construction that row is a private or quarantined repo the caller may not read:
|
||||
/// its `created_at` and its `id` (which carries the owner's DID) are withheld
|
||||
/// fields. The token is therefore AEAD-SEALED, never signed plaintext and never
|
||||
/// base64-of-plaintext, since integrity is not confidentiality.
|
||||
///
|
||||
/// DERIVED, not random per boot. This reverses an earlier revision of this design,
|
||||
/// which argued that derivation "would make old tokens valid across restarts for no
|
||||
/// benefit and tie a throwaway transport secret to a long-lived signing key." Both
|
||||
/// halves were wrong. Surviving a restart IS the benefit: the token is the ONLY way
|
||||
/// a caller resumes a ladder, an unopenable one is treated as absent, and a caller
|
||||
/// treated as absent silently restarts at the front of the scan. A node whose
|
||||
/// inventory needs several ladder steps and which deploys more often than a caller
|
||||
/// can climb therefore keeps that caller from ever reaching a holder, which
|
||||
/// contradicts the reach bound the README states. And the tie to the signing key is
|
||||
/// what the HKDF domain separation removes: the derived key is a one-way function of
|
||||
/// the seed under an `info` string nothing else uses, so it is not the signing key
|
||||
/// and cannot be worked back into one.
|
||||
///
|
||||
/// Persisting a random key instead would need a schema change for a value the node
|
||||
/// already has on disk, so derivation is also the smaller mechanism. Rotation is a
|
||||
/// bump of the version in [`SCAN_TOKEN_KEY_INFO`].
|
||||
pub ipfs_scan_token_key: Arc<[u8; 32]>,
|
||||
/// Hard ceiling on the byte size of an object `GET /ipfs/{cid}` will buffer and
|
||||
/// serve (default `api::ipfs::MAX_SERVED_OBJECT_BYTES`). The serve reads via a
|
||||
/// blocking `git cat-file` and buffers the whole object; without a bound a large
|
||||
@@ -381,116 +315,25 @@ impl AppState {
|
||||
config.ipfs_max_legacy_probes as u32
|
||||
}
|
||||
|
||||
/// Legacy-scan ROW budget wired from the `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` knob,
|
||||
/// the same helper shape as the probe budget above so the knob cannot become a
|
||||
/// silent no-op if a struct literal drifts back to the bare constant.
|
||||
pub(crate) fn ipfs_legacy_scan_row_budget(config: &crate::config::Config) -> usize {
|
||||
config.ipfs_max_legacy_scan_rows
|
||||
}
|
||||
|
||||
/// The key sealing legacy-scan continuation tokens (INV-13), DERIVED from the node's
|
||||
/// persistent identity rather than minted per boot.
|
||||
///
|
||||
/// HKDF-SHA256 over the node's Ed25519 seed, the same key material
|
||||
/// `load_or_create_keypair` reads back from its PKCS#8 PEM on every boot, so the
|
||||
/// derived key is byte-identical after a restart or a rolling deploy.
|
||||
///
|
||||
/// Two properties the derivation has to carry, both load-bearing:
|
||||
///
|
||||
/// * DOMAIN SEPARATION. The `info` string below is unique to this use, so the
|
||||
/// derived key is not the signing seed and is not any other secret derived from
|
||||
/// it. HKDF is one-way, so a leaked token key yields nothing about the signing
|
||||
/// key and cannot be turned against a signature.
|
||||
/// * A VERSION component, carried in the same `info` string
|
||||
/// ([`SCAN_TOKEN_KEY_INFO`]) alongside a fixed [`SCAN_TOKEN_KEY_SALT`]. Bumping
|
||||
/// the version rotates every token key on the next boot without touching the
|
||||
/// identity, the token format, or any caller, so rotation is a constant change
|
||||
/// rather than a fork of this derivation.
|
||||
pub(crate) fn derive_scan_token_key(keypair: &Keypair) -> [u8; 32] {
|
||||
use hmac::{Hmac, Mac};
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
|
||||
let seed = keypair.to_seed();
|
||||
// HKDF-Extract: PRK = HMAC(salt, ikm).
|
||||
let mut extract =
|
||||
HmacSha256::new_from_slice(SCAN_TOKEN_KEY_SALT).expect("HMAC takes a key of any size");
|
||||
extract.update(seed.as_slice());
|
||||
let prk = extract.finalize().into_bytes();
|
||||
// HKDF-Expand, one block: T(1) = HMAC(PRK, info || 0x01). One 32-byte output
|
||||
// needs exactly one block of SHA-256, so there is no counter loop to get wrong.
|
||||
let mut expand =
|
||||
HmacSha256::new_from_slice(prk.as_slice()).expect("HMAC takes a key of any size");
|
||||
expand.update(SCAN_TOKEN_KEY_INFO);
|
||||
expand.update(&[0x01]);
|
||||
expand.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
/// Work-budget capacity for [`ipfs_work_rate_limiter`](Self#structfield.ipfs_work_rate_limiter)
|
||||
/// (R6, KTD6), DERIVED from the route limit rather than a new operator knob. The route
|
||||
/// limiter (`ipfs_rate_limiter`) charges once per request; this separate bucket absorbs
|
||||
/// the resolver's per-probe/per-walk work charges so both the route "requests per hour"
|
||||
/// contract and the amplification bound hold. Floor: at least one complete COMBINED
|
||||
/// resolution per window, the provenance phase's walks plus a full legacy search
|
||||
/// (probes plus page tolls), so a single default-config resolution cannot
|
||||
/// self-throttle part-way and recreate the admit-then-429 for a legitimate caller.
|
||||
/// `GITLAWB_IPFS_RATE_LIMIT=0`
|
||||
/// 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_LEGACY_PROBES`
|
||||
/// knob), so a single default-config deep search cannot self-throttle mid-scan and
|
||||
/// recreate the admit-then-429 for a legitimate caller. `GITLAWB_IPFS_RATE_LIMIT=0`
|
||||
/// disables the route brake and this derived bucket alike (a 0-capacity limiter admits
|
||||
/// everything).
|
||||
///
|
||||
/// The floor carries a WALK term (#173 round 15, F2). The provenance visibility walk
|
||||
/// in `gate_and_serve` debits this SAME bucket (its `!legacy_scan` charge), once per
|
||||
/// path-scoped source, before the legacy fallback runs at all, and the walk cap is
|
||||
/// charged per phase. A floor counting only the search therefore under-sizes the
|
||||
/// window by up to that cap exactly when the floor binds (a route limit set below
|
||||
/// it): the provenance phase spends from the budget the floor reserved for the
|
||||
/// search, the fallback 429s short of its configured reach, and the retry re-pays the
|
||||
/// same provenance charges. The term is
|
||||
/// `min(MAX_HISTORY_WALKS_PER_REQUEST, ipfs_max_repos_walked)` because that is what
|
||||
/// the resolver's own `walk_cap` in `gate_and_serve` evaluates to. The two
|
||||
/// expressions are separate, not one shared value: this one reads the CONSTANT
|
||||
/// `crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST`, while `walk_cap` reads the
|
||||
/// `AppState` seam `state.ipfs_max_history_walks`. They agree only because every
|
||||
/// construction seeds that seam from that constant: the production one in `main.rs`,
|
||||
/// and the two test ones in `auth`'s test module and `test_support`. Nothing
|
||||
/// mechanically ties the two expressions beyond this comment and its counterpart at
|
||||
/// `walk_cap` (no shared helper, no type, no assertion outside the one fixture that
|
||||
/// pins the seam as a precondition), so the two `min()`s have to be moved together
|
||||
/// by hand, and a construction that seeded the seam from anything else would size
|
||||
/// the floor for a cap the resolver does not enforce. It reads the
|
||||
/// CONSTANT and the CONFIG knob for the same reason the page term below reads the
|
||||
/// constant page size: `ipfs_max_history_walks` is an `AppState` test seam, and
|
||||
/// sizing a production floor from a seam would inflate the budget by whatever a test
|
||||
/// chose.
|
||||
///
|
||||
/// The PROBE term is the LEGACY-PROBE knob, not `ipfs_max_repos_walked`. Those were
|
||||
/// one field before the walk cap and the probe budget were split apart, and sizing
|
||||
/// the probe term off the walk cap would silently read 64 instead of 256. That is a
|
||||
/// statement about which knob sizes the PROBE term; it is not a claim that the walk
|
||||
/// cap has no place in the floor, since the walk term above is added to this one
|
||||
/// rather than substituted for it.
|
||||
///
|
||||
/// The floor also carries the scan's PAGE toll (#173 round 13, F2): every page the
|
||||
/// legacy scan buys is charged to this same bucket, so a deep scan spends
|
||||
/// `ceil(ipfs_max_legacy_scan_rows / LEGACY_SCAN_PAGE_ROWS)` tokens on pages on top
|
||||
/// of its probes. Leaving those out would 429 an honest caller part-way down their
|
||||
/// own continuation-token ladder, which is the F6 admit-then-429 shape in a new
|
||||
/// place. The page term uses the CONSTANT page size, not `AppState`'s field: the
|
||||
/// field is a test seam that shrinks pages to make paging observable, and sizing a
|
||||
/// production floor from it would inflate the budget by whatever a test chose.
|
||||
/// The floor is the LEGACY-PROBE knob, not `ipfs_max_repos_walked`. Those were one
|
||||
/// field before the walk cap and the probe budget were split apart, and reading the
|
||||
/// walk cap here would silently size this bucket at 64 instead of 256.
|
||||
pub(crate) fn ipfs_work_budget(config: &crate::config::Config) -> usize {
|
||||
if config.ipfs_rate_limit == 0 {
|
||||
return 0;
|
||||
}
|
||||
let pages = config
|
||||
.ipfs_max_legacy_scan_rows
|
||||
.div_ceil(crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS);
|
||||
let walks = std::cmp::min(
|
||||
crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST as usize,
|
||||
config.ipfs_max_repos_walked,
|
||||
);
|
||||
config
|
||||
.ipfs_rate_limit
|
||||
.max(config.ipfs_max_legacy_probes + pages + walks)
|
||||
config.ipfs_rate_limit.max(config.ipfs_max_legacy_probes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1463,80 +1306,3 @@ mod repo_write_lease_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scan_token_key_tests {
|
||||
use super::AppState;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::scan_token::{open_scan_token, seal_scan_token, ScanPosition};
|
||||
|
||||
const CID: &str = "bafkreiscantokenkeyfixtureaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
fn pos() -> ScanPosition {
|
||||
ScanPosition {
|
||||
created_at_key: "2020-01-01T12:00:00+00:00".to_string(),
|
||||
id: "did:key:z6MkScanTokenOwner/repo".to_string(),
|
||||
// 40 hex: the production shape, since the node's own repos are sha1.
|
||||
sha256_hex: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The RESTART case. A token minted before a restart must still open after it, or a
|
||||
/// caller laddering a deep inventory is silently returned to the front of the scan on
|
||||
/// every rolling deploy and can never reach a holder buried past one window.
|
||||
///
|
||||
/// The second key is derived from the identity RELOADED THROUGH ITS ON-DISK PEM,
|
||||
/// which is exactly what `load_or_create_keypair` does on boot, so this exercises the
|
||||
/// real restart path rather than a clone of the in-memory keypair.
|
||||
#[test]
|
||||
fn scan_token_key_survives_a_restart_of_the_same_identity() {
|
||||
let kp = Keypair::generate();
|
||||
let pem = kp.to_pem().expect("the identity serializes");
|
||||
let reloaded = Keypair::from_pem(&pem).expect("the identity reloads");
|
||||
|
||||
let before = AppState::derive_scan_token_key(&kp);
|
||||
let after = AppState::derive_scan_token_key(&reloaded);
|
||||
|
||||
let token = seal_scan_token(&before, CID, &pos(), i64::MAX - 1).expect("seal");
|
||||
assert_eq!(
|
||||
open_scan_token(&after, CID, &token, 0),
|
||||
Some(pos()),
|
||||
"a continuation minted before a restart must open after it: the node's \
|
||||
identity is the same, so the derived sealing key must be too"
|
||||
);
|
||||
}
|
||||
|
||||
/// The must-not: a DIFFERENT node identity must derive a DIFFERENT key, so a token is
|
||||
/// no more portable between nodes than it was when the key was random per boot.
|
||||
#[test]
|
||||
fn scan_token_key_does_not_open_under_a_different_identity() {
|
||||
let mine = Keypair::generate();
|
||||
let theirs = Keypair::generate();
|
||||
|
||||
let token = seal_scan_token(
|
||||
&AppState::derive_scan_token_key(&mine),
|
||||
CID,
|
||||
&pos(),
|
||||
i64::MAX - 1,
|
||||
)
|
||||
.expect("seal");
|
||||
assert_eq!(
|
||||
open_scan_token(&AppState::derive_scan_token_key(&theirs), CID, &token, 0),
|
||||
None,
|
||||
"a continuation sealed by one node must not open under another node's identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// Domain separation, executed rather than asserted in prose: the derived token key
|
||||
/// must not be the signing seed itself. Compromising a token key must not hand an
|
||||
/// attacker the material that signs.
|
||||
#[test]
|
||||
fn scan_token_key_is_not_the_signing_seed() {
|
||||
let kp = Keypair::generate();
|
||||
assert_ne!(
|
||||
AppState::derive_scan_token_key(&kp),
|
||||
*kp.to_seed(),
|
||||
"the token key must be a DERIVED secret, never the Ed25519 signing seed"
|
||||
);
|
||||
}
|
||||
}
|
||||
+107
-2865
File diff suppressed because it is too large.
Load diff
@@ -257,10 +257,10 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
);
|
||||
}
|
||||
|
||||
/// F6/KTD-5 (IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
|
||||
/// the scarce walk permits (RAII, held for the whole request) BEFORE its metadata
|
||||
/// queries, and the per-repo loop's first budget gate runs only later. So
|
||||
/// both `list_repos_page_for_scan` and `list_visibility_rules_for_repos` must be clamped to the
|
||||
/// F6/KTD-5 (initial IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
|
||||
/// the scarce walk permits (RAII, held for the whole request) BEFORE its two initial
|
||||
/// metadata queries, and the per-repo loop's first budget gate runs only later. So
|
||||
/// both `list_all_repos` and `list_visibility_rules_for_repos` must be clamped to the
|
||||
/// remaining request budget — otherwise a query blocked in Postgres pins the walk slot
|
||||
/// for the whole stall, past the budget. This scans the PRODUCTION half of `api/ipfs.rs`
|
||||
/// (the `mod tests` half names the same calls in its own harness and would make the
|
||||
@@ -285,7 +285,7 @@ fn f6_ipfs_metadata_queries_are_deadline_wrapped() {
|
||||
// every time a call site is added, which is how a guard quietly stops covering the
|
||||
// site that matters. Requiring all of them scales with the code instead.
|
||||
for call in [
|
||||
".list_repos_page_for_scan(",
|
||||
".list_all_repos()",
|
||||
".list_visibility_rules_for_repos(",
|
||||
".get_repo_by_id(",
|
||||
".is_repo_quarantined(",
|
||||
|
||||
@@ -11,7 +11,7 @@ name = "gl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
icaptcha-client = { path = "../icaptcha-client" }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
+8
-259
@@ -12,58 +12,9 @@ use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
/// Report why one candidate blob could not be recovered, in the register of the
|
||||
/// recovery loop's other warnings.
|
||||
///
|
||||
/// Both halves are gateway-supplied text on their way to a terminal: the oid comes
|
||||
/// out of a manifest fetched from a caller-chosen Arweave gateway, and a transport
|
||||
/// error carries the URL the same manifest chose. So the whole line is defanged and
|
||||
/// length-capped, like every other node/gateway string this crate prints.
|
||||
///
|
||||
/// Under `cfg(test)` the line is also mirrored into a per-thread buffer, so a test
|
||||
/// can assert the loop actually says what it skipped rather than only that it
|
||||
/// returned nothing.
|
||||
fn warn_skip(oid: &str, why: &str) {
|
||||
emit_warning(&format!(
|
||||
"warning: could not fetch encrypted blob {oid}: {why}; skipping"
|
||||
));
|
||||
}
|
||||
|
||||
/// The same report for a manifest rather than a blob. The manifest is one step
|
||||
/// earlier in the same recovery: losing it silently means every blob it would have
|
||||
/// named is missing with no reason given anywhere.
|
||||
fn warn_skip_manifest(id: &str, why: &str) {
|
||||
emit_warning(&format!(
|
||||
"warning: could not fetch blob manifest {id}: {why}; skipping"
|
||||
));
|
||||
}
|
||||
|
||||
/// Sanitize a warning and write it, once.
|
||||
///
|
||||
/// The write goes to [`warn_sink`], which is stderr in a normal build and the tests'
|
||||
/// per-thread mirror under `cfg(test)`. That indirection is the point: the mirror
|
||||
/// used to sit BESIDE an `eprintln!`, so deleting the user-visible write left every
|
||||
/// assertion on the mirror green and the shipped behaviour could be removed with the
|
||||
/// tests unchanged. There is one write now, and the tests observe it.
|
||||
fn emit_warning(line: &str) {
|
||||
use std::io::Write;
|
||||
let line = sanitize_node_msg(line);
|
||||
let _ = writeln!(warn_sink(), "{line}");
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn warn_sink() -> impl std::io::Write {
|
||||
std::io::stderr()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn warn_sink() -> impl std::io::Write {
|
||||
tests::WarnMirror
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CloneArgs {
|
||||
/// Repo to clone: gitlawb://<owner_did>/<name> or <owner>/<name>.
|
||||
@@ -361,25 +312,10 @@ async fn recover_encrypted_blobs(
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
// The node path has the same silent exit the gateway path had: a 403, a
|
||||
// 404, or a dead connection all reached the caller as "blob not
|
||||
// recoverable" with no reason attached. One unreachable blob still must
|
||||
// not end the recovery of the rest, so it warns and moves on.
|
||||
Ok(r) => {
|
||||
warn_skip(oid, &format!("node returned {}", r.status()));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn_skip(oid, &format!("node request failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let envelope = match env_resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn_skip(oid, &format!("reading the envelope failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
let Ok(envelope) = env_resp.bytes().await else {
|
||||
continue;
|
||||
};
|
||||
let plaintext = match open_blob(&envelope, keypair) {
|
||||
Ok(p) => p,
|
||||
@@ -706,14 +642,7 @@ async fn recover_from_arweave(
|
||||
for r in refs {
|
||||
let m = match client.get(format!("{ag}/{}", r.id)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => resp,
|
||||
Ok(resp) => {
|
||||
warn_skip_manifest(&r.id, &format!("gateway returned {}", resp.status()));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn_skip_manifest(&r.id, &format!("gateway request failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if let Ok(parsed) = m.json::<Manifest>().await {
|
||||
manifests.push((parsed, r.height));
|
||||
@@ -755,31 +684,10 @@ async fn recover_from_arweave(
|
||||
}
|
||||
let env_resp = match client.get(format!("{ig}/ipfs/{cid}")).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
// Every other outcome used to leave through a bare `continue`, so a
|
||||
// gateway that answered 503, 404, or nothing at all was reported to the
|
||||
// caller as "blob not recoverable" with no reason attached. This is the
|
||||
// second in-repo client of GET /ipfs/{cid} and it has no resume ladder,
|
||||
// so saying what happened is all the recourse there is. It still skips to
|
||||
// the next candidate either way: one unreachable blob must not end the
|
||||
// recovery of the rest.
|
||||
Ok(r) => {
|
||||
warn_skip(&oid, &format!("gateway returned {}", r.status()));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn_skip(&oid, &format!("gateway request failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
// A body that dies part-way through is the same mid-read failure the capped
|
||||
// read exists to stop rendering as silence, and it sits INSIDE the loop whose
|
||||
// status arms were already fixed.
|
||||
let envelope = match env_resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn_skip(&oid, &format!("reading the envelope failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
let Ok(envelope) = env_resp.bytes().await else {
|
||||
continue;
|
||||
};
|
||||
// open_blob succeeds only if this caller is a recipient: this is the
|
||||
// authorization gate (no node, no DID check needed).
|
||||
@@ -893,42 +801,9 @@ pub async fn run(args: CloneArgs) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
thread_local! {
|
||||
/// Test-visible copy of the stderr warnings `warn_skip` emits.
|
||||
/// `#[tokio::test]` runs the future on the test's own thread, so a
|
||||
/// thread-local is enough.
|
||||
static WARNINGS: RefCell<String> = const { RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
/// The `cfg(test)` warning sink. `emit_warning` writes here instead of to
|
||||
/// stderr, so the assertions below observe the shipped write rather than a copy
|
||||
/// made beside it: delete that write and they go red.
|
||||
pub(super) struct WarnMirror;
|
||||
|
||||
impl std::io::Write for WarnMirror {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let text = String::from_utf8_lossy(buf).into_owned();
|
||||
WARNINGS.with(|w| w.borrow_mut().push_str(&text));
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_warnings() {
|
||||
WARNINGS.with(|w| w.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn warnings() -> String {
|
||||
WARNINGS.with(|w| w.borrow().clone())
|
||||
}
|
||||
|
||||
fn g(args: &[&str], dir: &Path) {
|
||||
assert!(Command::new("git")
|
||||
.args(args)
|
||||
@@ -1380,132 +1255,6 @@ mod tests {
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidTS"));
|
||||
}
|
||||
|
||||
/// A gateway that refuses one blob must SAY so, and must not take the rest of the
|
||||
/// recovery down with it.
|
||||
///
|
||||
/// The IPFS fetch used to leave through a bare `_ => continue` on every
|
||||
/// non-success, so a 503 (the truncation status a tuned node makes more likely),
|
||||
/// a 404, and a dead connection all reached the caller as the same silent "blob
|
||||
/// not recoverable". Two withheld blobs here: the gateway answers 503 for the
|
||||
/// first and serves the second. The recovered path proves the loop continued; the
|
||||
/// warning proves the skip was reported and names both the object and the status.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_reports_a_refusing_gateway_and_continues() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
reset_warnings();
|
||||
let (td, url) = bare_remote(&[
|
||||
("public/a.txt", b"pub\n"),
|
||||
("secret/b.txt", b"SECRET B\n"),
|
||||
("secret/c.txt", b"SECRET C\n"),
|
||||
]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
|
||||
let oid_of = |path: &str| {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
&format!("HEAD:{path}"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
let refused_oid = oid_of("secret/b.txt");
|
||||
let served_oid = oid_of("secret/c.txt");
|
||||
|
||||
// Origin death, so recovery has to go through the gateways.
|
||||
std::fs::remove_dir_all(bare).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET C\n", &[reader.verifying_key()]).unwrap();
|
||||
|
||||
let refused_cid = "cidrefused";
|
||||
let served_cid = "cidserved";
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _gql = server
|
||||
.mock("POST", "/graphql")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}}]}}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [
|
||||
{ "oid": refused_oid, "cid": refused_cid },
|
||||
{ "oid": served_oid, "cid": served_cid },
|
||||
],
|
||||
})
|
||||
.to_string();
|
||||
let _tx = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let refusal = server
|
||||
.mock("GET", format!("/ipfs/{refused_cid}").as_str())
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"search_incomplete","message":"scan truncated"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let ok = server
|
||||
.mock("GET", format!("/ipfs/{served_cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
refusal.assert_async().await;
|
||||
ok.assert_async().await;
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["secret/c.txt".to_string()],
|
||||
"a refused candidate must not stop the loop reaching the next one"
|
||||
);
|
||||
|
||||
let warnings = warnings();
|
||||
assert!(
|
||||
warnings.contains(&refused_oid),
|
||||
"the skip must name the object it could not fetch, got: {warnings}"
|
||||
);
|
||||
assert!(
|
||||
warnings.contains("503"),
|
||||
"the skip must name the gateway's status rather than failing silently, \
|
||||
got: {warnings}"
|
||||
);
|
||||
assert!(
|
||||
!warnings.contains(&served_oid),
|
||||
"the blob that was served must not be reported as skipped, got: {warnings}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-path end-to-end over a mocked Arweave + IPFS gateway: discover the
|
||||
/// manifest via GraphQL, fetch it, fetch the envelope, decrypt with the
|
||||
/// caller's key, and install the previously-withheld blob.
|
||||
|
||||
+4
-621
@@ -15,47 +15,6 @@ use icaptcha_client::IcaptchaCfg;
|
||||
/// (absorbs proof expiry / first-seen replay).
|
||||
const MAX_ICAPTCHA_RETRIES: usize = 2;
|
||||
|
||||
/// Total request timeout: from the start of connecting through the end of the
|
||||
/// response body, so it bounds a slow download and not just a slow handshake.
|
||||
const TOTAL_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Follow a redirect only when it stays on the origin that issued it AND re-issues the
|
||||
/// identical request-target, and only for as long as the chain bound allows.
|
||||
///
|
||||
/// The decision itself is [`gitlawb_core::redirect::may_follow`], shared with
|
||||
/// `git-remote-gitlawb` so the two signing clients cannot drift apart on it. Refusal
|
||||
/// is `stop`, not `error`: the 3xx comes back as an ordinary response and each caller
|
||||
/// reports it through the status path it already has.
|
||||
///
|
||||
/// `Policy::custom` replaces reqwest's built-in limit, so the chain bound is restated
|
||||
/// here. Same-origin redirects can cycle, and this is what stops a node answering 302
|
||||
/// to itself from being followed indefinitely. It is not what makes the request
|
||||
/// finite: `.timeout(...)` on the same builder is a TOTAL request timeout covering the
|
||||
/// whole chain, so without this bound the worst case is a 30 second spin, not an
|
||||
/// endless one. The bound is what keeps that spin from costing the node a request per
|
||||
/// round trip for the full 30 seconds.
|
||||
///
|
||||
/// `>` and not `>=`: reqwest pushes the redirecting URL onto `previous` before
|
||||
/// consulting the policy, so on the first redirect `previous.len()` is already 1, and
|
||||
/// `>=` would permit `MAX_REDIRECTS - 1` follows. `Policy::limited(max)` refuses at
|
||||
/// `previous.len() > max`, and matching it is the point of reusing its value.
|
||||
fn same_origin_redirect(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
|
||||
let Some(previous) = attempt.previous().last() else {
|
||||
// No previous URL to compare against. Unreachable through reqwest, which
|
||||
// pushes the redirecting URL before consulting the policy, but the safe
|
||||
// reading of "cannot prove same-origin" is to refuse.
|
||||
return attempt.stop();
|
||||
};
|
||||
if attempt.previous().len() > gitlawb_core::redirect::MAX_REDIRECTS {
|
||||
return attempt.stop();
|
||||
}
|
||||
if gitlawb_core::redirect::may_follow(previous, attempt.url()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NodeClient {
|
||||
inner: reqwest::Client,
|
||||
pub node_url: String,
|
||||
@@ -64,20 +23,8 @@ pub struct NodeClient {
|
||||
|
||||
impl NodeClient {
|
||||
pub fn new(node_url: impl Into<String>, keypair: Option<Keypair>) -> Self {
|
||||
Self::with_timeout(node_url, keypair, TOTAL_REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
/// `new` with the total request timeout as a parameter, so a test can drive the
|
||||
/// timeout's behaviour without waiting out the shipped value. `new` supplies the
|
||||
/// shipped one, which is what makes the scaled-down test cover the real client.
|
||||
fn with_timeout(
|
||||
node_url: impl Into<String>,
|
||||
keypair: Option<Keypair>,
|
||||
timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
let inner = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.redirect(reqwest::redirect::Policy::custom(same_origin_redirect))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(format!("gl/{} gitlawb-cli", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
@@ -256,64 +203,24 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
.context("iCaptcha solver task panicked")?
|
||||
}
|
||||
|
||||
/// What a capped body read produced. `text` is the bytes that arrived; the two flags
|
||||
/// say why the read stopped where it did, which a caller that CLASSIFIES on the body
|
||||
/// cannot work out from the text alone.
|
||||
pub(crate) struct CappedBody {
|
||||
/// The bytes read, lossily decoded.
|
||||
pub(crate) text: String,
|
||||
/// The cap cut the body short.
|
||||
pub(crate) truncated: bool,
|
||||
/// A chunk read FAILED part-way through, so the body is not merely short, it is
|
||||
/// unfinished and the node may have had more to say.
|
||||
pub(crate) read_failed: bool,
|
||||
}
|
||||
|
||||
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
|
||||
/// hostile or broken node returning a huge error body — the display is capped
|
||||
/// separately, but the read itself must not be unbounded (INV-6, read half).
|
||||
///
|
||||
/// `truncated` reports whether the cap cut the body short. A caller that CLASSIFIES
|
||||
/// on the body needs it: a cut body fails JSON parse, and a parse failure is
|
||||
/// indistinguishable from a node that sent no code at all, so without this flag an
|
||||
/// oversized body silently picks a different arm.
|
||||
///
|
||||
/// `read_failed` reports the other way a body can end early. A mid-body read error
|
||||
/// used to leave through the same exit as a clean end of stream, so a 500 whose body
|
||||
/// died in transit surfaced as an empty message and the caller was told
|
||||
/// `node returned 500: ` with nothing after the colon. That is a report of what the
|
||||
/// node said, and the node never got to say it.
|
||||
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> CappedBody {
|
||||
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut read_failed = false;
|
||||
while buf.len() < cap {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let take = (cap - buf.len()).min(chunk.len());
|
||||
buf.extend_from_slice(&chunk[..take]);
|
||||
if take < chunk.len() {
|
||||
truncated = true;
|
||||
break; // hit the cap mid-chunk
|
||||
}
|
||||
}
|
||||
Ok(None) => break, // clean end of body
|
||||
Err(_) => {
|
||||
read_failed = true;
|
||||
break;
|
||||
}
|
||||
_ => break, // end of body or read error — return what we have
|
||||
}
|
||||
}
|
||||
// A body that lands exactly on the cap may or may not have more behind it;
|
||||
// report it as cut, since the classification that follows cannot tell either.
|
||||
if buf.len() >= cap {
|
||||
truncated = true;
|
||||
}
|
||||
CappedBody {
|
||||
text: String::from_utf8_lossy(&buf).into_owned(),
|
||||
truncated,
|
||||
read_failed,
|
||||
}
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
/// Strip terminal-dangerous characters from (and cap the length of) a
|
||||
@@ -714,530 +621,6 @@ mod tests {
|
||||
ic.answer.assert();
|
||||
}
|
||||
|
||||
// ── redirect policy ─────────────────────────────────────────────────
|
||||
|
||||
/// The signed headers must not survive a redirect off the node's origin.
|
||||
///
|
||||
/// reqwest strips only `Authorization`, `Cookie`, `Proxy-Authorization` and
|
||||
/// `WWW-Authenticate` across hosts, so `Signature` and `Signature-Input` used to
|
||||
/// ride a 302 straight to whatever origin the node named. The signature binds
|
||||
/// `@method`, `@path` and `content-digest` and nothing about the authority, so the
|
||||
/// receiving host holds a credential that reads path-scoped objects as the caller
|
||||
/// at any node until the clock-skew window closes.
|
||||
///
|
||||
/// Two mockito servers are two ports on one host, which is exactly the boundary
|
||||
/// this policy draws (and the one reqwest's own header stripping draws). The
|
||||
/// second server answers everything and expects nothing: a followed redirect
|
||||
/// fails the expectation whether or not the signature came with it. MUTATION
|
||||
/// (RED): drop the `.redirect(...)` line and the second server is hit.
|
||||
#[tokio::test]
|
||||
async fn cross_origin_redirect_is_not_followed() {
|
||||
let mut elsewhere = Server::new_async().await;
|
||||
let never = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body("bytes from the redirect target")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let signature_seen = elsewhere
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut node = Server::new_async().await;
|
||||
let bounce = node
|
||||
.mock("GET", "/api/v1/thing")
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/api/v1/thing", elsewhere.url()))
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client.get_signed("/api/v1/thing").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
302,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx \
|
||||
and reports it through the status path it already has"
|
||||
);
|
||||
let body = resp.text().await.unwrap();
|
||||
assert!(
|
||||
!body.contains("bytes from the redirect target"),
|
||||
"the redirect target's bytes must never reach the caller, got: {body}"
|
||||
);
|
||||
bounce.assert_async().await;
|
||||
never.assert_async().await;
|
||||
signature_seen.assert_async().await;
|
||||
}
|
||||
|
||||
/// A node redirecting to itself is same-origin, so the origin predicate follows it
|
||||
/// every time and only the chain bound ends the loop. Deleting the bound left the
|
||||
/// whole suite green, because nothing here had ever built a cycle.
|
||||
///
|
||||
/// It has a second job now. A redirect back to the identical path and query is the
|
||||
/// only same-origin hop the predicate still follows, so the eleven hits below are
|
||||
/// also this crate's executed proof that such a hop IS followed. The old positive
|
||||
/// fixture drove a trailing-slash rewrite, which the request-target rule refuses,
|
||||
/// and an http-to-https upgrade cannot be mocked over mockito's plain http.
|
||||
///
|
||||
/// The route answers 301 pointing back at itself. Bounded, the handler is hit
|
||||
/// once for the original request plus `MAX_REDIRECTS` follows and the call returns
|
||||
/// the 301 (a refused redirect stops rather than errors). Unbounded, it runs until
|
||||
/// the client's total request timeout cuts it off, which is a 30 second spin at the
|
||||
/// shipped value and a request per round trip for the node.
|
||||
///
|
||||
/// MUTATION (RED): delete the `previous().len()` check.
|
||||
#[tokio::test]
|
||||
async fn a_self_redirect_stops_at_the_chain_bound() {
|
||||
let mut node = Server::new_async().await;
|
||||
let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let h = hits.clone();
|
||||
let loop_route = node
|
||||
.mock("GET", "/api/v1/loop")
|
||||
.with_status(301)
|
||||
.with_header("location", "/api/v1/loop")
|
||||
.with_body_from_request(move |_req| {
|
||||
h.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Vec::new()
|
||||
})
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::with_timeout(
|
||||
node.url(),
|
||||
Some(test_keypair()),
|
||||
std::time::Duration::from_secs(5),
|
||||
);
|
||||
let resp = client
|
||||
.get_signed("/api/v1/loop")
|
||||
.await
|
||||
.expect("the bound must end the chain, not the timeout");
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"the chain ends by refusing the next hop, so the last 3xx is what comes back"
|
||||
);
|
||||
assert_eq!(
|
||||
hits.load(std::sync::atomic::Ordering::SeqCst),
|
||||
gitlawb_core::redirect::MAX_REDIRECTS + 1,
|
||||
"one original request plus MAX_REDIRECTS follows, matching what \
|
||||
Policy::limited(MAX_REDIRECTS) would have permitted"
|
||||
);
|
||||
loop_route.assert_async().await;
|
||||
}
|
||||
|
||||
/// A same-origin hop that REWRITES the request-target is refused, even though the
|
||||
/// origin never changes.
|
||||
///
|
||||
/// The signature binds `@path` as the literal path-and-query the client signed, and
|
||||
/// the node rebuilds it from the URI it received. A `/api/v1/thing` to
|
||||
/// `/api/v1/thing/` bounce therefore presents a signature over a target the node
|
||||
/// never saw, and every signed read behind such a proxy 401s. Refusing the hop
|
||||
/// hands the caller the 3xx that names what happened instead.
|
||||
///
|
||||
/// The target mock expects zero hits and is asserted: mockito only checks
|
||||
/// `.expect(N)` when `.assert()` runs, so an unbound or unasserted mock passes
|
||||
/// vacuously.
|
||||
#[tokio::test]
|
||||
async fn same_origin_path_changing_redirect_is_refused() {
|
||||
let mut node = Server::new_async().await;
|
||||
let bounce = node
|
||||
.mock("GET", "/api/v1/thing")
|
||||
.with_status(301)
|
||||
.with_header("location", "/api/v1/thing/")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let target = node
|
||||
.mock("GET", "/api/v1/thing/")
|
||||
.with_status(200)
|
||||
.with_body("bytes from the rewritten target")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client.get_signed("/api/v1/thing").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"a refused redirect stops rather than errors, so the caller sees the 3xx \
|
||||
and reports it through the status path it already has"
|
||||
);
|
||||
let body = resp.text().await.unwrap();
|
||||
assert!(
|
||||
!body.contains("bytes from the rewritten target"),
|
||||
"the rewritten target's bytes must never reach the caller, got: {body}"
|
||||
);
|
||||
bounce.assert_async().await;
|
||||
target.assert_async().await;
|
||||
}
|
||||
|
||||
// ── the node's own verification, run over what the client actually sent ──
|
||||
|
||||
/// What the verifying mock made of a request that reached it.
|
||||
///
|
||||
/// The empty slot (`None`) is its own state and means the mock was never hit at
|
||||
/// all, which is what the refusal test asserts. It must stay distinguishable from
|
||||
/// [`Verdict::WrongIdentity`], because "nobody verified anything" and "something
|
||||
/// verified against the wrong key" are opposite findings.
|
||||
///
|
||||
/// The payloads are read through `Debug` in the assertion messages and nowhere
|
||||
/// else, which the dead-code pass does not count; they carry the detail that makes
|
||||
/// a failure legible, so they stay.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
enum Verdict {
|
||||
/// The chain accepted the signature AND the key it resolved is the test's DID.
|
||||
Accepted,
|
||||
/// The chain refused it. Carries the error so a failure reads as the actual
|
||||
/// rejection rather than a bare hit count.
|
||||
Rejected(String),
|
||||
/// The chain accepted a signature made by somebody else. A key resolved from
|
||||
/// the parsed `key_id` is read out of the artifact under verification, so an
|
||||
/// accept on it alone proves consistency, never authenticity.
|
||||
WrongIdentity { expected: String, got: String },
|
||||
}
|
||||
|
||||
/// The node's `require_signature` verification, over a request this crate did not
|
||||
/// build: parse the headers, recompute the content-digest from the body, rebuild
|
||||
/// the signing string over `@method`/`@path`/`content-digest`, Ed25519-verify.
|
||||
/// Returns the DID the signature resolved to, so a caller can pin the identity.
|
||||
///
|
||||
/// A hand-copy of its twin in `crates/git-remote-gitlawb/src/main.rs`, which gl
|
||||
/// cannot import (`git-remote-gitlawb` is a binary crate and this is its test
|
||||
/// module). Keep the two textually identical apart from the mockito seam around
|
||||
/// them, so an edit to one is visibly an edit to both.
|
||||
///
|
||||
/// The production verifier both copies mirror is `crate::auth::require_signature` in
|
||||
/// `crates/gitlawb-node/src/auth/mod.rs`. This is a re-implementation, not a call, so
|
||||
/// an edit to that middleware has to land here too: otherwise the copies drift and
|
||||
/// this test keeps passing against a rule the node has stopped applying.
|
||||
///
|
||||
/// It asserts internally, which is deliberate but constrains its callers: inside
|
||||
/// `with_body_from_request` those assertions fire on the server thread and reach
|
||||
/// the client as a transport error, not as a recorded verdict. So the identity
|
||||
/// check lives in the caller as a [`Verdict`] variant, never as an assert in here.
|
||||
fn node_verifies(
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
sig_input: &str,
|
||||
sig_header: &str,
|
||||
content_digest: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
|
||||
use gitlawb_core::identity::verify;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let sig = HttpSignature::parse(sig_input, sig_header)?;
|
||||
sig.check_created()?;
|
||||
assert!(
|
||||
sig.missing_components().is_empty(),
|
||||
"signature must cover all required components"
|
||||
);
|
||||
assert_eq!(sig.alg, "ed25519");
|
||||
assert_eq!(
|
||||
content_digest,
|
||||
compute_content_digest(body),
|
||||
"content-digest must match the body"
|
||||
);
|
||||
let vk = sig.key_id.to_verifying_key()?;
|
||||
let mut values = HashMap::new();
|
||||
values.insert("@method".to_string(), method.to_uppercase());
|
||||
values.insert("@path".to_string(), path_and_query.to_string());
|
||||
values.insert("content-digest".to_string(), content_digest.to_string());
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
|
||||
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
|
||||
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
|
||||
verify(&vk, signing_string.as_bytes(), &sig_array)?;
|
||||
Ok(sig.key_id.to_string())
|
||||
}
|
||||
|
||||
/// Pull a header value off a received mockito request, or explain which one the
|
||||
/// client failed to send.
|
||||
fn received_header(req: &mockito::Request, name: &str) -> String {
|
||||
req.header(name)
|
||||
.first()
|
||||
.unwrap_or_else(|| panic!("the client sent no {name} header"))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Run [`node_verifies`] over a GET that arrived at the mock and record what the
|
||||
/// node would have made of it, pinned to `expected_did`.
|
||||
fn record_get_verdict(
|
||||
req: &mockito::Request,
|
||||
expected_did: &str,
|
||||
slot: &std::sync::Arc<std::sync::Mutex<Option<Verdict>>>,
|
||||
) {
|
||||
let verdict = match node_verifies(
|
||||
"GET",
|
||||
req.path_and_query(),
|
||||
b"",
|
||||
&received_header(req, "signature-input"),
|
||||
&received_header(req, "signature"),
|
||||
&received_header(req, "content-digest"),
|
||||
) {
|
||||
Ok(did) if did == expected_did => Verdict::Accepted,
|
||||
Ok(did) => Verdict::WrongIdentity {
|
||||
expected: expected_did.to_string(),
|
||||
got: did,
|
||||
},
|
||||
Err(e) => Verdict::Rejected(e.to_string()),
|
||||
};
|
||||
*slot.lock().unwrap() = Some(verdict);
|
||||
}
|
||||
|
||||
/// The finding's repro, now a guard: a rewritten same-origin target must never
|
||||
/// receive the signature, and the proof is the node's own verification, not a hit
|
||||
/// count.
|
||||
///
|
||||
/// The target mock runs the full `require_signature` chain over the request it
|
||||
/// receives. Post-fix the hop is refused, so the slot stays empty. Pre-fix the hop
|
||||
/// is followed and the slot records the Ed25519 rejection of a signature made over
|
||||
/// `/api/v1/thing` and presented at `/api/v1/thing/`, which is the 401 an operator
|
||||
/// behind such a proxy actually sees. The verdict is asserted first, so a failure
|
||||
/// speaks about verification rather than about reachability.
|
||||
///
|
||||
/// Its paired positive control is
|
||||
/// `a_direct_signed_get_verifies_under_the_node_verifier`: without it, an empty
|
||||
/// slot would be satisfied just as well by a harness that can never record
|
||||
/// anything.
|
||||
#[tokio::test]
|
||||
async fn a_rewritten_target_never_receives_the_signature() {
|
||||
let kp = test_keypair();
|
||||
let expected_did = kp.did().to_string();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = Server::new_async().await;
|
||||
let bounce = node
|
||||
.mock("GET", "/api/v1/thing")
|
||||
.with_status(301)
|
||||
.with_header("location", "/api/v1/thing/")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let target = node
|
||||
.mock("GET", "/api/v1/thing/")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(kp));
|
||||
let resp = client.get_signed("/api/v1/thing").await.unwrap();
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
verdict.is_none(),
|
||||
"the node's own verifier must never see this request: the signature covers \
|
||||
/api/v1/thing and the rewritten target is /api/v1/thing/, so what arrives \
|
||||
there is a stale request-target; recorded verdict: {verdict:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
301,
|
||||
"the caller sees the 3xx, not the rewritten target's answer"
|
||||
);
|
||||
bounce.assert_async().await;
|
||||
target.assert_async().await;
|
||||
}
|
||||
|
||||
/// The positive control for the test above, and the proof that the client signs
|
||||
/// the query it sends.
|
||||
///
|
||||
/// A direct signed GET, no redirect anywhere, through the same verifying mock. The
|
||||
/// verdict must be `Accepted`, which is what makes the refusal test's empty slot
|
||||
/// attributable to the refusal rather than to a harness that cannot record. The
|
||||
/// path carries a query, so a client that signed the bare path would land here as
|
||||
/// `Rejected`.
|
||||
#[tokio::test]
|
||||
async fn a_direct_signed_get_verifies_under_the_node_verifier() {
|
||||
let kp = test_keypair();
|
||||
let expected_did = kp.did().to_string();
|
||||
let slot = std::sync::Arc::new(std::sync::Mutex::new(None::<Verdict>));
|
||||
|
||||
let mut node = Server::new_async().await;
|
||||
let recorder = slot.clone();
|
||||
let did_for_target = expected_did.clone();
|
||||
let route = node
|
||||
.mock("GET", "/api/v1/thing?x=1")
|
||||
.with_status(200)
|
||||
.with_body_from_request(move |req| {
|
||||
record_get_verdict(req, &did_for_target, &recorder);
|
||||
Vec::new()
|
||||
})
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(kp));
|
||||
let resp = client.get_signed("/api/v1/thing?x=1").await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let verdict = slot.lock().unwrap().take();
|
||||
assert!(
|
||||
matches!(verdict, Some(Verdict::Accepted)),
|
||||
"a direct signed GET must verify under the node's own chain and resolve to \
|
||||
{expected_did}, or the refusal test's empty slot proves nothing; recorded \
|
||||
verdict: {verdict:?}"
|
||||
);
|
||||
route.assert_async().await;
|
||||
}
|
||||
|
||||
// ── read_body_capped ────────────────────────────────────────────────
|
||||
|
||||
/// A body whose read FAILS mid-stream must be distinguishable from a body that
|
||||
/// ended. Both used to leave through the same `_ => break`, so a 500 whose body
|
||||
/// died in transit produced an empty message and the caller was told
|
||||
/// `node returned 500: ` with nothing after the colon.
|
||||
///
|
||||
/// The fixture is a raw listener that promises 64 bytes in `Content-Length`,
|
||||
/// writes 5, and closes. mockito cannot express that: it always completes the
|
||||
/// response it advertises. MUTATION (RED): restore the single `_ => break` arm
|
||||
/// (or hard-code `read_failed: false`) and the flag reads false.
|
||||
#[tokio::test]
|
||||
async fn read_body_capped_flags_a_mid_body_read_failure() {
|
||||
let addr = spawn_short_body_listener().await;
|
||||
let resp = reqwest::get(format!("http://{addr}/truncated"))
|
||||
.await
|
||||
.expect("headers arrive before the body is cut");
|
||||
let body = read_body_capped(resp, 8192).await;
|
||||
|
||||
assert!(
|
||||
body.read_failed,
|
||||
"a body cut off mid-stream must be reported as a failed read, not as a \
|
||||
body that ended: got {:?}",
|
||||
body.text
|
||||
);
|
||||
assert!(
|
||||
!body.truncated,
|
||||
"the cap did not cut this one; 5 bytes are nowhere near 8 KiB"
|
||||
);
|
||||
}
|
||||
|
||||
/// The must-not half: a body that ends cleanly must NOT be flagged, or the flag
|
||||
/// means nothing and every terminal starts claiming the node went quiet.
|
||||
#[tokio::test]
|
||||
async fn read_body_capped_does_not_flag_a_clean_body() {
|
||||
let mut server = Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/ok")
|
||||
.with_status(500)
|
||||
.with_body("node said this")
|
||||
.create_async()
|
||||
.await;
|
||||
let resp = reqwest::get(format!("{}/ok", server.url())).await.unwrap();
|
||||
let body = read_body_capped(resp, 8192).await;
|
||||
|
||||
assert_eq!(body.text, "node said this");
|
||||
assert!(!body.read_failed, "a complete body is not a failed read");
|
||||
assert!(!body.truncated, "a complete body is not a truncated one");
|
||||
}
|
||||
|
||||
/// Answer one request with headers promising more body than gets written, then
|
||||
/// close the connection. Returns the listener's address.
|
||||
async fn spawn_short_body_listener() -> std::net::SocketAddr {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut scratch = [0u8; 1024];
|
||||
let _ = sock.read(&mut scratch).await;
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 64\r\n\r\nshort")
|
||||
.await;
|
||||
let _ = sock.flush().await;
|
||||
// Drop closes the socket with 59 of the promised bytes never sent.
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
// ── total request timeout ───────────────────────────────────────────
|
||||
|
||||
/// The client's timeout is a TOTAL request timeout, so it bounds a download and
|
||||
/// not just the handshake. `gl ipfs get`'s documentation leans on exactly that:
|
||||
/// the wall-clock deadline covers the search and deliberately stops at the
|
||||
/// response headers, and this timeout is the only thing left bounding the body.
|
||||
///
|
||||
/// Driven at 250ms through `with_timeout`, the seam `new` itself calls with
|
||||
/// `TOTAL_REQUEST_TIMEOUT`, because a test at the shipped 30s has no place in this
|
||||
/// suite. What that costs is the value; what it proves is the SHAPE, which is the
|
||||
/// part in doubt: that the deadline keeps running once the headers have landed. A
|
||||
/// timeout that covered only the handshake would let this request hang until the
|
||||
/// listener gives up.
|
||||
#[tokio::test]
|
||||
async fn total_timeout_cuts_off_a_body_that_outruns_it() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut scratch = [0u8; 1024];
|
||||
let _ = sock.read(&mut scratch).await;
|
||||
// Headers land immediately, then the body stalls indefinitely.
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1024\r\n\r\nfirst")
|
||||
.await;
|
||||
let _ = sock.flush().await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
});
|
||||
|
||||
let client = NodeClient::with_timeout(
|
||||
format!("http://{addr}"),
|
||||
None,
|
||||
std::time::Duration::from_millis(250),
|
||||
);
|
||||
let started = std::time::Instant::now();
|
||||
let resp = client.get("/slow").await.expect("headers arrive promptly");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"the stall is in the body, not the status"
|
||||
);
|
||||
let err = resp
|
||||
.bytes()
|
||||
.await
|
||||
.expect_err("a body still arriving past the total timeout must be cut off");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
err.is_timeout(),
|
||||
"the body read must end in a timeout, got: {err}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(5),
|
||||
"the timeout must fire on its own schedule, not wait out the listener; \
|
||||
took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_client_uses_the_documented_total_timeout() {
|
||||
// The scaled-down test above proves the shape at 250ms. This pins the value
|
||||
// `new` actually ships, so the two together cover the documented behaviour.
|
||||
assert_eq!(TOTAL_REQUEST_TIMEOUT, std::time::Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_controls_bidi_and_caps_length() {
|
||||
// C0 (ESC/BEL) and the Cf bidi override (U+202E) are both removed; the
|
||||
|
||||
+34
-1931
File diff suppressed because it is too large.
Load diff
@@ -199,7 +199,7 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
// in the command: bound the read, and defang the message before it reaches
|
||||
// the terminal through the error return. An announce reply is a DID, a URL
|
||||
// and a count, so 8 KiB is well past what the shape needs.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await.text;
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
|
||||
if let Some(failure) = remote_announce_failure(status, &raw) {
|
||||
anyhow::bail!("{failure}");
|
||||
@@ -237,7 +237,7 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
// node (or a MITM on plain http) must not force an unbounded
|
||||
// read. A body that does not parse stays `Null`, which still
|
||||
// routes a non-success status to the warning.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await.text;
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
let result: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
|
||||
let (is_warning, line) = local_add_report(status, &result);
|
||||
if is_warning {
|
||||
|
||||
@@ -46,7 +46,7 @@ pub async fn run(args: SyncArgs) -> Result<()> {
|
||||
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.text;
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
@@ -261,7 +261,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
let resp = reqwest::get(format!("{}/big", server.url())).await.unwrap();
|
||||
let out = read_body_capped(resp, 8192).await.text;
|
||||
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");
|
||||
}
|
||||
|
||||
+14
-334
@@ -1,11 +1,11 @@
|
||||
//! `gl whoami` — print current identity and optional node registration info.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{read_body_capped, sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -22,10 +22,6 @@ pub struct WhoamiArgs {
|
||||
}
|
||||
|
||||
pub async fn run(args: WhoamiArgs) -> Result<()> {
|
||||
run_to_writer(args, &mut std::io::stdout()).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(args.dir.as_deref())?;
|
||||
let did = keypair.did().to_string();
|
||||
let short = did.split(':').next_back().unwrap_or(&did).to_string();
|
||||
@@ -45,7 +41,7 @@ pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write)
|
||||
if let Some(caps) = info["capabilities"].as_array() {
|
||||
capabilities = caps
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(sanitize_node_msg))
|
||||
.filter_map(|c| c.as_str().map(String::from))
|
||||
.collect();
|
||||
}
|
||||
// Try to get repo count
|
||||
@@ -55,35 +51,11 @@ pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status().as_u16() == 404 => {
|
||||
Ok(_) => {
|
||||
registered = Some(false);
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let raw = read_body_capped(resp, 8 * 1024).await.text;
|
||||
let msg = serde_json::from_str::<Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
let non_empty = |m: Option<&Value>| {
|
||||
m.and_then(|m| m.as_str())
|
||||
.map(String::from)
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
non_empty(v.get("message")).or_else(|| non_empty(v.get("error")))
|
||||
})
|
||||
.unwrap_or(raw);
|
||||
bail!(
|
||||
"agent lookup failed ({status}): {}",
|
||||
sanitize_node_msg(&msg)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let detail: String = e
|
||||
.chain()
|
||||
.map(|e| sanitize_node_msg(&e.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(": ");
|
||||
bail!("agent lookup failed: {detail}");
|
||||
Err(_) => {
|
||||
registered = Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,21 +77,21 @@ pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write)
|
||||
if let Some(rc) = repo_count {
|
||||
out["repos"] = json!(rc);
|
||||
}
|
||||
writeln!(w, "{}", serde_json::to_string_pretty(&out)?)?;
|
||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||
} else {
|
||||
writeln!(w, "DID: {did}")?;
|
||||
writeln!(w, "Short: {short}")?;
|
||||
println!("DID: {did}");
|
||||
println!("Short: {short}");
|
||||
if let Some(reg) = registered {
|
||||
writeln!(w, "Registered: {}", if reg { "yes" } else { "no" })?;
|
||||
println!("Registered: {}", if reg { "yes" } else { "no" });
|
||||
}
|
||||
if let Some(ts) = trust_score {
|
||||
writeln!(w, "Trust: {ts:.2}")?;
|
||||
println!("Trust: {ts:.2}");
|
||||
}
|
||||
if !capabilities.is_empty() {
|
||||
writeln!(w, "Caps: {}", capabilities.join(", "))?;
|
||||
println!("Caps: {}", capabilities.join(", "));
|
||||
}
|
||||
if let Some(rc) = repo_count {
|
||||
writeln!(w, "Repos: {rc}")?;
|
||||
println!("Repos: {rc}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -220,236 +192,7 @@ mod tests {
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
run_to_writer(args, &mut out).await.unwrap();
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
assert!(out.contains("Registered: no"), "unexpected output: {out}");
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: true,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
run_to_writer(args, &mut out).await.unwrap();
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
assert!(
|
||||
out.contains("\"registered\": false"),
|
||||
"unexpected output: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_with_node_forbidden() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _agent = server
|
||||
.mock("GET", format!("/api/v1/agents/{did}").as_str())
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("403"), "expected 403 error, got: {msg}");
|
||||
assert!(
|
||||
msg.contains("forbidden"),
|
||||
"expected 'forbidden' in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_with_node_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _agent = server
|
||||
.mock("GET", format!("/api/v1/agents/{did}").as_str())
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"internal error"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(msg.contains("500"), "expected 500 error, got: {msg}");
|
||||
assert!(
|
||||
msg.contains("internal error"),
|
||||
"expected 'internal error' in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_server_error_unusable_message_uses_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _agent = server
|
||||
.mock("GET", format!("/api/v1/agents/{did}").as_str())
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"","error":"boom"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("boom"),
|
||||
"expected 'boom' from error field, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_with_node_transport_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let node = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(node),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("agent lookup failed"),
|
||||
"expected transport error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_transport_sanitizes_control_chars_in_node_url() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
|
||||
let node = format!("http://127.0.0.1:{port}/\u{1b}]0;PWNED\u{7} \u{202e}gnitirw");
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(node),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("agent lookup failed"),
|
||||
"expected transport error, got: {msg}"
|
||||
);
|
||||
assert!(!msg.contains('\u{1b}'), "ESC leaked into output: {msg:?}");
|
||||
assert!(!msg.contains('\u{7}'), "BEL leaked into output: {msg:?}");
|
||||
assert!(!msg.contains('\u{202e}'), "RLO leaked into output: {msg:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_server_error_body_display_bounded() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _agent = server
|
||||
.mock("GET", format!("/api/v1/agents/{did}").as_str())
|
||||
.with_status(502)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("x".repeat(100_000))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("502"),
|
||||
"expected 502 error with bounded body, got: {msg}"
|
||||
);
|
||||
let display = format!("{err}");
|
||||
assert!(
|
||||
display.len() < 1000,
|
||||
"error message too long ({} bytes) — body was not capped",
|
||||
display.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_server_error_sanitizes_controls() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _agent = server
|
||||
.mock("GET", format!("/api/v1/agents/{did}").as_str())
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("{\"message\":\"\\u001b[31mowned\\u0007\\u202eevil\"}")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
let msg = format!("{err:?}");
|
||||
assert!(
|
||||
msg.contains("owned"),
|
||||
"expected sanitized error body, got: {msg}"
|
||||
);
|
||||
assert!(!msg.contains('\u{1b}'), "ESC control char leaked: {msg}");
|
||||
assert!(!msg.contains('\u{07}'), "BEL control char leaked: {msg}");
|
||||
assert!(!msg.contains('\u{202e}'), "RTL override leaked: {msg}");
|
||||
run(args).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -487,67 +230,4 @@ mod tests {
|
||||
};
|
||||
run(args).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_sanitizes_hostile_capabilities() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
let short = did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _agent = server
|
||||
.mock("GET", format!("/api/v1/agents/{did}").as_str())
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
"{\"trust_score\":0.5,\"capabilities\":[\"\\u001b]0;PWNED\\u0007repo:write\",\"\\u202egnitirw-tfel\"]}",
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _repos = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(format!(r"^/api/v1/repos\?owner={short}")),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Human mode: no control bytes or bidi overrides reach the terminal
|
||||
let mut buf = Vec::new();
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: false,
|
||||
};
|
||||
run_to_writer(args, &mut buf).await.unwrap();
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(!out.contains('\u{1b}'), "ESC leaked in human mode: {out:?}");
|
||||
assert!(!out.contains('\u{07}'), "BEL leaked in human mode: {out:?}");
|
||||
assert!(
|
||||
!out.contains('\u{202e}'),
|
||||
"RLO leaked in human mode: {out:?}"
|
||||
);
|
||||
assert!(out.contains("repo:write"), "benign text missing: {out:?}");
|
||||
assert!(out.contains("tfel"), "reversed text missing: {out:?}");
|
||||
|
||||
// JSON mode: serde escapes C0 but passes bidi — ensure no U+202E
|
||||
let mut buf = Vec::new();
|
||||
let args = WhoamiArgs {
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
node: Some(server.url()),
|
||||
json: true,
|
||||
};
|
||||
run_to_writer(args, &mut buf).await.unwrap();
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
!out.contains('\u{202e}'),
|
||||
"RLO leaked in JSON mode: {out:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,6 @@ services:
|
||||
GITLAWB_BOOTSTRAP_PEERS: ${GITLAWB_BOOTSTRAP_PEERS:-}
|
||||
GITLAWB_BOOTSTRAP_DISABLE_SEEDS: ${GITLAWB_BOOTSTRAP_DISABLE_SEEDS:-false}
|
||||
GITLAWB_REQUIRE_SIGNED_PEER_WRITES: ${GITLAWB_REQUIRE_SIGNED_PEER_WRITES:-false}
|
||||
# Owner-only push. The default matches the node's own, so the container path
|
||||
# behaves like a source build; forwarding it is what makes the documented
|
||||
# rolling-upgrade opt-out reachable for an operator running compose.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
|
||||
GITLAWB_P2P_BOOTSTRAP: ${GITLAWB_P2P_BOOTSTRAP:-}
|
||||
GITLAWB_PUBLIC_READ: ${GITLAWB_PUBLIC_READ:-true}
|
||||
GITLAWB_MAX_PACK_BYTES: ${GITLAWB_MAX_PACK_BYTES:-2147483648}
|
||||
|
||||
@@ -38,10 +38,10 @@ Owner focus: node reliability.
|
||||
|
||||
Owner focus: protocol/auth.
|
||||
|
||||
- Close default-open repo write authorization and wire trusted UCAN delegation into repository permissions.
|
||||
- Close the remaining visibility gaps: restrict or document task, pin, and anchor metadata routes, and publish an irreversible-publication policy.
|
||||
- Implement repo write authorization: repo owner checks, protected branches, UCAN capability checks, and clear delegation semantics.
|
||||
- Implement private-read enforcement or remove private-repo affordances until it exists.
|
||||
- Add UCAN revocation or blocklisting, with an emergency compromised-key runbook.
|
||||
- GraphQL mutations require a verified signer (shipped, #87). Keep the surface limited to the agent-task queue; do not expose an owner-gated repo-write mutation over GraphQL without its owner-gate and REST parity. The `every_graphql_mutation_has_its_gate` fence forces any new mutation to be classified in its table (discovered from the schema, so none can slip in unlisted); the owner-gate and REST parity themselves stay the INV-1 review check, not something the fence proves.
|
||||
- Add mutation-aware GraphQL auth before GraphQL becomes a public write API surface.
|
||||
- Harden peer registration and outbound peer calls against SSRF and peer-list poisoning.
|
||||
- After all live peers upgrade, enable signed peer writes by default.
|
||||
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
Date: 2026-05-28
|
||||
Repo state inspected: `main` tracking `origin/main`, starting at `b12c6bc feat: per-DID rate limiting on creation endpoints (10/hour) (#13)`.
|
||||
|
||||
> **Historical snapshot.** This audit describes the repository at the commit above. Its
|
||||
> statements about incomplete UCAN chain validation and absent per-repository read
|
||||
> enforcement have been superseded; see [`SECURITY.md`](../SECURITY.md) for the current
|
||||
> security posture.
|
||||
|
||||
## Commands run
|
||||
|
||||
```sh
|
||||
@@ -92,8 +87,8 @@ Fixed or staged in this pass:
|
||||
|
||||
Live-network blockers to prioritize:
|
||||
|
||||
- GraphQL mutations now require a verified signer bound to the acting DID (#87), and the surface stays limited to the agent-task queue, so there is no public repo-write GraphQL API to gate. Queries remain open by design. (Resolved; kept here as posture, not a live blocker.)
|
||||
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization. Owner checks are now enforced on every branch, protected or not (`GITLAWB_ENFORCE_OWNER_PUSH`, on by default); what remains is that a UCAN `git/push` capability is not yet honored, so a delegated or CI key cannot push.
|
||||
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
|
||||
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization; unprotected repo branches do not yet enforce owner/UCAN capability checks.
|
||||
- UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature.
|
||||
- Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired.
|
||||
- Peer URLs are self-asserted by DIDs. Signatures prove control of the DID key when present, not ownership/safety of the announced URL.
|
||||
|
||||
+19
-32
@@ -141,43 +141,31 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
|
||||
|
||||
---
|
||||
|
||||
## Owner-only push
|
||||
## Hardening: owner-only push
|
||||
|
||||
The node requires the authenticated pusher to be the repo owner on **every**
|
||||
branch. A push whose authenticated DID is not the repo owner is rejected with
|
||||
HTTP 403 before any ref update is applied. The owner is matched in both the full
|
||||
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
By default the node authenticates every `git-receive-pack` push (a valid RFC 9421
|
||||
`did:key` signature) but does **not** check that the pusher owns the repo, except
|
||||
on branches that are explicitly protected. Because `did:key` is self-certifying,
|
||||
any party can generate a key, derive its DID, sign, and push to an unprotected
|
||||
branch — authentication is not authorization.
|
||||
|
||||
This is on by default, and the default is the point. The node authenticates every
|
||||
`git-receive-pack` push with a valid RFC 9421 `did:key` signature, but `did:key`
|
||||
is self-certifying: any party can generate a key, derive its DID and sign.
|
||||
Authentication is not authorization, so without this gate every signed caller can
|
||||
push to every repository — private ones included — on any branch that is not
|
||||
explicitly protected.
|
||||
|
||||
### Turning it off
|
||||
To require the authenticated pusher to be the repo owner on **every** branch, set:
|
||||
|
||||
```bash
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=false
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
```
|
||||
|
||||
Both the environment variable and `--enforce-owner-push false` disable it; the
|
||||
bare `--enforce-owner-push` flag still means `true`.
|
||||
|
||||
Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
|
||||
treat it as temporary: while it is off, your node accepts a push to any repository
|
||||
from anyone who can generate a keypair.
|
||||
|
||||
### Caution: delegated and CI keys are non-owners
|
||||
|
||||
Push authorization is owner-only today. A UCAN `git/push` capability is verified
|
||||
but **not yet honored for authorization**, so a delegated key cannot push while
|
||||
this gate is on, even when it holds a valid capability for the repo.
|
||||
|
||||
If your automation pushes under its own DID rather than the owner's, it will start
|
||||
getting 403s. Either have it push as the owner, or set
|
||||
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
|
||||
rights land — that work is what removes this trade-off.
|
||||
- **Default `false`** — preserves current behavior so live nodes are unaffected by
|
||||
an upgrade. Turn it on once you're ready for owner-only writes.
|
||||
- **When `true`** — a push whose authenticated DID is not the repo owner is
|
||||
rejected (HTTP 403) before any ref update is applied. The owner is matched in
|
||||
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
- **Caution: this blocks every non-owner pusher, including your own delegated and
|
||||
CI agents.** Push authorization is owner-only today — a UCAN `git/push`
|
||||
capability is verified but not yet honored for authorization, so delegated keys
|
||||
cannot push while this is on. Don't enable it until every identity that pushes
|
||||
to your repos is the owner, or you'll lock out your own automation. Scoped
|
||||
collaborator / UCAN-delegated push rights are a planned follow-up.
|
||||
|
||||
---
|
||||
|
||||
@@ -190,7 +178,6 @@ rights land — that work is what removes this trade-off.
|
||||
| Operator key | Dedicated wallet, small ETH balance, not your main treasury |
|
||||
| Monitoring | Watch `lastHeartbeat` on-chain; alert if > 22h since last beat |
|
||||
| Public URL | Must resolve and serve `/health` for liveness and DB-aware `/ready` for peer readiness |
|
||||
| Rolling upgrade (advisory-lock algorithm) | The SHA-256 advisory-lock key swap (replacing `DefaultHasher`) re-keys every repo in the first release that carries it. During a shared-Postgres rolling upgrade, old nodes compute a different `i64` key than new nodes for the same repo, so PostgreSQL treats them as independent locks and cross-machine write-exclusion is lost. **Drain in-flight writes** or cut over through a **single node** before bringing new-binary nodes online. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+11
-60
@@ -91,39 +91,13 @@ User-data only runs at first boot, so upgrades go through SSM:
|
||||
$(terraform output -raw upgrade_command)
|
||||
```
|
||||
|
||||
This reinstalls the rendered `/opt/gitlawb/compose.yaml`, then runs
|
||||
`docker compose pull && docker compose up -d` on the instance.
|
||||
|
||||
**Run `terraform apply` before the upgrade command.** The command is an SSM
|
||||
document managed by Terraform, and it embeds the compose file rendered from your
|
||||
current variables. An instance that has not had a fresh `apply` still holds the
|
||||
previous document and will reinstall the previous compose file.
|
||||
This runs `docker compose pull && docker compose up -d` on the instance.
|
||||
|
||||
- With `image_tag = "latest"` (default) that picks up the newest release.
|
||||
- With a **pinned tag**, set `image_tag` in `terraform.tfvars` and `apply` — the
|
||||
rendered compose carries it, so the upgrade installs the right version.
|
||||
- **`compose.yaml` is Terraform-owned and is overwritten on every upgrade.**
|
||||
Per-instance settings belong in `/opt/gitlawb/.env`, which is never touched.
|
||||
- **The upgrade reconciles the service set, not just the image.** The rendering is
|
||||
conditional — `postgres` exists only when `db_host` is empty, `caddy` only when
|
||||
`domain_name` is set — and the command runs `--remove-orphans`. So setting
|
||||
`use_rds`, or clearing `domain_name`, and then upgrading will **stop and remove**
|
||||
the local postgres or the TLS terminator. That is the intended outcome of those
|
||||
variables, but it happens on the upgrade rather than on `apply`. Data survives
|
||||
either way: both bind-mount under `/mnt/data`.
|
||||
|
||||
### Why the upgrade rewrites the compose file
|
||||
|
||||
`aws_instance` deliberately ignores `user_data` drift, so an instance keeps the
|
||||
compose file it was created with. Compose passes only the variables named in a
|
||||
service's `environment:` block into the container, so **a node setting added to
|
||||
the template never reaches an existing instance** — writing it to
|
||||
`/opt/gitlawb/.env` and restarting leaves the node on its built-in default, with
|
||||
no error to indicate it. `pull && up -d` alone cannot fix that, because the file
|
||||
on disk is the authoritative one.
|
||||
|
||||
Reinstalling the rendering first makes the upgrade a real migration. It is
|
||||
idempotent: on an already-current instance it writes identical bytes.
|
||||
- With a **pinned tag**, first edit the tag in `/opt/gitlawb/compose.yaml` on
|
||||
the instance (via SSM session), then run the upgrade command — and keep
|
||||
`image_tag` in terraform.tfvars in sync so a future instance replacement
|
||||
boots the same version.
|
||||
|
||||
Replace the instance itself (OS/AMI/instance-type changes) with
|
||||
`terraform apply -replace=aws_instance.node` — the data volume reattaches and
|
||||
@@ -131,24 +105,12 @@ Replace the instance itself (OS/AMI/instance-type changes) with
|
||||
|
||||
## Changing configuration
|
||||
|
||||
`terraform apply` alone never changes a running instance: user-data runs once and
|
||||
the instance ignores `user_data` drift. How a change rolls out depends on which
|
||||
of the two files carries it.
|
||||
|
||||
**Rendered into `compose.yaml`** — `image_tag`, `gitlawb_port`, `metrics_port`,
|
||||
`domain_name`, `db_host`, the `icaptcha_*` values. `apply`, then run
|
||||
`upgrade_command`: it installs the new rendering and restarts.
|
||||
|
||||
```sh
|
||||
terraform apply
|
||||
$(terraform output -raw upgrade_command)
|
||||
```
|
||||
|
||||
**Written into `/opt/gitlawb/.env` at first boot** — `public_url`,
|
||||
`bootstrap_peers`, `auto_sync`, `max_pack_bytes`, and the integration secrets.
|
||||
Editing these in `terraform.tfvars` does **not** reach a running instance, because
|
||||
nothing rewrites `.env` after the first boot. Either edit `/opt/gitlawb/.env` on
|
||||
the instance and `docker compose up -d`, or replace it:
|
||||
User-data only runs at first boot, and the instance ignores `user_data` drift
|
||||
(`ignore_changes`), so editing terraform.tfvars values that feed the bootstrap
|
||||
(`bootstrap_peers`, `public_url`, integrations, `image_tag`) does **not**
|
||||
affect a running instance on `terraform apply`. To roll out such changes,
|
||||
either edit `/opt/gitlawb/.env` on the instance (SSM session, then
|
||||
`docker compose up -d`), or replace the instance:
|
||||
|
||||
```sh
|
||||
terraform apply -replace=aws_instance.node
|
||||
@@ -156,17 +118,6 @@ terraform apply -replace=aws_instance.node
|
||||
|
||||
The data volume reattaches; repos, postgres data, and the identity key survive.
|
||||
|
||||
**A node setting the installed `compose.yaml` does not already name** —
|
||||
`GITLAWB_ENFORCE_OWNER_PUSH` on an instance created before it was added, for
|
||||
example. Editing `.env` is **not enough**: compose only passes through the
|
||||
variables its `environment:` block lists, so the value is read from `.env` and
|
||||
then dropped. Run `apply` plus `upgrade_command` first, which installs a compose
|
||||
file that names the key; after that, `.env` governs it like any other.
|
||||
|
||||
That last case is the one worth remembering: `.env` reaches the container only for
|
||||
keys the *installed* compose interpolates, and the upgrade is what makes a newly
|
||||
added key one of them.
|
||||
|
||||
## Remote state (optional)
|
||||
|
||||
Local state is the default. To move state to S3: create a versioned bucket,
|
||||
|
||||
@@ -72,9 +72,6 @@ services:
|
||||
GITLAWB_BOOTSTRAP_PEERS: $${GITLAWB_BOOTSTRAP_PEERS}
|
||||
GITLAWB_AUTO_SYNC: $${GITLAWB_AUTO_SYNC}
|
||||
GITLAWB_MAX_PACK_BYTES: $${GITLAWB_MAX_PACK_BYTES}
|
||||
# Owner-only push. Matches the node's own default; forwarding it is what makes
|
||||
# the documented rolling-upgrade opt-out reachable on the container path.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH: $${GITLAWB_ENFORCE_OWNER_PUSH:-true}
|
||||
# On-chain PoS (optional — empty unless set in terraform.tfvars)
|
||||
GITLAWB_CHAIN_RPC_URL: $${GITLAWB_CHAIN_RPC_URL}
|
||||
GITLAWB_CONTRACT_NODE_STAKING: $${GITLAWB_CONTRACT_NODE_STAKING}
|
||||
|
||||
+7
-62
@@ -350,16 +350,10 @@ resource "aws_instance" "node" {
|
||||
# ami: new AL2023 releases shouldn't churn the instance; replace
|
||||
# deliberately for OS upgrades.
|
||||
# user_data: only runs at first boot, so re-rendering it on a live
|
||||
# instance is a pointless stop/start. How a config change reaches a running
|
||||
# instance depends on which file carries it:
|
||||
# - rendered into compose.yaml (image_tag, ports, domain_name, db_host,
|
||||
# icaptcha_*): `terraform apply` then `upgrade_command`, which
|
||||
# reinstalls the rendering and restarts.
|
||||
# - written into /opt/gitlawb/.env at first boot (public_url,
|
||||
# bootstrap_peers, integrations): nothing rewrites .env afterwards, so
|
||||
# these need an edit on the instance or
|
||||
# `terraform apply -replace=aws_instance.node`.
|
||||
# See README "Changing configuration".
|
||||
# instance is a pointless stop/start. Config changes that feed user-data
|
||||
# (bootstrap peers, integrations, image tag) require a deliberate
|
||||
# `terraform apply -replace=aws_instance.node` — see README "Changing
|
||||
# configuration".
|
||||
ignore_changes = [ami, user_data]
|
||||
}
|
||||
}
|
||||
@@ -443,62 +437,13 @@ resource "aws_ssm_document" "upgrade" {
|
||||
|
||||
content = jsonencode({
|
||||
schemaVersion = "2.2"
|
||||
description = "Reinstall the rendered compose file, pull the latest image, and restart"
|
||||
description = "Pull the latest gitlawb node image and restart the compose stack"
|
||||
mainSteps = [{
|
||||
action = "aws:runShellScript"
|
||||
name = "upgrade"
|
||||
inputs = {
|
||||
# Reinstall the CURRENT rendering before restarting.
|
||||
#
|
||||
# `aws_instance` ignores user_data drift, so an instance created before a
|
||||
# template change keeps the compose file it was born with. Compose passes
|
||||
# only the variables named in its `environment:` block into the container,
|
||||
# so a node setting added to the template never reaches an existing
|
||||
# instance — the operator writes it to /opt/gitlawb/.env, restarts, and the
|
||||
# node silently keeps the built-in default. `docker compose pull && up -d`
|
||||
# cannot fix that: the authoritative file is the one on disk.
|
||||
#
|
||||
# Idempotent: writing the same bytes and restarting is a no-op. This does
|
||||
# overwrite hand-edits to compose.yaml, which is intended — the file is
|
||||
# Terraform-owned; per-instance settings belong in /opt/gitlawb/.env.
|
||||
#
|
||||
# One shell step, under flock, writing through mktemp. Two overlapping
|
||||
# executions — a double run, a retry while the first is still pulling, two
|
||||
# operators — otherwise share one fixed temp path: the second truncates the
|
||||
# file the first is about to install, and once the first renames, the
|
||||
# second's open descriptor keeps writing into the live compose.yaml inode.
|
||||
# Both outcomes stay valid YAML, so the `up -d --remove-orphans` on the next
|
||||
# line accepts a blended or truncated service set and deletes whatever fell
|
||||
# off — on a live node, the local postgres or the TLS terminator. The lock
|
||||
# also covers pull and up, so a second run cannot restart against a file the
|
||||
# first is mid-install.
|
||||
runCommand = [<<-UPGRADE
|
||||
set -euo pipefail
|
||||
install -d -m 0755 /opt/gitlawb
|
||||
|
||||
exec 9>/opt/gitlawb/.upgrade.lock
|
||||
if ! flock -n 9; then
|
||||
echo "another upgrade is already running on this instance" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp="$(mktemp /opt/gitlawb/compose.yaml.XXXXXX)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
# base64 rather than a nested heredoc: the rendered compose contains
|
||||
# `$${VAR}` passthroughs that must reach the file unexpanded, and a
|
||||
# heredoc inside an indented Terraform heredoc depends on the dedent
|
||||
# landing the delimiter at column 0. Decoding a single line has neither
|
||||
# failure mode, and no delimiter can collide with the content.
|
||||
echo '${base64encode(local.compose_yaml)}' | base64 -d > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
mv "$tmp" /opt/gitlawb/compose.yaml
|
||||
trap - EXIT
|
||||
|
||||
cd /opt/gitlawb
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
UPGRADE
|
||||
runCommand = [
|
||||
"cd /opt/gitlawb && docker compose pull && docker compose up -d --remove-orphans && docker image prune -f"
|
||||
]
|
||||
}
|
||||
}]
|
||||
|
||||
@@ -19,11 +19,6 @@ class Config {
|
||||
var operatorPrivateKey: String = ""
|
||||
var tigrisBucket: String = ""
|
||||
var autoSync: Bool = false
|
||||
/// Owner-only push. Persisted as a setting rather than read from a hand-edited
|
||||
/// `.env`, because `writeEnvFile()` regenerates that file on every start and
|
||||
/// would discard the edit. Matches the node's own default; set false only for a
|
||||
/// rolling upgrade whose pushers are not yet the repo owner.
|
||||
var enforceOwnerPush: Bool = true
|
||||
var repoPathString: String = ""
|
||||
|
||||
// MARK: - Paths
|
||||
@@ -81,7 +76,6 @@ class Config {
|
||||
"operatorPrivateKey": operatorPrivateKey,
|
||||
"tigrisBucket": tigrisBucket,
|
||||
"autoSync": autoSync,
|
||||
"enforceOwnerPush": enforceOwnerPush,
|
||||
"repoPath": repoPathString,
|
||||
]
|
||||
|
||||
@@ -111,7 +105,6 @@ class Config {
|
||||
operatorPrivateKey = dict["operatorPrivateKey"] as? String ?? operatorPrivateKey
|
||||
tigrisBucket = dict["tigrisBucket"] as? String ?? tigrisBucket
|
||||
autoSync = dict["autoSync"] as? Bool ?? autoSync
|
||||
enforceOwnerPush = dict["enforceOwnerPush"] as? Bool ?? enforceOwnerPush
|
||||
repoPathString = dict["repoPath"] as? String ?? repoPathString
|
||||
}
|
||||
|
||||
@@ -128,7 +121,6 @@ class Config {
|
||||
lines.append("GITLAWB_PORT=\(httpPort)")
|
||||
lines.append("GITLAWB_P2P_PORT=\(p2pPort)")
|
||||
lines.append("GITLAWB_AUTO_SYNC=\(autoSync)")
|
||||
lines.append("GITLAWB_ENFORCE_OWNER_PUSH=\(enforceOwnerPush)")
|
||||
|
||||
if !chainRpcURL.isEmpty {
|
||||
lines.append("GITLAWB_CHAIN_RPC_URL=\(chainRpcURL)")
|
||||
|
||||
@@ -294,10 +294,6 @@ services:
|
||||
GITLAWB_HOST: 0.0.0.0
|
||||
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
|
||||
GITLAWB_P2P_PORT: 7546
|
||||
# Owner-only push. Must match the bundled Resources/docker-compose.yml: this
|
||||
# generated file — not the bundled resource — is what a packaged app runs, so
|
||||
# omitting it here leaves the documented opt-out unreachable for those users.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
|
||||
GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-}
|
||||
GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-}
|
||||
GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-}
|
||||
|
||||
@@ -33,9 +33,6 @@ services:
|
||||
GITLAWB_PORT: ${GITLAWB_PORT:-7545}
|
||||
GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545}
|
||||
GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546}
|
||||
# Owner-only push. Matches the node's own default; forwarding it is what makes
|
||||
# the documented rolling-upgrade opt-out reachable on the container path.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
|
||||
# Sync
|
||||
GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false}
|
||||
# On-chain PoS (optional — leave unset for local/dev)
|
||||
|
||||
@@ -28,7 +28,6 @@ struct SettingsView: View {
|
||||
@State private var operatorPrivateKey: String = Config.shared.operatorPrivateKey
|
||||
@State private var tigrisBucket: String = Config.shared.tigrisBucket
|
||||
@State private var autoSync: Bool = Config.shared.autoSync
|
||||
@State private var enforceOwnerPush: Bool = Config.shared.enforceOwnerPush
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -54,15 +53,6 @@ struct SettingsView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Toggle("Require repo owner to push", isOn: $enforceOwnerPush)
|
||||
Text("A signature proves who is pushing, not that they own the repo. "
|
||||
+ "Turn this off only during a rolling upgrade whose pushers are "
|
||||
+ "not yet the owner — while off, anyone who can sign may push to "
|
||||
+ "any repo on this node.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -106,7 +96,6 @@ struct SettingsView: View {
|
||||
Config.shared.operatorPrivateKey = operatorPrivateKey
|
||||
Config.shared.tigrisBucket = tigrisBucket
|
||||
Config.shared.autoSync = autoSync
|
||||
Config.shared.enforceOwnerPush = enforceOwnerPush
|
||||
|
||||
Config.shared.persist()
|
||||
Config.shared.writeEnvFile()
|
||||
|
||||
Reference in new issue
Block a user