mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b51d7264 | ||
|
|
5a6d2f4fb2 | ||
|
|
a4026e84ff | ||
|
|
38bab4191b | ||
|
|
d84c445f94 | ||
|
|
42cc893fb3 | ||
|
|
0189990ac9 | ||
|
|
61d8a4f8f1 | ||
|
|
b33562f1a9 | ||
|
|
d3845ced69 | ||
|
|
bac49f150a | ||
|
|
b4a3122500 | ||
|
|
0cdb4bcc7c | ||
|
|
1514f3d806 | ||
|
|
0e5a1608e2 | ||
|
|
37947208c5 | ||
|
|
7a1f644859 | ||
|
|
408d588aaa | ||
|
|
daa3e982ec | ||
|
|
7ad01befb7 | ||
|
|
3d4ac67ab4 | ||
|
|
4f12e15e17 | ||
|
|
96726a33ee | ||
|
|
e394f90e61 | ||
|
|
7a98ab1558 | ||
|
|
29fbec5962 | ||
|
|
7527be870b | ||
|
|
6ebf754e09 | ||
|
|
8b0a8cb9e6 | ||
|
|
b7bd1083c0 | ||
|
|
ac0cac12e3 | ||
|
|
43b34ad3e7 | ||
|
|
e864f92dbb | ||
|
|
16d2466164 | ||
|
|
fc4fa033b1 | ||
|
|
36a2748557 | ||
|
|
a0eac9947e | ||
|
|
7c3969f7c0 | ||
|
|
deaee40df0 | ||
|
|
f0a001a4de | ||
|
|
eebcfd8881 | ||
|
|
1c6d67ae0f | ||
|
|
a60272fb10 | ||
|
|
585b33534d | ||
|
|
58bce06cc2 | ||
|
|
2a646d1e9e | ||
|
|
2445f7869e | ||
|
|
8f202b660e | ||
|
|
b175fdb5df | ||
|
|
7c2741ecf4 | ||
|
|
1a89c0eed6 | ||
|
|
ffe87c6f6b | ||
|
|
b054189453 | ||
|
|
86c1dc7a75 | ||
|
|
f15ca136f2 | ||
|
|
e6efc9e3bb | ||
|
|
536ffb4c32 | ||
|
|
7d481660f3 | ||
|
|
21b576a413 | ||
|
|
4f58cddce5 | ||
|
|
d4b5e99c99 | ||
|
|
c268c03b26 | ||
|
|
f0f031f73c | ||
|
|
7149a37d77 | ||
|
|
8d5adeeb06 | ||
|
|
f22ebacf79 | ||
|
|
171a4342ec | ||
|
|
e1ca1b6d10 |
No files matched your search
@@ -39,6 +39,12 @@ GITLAWB_KEY=/data/keys/identity.pem
|
||||
# Publicly reachable URL of this node (used in peer announcements)
|
||||
GITLAWB_PUBLIC_URL=https://your-node.example.com
|
||||
|
||||
# Base URL for the web view of repos on this node (used by `gl` to print a
|
||||
# working View: link after repo creation). Omit for nodes with no web front-end.
|
||||
# Distinct from GITLAWB_PUBLIC_URL: that is API reachability for peers;
|
||||
# this is browser reachability for humans.
|
||||
# GITLAWB_WEB_URL=https://gitlawb.com
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
GITLAWB_HOST=0.0.0.0
|
||||
GITLAWB_PORT=7545
|
||||
@@ -320,6 +326,19 @@ GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
|
||||
# from the concurrency caps above). 0 disables. Default 600.
|
||||
GITLAWB_IPFS_RATE_LIMIT=600
|
||||
|
||||
# Per-client-IP rate limit for the anonymous task read routes
|
||||
# (GET /api/v1/tasks, GET /api/v1/tasks/{id}), in requests per hour.
|
||||
# Both are publicly reachable (optional_signature). GET /api/v1/tasks runs
|
||||
# collect_visible_tasks and returns a visibility-filtered page;
|
||||
# GET /api/v1/tasks/{id} runs get_visible_task and returns an opaque 404 when
|
||||
# the task is hidden or missing. These reads can require task, repository,
|
||||
# and visibility-rule queries even when no task is returned, so the brake
|
||||
# bounds the cost of anonymous probes. Keyed on the resolved client IP via
|
||||
# GITLAWB_TRUSTED_PROXY. 0 disables. Default: 1200 (a list page
|
||||
# followed by per-task reads is a normal client pattern, so this sits above
|
||||
# the /ipfs budget). The GraphQL/WS task-read brake shares this budget.
|
||||
GITLAWB_TASK_READ_RATE_LIMIT=1200
|
||||
|
||||
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
|
||||
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
|
||||
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
|
||||
|
||||
Generated
+12
-8
@@ -2208,7 +2208,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2701,7 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 2.0.117",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3085,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3563,6 +3563,7 @@ dependencies = [
|
||||
"libp2p-swarm",
|
||||
"mockito",
|
||||
"multiaddr",
|
||||
"percent-encoding",
|
||||
"prometheus",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3580,6 +3581,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
"zstd",
|
||||
@@ -3599,6 +3601,7 @@ dependencies = [
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"mockito",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3607,6 +3610,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
@@ -3642,9 +3646,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -6223,7 +6227,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7157,7 +7161,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7949,7 +7953,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-1
@@ -54,8 +54,10 @@ 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)
|
||||
# take a parsed URL without pulling reqwest into gitlawb-core; also used for the
|
||||
# absolute-browser-url validation on GITLAWB_WEB_URL in the node and CLI)
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
|
||||
@@ -66,10 +66,10 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
|
||||
- Repository write authorization is secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` only for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint a `did:key` and sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Task list/get reads (REST and GraphQL) are repository/task-gated and omit `ucan_token`. Read visibility is not a blanket data-classification boundary: IPFS-pin and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
|
||||
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
|
||||
@@ -311,6 +311,7 @@ metadata local disk / optional S3
|
||||
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
|
||||
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
|
||||
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
|
||||
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
|
||||
| Gossipsub | libp2p topic for ref-update events. |
|
||||
@@ -400,7 +401,7 @@ Important node settings:
|
||||
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
|
||||
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
|
||||
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
|
||||
@@ -419,6 +420,7 @@ Important node settings:
|
||||
| `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_TASK_READ_RATE_LIMIT` | Max anonymous task-read requests (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`) per client IP per hour, sharing the budget with the GraphQL/WS task-read brake. Uses `GITLAWB_TRUSTED_PROXY` to resolve the client IP. 0 disables. Default 1200 (above the `/ipfs` budget to allow list pages followed by per-task reads). |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
|
||||
@@ -553,7 +555,7 @@ Short-term priorities:
|
||||
2. Add Docker and installer smoke tests.
|
||||
3. Improve operator docs and `gl doctor` checks.
|
||||
4. Harden peer writes and publish the signed-peer rollout plan.
|
||||
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
|
||||
5. Wire trusted UCAN delegation into repository write authorization.
|
||||
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
|
||||
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
|
||||
|
||||
|
||||
+11
-11
@@ -26,7 +26,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
|
||||
**Content addressing**
|
||||
- Every git object is content-addressed via CIDv1 (SHA-256)
|
||||
- Tamper-evident by construction — a modified object changes its CID
|
||||
- Tamper-evident by construction: a modified object changes its CID
|
||||
|
||||
**UCAN token validation**
|
||||
- Bootstrap UCAN tokens are issued at registration.
|
||||
@@ -35,8 +35,8 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
|
||||
|
||||
**Smart contracts (Base Sepolia testnet)**
|
||||
- `GitlawbDIDRegistry` — on-chain DID → document registry
|
||||
- `GitlawbNameRegistry` — human name → DID registry
|
||||
- `GitlawbDIDRegistry`: on-chain DID → document registry
|
||||
- `GitlawbNameRegistry`: human name → DID registry
|
||||
- Both auditable on-chain, no admin keys
|
||||
|
||||
---
|
||||
@@ -54,9 +54,9 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
|
||||
### Repository write authorization defaults
|
||||
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
|
||||
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
|
||||
- `git-receive-pack` verifies HTTP Signatures and, with the default configuration, also requires the pusher DID to be the repository owner: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher, and the pusher must be the repository owner. Authentication is not authorization on its own, since anyone can mint a `did:key` and sign, so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, including private ones.
|
||||
- **Mitigation:** `GITLAWB_ENFORCE_OWNER_PUSH=false` is the explicit rolling-upgrade compatibility override. Disable it only for the duration of an upgrade whose pushers are not yet the repo owner, then turn it back on. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on.
|
||||
|
||||
### UCAN delegation and revocation
|
||||
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
|
||||
@@ -73,7 +73,7 @@ These are documented limitations of the current live release. They should be pri
|
||||
### 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.
|
||||
- Task list/get reads (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`, and GraphQL `tasks` / `task` over HTTP and WebSocket) are repository/task-gated through shared visibility collectors and omit `ucan_token`. All of those transports share the same per-IP `GITLAWB_TASK_READ_RATE_LIMIT` budget. `/api/v1/ipfs/pins` and `/api/v1/arweave/anchors` are not repository-gated; 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.
|
||||
@@ -119,10 +119,10 @@ These are documented limitations of the current live release. They should be pri
|
||||
## Threat Model
|
||||
|
||||
Twigpine is designed to be secure against:
|
||||
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
- **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
|
||||
- **Unauthenticated writes:** HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
- **Tampered git objects:** CIDv1 content addressing detects modification
|
||||
- **Identity spoofing:** DIDs derived from public keys, unforgeable without the private key
|
||||
- **Centralized takedown:** no single point of control; data on IPFS + Arweave
|
||||
|
||||
Twigpine is **not yet** designed to defend against:
|
||||
- A compromised node operator (node operators are trusted for their own node)
|
||||
|
||||
@@ -137,6 +137,11 @@ enum ShimMode {
|
||||
struct Shim {
|
||||
base_url: String,
|
||||
posts: Arc<AtomicUsize>,
|
||||
/// Advertisement (GET /info/refs) count. One `git fetch` performs exactly
|
||||
/// one advertisement, so tests assert this is EXACTLY one: any fetch-level
|
||||
/// retry that comes back re-advertises and goes red on the spot, which the
|
||||
/// lower-bound POST assertions alone cannot see (#275 round 6).
|
||||
gets: Arc<AtomicUsize>,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
@@ -166,15 +171,17 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{addr}");
|
||||
let posts = Arc::new(AtomicUsize::new(0));
|
||||
let gets = Arc::new(AtomicUsize::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let posts_t = posts.clone();
|
||||
let gets_t = gets.clone();
|
||||
let stop_t = stop.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
while !stop_t.load(Ordering::SeqCst) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
handle_conn(stream, &repo, mode, &posts_t);
|
||||
handle_conn(stream, &repo, mode, &posts_t, &gets_t);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
@@ -187,6 +194,7 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
|
||||
Shim {
|
||||
base_url,
|
||||
posts,
|
||||
gets,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
}
|
||||
@@ -212,7 +220,13 @@ fn normalize_accepted_stream(stream: &TcpStream) {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
}
|
||||
|
||||
fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsize) {
|
||||
fn handle_conn(
|
||||
stream: TcpStream,
|
||||
repo: &Path,
|
||||
mode: ShimMode,
|
||||
posts: &AtomicUsize,
|
||||
gets: &AtomicUsize,
|
||||
) {
|
||||
normalize_accepted_stream(&stream);
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
@@ -263,6 +277,7 @@ fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsi
|
||||
receive_pack(repo, false, &body),
|
||||
)
|
||||
} else if method == "GET" && target.contains("/info/refs") {
|
||||
gets.fetch_add(1, Ordering::SeqCst);
|
||||
// v0 advertisement, wrapped exactly as the node's info_refs does.
|
||||
let adv = upload_pack(repo, true, b"");
|
||||
let mut wrapped = pkt(b"# service=git-upload-pack\n");
|
||||
@@ -1007,6 +1022,7 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
let posts = AtomicUsize::new(0);
|
||||
let gets = AtomicUsize::new(0);
|
||||
let stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((s, _)) => break s,
|
||||
@@ -1021,7 +1037,7 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
|
||||
// the assertions below run on every platform.
|
||||
stream.set_nonblocking(true).unwrap();
|
||||
accepted_tx.send(()).unwrap();
|
||||
handle_conn(stream, &repo, ShimMode::Normal, &posts);
|
||||
handle_conn(stream, &repo, ShimMode::Normal, &posts, &gets);
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).unwrap();
|
||||
@@ -1085,6 +1101,17 @@ fn real_git_multi_round_fetch_completes() {
|
||||
posts >= 2,
|
||||
"fixture did not force multi-round negotiation (observed {posts} POST(s)); the bridging path was not exercised"
|
||||
);
|
||||
// Exactly one advertisement: the POST bound above is a floor, so a
|
||||
// reintroduced fetch-level retry could satisfy it by running a second full
|
||||
// fetch instead of multi-round negotiation. Re-advertising is what a
|
||||
// second fetch cannot avoid, so this is the committed guard on the
|
||||
// retry's absence.
|
||||
let gets = shim.gets.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
gets, 1,
|
||||
"expected exactly one advertisement GET per fetch; {gets} means a \
|
||||
fetch-level retry is back"
|
||||
);
|
||||
|
||||
// The fetched tip is present and the clone's object graph is intact.
|
||||
let server_head = String::from_utf8(git(&server, &["rev-parse", "HEAD"])).unwrap();
|
||||
@@ -1177,6 +1204,17 @@ fn real_git_withheld_shaped_first_post() {
|
||||
"helper hung on a withheld-shaped response (should forward-and-terminate, not deadlock). stderr:\n{stderr}"
|
||||
);
|
||||
|
||||
// Exactly one advertisement, pass or fail: the POST assertions below are
|
||||
// floors, so a reintroduced fetch-level retry could mask the withheld
|
||||
// shape behind a second full fetch. A retry cannot avoid re-advertising,
|
||||
// so this is the committed guard on its absence.
|
||||
let gets = shim.gets.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
gets, 1,
|
||||
"expected exactly one advertisement GET per fetch; {gets} means a \
|
||||
fetch-level retry is back"
|
||||
);
|
||||
|
||||
if out.status.success() {
|
||||
// Real git accepted the mid-negotiation pack: the withheld multi-round
|
||||
// path works end to end with no extra handling.
|
||||
|
||||
@@ -5,6 +5,28 @@
|
||||
//!
|
||||
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
|
||||
//! Nodes that receive a certificate with an unknown version MUST reject it.
|
||||
//!
|
||||
//! ## Replay
|
||||
//!
|
||||
//! A certificate proves *who* asserted a ref update and *what* they asserted.
|
||||
//! It does not prove the assertion is fresh. [`RefUpdateBody::seq`] and
|
||||
//! [`RefUpdateBody::nonce`] are inside the signed bytes (see
|
||||
//! [`RefUpdateBody::to_signing_bytes`]), so a verifier can trust the values,
|
||||
//! but deciding whether a nonce is being reused or a `seq` is going backwards
|
||||
//! needs state.
|
||||
//!
|
||||
//! This crate is deliberately stateless — it is embedded in both nodes and
|
||||
//! clients — so replay is **not** enforced here and cannot be. Ownership of
|
||||
//! the check sits with the verifier that accepts and persists certificates,
|
||||
//! which must:
|
||||
//!
|
||||
//! - reject a `nonce` it has already accepted for the same `(repo, ref_name)`;
|
||||
//! - reject a `seq` that does not advance past the last accepted `seq` for
|
||||
//! that `(repo, ref_name)`.
|
||||
//!
|
||||
//! Until such a verifier exists, replay of ref-update certificates is not
|
||||
//! enforced anywhere in this workspace. This contract exists so the first one
|
||||
//! inherits the requirement instead of re-deriving it.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -41,11 +63,19 @@ pub struct RefUpdateBody {
|
||||
pub from: String,
|
||||
/// The new commit hash (SHA-256 hex).
|
||||
pub to: String,
|
||||
/// Monotonically increasing sequence number. Prevents replay.
|
||||
/// Monotonically increasing sequence number, scoped to `(repo, ref_name)`.
|
||||
///
|
||||
/// Signed, but not enforced by this crate: core holds no state. The
|
||||
/// verifier that persists certificates must reject a `seq` that does not
|
||||
/// advance for the same repo and ref. See the module docs on replay.
|
||||
pub seq: u64,
|
||||
/// RFC 3339 timestamp of this update.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Random nonce for deduplication.
|
||||
/// Random nonce, unique per certificate.
|
||||
///
|
||||
/// Signed, but not enforced by this crate: core holds no state. The
|
||||
/// verifier that persists certificates must reject a nonce it has already
|
||||
/// accepted for the same repo and ref. See the module docs on replay.
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
@@ -140,7 +170,16 @@ impl RefUpdateCert {
|
||||
///
|
||||
/// Counts distinct signer DIDs, not signature entries: a repeated
|
||||
/// signature from the same maintainer counts once.
|
||||
///
|
||||
/// `threshold` must be at least 1. Zero is rejected instead of being
|
||||
/// treated as "no signatures required": a caller that derives the
|
||||
/// threshold from configuration and gets 0 would otherwise accept any
|
||||
/// certificate, whether its signatures verify or not.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
if threshold == 0 {
|
||||
return Err(Error::RefCert("threshold must be at least 1".to_string()));
|
||||
}
|
||||
|
||||
let valid = self.verify_all()?;
|
||||
let distinct_signers: HashSet<&Did> =
|
||||
valid.iter().filter(|d| maintainers.contains(d)).collect();
|
||||
@@ -378,7 +417,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_always_satisfied() {
|
||||
fn threshold_zero_is_rejected() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
@@ -389,6 +428,120 @@ mod tests {
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cert.satisfies_threshold(&[], 0).unwrap());
|
||||
|
||||
// Zero must never read as "no signatures required", even with an empty
|
||||
// maintainer set.
|
||||
let err = cert.satisfies_threshold(&[], 0).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("threshold must be at least 1"),
|
||||
"zero must be refused as an invalid threshold"
|
||||
);
|
||||
}
|
||||
|
||||
/// The threshold is checked before any signature work, so a zero is
|
||||
/// reported as a threshold error even when the certificate's signatures
|
||||
/// are also invalid. Otherwise a broken certificate would let a caller
|
||||
/// mistake the failure for a legitimate verification result.
|
||||
#[test]
|
||||
fn threshold_zero_is_rejected_before_signature_verification() {
|
||||
let kp = Keypair::generate();
|
||||
let other = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.signatures[0].sig = other.sign_b64(b"unrelated bytes");
|
||||
|
||||
let err = cert.satisfies_threshold(&[kp.did()], 0).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("threshold must be at least 1"),
|
||||
"the invalid threshold must win over the invalid signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// `seq` and `nonce` are the verifier's replay inputs, so they have to be
|
||||
/// inside the signed body; tapping either one must invalidate the
|
||||
/// certificate rather than leave it verifying with different values.
|
||||
#[test]
|
||||
fn replay_fields_are_covered_by_the_signature() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
7,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.verify_all().expect("baseline must verify");
|
||||
|
||||
let mut bumped = cert.clone();
|
||||
bumped.body.seq = 8;
|
||||
assert!(
|
||||
matches!(bumped.verify_all(), Err(Error::SignatureInvalid)),
|
||||
"changing seq must invalidate the signature"
|
||||
);
|
||||
|
||||
let mut renamed = cert.clone();
|
||||
renamed.body.nonce = "different-nonce".to_string();
|
||||
assert!(
|
||||
matches!(renamed.verify_all(), Err(Error::SignatureInvalid)),
|
||||
"changing nonce must invalidate the signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signature minted by a real key must not verify when the signer is
|
||||
/// spelled in another multibase alphabet. Otherwise the same identity
|
||||
/// would satisfy a maintainer allowlist under several DID strings.
|
||||
///
|
||||
/// Base64url is deliberate: its 47-character method-id fits under the
|
||||
/// pre-existing length bound, so this test reaches the canonical-prefix
|
||||
/// rule and fails on the pre-fix code instead of being refused for length.
|
||||
#[test]
|
||||
fn verify_all_rejects_a_non_canonical_did_spelling() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut prefixed = vec![0xed, 0x01];
|
||||
prefixed.extend_from_slice(&kp.verifying_key().to_bytes());
|
||||
cert.signatures[0].signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base64Url, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = cert
|
||||
.verify_all()
|
||||
.expect_err("a non-canonical did:key spelling must not resolve");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"rejection must come from the canonical-prefix rule, not the length guard"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
cert.satisfies_threshold(&[cert.signatures[0].signer.clone()], 1),
|
||||
Ok(true)
|
||||
),
|
||||
"the alternate spelling must not satisfy a threshold"
|
||||
);
|
||||
|
||||
// The canonical spelling of the same key is unaffected.
|
||||
cert.signatures[0].signer = kp.did();
|
||||
assert!(cert.satisfies_threshold(&[kp.did()], 1).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,19 @@ use crate::{Error, Result};
|
||||
/// Value: 0xed01 encoded as a varint.
|
||||
const ED25519_MULTICODEC: &[u8] = &[0xed, 0x01];
|
||||
|
||||
/// The only multibase prefix a `did:key` method-specific id may carry.
|
||||
///
|
||||
/// The DID:key spec encodes Ed25519 keys as base58btc, which is multibase
|
||||
/// `z`. `multibase::decode` happily accepts every other alphabet, and each
|
||||
/// spelling of the same key bytes would produce a distinct DID string while
|
||||
/// gates, allowlists, and database rows compare DIDs as strings — one
|
||||
/// identity would read as several. Rejecting the alternatives here keeps the
|
||||
/// mapping from `did:key` to key single-valued for every caller of
|
||||
/// [`Did::to_verifying_key`] (HTTP signatures, UCAN, ref certs, peer
|
||||
/// announces). `gitlawb-attest` enforces the same rule for attestation
|
||||
/// signers.
|
||||
const DID_KEY_MULTIBASE_PREFIX: char = 'z';
|
||||
|
||||
/// A Decentralized Identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -71,8 +84,14 @@ impl Did {
|
||||
|
||||
/// Resolve the Ed25519 verifying key from a `did:key`.
|
||||
///
|
||||
/// Returns `Err` if the DID is not a `did:key`, the key bytes are malformed,
|
||||
/// or the key is a small-order (weak) point.
|
||||
/// Only the canonical base58btc (`z`) spelling is accepted. Other
|
||||
/// multibase encodings decode to the same key but spell a different DID
|
||||
/// string, so accepting them would let one identity appear under several
|
||||
/// identifiers to string-comparing gates and allowlists.
|
||||
///
|
||||
/// Returns `Err` if the DID is not a `did:key`, uses a non-canonical
|
||||
/// multibase prefix, the key bytes are invalid, or the key is a
|
||||
/// small-order (weak) point.
|
||||
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
|
||||
if !self.is_did_key() {
|
||||
return Err(Error::InvalidDid(format!(
|
||||
@@ -94,9 +113,27 @@ impl Did {
|
||||
));
|
||||
}
|
||||
|
||||
let (_, bytes) =
|
||||
// Cheap prefix gate before the decode, then the base check on what
|
||||
// multibase actually parsed. The first keeps a clearly non-canonical id
|
||||
// off the decoder. The second is belt-and-braces — multibase maps a
|
||||
// leading `z` only to `Base58Btc` — but it states the requirement
|
||||
// directly instead of relying on that mapping, and `gitlawb-attest`
|
||||
// makes the same pair of checks.
|
||||
if !self.method_id().starts_with(DID_KEY_MULTIBASE_PREFIX) {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key must use base58btc (z-prefix)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (base, bytes) =
|
||||
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
if base != Base::Base58Btc {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key must use base58btc (z-prefix)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !bytes.starts_with(ED25519_MULTICODEC) {
|
||||
return Err(Error::InvalidDid(
|
||||
"not an ed25519 multicodec key".to_string(),
|
||||
@@ -389,6 +426,124 @@ mod tests {
|
||||
did.to_verifying_key().expect("a real did:key must resolve");
|
||||
}
|
||||
|
||||
/// `multibase::decode` accepts every alphabet, so the same Ed25519 key can
|
||||
/// be spelled as several `did:key` strings. Only base58btc is canonical;
|
||||
/// the rest must be refused so one identity cannot appear as several to
|
||||
/// gates and allowlists that compare DID strings.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_non_base58btc_multibase() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[9u8; 32]);
|
||||
let mut prefixed = ED25519_MULTICODEC.to_vec();
|
||||
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let canonical = Did::from_verifying_key(&signing.verifying_key());
|
||||
assert_eq!(
|
||||
canonical.to_verifying_key().unwrap().to_bytes(),
|
||||
signing.verifying_key().to_bytes(),
|
||||
"base58btc is the accepted spelling"
|
||||
);
|
||||
|
||||
// These spellings fit inside the method-id bound, so they reach the
|
||||
// canonical-prefix check and must be refused there.
|
||||
for base in [Base::Base64Url, Base::Base32Lower] {
|
||||
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
|
||||
let did: Did = alternative.parse().expect("method is still `key`");
|
||||
assert_ne!(
|
||||
did.as_str(),
|
||||
canonical.as_str(),
|
||||
"the same key must not compare equal under another spelling"
|
||||
);
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("non-canonical multibase must be refused");
|
||||
assert!(
|
||||
matches!(err, Error::InvalidDid(_)),
|
||||
"must be an InvalidDid, got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// A base16 spelling of the same key is longer than the method-id
|
||||
// bound, so it is refused by the length guard before the prefix check.
|
||||
// Either way it must not resolve.
|
||||
for base in [Base::Base16Lower, Base::Base16Upper] {
|
||||
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
|
||||
let did: Did = alternative.parse().expect("method is still `key`");
|
||||
assert!(
|
||||
matches!(did.to_verifying_key(), Err(Error::InvalidDid(_))),
|
||||
"a base16 spelling of a real key must not resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// base58flickr (`Z`) is the non-bitcoin sibling alphabet. Its spelling of
|
||||
/// a real key fits under the length bound, so it must be refused for the
|
||||
/// prefix, not merely for its size.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_a_base58flickr_spelling() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[11u8; 32]);
|
||||
let mut prefixed = ED25519_MULTICODEC.to_vec();
|
||||
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let did: Did = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(Base::Base58Flickr, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("base58flickr must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty method-specific id must be refused by the prefix rule without
|
||||
/// panicking on a missing first character.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_an_empty_method_id() {
|
||||
let did: Did = "did:key:".parse().expect("method is still `key`");
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("an empty method id must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A canonical base58btc spelling is not enough on its own: the bytes must
|
||||
/// also carry the ed25519-pub multicodec. This pins the check that stops a
|
||||
/// key of another type from resolving as an Ed25519 key.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_base58btc_with_a_wrong_multicodec() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[12u8; 32]);
|
||||
let mut buf = vec![0x12, 0x00]; // sha2-256, not ed25519-pub
|
||||
buf.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let did: Did = format!("did:key:{}", multibase::encode(Base::Base58Btc, &buf))
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("a non-ed25519 multicodec must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("ed25519"),
|
||||
"the error must name the expected multicodec: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `did:key` can encode a small-order (weak) Ed25519 point. Such a key
|
||||
/// satisfies the verification equation for any message and, converted to
|
||||
/// Montgomery form for X25519, yields the all-zero shared secret, so an
|
||||
|
||||
@@ -566,6 +566,26 @@ mod tests {
|
||||
assert!(sig.check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_created_timestamps_reject_without_wrapping() {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let parse = |created: i64| {
|
||||
let sig_input =
|
||||
format!(r#"sig1=("@method");keyid="{did}";alg="ed25519";created={created}"#);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
};
|
||||
// i64::MIN used to panic in debug (subtraction overflow) and wrap to a
|
||||
// passing skew in release; now - 2^63 wrapped to i64::MIN, whose abs()
|
||||
// wrapped back to itself and read as fresh. Both must reject.
|
||||
for created in [i64::MIN, i64::MAX, Utc::now().timestamp() - i64::MAX] {
|
||||
assert!(
|
||||
parse(created).check_created().is_err(),
|
||||
"created={created} must not pass the freshness gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_missing_sig1_prefix() {
|
||||
let err = HttpSignature::parse(
|
||||
|
||||
@@ -23,6 +23,8 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
axum = { version = "0.8", features = ["http1", "http2", "json", "ws"] }
|
||||
async-graphql = { version = "7", features = ["chrono", "uuid", "tracing"] }
|
||||
async-graphql-axum = "7"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::{Extension, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::db::normalize_owner_key;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -21,17 +22,62 @@ fn default_limit() -> i64 {
|
||||
}
|
||||
|
||||
/// GET /api/v1/arweave/anchors
|
||||
///
|
||||
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
|
||||
/// request to the canonical read-authorization path used by every other
|
||||
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
|
||||
/// identity attached. Missing repositories, quarantined mirrors, and signed
|
||||
/// non-readers all collapse to the standard `404` (no existence oracle). The
|
||||
/// unscoped listing is auth-only — the #121 contract — and does not admit
|
||||
/// visibility filtering, which is the #136 stale-index class and explicitly
|
||||
/// out of scope for this auth slice.
|
||||
pub async fn list_anchors(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListAnchorsQuery>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let limit = q.limit.min(200);
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let anchors = state
|
||||
.db
|
||||
.list_arweave_anchors(q.repo.as_deref(), limit)
|
||||
.await?;
|
||||
// The route's `optional_signature` layer is permissive (it admits unsigned
|
||||
// legacy callers) so this in-handler check is the actual gate. A signed
|
||||
// non-reader still passes this check and proceeds to the authz step below.
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
if caller.is_none() {
|
||||
return Err(AppError::Unauthorized(
|
||||
"authentication required for anchor listing".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp before the SQL query. A negative or non-numeric limit must not
|
||||
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
|
||||
// contract is the same default ceiling as the listing page itself.
|
||||
let limit = q.limit.clamp(0, 200);
|
||||
|
||||
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
|
||||
// Parse the user-supplied "owner/name" through the same slug validator
|
||||
// the sync path uses, so a malformed query is a 400, not a 500.
|
||||
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
|
||||
};
|
||||
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
|
||||
// missing repos, quarantined mirrors, and signed non-readers.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
|
||||
// Build the SQL filter from the canonical stored slug, NOT from the
|
||||
// user-supplied `?repo=` string. Anchor rows are written as
|
||||
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
|
||||
// request that passes authz with the full `did:key:…/name` form would
|
||||
// match zero rows and return a false empty page. The gate already
|
||||
// verified the repo is readable; `record.owner_did` is the canonical
|
||||
// identity and `record.name` is the stored name.
|
||||
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
|
||||
state
|
||||
.db
|
||||
.list_arweave_anchors(Some(&stored_slug), limit)
|
||||
.await?
|
||||
} else {
|
||||
// No scope → no repo-read decision. Auth-only.
|
||||
state.db.list_arweave_anchors(None, limit).await?
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
@@ -60,6 +106,7 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/arweave/anchors")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ pub async fn list_certs(
|
||||
"node_did": c.node_did,
|
||||
"signature": c.signature,
|
||||
"issued_at": c.issued_at,
|
||||
"version": c.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -92,5 +93,6 @@ pub async fn get_cert(
|
||||
"node_did": cert.node_did,
|
||||
"signature": cert.signature,
|
||||
"issued_at": cert.issued_at,
|
||||
"version": cert.version,
|
||||
})))
|
||||
}
|
||||
@@ -437,6 +437,7 @@ mod ref_updates_feed_tests {
|
||||
node_did: "did:key:z6MkNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: Utc::now().to_rfc3339(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2133,7 +2133,15 @@ async fn gate_and_serve(
|
||||
/// Returns all CIDs that have been pinned to the local IPFS node from git
|
||||
/// objects received via push. Each entry includes the git SHA-256 hex, the
|
||||
/// CIDv1 string, and the timestamp when it was pinned.
|
||||
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
|
||||
pub async fn list_pins(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if auth.is_none() {
|
||||
return Err(crate::error::AppError::Unauthorized(
|
||||
"authentication required for pin listing".into(),
|
||||
));
|
||||
}
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let pins = state.db.list_pinned_cids().await?;
|
||||
@@ -2426,6 +2434,7 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/ipfs/pins")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod replicas;
|
||||
pub mod repos;
|
||||
pub mod resolve;
|
||||
pub mod stars;
|
||||
pub mod task_cursor;
|
||||
pub mod tasks;
|
||||
pub mod visibility;
|
||||
pub mod webhooks;
|
||||
@@ -80,15 +81,20 @@ pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
|
||||
/// renders it as `AppError::Forbidden`; a handler that emits its own
|
||||
/// `(StatusCode, Json)` envelope instead of `AppError` (e.g.
|
||||
/// `api::tasks::create_task`) must use this constant too, so two copies of one
|
||||
/// gate cannot drift in observable output.
|
||||
pub(crate) const REPO_OWNER_ONLY_MESSAGE: &str = "only the repo owner can perform this action";
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
/// and the author check (close policy) share one normalization.
|
||||
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
|
||||
if did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
"only the repo owner can perform this action".into(),
|
||||
))
|
||||
Err(AppError::Forbidden(REPO_OWNER_ONLY_MESSAGE.into()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +248,11 @@ mod authz_guard {
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
// #496: create_task is signer-self AND owner-when-repo-scoped — a
|
||||
// repo_id naming a hosted repo admits tasks only from its owner.
|
||||
// Both halves are pinned: the did_matches row guards the signer
|
||||
// binding, this one the repo-ownership gate.
|
||||
(tasks, "create_task", "require_repo_owner("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(tasks, "complete_task", "did_matches("),
|
||||
(tasks, "fail_task", "did_matches("),
|
||||
@@ -341,7 +352,11 @@ mod authz_guard {
|
||||
(
|
||||
"create_task",
|
||||
"createTask",
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
&[
|
||||
"require_signer(ctx)?",
|
||||
"did_matches(",
|
||||
"require_repo_owner(",
|
||||
],
|
||||
),
|
||||
(
|
||||
"claim_task",
|
||||
|
||||
@@ -3,6 +3,8 @@ use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use futures::{stream, StreamExt};
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -22,6 +24,18 @@ use crate::webhooks;
|
||||
/// The git all-zeros object id — the create/delete sentinel in a ref update.
|
||||
const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
const FEDERATED_PEER_CONCURRENCY: usize = 4;
|
||||
const FEDERATED_PEER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const FEDERATED_AGGREGATE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
pub(crate) const MAX_FEDERATED_PEERS: usize = 200;
|
||||
const MAX_FEDERATED_PEER_BYTES: usize = 512 * 1024;
|
||||
const MAX_FEDERATED_PEER_REPOS: usize = 200;
|
||||
const MAX_FEDERATED_REPOS: usize = 1_000;
|
||||
const MAX_FEDERATED_REPO_JSON_BYTES: usize = 2 * 1024 * 1024;
|
||||
// The aggregate byte budget counts serialized repo values and separators. Keep
|
||||
// a fixed reserve for the response object's keys, counters, and closing bytes.
|
||||
const FEDERATED_RESPONSE_OVERHEAD_BYTES: usize = 256;
|
||||
|
||||
/// The set of blob and withheld-subtree tree OIDs withheld from **anonymous**
|
||||
/// replication for a repo, or `None` when the repo must not replicate at all
|
||||
/// (private / mode A / undetermined, fail closed). This is the anonymous
|
||||
@@ -3208,11 +3222,206 @@ pub async fn list_refs(
|
||||
))
|
||||
}
|
||||
|
||||
struct FederatedPeerRepos {
|
||||
node_url: String,
|
||||
node_did: String,
|
||||
repos: Vec<serde_json::Value>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
struct FederatedPeerPage {
|
||||
repos: Vec<serde_json::Value>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
struct BoundedFederatedPeerRows(Vec<serde_json::Value>, bool);
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for BoundedFederatedPeerRows {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct RowsVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for RowsVisitor {
|
||||
type Value = BoundedFederatedPeerRows;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("a bounded array of repository objects")
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut rows: A) -> std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
{
|
||||
let mut repos = Vec::with_capacity(MAX_FEDERATED_PEER_REPOS);
|
||||
while repos.len() < MAX_FEDERATED_PEER_REPOS {
|
||||
let Some(repo) = rows.next_element::<serde_json::Value>()? else {
|
||||
return Ok(BoundedFederatedPeerRows(repos, false));
|
||||
};
|
||||
if !repo.is_object() {
|
||||
return Err(<A::Error as serde::de::Error>::custom(
|
||||
"peer repository row is not an object",
|
||||
));
|
||||
}
|
||||
repos.push(repo);
|
||||
}
|
||||
let mut truncated = false;
|
||||
while rows.next_element::<serde::de::IgnoredAny>()?.is_some() {
|
||||
truncated = true;
|
||||
}
|
||||
Ok(BoundedFederatedPeerRows(repos, truncated))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_seq(RowsVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct FederatedRepoBudget {
|
||||
repos: Vec<serde_json::Value>,
|
||||
json_bytes: usize,
|
||||
max_repos: usize,
|
||||
max_json_bytes: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl FederatedRepoBudget {
|
||||
fn new(capacity: usize, max_repos: usize, max_json_bytes: usize) -> Self {
|
||||
Self {
|
||||
repos: Vec::with_capacity(capacity.min(max_repos)),
|
||||
json_bytes: FEDERATED_RESPONSE_OVERHEAD_BYTES,
|
||||
max_repos,
|
||||
max_json_bytes,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, repo: serde_json::Value) -> bool {
|
||||
if self.is_saturated() {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
let Ok(repo_bytes) = serde_json::to_vec(&repo) else {
|
||||
return true;
|
||||
};
|
||||
let charged = repo_bytes.len().saturating_add(1);
|
||||
if self.json_bytes.saturating_add(charged) > self.max_json_bytes {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
self.json_bytes += charged;
|
||||
self.repos.push(repo);
|
||||
true
|
||||
}
|
||||
|
||||
fn is_saturated(&self) -> bool {
|
||||
self.repos.len() >= self.max_repos || self.json_bytes >= self.max_json_bytes
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_federated_peer_repos(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Option<FederatedPeerPage> {
|
||||
let mut response = client.get(url).send().await.ok()?;
|
||||
if !response.status().is_success()
|
||||
|| response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > MAX_FEDERATED_PEER_BYTES as u64)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let total = response
|
||||
.headers()
|
||||
.get("x-total-count")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.trim().parse::<u64>().ok());
|
||||
|
||||
let capacity = response
|
||||
.content_length()
|
||||
.and_then(|len| usize::try_from(len).ok())
|
||||
.unwrap_or(0)
|
||||
.min(MAX_FEDERATED_PEER_BYTES);
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
while let Some(chunk) = response.chunk().await.ok()? {
|
||||
if body.len().saturating_add(chunk.len()) > MAX_FEDERATED_PEER_BYTES {
|
||||
return None;
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
let BoundedFederatedPeerRows(repos, overflow) = serde_json::from_slice(&body).ok()?;
|
||||
let truncated = overflow || total.is_some_and(|total| total > repos.len() as u64);
|
||||
Some(FederatedPeerPage { repos, truncated })
|
||||
}
|
||||
|
||||
fn enrich_federated_repo(
|
||||
mut repo: serde_json::Value,
|
||||
node_url: &str,
|
||||
node_did: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let object = repo.as_object_mut()?;
|
||||
object.insert(
|
||||
"node_url".to_string(),
|
||||
serde_json::Value::String(node_url.to_string()),
|
||||
);
|
||||
object.insert(
|
||||
"node_did".to_string(),
|
||||
serde_json::Value::String(node_did.to_string()),
|
||||
);
|
||||
object.insert("local".to_string(), serde_json::Value::Bool(false));
|
||||
Some(repo)
|
||||
}
|
||||
|
||||
async fn collect_federated_fetches<I, F>(fetches: I, aggregate: &mut FederatedRepoBudget) -> usize
|
||||
where
|
||||
I: IntoIterator<Item = F>,
|
||||
F: Future<Output = Option<FederatedPeerRepos>>,
|
||||
{
|
||||
let mut responses = stream::iter(fetches).buffer_unordered(FEDERATED_PEER_CONCURRENCY);
|
||||
let mut nodes_queried = 0;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + FEDERATED_AGGREGATE_DEADLINE;
|
||||
loop {
|
||||
let response = match tokio::time::timeout_at(deadline, responses.next()).await {
|
||||
Ok(Some(response)) => response,
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
aggregate.truncated = true;
|
||||
tracing::warn!("federated aggregation deadline exceeded");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let Some(response) = response else {
|
||||
aggregate.truncated = true;
|
||||
tracing::warn!("federated peer fetch failed or exceeded its response limits");
|
||||
continue;
|
||||
};
|
||||
nodes_queried += 1;
|
||||
aggregate.truncated |= response.truncated;
|
||||
for repo in response.repos {
|
||||
let Some(repo) = enrich_federated_repo(repo, &response.node_url, &response.node_did)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !aggregate.push(repo) || aggregate.is_saturated() {
|
||||
aggregate.truncated = true;
|
||||
return nodes_queried;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes_queried
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/federated
|
||||
///
|
||||
/// Query all known peers for their public repos and return a merged view of
|
||||
/// the network. Each repo includes a `node_url` and `node_did` indicating
|
||||
/// which node hosts it. Results from unreachable peers are silently omitted.
|
||||
/// which node hosts it. Peer work is bounded by `FEDERATED_AGGREGATE_DEADLINE`
|
||||
/// and `MAX_FEDERATED_PEERS`; omitted
|
||||
/// results set `truncated`. The route admits 12 requests per minute per client IP.
|
||||
pub async fn list_federated_repos(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
@@ -3240,66 +3449,76 @@ pub async fn list_federated_repos(
|
||||
.unwrap_or_else(|| "http://127.0.0.1:7545".to_string());
|
||||
let local_node_did = state.node_did.to_string();
|
||||
|
||||
let mut all_repos: Vec<serde_json::Value> = Vec::with_capacity(local_repos.len());
|
||||
let mut aggregate = FederatedRepoBudget::new(
|
||||
local_repos.len(),
|
||||
MAX_FEDERATED_REPOS,
|
||||
MAX_FEDERATED_REPO_JSON_BYTES,
|
||||
);
|
||||
for (r, count) in &local_repos {
|
||||
let mut v = serde_json::to_value(to_response(r, &state, *count)).unwrap_or_default();
|
||||
v["node_url"] = serde_json::Value::String(local_node_url.clone());
|
||||
v["node_did"] = serde_json::Value::String(local_node_did.clone());
|
||||
v["local"] = serde_json::Value::Bool(true);
|
||||
all_repos.push(v);
|
||||
}
|
||||
|
||||
// Query peers in parallel
|
||||
let peers = state.db.list_peers().await.unwrap_or_default();
|
||||
let client = &state.http_client;
|
||||
|
||||
let fetch_tasks: Vec<_> = peers
|
||||
.into_iter()
|
||||
.filter(|p| p.last_ping_ok && !p.http_url.is_empty())
|
||||
.map(|peer| {
|
||||
let client = Arc::clone(client);
|
||||
let url = format!("{}/api/v1/repos", peer.http_url.trim_end_matches('/'));
|
||||
let peer_did = peer.did.clone();
|
||||
let peer_url = peer.http_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.get(&url).send(),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(resp)) if resp.status().is_success() => {
|
||||
if let Ok(repos) = resp.json::<Vec<serde_json::Value>>().await {
|
||||
let enriched: Vec<serde_json::Value> = repos
|
||||
.into_iter()
|
||||
.map(|mut r| {
|
||||
r["node_url"] = serde_json::Value::String(peer_url.clone());
|
||||
r["node_did"] = serde_json::Value::String(peer_did.clone());
|
||||
r["local"] = serde_json::Value::Bool(false);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
vec![]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for task in fetch_tasks {
|
||||
if let Ok(repos) = task.await {
|
||||
all_repos.extend(repos);
|
||||
let Ok(mut repo) = serde_json::to_value(to_response(r, &state, *count)) else {
|
||||
continue;
|
||||
};
|
||||
repo["node_url"] = serde_json::Value::String(local_node_url.clone());
|
||||
repo["node_did"] = serde_json::Value::String(local_node_did.clone());
|
||||
repo["local"] = serde_json::Value::Bool(true);
|
||||
if !aggregate.push(repo) || aggregate.is_saturated() {
|
||||
aggregate.truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let count = all_repos.len();
|
||||
// Keep peer work bounded even when the table contains many reachable rows.
|
||||
// Dropping the buffered stream at the aggregate ceiling cancels its in-flight
|
||||
// request futures and leaves the remaining iterator entries unpolled.
|
||||
// One extra row detects omitted peers without materializing the full table.
|
||||
let mut peers = state
|
||||
.db
|
||||
.list_federation_peers(MAX_FEDERATED_PEERS as i64 + 1)
|
||||
.await?;
|
||||
aggregate.truncated |= peers.len() > MAX_FEDERATED_PEERS;
|
||||
peers.truncate(MAX_FEDERATED_PEERS);
|
||||
let fetches: Vec<_> = peers
|
||||
.into_iter()
|
||||
.filter(|p| p.last_ping_ok && !p.http_url.is_empty())
|
||||
.map(|peer| {
|
||||
let client = Arc::clone(&state.http_client);
|
||||
let url = format!(
|
||||
"{}/api/v1/repos?limit={MAX_FEDERATED_PEER_REPOS}&offset=0",
|
||||
peer.http_url.trim_end_matches('/')
|
||||
);
|
||||
let peer_did = peer.did.clone();
|
||||
let peer_url = peer.http_url.clone();
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
FEDERATED_PEER_TIMEOUT,
|
||||
fetch_federated_peer_repos(&client, &url),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|page| FederatedPeerRepos {
|
||||
node_url: peer_url,
|
||||
node_did: peer_did,
|
||||
repos: page.repos,
|
||||
truncated: page.truncated,
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let peer_nodes_queried = if aggregate.is_saturated() {
|
||||
0
|
||||
} else {
|
||||
collect_federated_fetches(fetches, &mut aggregate).await
|
||||
};
|
||||
|
||||
let count = aggregate.repos.len();
|
||||
let truncated = aggregate.truncated;
|
||||
Ok(Json(serde_json::json!({
|
||||
"repos": all_repos,
|
||||
"repos": aggregate.repos,
|
||||
"count": count,
|
||||
"nodes_queried": 1, // local + peers that responded
|
||||
"nodes_queried": 1 + peer_nodes_queried,
|
||||
"truncated": truncated,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -3508,6 +3727,17 @@ pub struct ForkRepoRequest {
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/fork
|
||||
/// Argv for the fork's mirror clone, with `--` before the first positional (#374).
|
||||
///
|
||||
/// Both positionals here are server-derived paths rather than caller-supplied
|
||||
/// URLs, so this is defence in depth rather than a reachable injection. It is
|
||||
/// still the same argv contract as the other clone sites, and it is extracted for
|
||||
/// the same reason: so the delimiter's placement is pinned by a test instead of
|
||||
/// resting on review.
|
||||
fn fork_clone_args<'a>(source: &'a str, dest: &'a str) -> [&'a str; 5] {
|
||||
["clone", "--mirror", "--", source, dest]
|
||||
}
|
||||
|
||||
pub async fn fork_repo(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
@@ -3593,12 +3823,10 @@ pub async fn fork_repo(
|
||||
|
||||
// Clone the source repo as a mirror
|
||||
let output = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--mirror",
|
||||
.args(fork_clone_args(
|
||||
source_path.to_str().unwrap_or(""),
|
||||
disk_path.to_str().unwrap_or(""),
|
||||
])
|
||||
))
|
||||
.output()
|
||||
.map_err(|e| AppError::Git(format!("git clone --mirror failed: {e}")))?;
|
||||
|
||||
@@ -4065,6 +4293,30 @@ fn dedupe_canonical_repos(rows: Vec<(RepoRecord, i64)>) -> Vec<(RepoRecord, i64)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #374: the fork path builds its own `git clone --mirror` argv. Driven
|
||||
/// through real git so a missing or misplaced `--` fails, matching the
|
||||
/// coverage on the `gl clone`, `gl mirror`, and sync sinks.
|
||||
#[test]
|
||||
fn fork_clone_never_parses_the_source_as_a_git_option() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let dest = td.path().join("fork.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let out = std::process::Command::new("git")
|
||||
.env("LC_ALL", "C")
|
||||
.args(fork_clone_args(injected, dest.to_str().unwrap()))
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(!out.status.success(), "an option-shaped source must fail");
|
||||
assert!(
|
||||
stderr.contains(&format!("repository '{injected}'")),
|
||||
"git must name the source as the repository, not consume it as an option: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
use crate::auth::caller_authorized_to_push;
|
||||
use crate::error::AppError;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
@@ -4252,6 +4504,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn federated_peer_query_is_bounded(pool: sqlx::PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
sqlx::query("INSERT INTO peers (did, http_url, last_ping_ok, announced_at) SELECT 'peer-' || n, 'https://example.com', TRUE, '2026-01-01T00:00:00Z' FROM generate_series(1, $1) n")
|
||||
.bind(MAX_FEDERATED_PEERS as i64 + 5)
|
||||
.execute(&pool).await.unwrap();
|
||||
let peers = state.db.list_federation_peers(i64::MAX).await.unwrap();
|
||||
assert_eq!(peers.len(), MAX_FEDERATED_PEERS + 1);
|
||||
assert_eq!(state.db.list_federation_peers(2).await.unwrap().len(), 2);
|
||||
assert!(state.db.list_federation_peers(0).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn federated_route_brakes_repeated_anonymous_requests(pool: sqlx::PgPool) {
|
||||
use tower::ServiceExt;
|
||||
let mut state = crate::test_support::test_state(pool).await;
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
state.federated_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new_bounded(1, std::time::Duration::from_secs(60), 10);
|
||||
let router = crate::server::build_router(state);
|
||||
for n in 0..2 {
|
||||
let mut request = axum::http::Request::builder()
|
||||
.uri("/api/v1/repos/federated")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
request.extensions_mut().insert(axum::extract::ConnectInfo(
|
||||
"203.0.113.42:5000".parse::<std::net::SocketAddr>().unwrap(),
|
||||
));
|
||||
let response = router.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
if n == 0 {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_clone_guard_removes_its_own_mirror_on_drop() {
|
||||
let root = tempfile::TempDir::new().unwrap();
|
||||
@@ -4299,6 +4591,464 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn federated_route_uses_the_state_owned_bucket_before_database_access() {
|
||||
use tower::ServiceExt;
|
||||
let mut state = crate::test_support::test_state_lazy();
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
state.federated_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new_bounded(1, std::time::Duration::from_secs(60), 10);
|
||||
assert!(state.federated_rate_limiter.check("203.0.113.42").await);
|
||||
// Both routers must use the same exhausted bucket from AppState.
|
||||
for router in [
|
||||
crate::server::build_router(state.clone()),
|
||||
crate::server::build_router(state),
|
||||
] {
|
||||
let mut request = axum::http::Request::builder()
|
||||
.uri("/api/v1/repos/federated")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
request.extensions_mut().insert(axum::extract::ConnectInfo(
|
||||
"203.0.113.42:5000".parse::<std::net::SocketAddr>().unwrap(),
|
||||
));
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), router.oneshot(request))
|
||||
.await
|
||||
.expect("the exhausted bucket must reject before accessing the lazy database")
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn federated_aggregate_deadline_returns_partial_results() {
|
||||
let mut budget = FederatedRepoBudget::new(0, 1000, MAX_FEDERATED_REPO_JSON_BYTES);
|
||||
let fetches =
|
||||
(0..8).map(|_| async { std::future::pending::<Option<FederatedPeerRepos>>().await });
|
||||
let nodes = tokio::time::timeout(
|
||||
FEDERATED_AGGREGATE_DEADLINE + std::time::Duration::from_secs(2),
|
||||
collect_federated_fetches(fetches, &mut budget),
|
||||
)
|
||||
.await
|
||||
.expect("the aggregate must stop before the caller deadline");
|
||||
assert_eq!(nodes, 0);
|
||||
assert!(budget.truncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn federated_peer_response_enforces_byte_and_row_ceilings() {
|
||||
let mut row_server = mockito::Server::new_async().await;
|
||||
let row_body =
|
||||
serde_json::to_string(&vec![serde_json::json!({}); MAX_FEDERATED_PEER_REPOS + 1])
|
||||
.unwrap();
|
||||
let row_mock = row_server
|
||||
.mock("GET", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(row_body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let page =
|
||||
fetch_federated_peer_repos(&client, &format!("{}/api/v1/repos", row_server.url()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.repos.len(), MAX_FEDERATED_PEER_REPOS);
|
||||
assert!(page.truncated);
|
||||
row_mock.assert_async().await;
|
||||
|
||||
let byte_body = serde_json::to_string(&vec![serde_json::json!({
|
||||
"description": "x".repeat(MAX_FEDERATED_PEER_BYTES)
|
||||
})])
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let byte_server = tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = [0; 1_024];
|
||||
let _ = socket.read(&mut request).await.unwrap();
|
||||
socket
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
|
||||
transfer-encoding: chunked\r\nconnection: close\r\n\r\n{:x}\r\n",
|
||||
byte_body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = socket.write_all(byte_body.as_bytes()).await;
|
||||
let _ = socket.write_all(b"\r\n0\r\n\r\n").await;
|
||||
});
|
||||
|
||||
assert!(
|
||||
fetch_federated_peer_repos(&client, &format!("http://{address}/api/v1/repos"))
|
||||
.await
|
||||
.is_none(),
|
||||
"a chunked peer body above the byte ceiling must be omitted without Content-Length"
|
||||
);
|
||||
byte_server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn federated_peer_total_count_prevents_false_complete_page() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let body = serde_json::to_string(
|
||||
&(0..MAX_FEDERATED_PEER_REPOS)
|
||||
.map(|id| serde_json::json!({ "id": id }))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let page_mock = server
|
||||
.mock("GET", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("x-total-count", &(MAX_FEDERATED_PEER_REPOS + 1).to_string())
|
||||
.with_body(body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let page = fetch_federated_peer_repos(
|
||||
&reqwest::Client::new(),
|
||||
&format!("{}/api/v1/repos", server.url()),
|
||||
)
|
||||
.await
|
||||
.expect("the capped peer page is otherwise valid");
|
||||
page_mock.assert_async().await;
|
||||
assert_eq!(page.repos.len(), MAX_FEDERATED_PEER_REPOS);
|
||||
assert!(page.truncated, "the peer total proves another row exists");
|
||||
|
||||
let mut aggregate = FederatedRepoBudget::new(300, 300, MAX_FEDERATED_REPO_JSON_BYTES);
|
||||
let nodes = collect_federated_fetches(
|
||||
[std::future::ready(Some(FederatedPeerRepos {
|
||||
node_url: server.url(),
|
||||
node_did: "did:key:peer".to_string(),
|
||||
repos: page.repos,
|
||||
truncated: page.truncated,
|
||||
}))],
|
||||
&mut aggregate,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(nodes, 1);
|
||||
assert_eq!(aggregate.repos.len(), MAX_FEDERATED_PEER_REPOS);
|
||||
assert!(
|
||||
aggregate.truncated,
|
||||
"the aggregate must not report the paged peer as complete"
|
||||
);
|
||||
|
||||
let mut malformed_server = mockito::Server::new_async().await;
|
||||
let malformed_mock = malformed_server
|
||||
.mock("GET", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("x-total-count", "not-a-count")
|
||||
.with_body("[{}]")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let malformed = fetch_federated_peer_repos(
|
||||
&reqwest::Client::new(),
|
||||
&format!("{}/api/v1/repos", malformed_server.url()),
|
||||
)
|
||||
.await
|
||||
.expect("a malformed optional total must not discard a bounded page");
|
||||
malformed_mock.assert_async().await;
|
||||
assert!(!malformed.truncated);
|
||||
assert_eq!(malformed.repos.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federated_aggregate_stays_inside_row_and_serialized_byte_budgets() {
|
||||
let mut rows = FederatedRepoBudget::new(3, 2, 4_096);
|
||||
assert!(rows.push(serde_json::json!({ "name": "one" })));
|
||||
assert!(rows.push(serde_json::json!({ "name": "two" })));
|
||||
assert!(rows.is_saturated());
|
||||
assert!(!rows.push(serde_json::json!({ "name": "three" })));
|
||||
assert_eq!(rows.repos.len(), 2);
|
||||
assert!(rows.truncated);
|
||||
|
||||
let max_bytes = 600;
|
||||
let mut bytes = FederatedRepoBudget::new(8, 8, max_bytes);
|
||||
while bytes.push(serde_json::json!({ "description": "x".repeat(100) })) {}
|
||||
let count = bytes.repos.len();
|
||||
let truncated = bytes.truncated;
|
||||
let response = serde_json::json!({
|
||||
"repos": bytes.repos,
|
||||
"count": count,
|
||||
"nodes_queried": 1,
|
||||
"truncated": truncated,
|
||||
});
|
||||
assert!(serde_json::to_vec(&response).unwrap().len() <= max_bytes);
|
||||
assert!(response["truncated"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
struct ActiveFetch {
|
||||
active: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Drop for ActiveFetch {
|
||||
fn drop(&mut self) {
|
||||
self.active
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn federated_collection_bounds_concurrency_and_cancels_at_aggregate_limit() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let max_active = Arc::new(AtomicUsize::new(0));
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let mut senders = Vec::new();
|
||||
let mut fetches = Vec::new();
|
||||
|
||||
for i in 0..10 {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
senders.push(Some(tx));
|
||||
let active = Arc::clone(&active);
|
||||
let max_active = Arc::clone(&max_active);
|
||||
let started = Arc::clone(&started);
|
||||
fetches.push(async move {
|
||||
started.fetch_add(1, Ordering::SeqCst);
|
||||
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max_active.fetch_max(current, Ordering::SeqCst);
|
||||
let _active = ActiveFetch { active };
|
||||
rx.await.ok()?;
|
||||
Some(FederatedPeerRepos {
|
||||
node_url: format!("https://peer-{i}.example"),
|
||||
node_did: format!("did:key:peer-{i}"),
|
||||
repos: vec![serde_json::json!({ "id": i })],
|
||||
truncated: false,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let mut aggregate = FederatedRepoBudget::new(1, 1, usize::MAX);
|
||||
let nodes = collect_federated_fetches(fetches, &mut aggregate).await;
|
||||
(aggregate, nodes)
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
while started.load(Ordering::SeqCst) < FEDERATED_PEER_CONCURRENCY {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the bounded fetch window should fill");
|
||||
|
||||
assert_eq!(started.load(Ordering::SeqCst), FEDERATED_PEER_CONCURRENCY);
|
||||
assert_eq!(active.load(Ordering::SeqCst), FEDERATED_PEER_CONCURRENCY);
|
||||
assert_eq!(
|
||||
max_active.load(Ordering::SeqCst),
|
||||
FEDERATED_PEER_CONCURRENCY
|
||||
);
|
||||
|
||||
senders[0].take().unwrap().send(()).unwrap();
|
||||
let (aggregate, nodes) = tokio::time::timeout(std::time::Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("the aggregate ceiling should stop collection")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(nodes, 1);
|
||||
assert_eq!(aggregate.repos.len(), 1);
|
||||
assert!(aggregate.truncated);
|
||||
assert_eq!(started.load(Ordering::SeqCst), FEDERATED_PEER_CONCURRENCY);
|
||||
assert_eq!(
|
||||
active.load(Ordering::SeqCst),
|
||||
0,
|
||||
"in-flight fetches must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
async fn json_body(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
serde_json::from_slice(&bytes).expect("json body")
|
||||
}
|
||||
|
||||
fn federated_request() -> axum::http::Request<axum::body::Body> {
|
||||
let mut request = axum::http::Request::builder()
|
||||
.uri("/api/v1/repos/federated")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
request.extensions_mut().insert(axum::extract::ConnectInfo(
|
||||
"203.0.113.42:5000".parse::<std::net::SocketAddr>().unwrap(),
|
||||
));
|
||||
request
|
||||
}
|
||||
|
||||
/// Pins the federated response contract end to end: the envelope fields
|
||||
/// (`count`, `nodes_queried`, `truncated`) and the per-repo annotations
|
||||
/// (`node_url`, `node_did`, `local`) are asserted on an actual HTTP 200
|
||||
/// body driven through `build_router`, not on a self-constructed value.
|
||||
/// The seeded repo is deliberately owned by a DID other than the node:
|
||||
/// the envelope must carry the node's DID, so a regression that reports
|
||||
/// the owner DID instead would fail the `node_did` assertion.
|
||||
#[sqlx::test]
|
||||
async fn federated_route_envelope_annotates_repos_on_real_200_body(pool: sqlx::PgPool) {
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let node_did = state.node_did.to_string();
|
||||
state
|
||||
.db
|
||||
.create_repo(&repo_owned_by("did:key:z6MkEnvelopeOwnerNotTheNode"))
|
||||
.await
|
||||
.expect("seed local repo");
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let response = router.oneshot(federated_request()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
|
||||
assert_eq!(body["count"].as_u64(), Some(1));
|
||||
assert_eq!(
|
||||
body["nodes_queried"].as_u64(),
|
||||
Some(1),
|
||||
"no peers seeded, only the local node was queried"
|
||||
);
|
||||
assert_eq!(body["truncated"].as_bool(), Some(false));
|
||||
let repo = &body["repos"][0];
|
||||
assert_eq!(repo["name"].as_str(), Some("demo"));
|
||||
assert_eq!(repo["local"].as_bool(), Some(true));
|
||||
assert_eq!(repo["node_did"].as_str(), Some(node_did.as_str()));
|
||||
assert!(
|
||||
repo["node_url"].as_str().is_some_and(|u| !u.is_empty()),
|
||||
"local repos must carry the node's URL, got {repo}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `truncated` must be observable on the wire when the peer table holds
|
||||
/// more rows than `MAX_FEDERATED_PEERS`. Every seeded peer points at the
|
||||
/// same reachable stub returning `[]`, so no fetch can fail and no peer
|
||||
/// page can report truncation: the count bound is the only reason the
|
||||
/// body says `truncated`. The `nodes_queried` assertion pins the fetch
|
||||
/// side: every peer inside the bound is queried, and dropping the count
|
||||
/// bound would report `truncated: false`.
|
||||
#[sqlx::test]
|
||||
async fn federated_route_reports_truncated_when_peer_table_overflows(pool: sqlx::PgPool) {
|
||||
use tower::ServiceExt;
|
||||
|
||||
let mut peer_server = mockito::Server::new_async().await;
|
||||
let peer_mock = peer_server
|
||||
.mock("GET", "/api/v1/repos")
|
||||
.match_query(mockito::Matcher::Exact(format!(
|
||||
"limit={MAX_FEDERATED_PEER_REPOS}&offset=0"
|
||||
)))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.expect(MAX_FEDERATED_PEERS)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
sqlx::query("INSERT INTO peers (did, http_url, last_ping_ok, announced_at) SELECT 'peer-' || n, $1, TRUE, '2026-01-01T00:00:00Z' FROM generate_series(1, $2) n")
|
||||
.bind(peer_server.url())
|
||||
.bind(MAX_FEDERATED_PEERS as i64 + 2)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let response = router.oneshot(federated_request()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(
|
||||
body["truncated"].as_bool(),
|
||||
Some(true),
|
||||
"more peers than the bound must surface as truncated"
|
||||
);
|
||||
assert_eq!(
|
||||
body["nodes_queried"].as_u64(),
|
||||
Some(1 + MAX_FEDERATED_PEERS as u64),
|
||||
"the stub is reachable, so every peer inside the bound is queried"
|
||||
);
|
||||
peer_mock.assert_async().await;
|
||||
}
|
||||
|
||||
/// A single unreachable peer must still flip `truncated` on the wire via
|
||||
/// the failed-fetch path, even though the peer table stays under the
|
||||
/// bound. Guards the `aggregate.truncated = true` on fetch failure.
|
||||
#[sqlx::test]
|
||||
async fn federated_route_reports_truncated_when_peer_fetch_fails(pool: sqlx::PgPool) {
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
sqlx::query("INSERT INTO peers (did, http_url, last_ping_ok, announced_at) VALUES ('did:key:deadpeer', 'http://127.0.0.1:1/', TRUE, '2026-01-01T00:00:00Z')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let response = router.oneshot(federated_request()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(
|
||||
body["truncated"].as_bool(),
|
||||
Some(true),
|
||||
"a failed peer fetch must surface as truncated"
|
||||
);
|
||||
assert_eq!(body["nodes_queried"].as_u64(), Some(1));
|
||||
}
|
||||
|
||||
/// Peer repos must carry `node_url`, `node_did` and `local: false` on the
|
||||
/// real response body. Guards `enrich_federated_repo`: dropping an
|
||||
/// annotation there must turn this red.
|
||||
#[sqlx::test]
|
||||
async fn federated_route_annotates_peer_repos_on_real_200_body(pool: sqlx::PgPool) {
|
||||
use tower::ServiceExt;
|
||||
|
||||
let mut peer_server = mockito::Server::new_async().await;
|
||||
let peer_mock = peer_server
|
||||
.mock("GET", "/api/v1/repos")
|
||||
// Pin the outbound peer query parameters: production must request
|
||||
// the bounded page. Dropping the parameters stops matching this
|
||||
// mock, so the peer fetch fails and the body reports
|
||||
// `truncated: true` instead of `false` below.
|
||||
.match_query(mockito::Matcher::Exact(format!(
|
||||
"limit={MAX_FEDERATED_PEER_REPOS}&offset=0"
|
||||
)))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"id": "peer-repo-1", "name": "from-peer"}]"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
sqlx::query("INSERT INTO peers (did, http_url, last_ping_ok, announced_at) VALUES ('did:key:peer1', $1, TRUE, '2026-01-01T00:00:00Z')")
|
||||
.bind(peer_server.url())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let response = router.oneshot(federated_request()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(body["truncated"].as_bool(), Some(false));
|
||||
assert_eq!(body["nodes_queried"].as_u64(), Some(2));
|
||||
let repo = body["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["id"].as_str() == Some("peer-repo-1"))
|
||||
.expect("peer repo present in federated body");
|
||||
assert_eq!(repo["node_url"].as_str(), Some(peer_server.url().as_str()));
|
||||
assert_eq!(repo["node_did"].as_str(), Some("did:key:peer1"));
|
||||
assert_eq!(repo["local"].as_bool(), Some(false));
|
||||
peer_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_pack_request_finalizes_only_with_done_pktline() {
|
||||
let want = "0032want 1111111111111111111111111111111111111111\n";
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
//! Opaque, integrity-protected continuation tokens for the task read surfaces.
|
||||
//!
|
||||
//! The task list pages by keyset over `(created_at, id)`, but the rows a
|
||||
//! caller may *see* are a filtered subset of the rows the query has to
|
||||
//! *examine*. Handing the caller a raw `(created_at, id)` cursor therefore
|
||||
//! forced a choice between two broken options (#327 review): anchor the cursor
|
||||
//! on the last visible row, and a window of denied tasks longer than the scan
|
||||
//! budget stalls paging forever; or anchor it on the last examined row, and a
|
||||
//! denied read leaks the id and timestamp of a task `GET /tasks/{id}` other-
|
||||
//! wise 404s.
|
||||
//!
|
||||
//! A server-issued token removes the choice. The position it carries is the
|
||||
//! last *examined* candidate, so paging always advances, and the payload is
|
||||
//! opaque and MAC'd, so the caller learns nothing from it and cannot forge one
|
||||
//! naming a row of their choosing.
|
||||
//!
|
||||
//! The position must be *confidential*, not merely unforgeable: a token that
|
||||
//! merely signed a base64 payload would still let its holder read the id and
|
||||
//! timestamp of the denied row it names, which is the disclosure the token
|
||||
//! exists to prevent. The payload is therefore encrypted under a
|
||||
//! synthetic-IV construction (SIV): the tag is an HMAC over the filter and the
|
||||
//! plaintext, and it doubles as the IV seeding the keystream the plaintext is
|
||||
//! XORed with. That needs no randomness source and no dependency beyond the
|
||||
//! `hmac`/`sha2` pair already used for webhook signatures and blob recipient
|
||||
//! tags, and it is decrypt-last: nothing is parsed until the tag verifies.
|
||||
//!
|
||||
//! Making the token the *only* accepted cursor also fixes the ordering-domain
|
||||
//! bug the same review found. `agent_tasks.created_at` is TEXT and compared as
|
||||
//! TEXT, so `...Z` and `...+00:00` denote one instant but sort differently. A
|
||||
//! caller-typed timestamp could silently skip or repeat same-time rows. The
|
||||
//! token instead carries the stored string verbatim, so the value compared is
|
||||
//! always a value the server wrote.
|
||||
//!
|
||||
//! A token is bound to the *caller* as well as the filter. The position it
|
||||
//! names is the last examined candidate, not the last visible one, so it
|
||||
//! encodes how far a scan got under one caller's visibility. Resuming it as a
|
||||
//! different caller would start the scan past rows that caller is entitled to
|
||||
//! read, silently dropping them from the answer (#327 review). The presenting
|
||||
//! caller's normalized DID (empty and flagged absent when anonymous) is
|
||||
//! therefore part of the MAC input, so a token presented by anyone else fails
|
||||
//! to verify rather than under-reporting.
|
||||
//!
|
||||
//! Wire form: `v1.<iv>.<body>`, both parts base64url unpadded: the 16-byte
|
||||
//! synthetic IV (the truncated MAC, which also seeds the keystream) and the
|
||||
//! padded, encrypted JSON payload.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const CURSOR_PREFIX: &str = "v1";
|
||||
const KEY_DERIVATION_LABEL: &[u8] = b"gitlawb/tasks-cursor-key/v1";
|
||||
const TAG_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-tag/v1";
|
||||
const STREAM_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-stream/v1";
|
||||
/// Truncated MAC length, and the synthetic IV width. 128 bits is far beyond
|
||||
/// forgery reach for a token that carries no authority of its own (every page
|
||||
/// re-runs the visibility gate against the presenting caller), and keeps the
|
||||
/// token short enough to sit in a query string.
|
||||
const TAG_LEN: usize = 16;
|
||||
|
||||
/// Padding bucket for the serialized cursor payload, in bytes. The keystream
|
||||
/// XORs the plaintext byte for byte, so token length would otherwise track
|
||||
/// payload length and leak the named row's field widths to a holder who
|
||||
/// cannot decrypt anything. 128 comfortably covers the widest realistic
|
||||
/// payload: a 35-char RFC3339 timestamp (`to_rfc3339()` renders 29 chars at
|
||||
/// millisecond precision and 32 at microsecond, 35 only with all nine
|
||||
/// fractional digits), a 36-char server-minted UUID, and the fixed JSON
|
||||
/// envelope plus a 10-digit expiry, which together total 101 bytes. Should a future payload ever exceed the bucket, the length
|
||||
/// rounds up to the next multiple instead of failing, so `encode` never
|
||||
/// breaks — it just mints a longer token.
|
||||
const PAYLOAD_BUCKET: usize = 128;
|
||||
|
||||
/// How long an issued cursor stays acceptable. Keyset positions never go stale
|
||||
/// on their own — `created_at`/`id` are immutable — so this is not a
|
||||
/// correctness bound. It bounds how long a token stays valid across a node
|
||||
/// restart-and-rotate and keeps an abandoned page from being resumed
|
||||
/// indefinitely.
|
||||
const CURSOR_TTL_SECS: i64 = 24 * 60 * 60;
|
||||
|
||||
/// Node-keyed MAC key for continuation tokens, derived from the node keypair
|
||||
/// seed so it needs no configuration and survives restarts of the same node.
|
||||
/// Derived rather than used directly so a token forgery oracle could not
|
||||
/// bear on the signing key itself.
|
||||
#[derive(Clone)]
|
||||
pub struct TaskCursorKey([u8; 32]);
|
||||
|
||||
impl std::fmt::Debug for TaskCursorKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("TaskCursorKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskCursorKey {
|
||||
pub fn derive(node_seed: &[u8; 32]) -> Self {
|
||||
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
|
||||
mac.update(KEY_DERIVATION_LABEL);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&mac.finalize().into_bytes());
|
||||
Self(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyset position a token carries: the last candidate row the previous
|
||||
/// request examined, whether or not the caller was allowed to see it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TaskPosition {
|
||||
pub created_at: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl TaskPosition {
|
||||
pub fn new(created_at: impl Into<String>, id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
created_at: created_at.into(),
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_pair(&self) -> (&str, &str) {
|
||||
(self.created_at.as_str(), self.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CursorPayload<'a> {
|
||||
/// `created_at` of the last examined candidate, verbatim as stored.
|
||||
t: &'a str,
|
||||
/// `id` of the last examined candidate.
|
||||
i: &'a str,
|
||||
/// Unix expiry.
|
||||
e: i64,
|
||||
}
|
||||
|
||||
/// The filter a page was issued under. A cursor is only meaningful against the
|
||||
/// same `status`/`assignee_did` filter that produced it: resuming a filtered
|
||||
/// scan from an unfiltered position (or the reverse) silently skips rows.
|
||||
/// Bound into the MAC rather than stored in the payload, so it costs no token
|
||||
/// length and cannot be edited without invalidating the tag.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TaskFilter<'a> {
|
||||
pub status: Option<&'a str>,
|
||||
pub assignee_did: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// The visibility identity a token was issued to. Normalized through
|
||||
/// `normalize_owner_key` so the two spellings of one `did:key` identity
|
||||
/// (`did:key:X` and bare `X`) bind identically, matching `did_matches` on the
|
||||
/// read path: a caller who authenticates with the other spelling of their own
|
||||
/// DID must be able to resume their own page.
|
||||
fn caller_binding(caller: Option<&str>) -> &str {
|
||||
caller.map(crate::db::normalize_owner_key).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The assignee filter a token was issued under. Normalized through
|
||||
/// `normalize_owner_key` so the two spellings of one `did:key` identity
|
||||
/// (`did:key:X` and bare `X`) bind identically, matching `list_tasks_keyset`
|
||||
/// in SQL: minting under `did:key:X` and resuming with bare `X` (or the reverse)
|
||||
/// hits the same rows and must verify under the MAC.
|
||||
fn assignee_binding(assignee: Option<&str>) -> &str {
|
||||
assignee.map(crate::db::normalize_owner_key).unwrap_or("")
|
||||
}
|
||||
|
||||
fn cursor_mac(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
plaintext: &[u8],
|
||||
) -> HmacSha256 {
|
||||
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
|
||||
mac.update(TAG_DOMAIN);
|
||||
// Length-prefix every field so no two distinct filter/caller/plaintext
|
||||
// tuples can produce the same MAC input.
|
||||
for field in [
|
||||
filter.status.unwrap_or("").as_bytes(),
|
||||
assignee_binding(filter.assignee_did).as_bytes(),
|
||||
caller_binding(caller).as_bytes(),
|
||||
plaintext,
|
||||
] {
|
||||
mac.update(&(field.len() as u64).to_be_bytes());
|
||||
mac.update(field);
|
||||
}
|
||||
// Presence is distinct from emptiness for all three optional fields, so an
|
||||
// anonymous token cannot collide with one issued to a caller whose
|
||||
// normalized DID is the empty string.
|
||||
mac.update(&[
|
||||
u8::from(filter.status.is_some()),
|
||||
u8::from(filter.assignee_did.is_some()),
|
||||
u8::from(caller.is_some()),
|
||||
]);
|
||||
mac
|
||||
}
|
||||
|
||||
/// Synthetic IV: the authentication tag over the filter and plaintext, which
|
||||
/// also seeds the keystream. Deterministic by construction, so no randomness
|
||||
/// source is needed and two tokens for the same page are byte-identical.
|
||||
fn siv(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
plaintext: &[u8],
|
||||
) -> [u8; TAG_LEN] {
|
||||
let mut out = [0u8; TAG_LEN];
|
||||
out.copy_from_slice(
|
||||
&cursor_mac(key, filter, caller, plaintext)
|
||||
.finalize()
|
||||
.into_bytes()[..TAG_LEN],
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// XOR `buf` with the keystream for `iv`. Its own inverse, so encrypt and
|
||||
/// decrypt are the same call.
|
||||
fn apply_keystream(key: &TaskCursorKey, iv: &[u8; TAG_LEN], buf: &mut [u8]) {
|
||||
for (block_index, chunk) in buf.chunks_mut(32).enumerate() {
|
||||
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
|
||||
mac.update(STREAM_DOMAIN);
|
||||
mac.update(iv);
|
||||
mac.update(&(block_index as u64).to_be_bytes());
|
||||
let block = mac.finalize().into_bytes();
|
||||
for (byte, k) in chunk.iter_mut().zip(block.iter()) {
|
||||
*byte ^= k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a token resuming at `position` for `filter`, usable only by `caller`.
|
||||
///
|
||||
/// The serialized payload is padded with trailing whitespace to a fixed
|
||||
/// bucket before the SIV tag is computed: the keystream preserves plaintext
|
||||
/// length, so an unpadded payload would let the token's length leak the
|
||||
/// named row's field widths (a 25-char `to_rfc3339()` rendering mints a
|
||||
/// shorter token than a 35-char one). `decode` tolerates the padding because
|
||||
/// `serde_json` ignores trailing whitespace, and the tag covers the padded
|
||||
/// bytes, so a token minted before padding landed still verifies.
|
||||
pub fn encode(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
position: &TaskPosition,
|
||||
) -> String {
|
||||
let mut payload = serde_json::to_vec(&CursorPayload {
|
||||
t: &position.created_at,
|
||||
i: &position.id,
|
||||
e: chrono::Utc::now().timestamp() + CURSOR_TTL_SECS,
|
||||
})
|
||||
.expect("cursor payload is plain strings and an integer");
|
||||
let padded_len = payload.len().next_multiple_of(PAYLOAD_BUCKET);
|
||||
payload.resize(padded_len, b' ');
|
||||
let iv = siv(key, filter, caller, &payload);
|
||||
apply_keystream(key, &iv, &mut payload);
|
||||
format!(
|
||||
"{CURSOR_PREFIX}.{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(iv),
|
||||
URL_SAFE_NO_PAD.encode(&payload)
|
||||
)
|
||||
}
|
||||
|
||||
/// One rejection message for every way a token can fail to verify. A caller
|
||||
/// who mangled a token, replayed an expired one, or tried to move one to a
|
||||
/// different filter or a different presenting identity learns only that the
|
||||
/// cursor is not usable — never which of those it was, and never anything
|
||||
/// about the row it named.
|
||||
const INVALID_CURSOR: &str = "invalid or expired cursor";
|
||||
|
||||
fn reject() -> AppError {
|
||||
AppError::BadRequest(INVALID_CURSOR.into())
|
||||
}
|
||||
|
||||
/// Verify a token against `filter` and the presenting `caller`, and return the
|
||||
/// position it carries.
|
||||
pub fn decode(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
token: &str,
|
||||
) -> crate::error::Result<TaskPosition> {
|
||||
let mut parts = token.split('.');
|
||||
let (Some(version), Some(iv_b64), Some(body_b64), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return Err(reject());
|
||||
};
|
||||
if version != CURSOR_PREFIX {
|
||||
return Err(reject());
|
||||
}
|
||||
let iv_bytes = URL_SAFE_NO_PAD.decode(iv_b64).map_err(|_| reject())?;
|
||||
let mut plaintext = URL_SAFE_NO_PAD.decode(body_b64).map_err(|_| reject())?;
|
||||
let iv: [u8; TAG_LEN] = iv_bytes.try_into().map_err(|_| reject())?;
|
||||
|
||||
apply_keystream(key, &iv, &mut plaintext);
|
||||
// Authenticate before parsing: until the tag matches, `plaintext` is just
|
||||
// attacker-chosen bytes run through a keystream. `verify_truncated_left`
|
||||
// is the constant-time compare, so a forgery attempt cannot be steered by
|
||||
// timing the first differing byte.
|
||||
cursor_mac(key, filter, caller, &plaintext)
|
||||
.verify_truncated_left(&iv)
|
||||
.map_err(|_| reject())?;
|
||||
|
||||
let decoded: CursorPayload<'_> = serde_json::from_slice(&plaintext).map_err(|_| reject())?;
|
||||
if decoded.e < chrono::Utc::now().timestamp() {
|
||||
return Err(reject());
|
||||
}
|
||||
Ok(TaskPosition::new(decoded.t, decoded.i))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> TaskCursorKey {
|
||||
TaskCursorKey::derive(&[7u8; 32])
|
||||
}
|
||||
|
||||
fn unfiltered() -> TaskFilter<'static> {
|
||||
TaskFilter {
|
||||
status: None,
|
||||
assignee_did: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_position_verbatim() {
|
||||
let k = key();
|
||||
// A stored timestamp the server wrote, fractional digits and all.
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), None, &pos);
|
||||
assert_eq!(decode(&k, unfiltered(), None, &token).unwrap(), pos);
|
||||
// A sub-bucket payload pads to exactly one bucket: the padding
|
||||
// claim is an exact width, and every other width assertion here is
|
||||
// bucket-relative, so only an absolute check catches a pad that
|
||||
// skips "next multiple" or a drifted bucket constant.
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
assert_eq!(
|
||||
raw.len(),
|
||||
PAYLOAD_BUCKET,
|
||||
"normal token must pad to exactly one bucket"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the token is that the caller may hold it without
|
||||
/// learning the denied row it names. A signed-but-plaintext payload would
|
||||
/// pass every other test here and still fail this one.
|
||||
#[test]
|
||||
fn token_does_not_expose_the_row_it_names() {
|
||||
let k = key();
|
||||
let token = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00+00:00", "denied-task-id"),
|
||||
);
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
let as_text = String::from_utf8_lossy(&raw);
|
||||
for secret in ["denied-task-id", "2026-01-03", "\"t\"", "\"i\""] {
|
||||
assert!(
|
||||
!as_text.contains(secret),
|
||||
"token body must not carry {secret:?} in the clear: {as_text:?}"
|
||||
);
|
||||
}
|
||||
// Also assert it is not merely reordered or whitespace-mangled JSON.
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&raw).is_err(),
|
||||
"token body must not be parseable as JSON"
|
||||
);
|
||||
}
|
||||
|
||||
/// The keystream preserves plaintext length, so without the fixed-width
|
||||
/// padding a token naming a 35-char timestamp would be measurably longer
|
||||
/// than one naming a 32-char one: the holder would learn the withheld
|
||||
/// row's field widths without decrypting anything. Mint the same shape
|
||||
/// both ways and require identical token length.
|
||||
#[test]
|
||||
fn token_length_does_not_vary_with_position_width() {
|
||||
let k = key();
|
||||
// Ids are server-minted fixed-length UUIDs; the timestamp is the
|
||||
// width that varies in production (`to_rfc3339()` renders 35 chars
|
||||
// with nanoseconds, 25 without fractional seconds).
|
||||
let id = "b3f7a1c2-4d5e-4f6a-8b9c-0d1e2f3a4b5c";
|
||||
let narrow = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00+00:00", id),
|
||||
);
|
||||
let wide = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id),
|
||||
);
|
||||
assert_eq!(
|
||||
narrow.len(),
|
||||
wide.len(),
|
||||
"tokens must not leak the named row's field widths: {narrow} vs {wide}"
|
||||
);
|
||||
// The padded token still decodes to the exact stored string.
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), None, &wide).unwrap(),
|
||||
TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id)
|
||||
);
|
||||
}
|
||||
|
||||
/// A token minted before the fixed-width padding landed (unpadded payload,
|
||||
/// tag over the raw bytes) must still verify: `decode` MACs whatever
|
||||
/// plaintext the keystream recovers and `serde_json` tolerates the
|
||||
/// missing trailing whitespace. This pins the back-compat claim in the
|
||||
/// `encode` docstring with a frozen v1 artifact, so a change to the v1
|
||||
/// recipe pins (domains, MAC input, keystream, segment layout, field
|
||||
/// names) breaks this test instead of silently re-deriving under the
|
||||
/// new internals. It does not pin the padding: `decode` never consults
|
||||
/// `PAYLOAD_BUCKET` and the fixture is unpadded, so the padding-width
|
||||
/// claim belongs to `oversized_payload_rounds_up_to_next_bucket`.
|
||||
///
|
||||
/// Fixture expiry is fixed at 2100-01-01 (`4102444800`): it must stay in
|
||||
/// the future whenever the test runs.
|
||||
#[test]
|
||||
fn unpadded_legacy_token_still_verifies() {
|
||||
let k = key();
|
||||
let filter = unfiltered();
|
||||
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", "legacy-task");
|
||||
// Pre-padding recipe minted once and frozen: serialize with no
|
||||
// padding, tag, and encrypt. Deterministic by construction (SIV needs
|
||||
// no randomness), so this literal is stable across runs.
|
||||
let token = "v1.ILJyy7JZScm_h-HM1-CggA.l4kJUoHTbqSGTVKL0DP4hORnbOS1_gHYV2fwJYXd2QtM-6TBMCKh-wvluIiaEYC9rUSUPVSteAKkp7jBCmK_bR2D";
|
||||
assert_eq!(
|
||||
decode(&k, filter, None, token).unwrap(),
|
||||
position,
|
||||
"pre-padding token must verify under the current decode"
|
||||
);
|
||||
// The fixture is a genuine pre-padding artifact, so its decoded
|
||||
// body must stay below one bucket: a literal re-minted under the
|
||||
// padded recipe would retire this back-compat pin silently.
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
assert!(
|
||||
raw.len() < PAYLOAD_BUCKET,
|
||||
"legacy fixture must be the unpadded shape: {} bytes",
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The `PAYLOAD_BUCKET` docstring promises an oversized payload rounds up
|
||||
/// to the next multiple instead of failing. Mint a position whose id
|
||||
/// pushes the serialized payload past one bucket and require the token
|
||||
/// to round-trip (at two buckets).
|
||||
#[test]
|
||||
fn oversized_payload_rounds_up_to_next_bucket() {
|
||||
let k = key();
|
||||
// ~140-char id: payload crosses the 128-byte bucket.
|
||||
let long_id = "a".repeat(140);
|
||||
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", long_id.clone());
|
||||
let probe = serde_json::to_vec(&CursorPayload {
|
||||
t: &position.created_at,
|
||||
i: &position.id,
|
||||
e: 1_700_000_000,
|
||||
})
|
||||
.expect("cursor payload is plain strings and an integer");
|
||||
assert!(
|
||||
probe.len() > PAYLOAD_BUCKET,
|
||||
"fixture payload must exceed one bucket: {} bytes",
|
||||
probe.len()
|
||||
);
|
||||
assert!(
|
||||
probe.len() <= 2 * PAYLOAD_BUCKET,
|
||||
"fixture payload must fit in two buckets: {} bytes",
|
||||
probe.len()
|
||||
);
|
||||
let token = encode(&k, unfiltered(), None, &position);
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), None, &token).unwrap(),
|
||||
position,
|
||||
"oversized payload must round-trip at two buckets"
|
||||
);
|
||||
// The padded body must be exactly two buckets, not merely longer
|
||||
// than one: the padding claim is an exact width, not a direction.
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
assert_eq!(
|
||||
raw.len(),
|
||||
2 * PAYLOAD_BUCKET,
|
||||
"oversized payload must pad to exactly two buckets"
|
||||
);
|
||||
}
|
||||
|
||||
/// Callers paste the token straight into a query string, so it must carry
|
||||
/// no character that needs percent-encoding.
|
||||
#[test]
|
||||
fn token_is_url_safe_verbatim() {
|
||||
let token = encode(
|
||||
&key(),
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("did:key:z6MkAssignee"),
|
||||
},
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a"),
|
||||
);
|
||||
assert!(
|
||||
token
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')),
|
||||
"token must be query-safe without encoding: {token}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Flipping any byte of a token must fail the tag, not decode to a
|
||||
/// different position: the keystream is malleable on its own, the tag is
|
||||
/// what stops a chosen-position forgery.
|
||||
#[test]
|
||||
fn rejects_any_tampered_byte() {
|
||||
let k = key();
|
||||
let token = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a"),
|
||||
);
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
for part in [1usize, 2] {
|
||||
for position in [0usize, 3] {
|
||||
let mut bytes = parts[part].as_bytes().to_vec();
|
||||
bytes[position] = if bytes[position] == b'A' { b'B' } else { b'A' };
|
||||
let mut mangled: Vec<String> = parts.iter().map(|p| p.to_string()).collect();
|
||||
mangled[part] = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
decode(&k, unfiltered(), None, &mangled.join(".")).is_err(),
|
||||
"byte {position} of part {part} must not be malleable"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Sanity: the untouched token still decodes, so the loop above is not
|
||||
// passing because every token is rejected.
|
||||
assert!(decode(&k, unfiltered(), None, &token).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_token_from_another_node() {
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&TaskCursorKey::derive(&[1u8; 32]), unfiltered(), None, &pos);
|
||||
assert!(decode(
|
||||
&TaskCursorKey::derive(&[2u8; 32]),
|
||||
unfiltered(),
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cursor_moved_to_a_different_filter() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: None,
|
||||
},
|
||||
None,
|
||||
&pos,
|
||||
);
|
||||
assert!(decode(&k, unfiltered(), None, &token).is_err());
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("claimed"),
|
||||
assignee_did: None
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: None
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
/// A filter of `Some("")` must not verify a token minted with `None`.
|
||||
#[test]
|
||||
fn distinguishes_absent_filter_from_empty_filter() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), None, &pos);
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some(""),
|
||||
assignee_did: None
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// A cursor names how far a scan got under one caller's visibility. Moved
|
||||
/// to a different presenting identity it would start that caller's scan
|
||||
/// past rows they are entitled to read, so it must fail to verify rather
|
||||
/// than silently under-report (#327 review).
|
||||
#[test]
|
||||
fn rejects_cursor_moved_to_a_different_caller() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let alice = Some("did:key:z6MkAlice");
|
||||
let bob = Some("did:key:z6MkBob");
|
||||
|
||||
let anon_token = encode(&k, unfiltered(), None, &pos);
|
||||
assert!(
|
||||
decode(&k, unfiltered(), alice, &anon_token).is_err(),
|
||||
"an anonymously minted cursor must not resume an authenticated scan"
|
||||
);
|
||||
assert!(decode(&k, unfiltered(), None, &anon_token).is_ok());
|
||||
|
||||
let alice_token = encode(&k, unfiltered(), alice, &pos);
|
||||
assert!(
|
||||
decode(&k, unfiltered(), bob, &alice_token).is_err(),
|
||||
"one caller's cursor must not resume another caller's scan"
|
||||
);
|
||||
assert!(
|
||||
decode(&k, unfiltered(), None, &alice_token).is_err(),
|
||||
"an authenticated cursor must not resume an anonymous scan"
|
||||
);
|
||||
assert!(decode(&k, unfiltered(), alice, &alice_token).is_ok());
|
||||
}
|
||||
|
||||
/// The binding is on identity, not spelling: `did:key:X` and bare `X` are
|
||||
/// one caller everywhere else on the read path (`did_matches`), so a caller
|
||||
/// who presents the other form of their own DID must keep their own page.
|
||||
#[test]
|
||||
fn caller_binding_is_normalized_across_did_key_spellings() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), Some("did:key:z6MkAlice"), &pos);
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), Some("z6MkAlice"), &token).unwrap(),
|
||||
pos
|
||||
);
|
||||
// A different method sharing the base58 tail is a different principal.
|
||||
assert!(decode(&k, unfiltered(), Some("did:web:z6MkAlice"), &token).is_err());
|
||||
}
|
||||
|
||||
/// The filter binding is on identity, not spelling: `assignee_did` matches
|
||||
/// normalized keys in SQL (`normalize_owner_key`), so minting under
|
||||
/// `did:key:X` and resuming under bare `X` (or vice versa) must succeed,
|
||||
/// while `did:web:X` remains distinct.
|
||||
#[test]
|
||||
fn assignee_filter_binding_is_normalized_across_did_key_spellings() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let filter_did_key = TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("did:key:z6MkAssignee"),
|
||||
};
|
||||
let filter_bare = TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("z6MkAssignee"),
|
||||
};
|
||||
let filter_web = TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("did:web:z6MkAssignee"),
|
||||
};
|
||||
|
||||
let token_from_did_key = encode(&k, filter_did_key, None, &pos);
|
||||
assert_eq!(
|
||||
decode(&k, filter_bare, None, &token_from_did_key).unwrap(),
|
||||
pos
|
||||
);
|
||||
assert!(decode(&k, filter_web, None, &token_from_did_key).is_err());
|
||||
|
||||
let token_from_bare = encode(&k, filter_bare, None, &pos);
|
||||
assert_eq!(
|
||||
decode(&k, filter_did_key, None, &token_from_bare).unwrap(),
|
||||
pos
|
||||
);
|
||||
assert!(decode(&k, filter_web, None, &token_from_bare).is_err());
|
||||
}
|
||||
|
||||
/// Anonymous is a distinct binding from a caller whose normalized DID is
|
||||
/// the empty string, the same way `Some("")` is distinct from `None` for
|
||||
/// the filter fields.
|
||||
#[test]
|
||||
fn distinguishes_anonymous_from_empty_caller() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), None, &pos);
|
||||
assert!(decode(&k, unfiltered(), Some(""), &token).is_err());
|
||||
}
|
||||
|
||||
/// Length-prefixing must stop a status/assignee pair from being re-split.
|
||||
#[test]
|
||||
fn rejects_field_boundary_shift() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pend"),
|
||||
assignee_did: Some("ing"),
|
||||
},
|
||||
None,
|
||||
&pos,
|
||||
);
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("")
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired_token() {
|
||||
let k = key();
|
||||
// Minted the same way `encode` does, but already past its expiry.
|
||||
let mut payload = serde_json::to_vec(&CursorPayload {
|
||||
t: "2026-01-03T00:00:00+00:00",
|
||||
i: "task-a",
|
||||
e: chrono::Utc::now().timestamp() - 1,
|
||||
})
|
||||
.unwrap();
|
||||
let iv = siv(&k, unfiltered(), None, &payload);
|
||||
apply_keystream(&k, &iv, &mut payload);
|
||||
let expired = format!(
|
||||
"v1.{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(iv),
|
||||
URL_SAFE_NO_PAD.encode(&payload)
|
||||
);
|
||||
let err = decode(&k, unfiltered(), None, &expired).unwrap_err();
|
||||
assert!(err.to_string().contains(INVALID_CURSOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_shapes() {
|
||||
let k = key();
|
||||
for bad in [
|
||||
"",
|
||||
"v1",
|
||||
"v1.",
|
||||
"v1.abc",
|
||||
"v2.abc.def",
|
||||
"v1.abc.def.ghi",
|
||||
"v1.!!!.def",
|
||||
] {
|
||||
assert!(
|
||||
decode(&k, unfiltered(), None, bad).is_err(),
|
||||
"must reject {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every rejection path renders the same text, so a caller cannot tell a
|
||||
/// forged token from an expired one from a filter mismatch.
|
||||
#[test]
|
||||
fn every_rejection_is_indistinguishable() {
|
||||
let k = key();
|
||||
for bad in ["v1.abc.def", "not-a-cursor", "v1..", "v9.a.b"] {
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), None, bad).unwrap_err().to_string(),
|
||||
format!("invalid request: {INVALID_CURSOR}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2835
-118
File diff suppressed because it is too large.
Load diff
@@ -634,10 +634,12 @@ mod tests {
|
||||
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
|
||||
.expect("lazy pool creation should not fail");
|
||||
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
|
||||
let task_cursor_key = crate::api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
|
||||
let schema = Arc::new(graphql::build_schema(
|
||||
db.clone(),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
task_cursor_key.clone(),
|
||||
));
|
||||
crate::state::AppState {
|
||||
config: Arc::new(Config::parse_from(["gitlawb-node"])),
|
||||
@@ -649,6 +651,7 @@ mod tests {
|
||||
ref_update_tx: ref_tx,
|
||||
task_event_tx: task_tx,
|
||||
graphql_schema: schema,
|
||||
task_cursor_key,
|
||||
machine_id: None,
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
@@ -668,6 +671,7 @@ mod tests {
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
federated_rate_limiter: RateLimiter::new_bounded(12, Duration::from_secs(60), 10_000),
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
@@ -683,6 +687,7 @@ mod tests {
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_ipfs_walk_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
task_read_rate_limiter: RateLimiter::new(1200, Duration::from_secs(3600)),
|
||||
git_bin: "git".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
+416
-10
@@ -11,6 +11,74 @@ use uuid::Uuid;
|
||||
use crate::db::RefCertificate;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Build the v1 signing payload — the EXACT bytes the node signs and
|
||||
/// gl's `verify_signature` reconstructs. This is the single source
|
||||
/// of truth shared by `issue_ref_certificate` and the frozen
|
||||
/// canonical-form tests below; drift here is what would break every
|
||||
/// existing cert.
|
||||
///
|
||||
/// Field order and key set are fixed: a default `serde_json::Value`
|
||||
/// serializes `Map<String, Value>` with sorted keys, so any change to
|
||||
/// the literal is observable as a different byte sequence on the
|
||||
/// wire.
|
||||
///
|
||||
/// v1 is the unversioned legacy shape: no `version` key in the signed
|
||||
/// JSON. From v2 onward the version belongs INSIDE the signed bytes
|
||||
/// (see `v2_signing_payload`): leaving it as an unsigned sibling
|
||||
/// would let a future v2 cert be downgraded to v1 and still verify
|
||||
/// cleanly, since the bytes would be identical.
|
||||
pub(crate) fn v1_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the v2 signing payload — the future shape for versioned
|
||||
/// certs. Identical to v1 except the wire `version` is part of the
|
||||
/// signed bytes, so stripping or flipping the `version` column
|
||||
/// invalidates the signature instead of verifying cleanly under the
|
||||
/// other version's path.
|
||||
///
|
||||
/// NOT yet issued: `issue_ref_certificate` still stamps v1 because
|
||||
/// the shipped `gl` verifier only supports v1. This builder exists
|
||||
/// to pin the downgrade-resistant shape now, before any v2 cert is
|
||||
/// ever signed.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn v2_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
"version": 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a signed ref-update certificate for a successful push.
|
||||
///
|
||||
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
|
||||
@@ -26,16 +94,12 @@ pub async fn issue_ref_certificate(
|
||||
let node_did = state.node_did.to_string();
|
||||
let issued_at = Utc::now().to_rfc3339();
|
||||
|
||||
// Build the canonical signing payload.
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
});
|
||||
// Build the canonical signing payload via the shared builder so
|
||||
// the frozen-vector test below and the live signer cannot drift
|
||||
// apart — a regression in either side fails both.
|
||||
let payload = v1_signing_payload(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher_did, &node_did, &issued_at,
|
||||
);
|
||||
let payload_bytes = serde_json::to_vec(&payload)?;
|
||||
|
||||
let signature = state.node_keypair.sign_b64(&payload_bytes);
|
||||
@@ -50,9 +114,351 @@ pub async fn issue_ref_certificate(
|
||||
node_did,
|
||||
signature,
|
||||
issued_at,
|
||||
// #26 Split PR 3: the wire-format version. New certs stamp
|
||||
// v1 — the pre-versioning 7-field payload with no `version`
|
||||
// key in the signed JSON — because the shipped `gl`
|
||||
// verifier only supports v1. Stamping v2 here would issue
|
||||
// certificates the shipped client refuses (round-3
|
||||
// finding: node pinned `version == 2` while gl pinned
|
||||
// "2 is refused", with nothing exercising the
|
||||
// composition). v2 is reserved for the future shape
|
||||
// defined by `v2_signing_payload`, which carries the
|
||||
// version INSIDE the signed bytes; land the v2 verify
|
||||
// path before stamping v2.
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// Persist and return the row as it exists in the database (on a
|
||||
// conflict the existing row survives when it is newer).
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod v1_payload_tests {
|
||||
//! #26 Split PR 3 — cert payload version compat.
|
||||
//!
|
||||
//! The v1 cert payload is the frozen canonical byte form the
|
||||
//! node signs. gl's `verify_signature` reconstructs the same
|
||||
//! payload and verifies. Any drift in field order, key set, or
|
||||
//! whitespace breaks every existing cert. The tests here pin
|
||||
//! the v1 payload shape, the round-trip, the cert id
|
||||
//! determinism per the v1 version, and the v2 read forward
|
||||
//! compat (an unknown version refuses to verify rather than
|
||||
//! guessing).
|
||||
//!
|
||||
//! Reviewer 1 finding: the live signer (`issue_ref_certificate`)
|
||||
//! and the frozen-vector test (`v1_payload_matches_frozen_canonical_form`)
|
||||
//! previously held two separate `serde_json::json!` literals,
|
||||
//! so a regression in the signer could not fail this test.
|
||||
//! Both now go through the shared `v1_signing_payload` builder.
|
||||
use super::{v1_signing_payload, v2_signing_payload};
|
||||
use crate::db::RefCertificate;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
/// v1 payload is the same shape the legacy code signed, byte
|
||||
/// for byte. Reverting this assertion to the v2 shape (which
|
||||
/// includes a `version` key) is what the versioned format
|
||||
/// forbids: an old client reading a v1 cert must not see a
|
||||
/// `version` key in the signed JSON.
|
||||
///
|
||||
/// The literal is built via the shared `v1_signing_payload`
|
||||
/// helper that `issue_ref_certificate` also uses, so the live
|
||||
/// signer and the frozen vector share one source of truth.
|
||||
#[test]
|
||||
fn v1_payload_matches_frozen_canonical_form() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let frozen = format!(
|
||||
r#"{{"new":"{}","node":"did:key:z6MkNode","old":"{}","pusher":"did:key:z6MkPusher","ref":"refs/heads/main","repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}}"#,
|
||||
"a".repeat(40),
|
||||
"0".repeat(40),
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
|
||||
}
|
||||
|
||||
/// Round-trip: sign the v1 payload with a keypair, build a
|
||||
/// RefCertificate with version: 1, and verify the structure.
|
||||
/// This is the construction the live code does on every push.
|
||||
///
|
||||
/// Uses the shared builder for both halves so a regression in
|
||||
/// the live signer (adding/removing/renaming a field) cannot
|
||||
/// pass this test by accident.
|
||||
#[test]
|
||||
fn v1_ref_certificate_structure_is_well_formed() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let payload_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let sig = kp.sign_b64(&payload_bytes);
|
||||
let cert = RefCertificate {
|
||||
id: "cert-id".into(),
|
||||
repo_id: "repo-1".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a".repeat(40),
|
||||
pusher_did: "did:key:z6MkPusher".into(),
|
||||
node_did: node_did.clone(),
|
||||
signature: sig.clone(),
|
||||
issued_at: "2026-07-22T00:00:00+00:00".into(),
|
||||
version: 1,
|
||||
};
|
||||
assert_eq!(cert.version, 1, "new certs carry version: 1");
|
||||
// The signed payload reconstructs identically: gl's
|
||||
// verify_signature would build the same JSON, hash the
|
||||
// same bytes, and verify the same signature.
|
||||
let reconstructed = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let reconstructed_bytes = serde_json::to_vec(&reconstructed).unwrap();
|
||||
assert_eq!(
|
||||
reconstructed_bytes, payload_bytes,
|
||||
"the round-trip serialization must be byte-identical; \
|
||||
this is what makes gl's verify_signature succeed"
|
||||
);
|
||||
// Round 3 (P3 reviewer): byte-equality alone never calls
|
||||
// `identity::verify`, so a bad signature helper would pass.
|
||||
// Verify the computed signature against the reconstructed
|
||||
// bytes — the same check gl performs.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &reconstructed_bytes, &sig_bytes)
|
||||
.expect("well-formed v1 cert signature must verify");
|
||||
}
|
||||
|
||||
/// Reviewer 1 finding: the live signer must share the
|
||||
/// canonical-form literal so a regression in either side
|
||||
/// fails both. This test pins the shared builder's output
|
||||
/// against the literal a future v2 byte stream would have
|
||||
/// to break — if someone adds a `version` key to
|
||||
/// `v1_signing_payload`, every existing cert breaks verify,
|
||||
/// and this test is one of the canaries that fires.
|
||||
#[test]
|
||||
fn v1_signing_payload_has_no_version_key() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let obj = payload.as_object().expect("payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"v1 signing payload must not carry a `version` key — adding \
|
||||
one changes the bytes and breaks every existing cert's \
|
||||
signature. v2 certs build a different payload, not this one"
|
||||
);
|
||||
// Pin the exact set so a future field addition is caught.
|
||||
let expected: std::collections::BTreeSet<&str> =
|
||||
["new", "node", "old", "pusher", "ref", "repo_id", "ts"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
|
||||
assert_eq!(actual, expected, "v1 payload key set is frozen");
|
||||
}
|
||||
|
||||
/// Round 3 (P2 reviewer): from v2 onward the version belongs
|
||||
/// INSIDE the signed bytes. v1 stays the unversioned legacy
|
||||
/// shape; v2 carries `"version": 2` in the payload so a
|
||||
/// version flip without re-signing invalidates the signature
|
||||
/// instead of verifying cleanly under the other path.
|
||||
#[test]
|
||||
fn v2_signing_payload_binds_version_and_resists_downgrade() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let ts = "2026-07-22T00:00:00+00:00";
|
||||
let v1 = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2 = v2_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2_obj = v2.as_object().expect("v2 payload is a JSON object");
|
||||
assert_eq!(
|
||||
v2_obj.get("version"),
|
||||
Some(&serde_json::json!(2)),
|
||||
"v2 payload must carry the version inside the signed bytes"
|
||||
);
|
||||
let v1_bytes = serde_json::to_vec(&v1).unwrap();
|
||||
let v2_bytes = serde_json::to_vec(&v2).unwrap();
|
||||
assert_ne!(
|
||||
v1_bytes, v2_bytes,
|
||||
"v1 and v2 payloads must differ, otherwise a version flip is cryptographically invisible"
|
||||
);
|
||||
|
||||
// A signature over v1 must NOT verify as v2 and vice versa.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_v1 = kp.sign_b64(&v1_bytes);
|
||||
let sig_v1_bytes: [u8; 64] = URL_SAFE_NO_PAD.decode(&sig_v1).unwrap().try_into().unwrap();
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v1_bytes, &sig_v1_bytes).is_ok(),
|
||||
"v1 signature must verify under v1 bytes"
|
||||
);
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v2_bytes, &sig_v1_bytes).is_err(),
|
||||
"a v1 signature presented as v2 (downgrade/upgrade flip) must not verify"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live issuer stamps the version the shipped client can
|
||||
/// verify. The gl client refuses to verify v2 certs explicitly;
|
||||
/// this test pins that the version the ISSUING PATH claims
|
||||
/// agrees with the payload shape it actually signs.
|
||||
///
|
||||
/// Round 2: the prior form built a `RefCertificate` literal with
|
||||
/// `version: 1` and asserted `version == 1` — true no matter what the
|
||||
/// signer does, so flipping the issuer's version (or its payload shape)
|
||||
/// left it green. This one drives the actual
|
||||
/// `issue_ref_certificate` so a regression in the issuer flips the test.
|
||||
/// The DB row is created against `test_support::test_state`, which
|
||||
/// gives us a real `AppState` with a real node keypair.
|
||||
///
|
||||
/// Round 3: the issuer stamps v1 because the shipped `gl`
|
||||
/// verifier only supports v1 (stamping v2 shipped certs the
|
||||
/// client refuses). The binding to the live issuer is kept —
|
||||
/// flipping the stamp still turns this test red — without
|
||||
/// changing what production issues.
|
||||
#[sqlx::test]
|
||||
#[allow(clippy::async_yields_async)]
|
||||
async fn issuer_stamps_v1_over_v1_payload(pool: sqlx::PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let repo_id = "repo-issuer-observe";
|
||||
let ref_name = "refs/heads/main";
|
||||
let old = "0".repeat(40);
|
||||
let new = "a".repeat(40);
|
||||
let pusher = "did:key:z6MkPusher";
|
||||
|
||||
// Drive the live issuer and read the persisted row back by
|
||||
// id, so the test covers the stored `version` column and
|
||||
// not just the in-process return value.
|
||||
let cert =
|
||||
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
|
||||
.await
|
||||
.expect("issue_ref_certificate must succeed");
|
||||
|
||||
// The cert returned by the issuer is the source of truth
|
||||
// for both the version claim and the signed bytes.
|
||||
assert_eq!(cert.repo_id, repo_id);
|
||||
assert_eq!(cert.ref_name, ref_name);
|
||||
|
||||
// Cross-check 1: the issuer's claimed version must match
|
||||
// what the live function stamps. This assertion is bound
|
||||
// to the live function, not a hand-built literal:
|
||||
// flipping the stamp in `issue_ref_certificate` breaks it
|
||||
// through the actual call path.
|
||||
assert_eq!(
|
||||
cert.version, 1,
|
||||
"the live issuer must stamp v1 until a v2 verifier ships; \
|
||||
flipping the stamp breaks this assertion through the actual call path"
|
||||
);
|
||||
|
||||
// Cross-check 1b: the persisted row carries the same stamp.
|
||||
let stored = state
|
||||
.db
|
||||
.get_ref_certificate(&cert.id)
|
||||
.await
|
||||
.expect("persisted cert must be readable")
|
||||
.expect("persisted cert must exist");
|
||||
assert_eq!(
|
||||
stored.version, cert.version,
|
||||
"the stored version column must match the issued claim"
|
||||
);
|
||||
|
||||
// Cross-check 2: the signature on the cert must verify
|
||||
// against the v1 payload builder's bytes, not against some
|
||||
// other shape the issuer might have introduced. This is
|
||||
// what gl's `verify_signature` does for a v1 cert: rebuild
|
||||
// the payload from the cert's own fields and verify the
|
||||
// signature. If the issuer ever signs a different shape
|
||||
// while still claiming v1, every shipped client breaks —
|
||||
// and so does this.
|
||||
let rebuilt = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let vk = cert
|
||||
.node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &serde_json::to_vec(&rebuilt).unwrap(), &sig_bytes)
|
||||
.expect("a v1 cert's signature must verify against the v1 payload shape");
|
||||
|
||||
// Cross-check 3: the v1 signed payload must NOT contain a
|
||||
// version key — v2 is the future shape that adds one inside
|
||||
// the signed bytes (see `v2_signing_payload`). The v1
|
||||
// payload (which the cert is signed over) is frozen at 7
|
||||
// fields. A change that adds `version` to the v1 payload
|
||||
// breaks every existing cert and this test.
|
||||
let obj = rebuilt
|
||||
.as_object()
|
||||
.expect("rebuilt v1 payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"the v1 signing payload must not carry a version key — \
|
||||
adding one changes the bytes and breaks every existing \
|
||||
cert's signature"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,12 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_PUBLIC_URL")]
|
||||
pub public_url: Option<String>,
|
||||
|
||||
/// Base URL for the web view of repos on this node (e.g. https://gitlawb.com).
|
||||
/// When set, `GET /` advertises it as `web_url` so the CLI can print a
|
||||
/// working `View:` link. Omit for nodes that have no web front-end.
|
||||
#[arg(long, env = "GITLAWB_WEB_URL")]
|
||||
pub web_url: Option<String>,
|
||||
|
||||
/// Comma-separated list of bootstrap peer URLs to announce to on startup
|
||||
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
|
||||
pub bootstrap_peers: Vec<String>,
|
||||
@@ -756,6 +762,21 @@ pub struct Config {
|
||||
value_parser = clap::builder::RangedU64ValueParser::<u64>::new().range(0..=86_400)
|
||||
)]
|
||||
pub pin_repair_sweep_delay_secs: u64,
|
||||
|
||||
/// Per-client-IP rate limit for the anonymous task read routes
|
||||
/// (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`), in requests per hour.
|
||||
/// Both are publicly reachable (`optional_signature`). `GET /api/v1/tasks`
|
||||
/// runs `collect_visible_tasks` and returns a visibility-filtered page;
|
||||
/// `GET /api/v1/tasks/{id}` runs `get_visible_task` and returns an opaque 404
|
||||
/// when the task is hidden or missing. These reads can require task,
|
||||
/// repository, and visibility-rule queries even when no task is returned,
|
||||
/// so the brake bounds the cost of anonymous probes. Keyed on the resolved
|
||||
/// client IP via `GITLAWB_TRUSTED_PROXY`. `0` disables. Default: 1200 (a list page
|
||||
/// followed by per-task reads is a normal client pattern, so this sits above
|
||||
/// the `/ipfs` budget). GraphQL `tasks` / `task` queries, including WebSocket
|
||||
/// operations, share this per-IP budget with REST task reads.
|
||||
#[arg(long, env = "GITLAWB_TASK_READ_RATE_LIMIT", default_value_t = 1200)]
|
||||
pub task_read_rate_limit: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -803,7 +824,13 @@ impl Config {
|
||||
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
|
||||
/// catches combinations that ship a denial-of-service under otherwise-valid
|
||||
/// values. Call once at startup and fail fast on `Err`.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
///
|
||||
/// `&mut self` is required so the validator can normalize `web_url` to
|
||||
/// its canonical form after validation: the configured value is
|
||||
/// advertised on public `GET /`, and any consumer reading
|
||||
/// `config.web_url` later must see the same string that was actually
|
||||
/// validated.
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
// Concurrent git writes pin the dedicated lock pool for their whole
|
||||
// duration (connection-affine advisory lock in acquire_write). The main
|
||||
// pool no longer carries that occupancy.
|
||||
@@ -920,10 +947,101 @@ impl Config {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// GITLAWB_WEB_URL is advertised in GET / so the CLI can print a working
|
||||
// View: link. Clap maps "" to Some("") which would produce a broken URL;
|
||||
// reject early instead of serving a malformed link. Non-blank values must
|
||||
// additionally parse as an absolute http(s) URL — the CLI appends
|
||||
// `/{owner}/{repo}` to it, so scheme-less hosts ("gitlawb.com") and other
|
||||
// garbage would render links no browser can follow.
|
||||
if let Some(raw) = &self.web_url {
|
||||
match validate_web_url(raw) {
|
||||
Ok(()) => {}
|
||||
Err(reason) => {
|
||||
return Err(format!(
|
||||
"GITLAWB_WEB_URL {reason} — \
|
||||
set it to an absolute URL like https://gitlawb.com or leave it unset."
|
||||
));
|
||||
}
|
||||
}
|
||||
// Normalize to one canonical form so every consumer (the regular
|
||||
// and degraded `GET /` handlers, anyone who reads
|
||||
// `config.web_url` directly) sees the same string that was
|
||||
// actually validated. The CLI's own defensive trim would paper
|
||||
// over the divergence on the `View:` line, but that hides the
|
||||
// bug from any other consumer that parses the value verbatim.
|
||||
let canonical = raw
|
||||
.trim()
|
||||
.parse::<url::Url>()
|
||||
.map(|u| u.to_string())
|
||||
.map_err(|_| "is not a valid absolute URL".to_string())?;
|
||||
if canonical != *raw {
|
||||
self.web_url = Some(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a `web_url` value for use as a browser-reachable base URL.
|
||||
/// Blank values are rejected (clap maps `--web-url ""` to `Some("")`), and
|
||||
/// non-blank values must parse as absolute `http`/`https` URLs with no
|
||||
/// query, fragment, or userinfo — the CLI treats this as a string prefix
|
||||
/// to append paths to, so anything else produces links no browser can
|
||||
/// follow (`?a=1/owner/repo` puts the repo path inside the query string);
|
||||
/// and userinfo is rejected outright because the configured value is
|
||||
/// published on public `GET /` and reprinted on the CLI's `View:` line,
|
||||
/// so an accidentally configured password would land in public metadata,
|
||||
/// terminal scrollback, and CI logs.
|
||||
pub(crate) fn validate_web_url(raw: &str) -> Result<(), String> {
|
||||
if raw.trim().is_empty() {
|
||||
return Err("must not be empty or whitespace-only".into());
|
||||
}
|
||||
let parsed: url::Url = raw
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "is not a valid absolute URL".to_string())?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => return Err(format!("must use http or https, got '{other}:'")),
|
||||
}
|
||||
// url::Url cannot represent a URL without a host for http/https schemes, so
|
||||
// reaching here guarantees an absolute browser-usable base.
|
||||
if parsed.query().is_some() {
|
||||
return Err(
|
||||
"must not contain a query string — it is used as a path prefix for View links".into(),
|
||||
);
|
||||
}
|
||||
if parsed.fragment().is_some() {
|
||||
return Err(
|
||||
"must not contain a fragment — it is used as a path prefix for View links".into(),
|
||||
);
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(
|
||||
"must not contain a username or password — it is advertised publicly and printed on the View: line"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Bidi/control bytes can hide behind percent-encoding: a literal scan of
|
||||
// the wire form misses `.../%E2%80%AE` even though a browser decodes it
|
||||
// into a reordering character on the rendered View: link. Check the
|
||||
// decoded host and path before the value is advertised on GET / (INV-6).
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
let decoded_path = percent_encoding::percent_decode_str(parsed.path()).decode_utf8_lossy();
|
||||
if host
|
||||
.chars()
|
||||
.chain(decoded_path.chars())
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
|
||||
{
|
||||
return Err(
|
||||
"must not contain control or bidi characters, even percent-encoded — the View: line is terminal output"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1566,7 +1684,7 @@ mod tests {
|
||||
.expect("default config must validate");
|
||||
|
||||
// An under-sized lock pool relative to the push cap is rejected.
|
||||
let under_lock = Config::parse_from([
|
||||
let mut under_lock = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"16",
|
||||
@@ -1579,7 +1697,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Exactly at the push cap without non-push headroom is rejected.
|
||||
let push_only = Config::parse_from([
|
||||
let mut push_only = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"32",
|
||||
@@ -1592,7 +1710,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
|
||||
let split = Config::parse_from([
|
||||
let mut split = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"16",
|
||||
@@ -1607,13 +1725,153 @@ mod tests {
|
||||
);
|
||||
|
||||
// Main pool still needs the app headroom floor.
|
||||
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
let mut under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
assert!(
|
||||
under_main.validate().is_err(),
|
||||
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_url_rejects_empty_and_whitespace() {
|
||||
// Unset is fine.
|
||||
Config::parse_from(["gitlawb-node"])
|
||||
.validate()
|
||||
.expect("no web_url must validate");
|
||||
|
||||
// A real URL validates.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("https://gitlawb.com".into());
|
||||
assert!(cfg.validate().is_ok());
|
||||
|
||||
// Empty string is rejected.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("".into());
|
||||
assert!(cfg.validate().is_err(), "empty web_url must be rejected");
|
||||
|
||||
// Whitespace-only is rejected.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" ".into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"whitespace-only web_url must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_url_rejects_non_absolute_or_non_browser_urls() {
|
||||
// Scheme-less host: parses nowhere, renders a broken View: link.
|
||||
for bad in [
|
||||
"gitlawb.com",
|
||||
"not a url",
|
||||
"ftp://files.example.com",
|
||||
"javascript:alert(1)",
|
||||
"https://", // no host
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(cfg.validate().is_err(), "web_url {bad:?} must be rejected");
|
||||
}
|
||||
|
||||
// Query strings and fragments corrupt the appended repo path, since
|
||||
// web_url is used as a raw string prefix (`?a=1` would swallow
|
||||
// `/{owner}/{repo}` into the query).
|
||||
for bad in [
|
||||
"https://gitlawb.com?a=1",
|
||||
"https://gitlawb.com/?utm_source=docs",
|
||||
"https://gitlawb.com#section",
|
||||
"https://gitlawb.com/#faq",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (query or fragment) must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// Surrounding whitespace around a valid URL is tolerated.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" https://gitlawb.com ".into());
|
||||
assert!(
|
||||
cfg.validate().is_ok(),
|
||||
"padded-but-valid web_url must validate"
|
||||
);
|
||||
|
||||
// Percent-encoded bidi must not boot: the wire form carries no Cf
|
||||
// code point, but a browser decodes %E2%80%AE into a reordering
|
||||
// character on the rendered View: link (INV-6). Literal bidi and
|
||||
// control bytes are rejected the same way.
|
||||
for bad in [
|
||||
"https://example.com/%E2%80%AE",
|
||||
"https://example.com/\u{202e}spoof",
|
||||
"https://example.com/%1b[31m",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (control or bidi, even encoded) must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// Non-default ports and paths are fine.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("http://localhost:8080/ui".into());
|
||||
assert!(cfg.validate().is_ok(), "port+path web_url must validate");
|
||||
|
||||
// Userinfo must be rejected: `web_url` is published on public
|
||||
// `GET /` and reprinted on the CLI's `View:` line, so an
|
||||
// accidentally configured password would land in public metadata,
|
||||
// terminal scrollback, and CI logs. Reject username-only and
|
||||
// user+password forms.
|
||||
for bad in [
|
||||
"https://[email protected]",
|
||||
"https://user:[email protected]",
|
||||
"https://:[email protected]",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (userinfo) must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `validate()` accepts whitespace-padded input but the stored value
|
||||
/// must match the canonical form actually validated, so every consumer
|
||||
/// (the regular and degraded `GET /` handlers, anyone who reads
|
||||
/// `config.web_url` directly) sees the same string. Without this, an
|
||||
/// operator that sets `GITLAWB_WEB_URL=" https://git.example "` boots
|
||||
/// a node that advertises `" https://git.example "` on public
|
||||
/// metadata — different from what was validated, different from what
|
||||
/// any other consumer would parse.
|
||||
#[test]
|
||||
fn web_url_is_normalized_to_canonical_form_after_validation() {
|
||||
// Padded input is rewritten to the canonical form. `url::Url`'s
|
||||
// own `to_string` is what we use for the canonicalization, and
|
||||
// it always appends a path-separator slash to a bare-host URL,
|
||||
// so the stored value is `https://git.example/` (which is also
|
||||
// what every browser would normalize the operator-typed form to).
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" https://git.example ".into());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(
|
||||
cfg.web_url.as_deref(),
|
||||
Some("https://git.example/"),
|
||||
"stored web_url must be the canonical form, not the operator-typed one"
|
||||
);
|
||||
|
||||
// Already-canonical input is left alone after a no-op
|
||||
// re-normalization — same form comes back out, so any consumer
|
||||
// reading the stored value sees a stable string across calls.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("https://git.example/".into());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(cfg.web_url.as_deref(), Some("https://git.example/"));
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1878,7 +2136,7 @@ mod tests {
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let key_path = link.join("p2p.key");
|
||||
|
||||
let config = Config::parse_from([
|
||||
let mut config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--p2p-port",
|
||||
"0",
|
||||
@@ -1973,7 +2231,7 @@ mod tests {
|
||||
let key_path = build(base.path());
|
||||
let before = snapshot(base.path());
|
||||
|
||||
let config = Config::parse_from([
|
||||
let mut config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--p2p-port",
|
||||
"7546",
|
||||
|
||||
+1110
-64
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,9 @@ pub enum AppError {
|
||||
#[error("repo already exists: {0}")]
|
||||
RepoExists(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
@@ -101,7 +104,7 @@ pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
pub(crate) fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
sqlx::Error::PoolTimedOut
|
||||
@@ -189,6 +192,7 @@ impl IntoResponse for AppError {
|
||||
"repo_exists",
|
||||
format!("repository '{r}' already exists"),
|
||||
),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
|
||||
@@ -321,6 +325,16 @@ pub type Result<T> = std::result::Result<T, AppError>;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conflict_maps_to_409() {
|
||||
assert_eq!(
|
||||
AppError::Conflict("task not claimable".into())
|
||||
.into_response()
|
||||
.status(),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504_distinct_from_git_500() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
|
||||
// Curated client-safe variants — `Display` is intentional API text.
|
||||
safe @ (crate::error::AppError::RepoNotFound(_)
|
||||
| crate::error::AppError::RepoExists(_)
|
||||
| crate::error::AppError::Conflict(_)
|
||||
| crate::error::AppError::NotFound(_)
|
||||
| crate::error::AppError::Unauthorized(_)
|
||||
| crate::error::AppError::Forbidden(_)
|
||||
@@ -76,15 +77,81 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a failed `claim_task` for GraphQL exactly as REST's claim handler
|
||||
/// classifies it: a lost race is a client-safe conflict, a real sqlx fault
|
||||
/// stays opaque. Lives here rather than at the three call sites so the
|
||||
/// classification cannot drift between transports (#327 review), and so
|
||||
/// `every_graphql_map_err_uses_opaque_helpers` can keep whitelisting by name.
|
||||
pub(crate) fn graphql_claim_conflict(e: anyhow::Error) -> async_graphql::Error {
|
||||
graphql_app_err(crate::api::tasks::task_write_conflict(
|
||||
e,
|
||||
"task not claimable: not found or already claimed",
|
||||
))
|
||||
}
|
||||
|
||||
/// The `finish_task` half of [`graphql_claim_conflict`], covering both
|
||||
/// `completeTask` and `failTask`.
|
||||
pub(crate) fn graphql_finish_conflict(e: anyhow::Error) -> async_graphql::Error {
|
||||
graphql_app_err(crate::api::tasks::task_write_conflict(
|
||||
e,
|
||||
"task not found or not in claimed state",
|
||||
))
|
||||
}
|
||||
|
||||
pub struct TaskReadBrakeExtension;
|
||||
|
||||
impl async_graphql::extensions::ExtensionFactory for TaskReadBrakeExtension {
|
||||
fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
|
||||
Arc::new(TaskReadBrakeExtensionImpl)
|
||||
}
|
||||
}
|
||||
|
||||
use async_graphql::async_trait::async_trait;
|
||||
|
||||
struct TaskReadBrakeExtensionImpl;
|
||||
|
||||
#[async_trait]
|
||||
impl async_graphql::extensions::Extension for TaskReadBrakeExtensionImpl {
|
||||
async fn prepare_request(
|
||||
&self,
|
||||
ctx: &async_graphql::extensions::ExtensionContext<'_>,
|
||||
mut request: async_graphql::Request,
|
||||
next: async_graphql::extensions::NextPrepareRequest<'_>,
|
||||
) -> async_graphql::ServerResult<async_graphql::Request> {
|
||||
if let Some(session_brake) = ctx
|
||||
.session_data
|
||||
.get(&std::any::TypeId::of::<crate::rate_limit::TaskReadBrake>())
|
||||
.and_then(|d| d.downcast_ref::<crate::rate_limit::TaskReadBrake>())
|
||||
{
|
||||
request = request.data(crate::rate_limit::TaskReadBrake {
|
||||
limiter: session_brake.limiter.clone(),
|
||||
key: session_brake.key.clone(),
|
||||
request_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
});
|
||||
}
|
||||
if let Some(did) = ctx
|
||||
.session_data
|
||||
.get(&std::any::TypeId::of::<crate::auth::AuthenticatedDid>())
|
||||
.and_then(|d| d.downcast_ref::<crate::auth::AuthenticatedDid>())
|
||||
{
|
||||
request = request.data(did.clone());
|
||||
}
|
||||
next.run(ctx, request).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_schema(
|
||||
db: Arc<Db>,
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
task_cursor_key: crate::api::task_cursor::TaskCursorKey,
|
||||
) -> GitlawbSchema {
|
||||
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||
.data(db)
|
||||
.data(ref_update_tx)
|
||||
.data(task_event_tx)
|
||||
.data(task_cursor_key)
|
||||
.extension(TaskReadBrakeExtension)
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -161,8 +228,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
/// through one of the curated helpers in this module, or discard the error
|
||||
/// (`|_|`). Same source-scrape pattern as `api::authz_guard` (#255
|
||||
/// review). The list is deliberately a whitelist of names rather than a
|
||||
/// prefix match, so adding a new mapper is a decision a reviewer sees.
|
||||
#[test]
|
||||
fn every_graphql_map_err_uses_opaque_helpers() {
|
||||
for (file, src) in [
|
||||
@@ -177,6 +246,8 @@ mod tests {
|
||||
let after = code[idx + ".map_err(".len()..].trim_start();
|
||||
let ok = after.starts_with("crate::graphql::graphql_db_err")
|
||||
|| after.starts_with("crate::graphql::graphql_app_err")
|
||||
|| after.starts_with("crate::graphql::graphql_claim_conflict")
|
||||
|| after.starts_with("crate::graphql::graphql_finish_conflict")
|
||||
|| after.starts_with("|_|")
|
||||
|| after.starts_with("|_ ");
|
||||
assert!(
|
||||
|
||||
@@ -36,6 +36,33 @@ impl MutationRoot {
|
||||
}
|
||||
let delegator_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// #496: same write gate as the REST handler — a repo_id naming a
|
||||
// hosted, non-quarantined repo admits tasks only from its owner, so a
|
||||
// stranger cannot file under a foreign repo id. An id naming no hosted
|
||||
// repo (unknown, or quarantined — hidden as if it did not exist) is
|
||||
// stored verbatim as an opaque label; the gate does not bind an
|
||||
// unresolved id to a repo. Task reads are caller-independent on this
|
||||
// base (the tasks resolver is not repo-scoped), before and after this
|
||||
// change. As in tasks.rs, the success-vs-denial split between a hosted
|
||||
// repo id and an unknown one is itself a one-bit oracle — a stranger
|
||||
// holding a private repo's id learns it is hosted here — but denying
|
||||
// foreign repos requires drawing exactly that line.
|
||||
if let Some(repo_id) = input.repo_id.as_deref() {
|
||||
let record = db
|
||||
.get_repo_by_id(repo_id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if let Some(record) = record {
|
||||
let quarantined = db
|
||||
.is_repo_quarantined(&record.id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if !quarantined {
|
||||
crate::api::require_repo_owner(&record, caller)
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -52,10 +79,14 @@ impl MutationRoot {
|
||||
updated_at: now,
|
||||
deadline: input.deadline,
|
||||
};
|
||||
db.create_task(&task)
|
||||
// Return the stored row, not the pre-insert input: create_task
|
||||
// normalizes the assignee (whitespace-only becomes NULL/open), and a
|
||||
// response that shows the raw input lies about the reservation.
|
||||
let stored = db
|
||||
.create_task(&task)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(AgentTaskType::from(task))
|
||||
Ok(AgentTaskType::from(stored))
|
||||
}
|
||||
|
||||
async fn claim_task(
|
||||
@@ -73,20 +104,39 @@ impl MutationRoot {
|
||||
let assignee_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
crate::api::tasks::get_claimable_task(db, &id, caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
// Same classification REST's claim handler applies: a claim race is a
|
||||
// client-safe conflict, a genuine sqlx failure stays opaque. Routing
|
||||
// both transports through `task_write_conflict` is what stops a normal
|
||||
// race from reaching a GraphQL caller as a generic database error
|
||||
// (#327 review).
|
||||
let task = db
|
||||
.claim_task(&id, &assignee_did)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: assignee_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
.map_err(crate::graphql::graphql_claim_conflict)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(assignee_did.as_str())
|
||||
.to_string();
|
||||
crate::api::tasks::announce_task_event(
|
||||
db,
|
||||
tx,
|
||||
TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
async fn complete_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -103,29 +153,43 @@ impl MutationRoot {
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: binding by_did to the signer is necessary but not
|
||||
// sufficient — only the task's assignee may finish it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
// Authorize the actor: the task must be visible to the caller (returning
|
||||
// not found for invisible tasks so existence is not leaked), and only
|
||||
// the task's assignee may finish it.
|
||||
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.map_err(crate::graphql::graphql_app_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
if !crate::api::did_matches(
|
||||
caller,
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can complete it",
|
||||
));
|
||||
}
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref())
|
||||
.finish_task(&id, "completed", input.result.as_deref(), caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "completed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
.map_err(crate::graphql::graphql_finish_conflict)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(by_did.as_str())
|
||||
.to_string();
|
||||
crate::api::tasks::announce_task_event(
|
||||
db,
|
||||
tx,
|
||||
TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "completed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
@@ -145,29 +209,44 @@ impl MutationRoot {
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: only the task's assignee may fail it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
// Authorize the actor: the task must be visible to the caller (returning
|
||||
// not found for invisible tasks so existence is not leaked), and only
|
||||
// the task's assignee may fail it.
|
||||
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.map_err(crate::graphql::graphql_app_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
if !crate::api::did_matches(
|
||||
caller,
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can fail it",
|
||||
));
|
||||
}
|
||||
let reason = input.reason.unwrap_or_default();
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
.finish_task(&id, "failed", Some(&reason), caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "failed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
.map_err(crate::graphql::graphql_finish_conflict)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(by_did.as_str())
|
||||
.to_string();
|
||||
crate::api::tasks::announce_task_event(
|
||||
db,
|
||||
tx,
|
||||
TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "failed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
}
|
||||
@@ -186,6 +265,22 @@ mod tests {
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// The `id` scalar returned by a successful `createTask` mutation.
|
||||
fn created_id(resp: &Response) -> String {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(task) = obj.get("createTask").expect("createTask key")
|
||||
else {
|
||||
panic!("createTask not an object");
|
||||
};
|
||||
let async_graphql::Value::String(id) = task.get("id").expect("id key") else {
|
||||
panic!("id not a string");
|
||||
};
|
||||
id.clone()
|
||||
}
|
||||
|
||||
/// N2: every GraphQL mutation requires a verified signer and binds the acting
|
||||
/// DID to it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// rejected; the matching signer passes the auth gate.
|
||||
@@ -249,7 +344,7 @@ mod tests {
|
||||
);
|
||||
if *name == "claimTask" {
|
||||
assert!(
|
||||
errs.contains("task not claimable"),
|
||||
errs.contains("task not found"),
|
||||
"claim race / missing task must keep its business message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
@@ -293,6 +388,210 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #327 review: `claimTask`/`completeTask`/`failTask` called
|
||||
/// `graphql_db_err` directly on db-layer business failures, so an ordinary
|
||||
/// claim race or stale finish reached GraphQL clients as a generic
|
||||
/// database error while REST clients got an actionable conflict. All three
|
||||
/// now route through the shared `task_write_conflict` classifier.
|
||||
///
|
||||
/// Deleting either `map_err` from a mutation turns this red: the message
|
||||
/// becomes the raw anyhow text instead of the fixed conflict form.
|
||||
#[sqlx::test]
|
||||
async fn task_write_races_surface_as_conflicts_not_db_errors(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let assignee = "did:key:zGQLRACEASSIGNEEAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let rival = "did:key:zGQLRACERIVALBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let delegator = "did:key:zGQLRACEDELEGATORCCCCCCCCCCCCCCCCCCCCC";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
state
|
||||
.db
|
||||
.create_repo(&crate::db::RepoRecord {
|
||||
id: "race-repo".into(),
|
||||
name: "race".into(),
|
||||
owner_did: delegator.into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/tmp/race-repo".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_task(&crate::db::AgentTask {
|
||||
id: "race-task".into(),
|
||||
repo_id: Some("race-repo".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: delegator.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The assignee wins the claim.
|
||||
let claim = |did: &str| {
|
||||
format!(r#"mutation {{ claimTask(id: "race-task", assigneeDid: "{did}") {{ id }} }}"#)
|
||||
};
|
||||
let resp = schema
|
||||
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(resp.errors.is_empty(), "first claim: {}", errors(&resp));
|
||||
|
||||
// The rival loses the race: ineligible caller receives opaque not-found.
|
||||
let resp = schema
|
||||
.execute(Request::new(claim(rival)).data(AuthenticatedDid(rival.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not found"),
|
||||
"ineligible caller after task is claimed must receive opaque not-found: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"ineligible claim is not a database failure: {errs}"
|
||||
);
|
||||
|
||||
// A second claim that reaches the conditional SQL write (e.g. assignee claiming
|
||||
// again on an already-claimed task) exercises the conflict mapper:
|
||||
let resp = schema
|
||||
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not claimable: not found or already claimed"),
|
||||
"a lost claim race must render the fixed conflict message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a claim race is not a database failure: {errs}"
|
||||
);
|
||||
|
||||
// A stale finish on an already-completed task is the same class.
|
||||
let complete = format!(
|
||||
r#"mutation {{ completeTask(id: "race-task", byDid: "{assignee}", input: {{ result: "ok" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(resp.errors.is_empty(), "first complete: {}", errors(&resp));
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not found or not in claimed state"),
|
||||
"a stale complete must render the fixed conflict message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a stale complete is not a database failure: {errs}"
|
||||
);
|
||||
|
||||
let fail = format!(
|
||||
r#"mutation {{ failTask(id: "race-task", byDid: "{assignee}", input: {{ reason: "nope" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(&fail).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not found or not in claimed state"),
|
||||
"a stale fail must render the fixed conflict message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a stale fail is not a database failure: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same classification: a genuine SQL fault on the
|
||||
/// write itself must stay opaque. Without this, routing conflicts through
|
||||
/// `task_write_conflict` could be "fixed" by making every db failure a
|
||||
/// client-visible conflict message.
|
||||
#[sqlx::test]
|
||||
async fn task_write_sql_faults_stay_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let assignee = "did:key:zGQLOPAQUEASSIGNEEAAAAAAAAAAAAAAAAAAAAA";
|
||||
let delegator = "did:key:zGQLOPAQUEDELEGATORBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
state
|
||||
.db
|
||||
.create_task(&crate::db::AgentTask {
|
||||
id: "opaque-task".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: delegator.into(),
|
||||
assignee_did: Some(assignee.into()),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fault the write itself, not the schema. Dropping a column the
|
||||
// visibility pre-check also SELECTs would fail in `get_visible_task`
|
||||
// and never reach `graphql_claim_conflict`, so this test would pass
|
||||
// against a claim mapper that leaks (#327 review). A BEFORE UPDATE
|
||||
// trigger keeps every read valid and faults only inside
|
||||
// `Db::claim_task`.
|
||||
sqlx::raw_sql(
|
||||
"CREATE FUNCTION gl_test_fault_task_update() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN RAISE EXCEPTION 'gl_test_forced_update_fault'; END;
|
||||
$fn$;
|
||||
CREATE TRIGGER gl_test_fault_task_update
|
||||
BEFORE UPDATE ON agent_tasks
|
||||
FOR EACH ROW EXECUTE FUNCTION gl_test_fault_task_update();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(
|
||||
Request::new(format!(
|
||||
r#"mutation {{ claimTask(id: "opaque-task", assigneeDid: "{assignee}") {{ id }} }}"#
|
||||
))
|
||||
.data(AuthenticatedDid(assignee.into())),
|
||||
)
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a real sqlx fault on the write must stay opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("task not claimable"),
|
||||
"a write-time sqlx fault must not be reclassified as a claim race: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("gl_test_forced_update_fault") && !errs.contains("trigger"),
|
||||
"database text leaked through the conflict mapper: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
|
||||
/// assignee, not merely signing as the by_did you pass. A signer who is not
|
||||
/// the assignee is rejected even though the by_did binding passes; the
|
||||
@@ -315,7 +614,7 @@ mod tests {
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed task");
|
||||
@@ -332,14 +631,69 @@ mod tests {
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves and passes byDid=self (so the signer
|
||||
// binding passes), but is not the assignee → rejected by authorization.
|
||||
// Stranger signs as themselves on a repo-less task they cannot see:
|
||||
// invisible task returns "task not found" so existence is not leaked.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("task not found"),
|
||||
"an invisible task must return not found, got: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// Seed a task on a public repo that stranger CAN see, but is not assignee of:
|
||||
let pub_repo = crate::db::RepoRecord {
|
||||
id: "pub-r".into(),
|
||||
name: "pub-r".into(),
|
||||
owner_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/tmp/pub-r".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&pub_repo).await.expect("create repo");
|
||||
let pub_task = crate::db::AgentTask {
|
||||
id: "task-pub".into(),
|
||||
repo_id: Some("pub-r".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state
|
||||
.db
|
||||
.create_task(&pub_task)
|
||||
.await
|
||||
.expect("seed pub task");
|
||||
state
|
||||
.db
|
||||
.claim_task("task-pub", assignee)
|
||||
.await
|
||||
.expect("claim pub task");
|
||||
|
||||
let q_pub = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ completeTask(id: "task-pub", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
|
||||
)
|
||||
};
|
||||
let resp = schema
|
||||
.execute(Request::new(q_pub(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("assignee"),
|
||||
"a non-assignee signer must be rejected: {}",
|
||||
"a non-assignee signer on a visible task must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
@@ -353,4 +707,341 @@ mod tests {
|
||||
errors(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): createTask applies the same repo-ownership gate as the
|
||||
/// REST handler — a stranger naming a hosted repo is rejected without
|
||||
/// persisting anything, while the owner files. The quarantined and
|
||||
/// unknown-id arms are pinned too, including that each accepted write
|
||||
/// stores the supplied id verbatim, so this copy of the gate cannot regress
|
||||
/// silently.
|
||||
#[sqlx::test]
|
||||
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let owner = "did:key:zGQLTASKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zGQLTASKSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves (so the signer binding passes) but
|
||||
// names the victim's repo → rejected by the ownership gate, leaking
|
||||
// nothing about the repo.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
|
||||
"a non-owner must not file under a foreign repo id (shared denial text): {errs}"
|
||||
);
|
||||
|
||||
// The denial must not persist: no task row may carry the victim's
|
||||
// repo id afterwards.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks_keyset(None, None, 50, None)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
stored
|
||||
.iter()
|
||||
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
|
||||
"a denied write must not leave a task under the foreign repo id"
|
||||
);
|
||||
|
||||
// The owner files under their own repo id.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(owner)).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"the owner should file against their repo: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// Unknown repo ids stay accepted as opaque labels.
|
||||
let q_unknown = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{stranger}", input: {{ kind: "build", capability: "repo:write", repoId: "no-such-repo-id" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q_unknown).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"an id naming no hosted repo stays an opaque label: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the unresolved id must be stored verbatim, not
|
||||
// dropped or rewritten to a repo it never named.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some("no-such-repo-id"),
|
||||
"an unresolved id must be stored verbatim as a label"
|
||||
);
|
||||
|
||||
// A quarantined repo id is treated as unknown (accepted, never a 403
|
||||
// that would confirm the id names a real repo). This pins the
|
||||
// `!quarantined` arm on this copy of the gate.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&repo.id, true)
|
||||
.await
|
||||
.expect("quarantine");
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"a quarantined repo id must not 403 (existence oracle): {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the quarantined id is stored verbatim as a label
|
||||
// too, so the accepted write is not silently dropped.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some(repo.id.as_str()),
|
||||
"a quarantined id must be stored verbatim as a label"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): a repo-lookup fault in createTask's gate must stay
|
||||
/// opaque. Dropping a column `get_repo_by_id` selects faults that lookup;
|
||||
/// the resolver must return the fixed DB message and leak no schema text
|
||||
/// (#250), as `create_task_db_error_message_is_opaque` pins for the
|
||||
/// agent_tasks fault.
|
||||
#[sqlx::test]
|
||||
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner = "did:key:zGQLTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-fault-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-fault-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop machine_id");
|
||||
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{owner}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"gate sqlx fault must be opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("machine_id") && !errs.contains("column"),
|
||||
"schema text leaked through the gate: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-assigned GraphQL claimTask must reject a stranger (no UCAN leak)
|
||||
/// and admit the reserved assignee.
|
||||
#[sqlx::test]
|
||||
async fn claim_task_honors_preassigned_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let reserved = "did:key:zGQLRESERVEDAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let thief = "did:key:zGQLTHIEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-gql-reserved".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: Some(reserved.into()),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: Some("gql-ucan-secret".into()),
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ claimTask(id: "task-gql-reserved", assigneeDid: "{actor}") {{ id status ucanToken }} }}"#
|
||||
)
|
||||
};
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q(thief)).data(AuthenticatedDid(thief.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
// The claim gate hides existence: an ineligible claimer gets the same
|
||||
// not-found as a missing task, not a reserved deny that would leak it.
|
||||
assert!(
|
||||
errs.contains("task not found"),
|
||||
"stranger claim must read as not-found, got: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("gql-ucan-secret"),
|
||||
"UCAN must not leak in GraphQL errors: {errs}"
|
||||
);
|
||||
if let Ok(data) = resp.data.into_json() {
|
||||
let s = data.to_string();
|
||||
assert!(
|
||||
!s.contains("gql-ucan-secret"),
|
||||
"UCAN must not leak in GraphQL data on failed claim: {s}"
|
||||
);
|
||||
}
|
||||
// Parity with REST: failed steal must leave the row untouched.
|
||||
let still = state
|
||||
.db
|
||||
.get_task("task-gql-reserved")
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("task exists");
|
||||
assert_eq!(
|
||||
still.status, "pending",
|
||||
"status must stay pending after steal"
|
||||
);
|
||||
assert_eq!(
|
||||
still.assignee_did.as_deref(),
|
||||
Some(reserved),
|
||||
"assignee_did must stay reserved after steal"
|
||||
);
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q(reserved)).data(AuthenticatedDid(reserved.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"reserved assignee must claim: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert_eq!(data["claimTask"]["status"], "claimed");
|
||||
assert_eq!(data["claimTask"]["ucanToken"], "gql-ucan-secret");
|
||||
}
|
||||
|
||||
/// create_task normalizes a whitespace-only assignee to NULL (open task),
|
||||
/// so the create response must show the stored row, not echo the input: a
|
||||
/// client trusting the echoed "reservation" would believe a claimable task
|
||||
/// is reserved. Before the fix the response was built from the pre-insert
|
||||
/// struct and returned the padded input verbatim.
|
||||
#[sqlx::test]
|
||||
async fn create_task_reports_stored_assignee_not_input(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let caller = "did:key:zGQLCREATORAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{caller}", input: {{ kind: "build", capability: "repo:write", assigneeDid: " " }}) {{ id assigneeDid }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q).data(AuthenticatedDid(caller.into())))
|
||||
.await;
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert!(
|
||||
data["createTask"]["assigneeDid"].is_null(),
|
||||
"whitespace-only assigneeDid must come back null (stored shape): {data}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A task seeded with a whitespace-padded assignee must still be
|
||||
/// completable by that assignee over GraphQL: REST and Db::finish_task
|
||||
/// trim the stored value before comparing, and the GraphQL gate must do
|
||||
/// the same or padded reservations finish only through REST.
|
||||
#[sqlx::test]
|
||||
async fn graphql_finish_accepts_padded_stored_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let assignee = "did:key:zGQLPADDEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-gql-padded".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORDDDDDDDDDDDDDDDDDDDDDDDDDD".into(),
|
||||
assignee_did: Some(format!("\t{assignee}\n")),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let claim = format!(
|
||||
r#"mutation {{ claimTask(id: "task-gql-padded", assigneeDid: "{assignee}") {{ id status }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(claim).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"reserved assignee must claim a padded reservation: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
let finish = format!(
|
||||
r#"mutation {{ completeTask(id: "task-gql-padded", byDid: "{assignee}", input: {{}}) {{ id status }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(finish).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.is_empty(),
|
||||
"assignee must complete a padded reservation: {errs}"
|
||||
);
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert_eq!(data["completeTask"]["status"], "completed");
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,27 @@ use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoType};
|
||||
use super::types::{AgentTaskReadType, RefUpdateType, RepoType, TaskPageType};
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
/// Debit the per-IP task-read brake for one task field, mirroring the
|
||||
/// `rate_limit_by_ip` layer on `task_read_routes`. Both surfaces run the same
|
||||
/// #268 visibility gate and pay the same queries before answering, so an
|
||||
/// anonymous prober must not get an unbraked lane by asking over GraphQL
|
||||
/// (#327 review).
|
||||
///
|
||||
/// A schema built without the brake (unit tests, subscriptions) is not braked,
|
||||
/// exactly as `rate_limit_by_ip` is a no-op without its extension.
|
||||
async fn task_read_brake(ctx: &Context<'_>, field: &str) -> Result<()> {
|
||||
match ctx.data::<crate::rate_limit::TaskReadBrake>() {
|
||||
Ok(brake) if !brake.check(field).await => Err(async_graphql::Error::new(
|
||||
crate::rate_limit::RATE_LIMIT_MESSAGE,
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
@@ -112,29 +129,73 @@ impl QueryRoot {
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0. Use `nextCursor` to read past the clamp."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
#[graphql(
|
||||
desc = "Opaque continuation token from a previous page's `nextCursor`. Must be presented with the same `status`/`assigneeDid` filter that issued it."
|
||||
)]
|
||||
cursor: Option<String>,
|
||||
) -> Result<TaskPageType> {
|
||||
use crate::api::task_cursor::{self, TaskCursorKey, TaskFilter};
|
||||
|
||||
task_read_brake(ctx, "tasks").await?;
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
|
||||
// rejects with 2201W, which would otherwise trip the opaque DB path
|
||||
// and write an error-level log on every probe (#250 review).
|
||||
let limit = limit.clamp(0, 200);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
// #268: gate rows via the same collector the REST list route uses (like
|
||||
// `ref_updates` shares `collect_visible_ref_updates` with its REST feed),
|
||||
// so the two surfaces cannot drift. The collector clamps `limit` itself,
|
||||
// including the negative-LIMIT case #250 called out for this resolver.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let key = ctx.data_unchecked::<TaskCursorKey>();
|
||||
let filter = TaskFilter {
|
||||
status: status.as_deref(),
|
||||
assignee_did: assignee_did.as_deref(),
|
||||
};
|
||||
let resume = cursor
|
||||
.as_deref()
|
||||
.map(|token| task_cursor::decode(key, filter, caller, token))
|
||||
.transpose()
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
let result = crate::api::tasks::collect_visible_tasks(
|
||||
db,
|
||||
status.as_deref(),
|
||||
assignee_did.as_deref(),
|
||||
limit,
|
||||
resume.as_ref(),
|
||||
caller,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
Ok(TaskPageType {
|
||||
next_cursor: result
|
||||
.next_position
|
||||
.as_ref()
|
||||
.map(|pos| task_cursor::encode(key, filter, caller, pos)),
|
||||
items: result
|
||||
.tasks
|
||||
.into_iter()
|
||||
.map(AgentTaskReadType::from)
|
||||
.collect(),
|
||||
has_more: result.has_more,
|
||||
incomplete: result.incomplete,
|
||||
})
|
||||
}
|
||||
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskReadType>> {
|
||||
task_read_brake(ctx, "task").await?;
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let t = db
|
||||
.get_task(&id)
|
||||
// #268: same gate as the REST get route, via the shared helper.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let t = crate::api::tasks::get_visible_task(db, &id, caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(t.map(AgentTaskType::from))
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
Ok(t.map(AgentTaskReadType::from))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +214,14 @@ mod tests {
|
||||
Arc::new(db)
|
||||
}
|
||||
|
||||
fn cursor_key() -> crate::api::task_cursor::TaskCursorKey {
|
||||
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32])
|
||||
}
|
||||
|
||||
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
super::super::build_schema(db, ref_tx, task_tx)
|
||||
super::super::build_schema(db, ref_tx, task_tx, cursor_key())
|
||||
}
|
||||
|
||||
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
|
||||
@@ -466,7 +531,7 @@ mod tests {
|
||||
async fn tasks_negative_limit_clamped(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
|
||||
let resp = anon(&schema, "{ tasks(limit: -1) { items { id } } }").await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
@@ -500,18 +565,409 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
|
||||
// Queried as the delegator, not anonymously: since #268 the task read
|
||||
// surface is visibility-gated, and an anonymous caller sees none of
|
||||
// these repo-less tasks at all. The clamp is what this test pins, so it
|
||||
// needs a caller who can legitimately see all 201 rows.
|
||||
let resp = authed(&schema, "{ tasks(limit: 5000) { items { id } } }", OWNER).await;
|
||||
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
/// Seed one repo-less task carrying a `ucan_token`, so a leak on any read
|
||||
/// surface is visible in the response body.
|
||||
async fn seed_task(db: &Db, id: &str, delegator: &str) {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: id.into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: delegator.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: Some("SECRET-UCAN-TOKEN".into()),
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// #268: the `tasks` resolver must delegate to the gated collector, not
|
||||
/// query the DB directly. A repo-less task belonging to someone else is
|
||||
/// invisible to an anonymous caller. `tasks_negative_limit_clamped` cannot
|
||||
/// catch a resolver that stops calling `collect_visible_tasks` because it
|
||||
/// seeds no rows — this seeds one, so the gate is load-bearing here.
|
||||
#[sqlx::test]
|
||||
async fn tasks_repo_less_task_hidden_from_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_task(&db, "t1", OWNER).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks { items { id } } }").await;
|
||||
assert_eq!(
|
||||
count_tasks(&resp),
|
||||
0,
|
||||
"anon must not enumerate another party's repo-less task"
|
||||
);
|
||||
assert!(
|
||||
!format!("{:?}", resp.data).contains("SECRET-UCAN-TOKEN"),
|
||||
"no ucan token may reach an anonymous caller"
|
||||
);
|
||||
}
|
||||
|
||||
/// #268 sibling for the single-task resolver: an invisible task reads as
|
||||
/// `null`, indistinguishable from one that does not exist.
|
||||
#[sqlx::test]
|
||||
async fn task_by_id_is_null_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_task(&db, "t1", OWNER).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, r#"{ task(id: "t1") { id } }"#).await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not a list");
|
||||
assert_eq!(
|
||||
obj.get("task"),
|
||||
Some(&async_graphql::Value::Null),
|
||||
"an invisible task must read as null, got {:?}",
|
||||
obj.get("task")
|
||||
);
|
||||
}
|
||||
|
||||
/// #268: `ucanToken` is absent from the read type's schema entirely, so the
|
||||
/// delegator cannot request it either. Asking for it is a validation error,
|
||||
/// which pins the redaction at the schema level rather than per-resolver.
|
||||
#[sqlx::test]
|
||||
async fn task_read_schema_has_no_ucan_token_field(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_task(&db, "t1", OWNER).await;
|
||||
let schema = schema(db);
|
||||
let resp = authed(&schema, r#"{ task(id: "t1") { id ucanToken } }"#, OWNER).await;
|
||||
assert!(
|
||||
!resp.errors.is_empty(),
|
||||
"ucanToken must not exist on the task read type"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn tasks_find_older_visible_row_behind_denied_window(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("public-repo", OWNER, "public", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let visible = crate::db::AgentTask {
|
||||
id: "visible".into(),
|
||||
repo_id: Some("public-repo".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: OWNER.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
rows.len()
|
||||
db.create_task(&visible).await.unwrap();
|
||||
for i in 0..200 {
|
||||
let mut hidden = visible.clone();
|
||||
hidden.id = format!("hidden-{i:03}");
|
||||
hidden.repo_id = None;
|
||||
hidden.created_at = "2026-01-02T00:00:00Z".into();
|
||||
hidden.updated_at = hidden.created_at.clone();
|
||||
db.create_task(&hidden).await.unwrap();
|
||||
}
|
||||
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 1) { items { id } incomplete } }").await;
|
||||
assert_eq!(count_tasks(&resp), 1);
|
||||
assert!(!task_incomplete(&resp));
|
||||
assert!(format!("{:?}", resp.data).contains("visible"));
|
||||
}
|
||||
|
||||
/// The GraphQL surface must page past a denied window on server-issued
|
||||
/// cursors alone, exactly as REST does (#327 review). Before this, the
|
||||
/// only cursor a caller could hold named the last row they saw, so the
|
||||
/// query stalled at `incomplete: true` forever and never reached
|
||||
/// `past-ceiling`.
|
||||
#[sqlx::test]
|
||||
async fn tasks_page_past_candidate_ceiling_on_server_cursors(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("public-repo", OWNER, "public", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let visible_newer = crate::db::AgentTask {
|
||||
id: "newer-visible".into(),
|
||||
repo_id: Some("public-repo".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: OWNER.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: "2026-01-03T00:00:00Z".into(),
|
||||
updated_at: "2026-01-03T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&visible_newer).await.unwrap();
|
||||
for i in 0..1500 {
|
||||
let mut hidden = visible_newer.clone();
|
||||
hidden.id = format!("hidden-{i:04}");
|
||||
hidden.repo_id = None;
|
||||
hidden.created_at = "2026-01-02T00:00:00Z".into();
|
||||
hidden.updated_at = hidden.created_at.clone();
|
||||
db.create_task(&hidden).await.unwrap();
|
||||
}
|
||||
|
||||
let mut visible_older = visible_newer.clone();
|
||||
visible_older.id = "past-ceiling".into();
|
||||
visible_older.created_at = "2026-01-01T00:00:00Z".into();
|
||||
visible_older.updated_at = visible_older.created_at.clone();
|
||||
db.create_task(&visible_older).await.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let mut cursor: Option<String> = None;
|
||||
for request in 0..10 {
|
||||
let query = match &cursor {
|
||||
Some(c) => format!(
|
||||
r#"{{ tasks(limit: 1, cursor: "{c}") {{ items {{ id }} hasMore incomplete nextCursor }} }}"#
|
||||
),
|
||||
None => {
|
||||
"{ tasks(limit: 1) { items { id } hasMore incomplete nextCursor } }".to_string()
|
||||
}
|
||||
};
|
||||
let resp = anon(&schema, &query).await;
|
||||
let rendered = format!("{:?}", resp.data);
|
||||
assert!(
|
||||
!rendered.contains("hidden-"),
|
||||
"request {request} disclosed a denied row: {rendered}"
|
||||
);
|
||||
for item in task_items(&resp) {
|
||||
let async_graphql::Value::Object(row) = item else {
|
||||
panic!("task item not an object");
|
||||
};
|
||||
seen.push(row.get("id").unwrap().to_string().replace('"', ""));
|
||||
}
|
||||
// Short page with more behind it is the scan wall, and says so.
|
||||
if task_page_bool(&resp, "hasMore") && task_items(&resp).is_empty() {
|
||||
assert!(
|
||||
task_incomplete(&resp),
|
||||
"request {request}: an empty page with rows behind it is a paused scan"
|
||||
);
|
||||
}
|
||||
match task_page_cursor(&resp) {
|
||||
Some(c) => cursor = Some(c),
|
||||
None => {
|
||||
assert!(!task_page_bool(&resp, "hasMore"));
|
||||
assert!(!task_incomplete(&resp));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec!["newer-visible".to_string(), "past-ceiling".to_string()],
|
||||
"both visible rows must be reachable using only server-issued cursors"
|
||||
);
|
||||
}
|
||||
|
||||
/// REST and GraphQL mint the same tokens from the same node key, so a
|
||||
/// cursor must not be usable against a filter it was not issued for, and
|
||||
/// must render the same single rejection every other bad cursor renders.
|
||||
#[sqlx::test]
|
||||
async fn tasks_rejects_unusable_cursor(pool: PgPool) {
|
||||
use crate::api::task_cursor::{self, TaskFilter, TaskPosition};
|
||||
|
||||
let db = db(pool).await;
|
||||
let schema = schema(db);
|
||||
|
||||
let wrong_filter = task_cursor::encode(
|
||||
&cursor_key(),
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: None,
|
||||
},
|
||||
None,
|
||||
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
|
||||
);
|
||||
let foreign = task_cursor::encode(
|
||||
&crate::api::task_cursor::TaskCursorKey::derive(&[9u8; 32]),
|
||||
TaskFilter {
|
||||
status: None,
|
||||
assignee_did: None,
|
||||
},
|
||||
None,
|
||||
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
|
||||
);
|
||||
|
||||
for (label, query) in [
|
||||
(
|
||||
"garbage",
|
||||
r#"{ tasks(cursor: "not-a-cursor") { items { id } } }"#.to_string(),
|
||||
),
|
||||
(
|
||||
"another node's key",
|
||||
format!(r#"{{ tasks(cursor: "{foreign}") {{ items {{ id }} }} }}"#),
|
||||
),
|
||||
(
|
||||
"different filter",
|
||||
format!(r#"{{ tasks(cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#),
|
||||
),
|
||||
] {
|
||||
let resp = anon(&schema, &query).await;
|
||||
assert_eq!(
|
||||
resp.errors.len(),
|
||||
1,
|
||||
"{label}: must be rejected, not treated as page one"
|
||||
);
|
||||
assert!(
|
||||
resp.errors[0].message.contains("invalid or expired cursor"),
|
||||
"{label}: unexpected message {:?}",
|
||||
resp.errors[0].message
|
||||
);
|
||||
}
|
||||
|
||||
let resp = anon(
|
||||
&schema,
|
||||
&format!(
|
||||
r#"{{ tasks(status: "pending", cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn tasks_anonymous_denial_hides_repoless_task_and_leaks_no_token(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let task = crate::db::AgentTask {
|
||||
id: "t1".into(),
|
||||
repo_id: None,
|
||||
kind: "code-review".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:z6MkDelegator".into(),
|
||||
assignee_did: None,
|
||||
capability: "agent:task".into(),
|
||||
ucan_token: Some("secret-ucan-token".into()),
|
||||
payload: Some("payload".into()),
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&task).await.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let query = "{ tasks { items { id } } }";
|
||||
let resp = anon(&schema, query).await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
let rendered = format!("{:?}", resp.data);
|
||||
assert!(!rendered.contains("secret-ucan-token"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn task_anonymous_denial_returns_null_and_leaks_no_token(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let task = crate::db::AgentTask {
|
||||
id: "t1".into(),
|
||||
repo_id: None,
|
||||
kind: "code-review".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:z6MkDelegator".into(),
|
||||
assignee_did: None,
|
||||
capability: "agent:task".into(),
|
||||
ucan_token: Some("secret-ucan-token".into()),
|
||||
payload: Some("payload".into()),
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&task).await.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let query = r#"{ task(id: "t1") { id } }"#;
|
||||
let resp = anon(&schema, query).await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
assert_eq!(obj.get("task"), Some(&async_graphql::Value::Null));
|
||||
let rendered = format!("{:?}", resp.data);
|
||||
assert!(!rendered.contains("secret-ucan-token"));
|
||||
}
|
||||
|
||||
fn task_page_bool(resp: &async_graphql::Response, field: &str) -> bool {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
let async_graphql::Value::Boolean(value) = page.get(field).expect("field present") else {
|
||||
panic!("{field} not a bool");
|
||||
};
|
||||
*value
|
||||
}
|
||||
|
||||
fn task_page_cursor(resp: &async_graphql::Response) -> Option<String> {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
match page.get("nextCursor").expect("nextCursor key") {
|
||||
async_graphql::Value::Null => None,
|
||||
async_graphql::Value::String(c) => Some(c.clone()),
|
||||
other => panic!("nextCursor not a string: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_items(resp: &async_graphql::Response) -> &Vec<async_graphql::Value> {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
let async_graphql::Value::List(rows) = page.get("items").expect("items key") else {
|
||||
panic!("items not a list");
|
||||
};
|
||||
rows
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
task_items(resp).len()
|
||||
}
|
||||
|
||||
fn task_incomplete(resp: &async_graphql::Response) -> bool {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
let async_graphql::Value::Boolean(incomplete) =
|
||||
page.get("incomplete").expect("incomplete key")
|
||||
else {
|
||||
panic!("incomplete not a bool");
|
||||
};
|
||||
*incomplete
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ pub struct SubscriptionRoot;
|
||||
|
||||
#[Subscription]
|
||||
impl SubscriptionRoot {
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted outside the
|
||||
/// `optional_signature` layer, so this resolver has NO caller identity and
|
||||
/// cannot gate per-subscriber — it relays whatever enters the broadcast
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted under the
|
||||
/// `optional_signature` layer and carries optional caller identity, but this
|
||||
/// resolver does not inspect it — it relays whatever enters the broadcast
|
||||
/// channel to any anonymous client. Its visibility safety therefore rests
|
||||
/// entirely on the WRITE side: the push handler only sends a
|
||||
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
|
||||
|
||||
@@ -48,6 +48,65 @@ impl From<AgentTask> for AgentTaskType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only projection of `AgentTask` for the `tasks`/`task` queries, as
|
||||
/// opposed to `AgentTaskType`, which the task mutations (`createTask`,
|
||||
/// `claimTask`, `completeTask`, `failTask` — all `require_signer`-gated)
|
||||
/// return. Identical except for the missing `ucan_token` (#268): a read
|
||||
/// surface never needs to echo it back, since the assignee already received it
|
||||
/// at delegation/claim time via the mutation response.
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct AgentTaskReadType {
|
||||
pub id: String,
|
||||
pub repo_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub status: String,
|
||||
pub delegator_did: String,
|
||||
pub assignee_did: Option<String>,
|
||||
pub capability: String,
|
||||
pub payload: Option<String>,
|
||||
pub result: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub deadline: Option<String>,
|
||||
}
|
||||
|
||||
/// Wraps a `tasks` page with the same completion signals the REST list route
|
||||
/// exposes, so the two surfaces answer pagination identically.
|
||||
///
|
||||
/// `hasMore` and `incomplete` are separate because they are separate facts
|
||||
/// (#327 review): `hasMore` says more candidates remain, `incomplete` says
|
||||
/// this page is short *only* because the authorization scan hit its safety
|
||||
/// wall. `nextCursor` is present exactly when `hasMore` is true and is an
|
||||
/// opaque MAC'd token — it can name the last examined candidate, denied or
|
||||
/// not, without disclosing it, which is what lets a caller page past a denied
|
||||
/// window instead of stalling on it.
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct TaskPageType {
|
||||
pub items: Vec<AgentTaskReadType>,
|
||||
pub has_more: bool,
|
||||
pub incomplete: bool,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
impl From<AgentTask> for AgentTaskReadType {
|
||||
fn from(t: AgentTask) -> Self {
|
||||
Self {
|
||||
id: t.id,
|
||||
repo_id: t.repo_id,
|
||||
kind: t.kind,
|
||||
status: t.status,
|
||||
delegator_did: t.delegator_did,
|
||||
assignee_did: t.assignee_did,
|
||||
capability: t.capability,
|
||||
payload: t.payload,
|
||||
result: t.result,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
deadline: t.deadline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct RefUpdateType {
|
||||
pub repo: String,
|
||||
|
||||
@@ -51,6 +51,7 @@ use state::AppState;
|
||||
struct DegradedState {
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Two independent counters with no cross-field invariant — atomics, not a
|
||||
@@ -159,6 +160,7 @@ async fn main() -> Result<()> {
|
||||
degraded_listener,
|
||||
node_did.to_string(),
|
||||
Arc::clone(&db_startup),
|
||||
config.web_url.clone(),
|
||||
db_ready_rx,
|
||||
shutdown_tx.subscribe(),
|
||||
));
|
||||
@@ -304,10 +306,15 @@ async fn main() -> Result<()> {
|
||||
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
|
||||
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
|
||||
|
||||
// Node-keyed, so continuation tokens this node mints are only accepted by
|
||||
// this node and survive its restarts without any configured secret.
|
||||
let task_cursor_key = api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
|
||||
|
||||
let graphql_schema = Arc::new(graphql::build_schema(
|
||||
Arc::clone(&db),
|
||||
ref_update_tx.clone(),
|
||||
task_event_tx.clone(),
|
||||
task_cursor_key.clone(),
|
||||
));
|
||||
|
||||
let machine_id = std::env::var("FLY_MACHINE_ID").ok();
|
||||
@@ -429,6 +436,8 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
let federated_rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(12, std::time::Duration::from_secs(60), 10_000);
|
||||
if config.sync_trigger_rate_limit == 0 {
|
||||
tracing::warn!(
|
||||
"GITLAWB_SYNC_TRIGGER_RATE_LIMIT=0 — /sync/trigger IP rate limiting disabled"
|
||||
@@ -446,6 +455,7 @@ async fn main() -> Result<()> {
|
||||
db,
|
||||
node_did: node_did.clone(),
|
||||
node_keypair: keypair,
|
||||
task_cursor_key,
|
||||
p2p: p2p_handle,
|
||||
http_client,
|
||||
ref_update_tx,
|
||||
@@ -467,6 +477,7 @@ async fn main() -> Result<()> {
|
||||
push_limiter_trust,
|
||||
sync_trigger_rate_limiter,
|
||||
peer_write_rate_limiter,
|
||||
federated_rate_limiter,
|
||||
shutdown_tx: shutdown_tx.clone(),
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_git_ops)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(
|
||||
@@ -543,11 +554,19 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
task_read_rate_limiter: rate_limit::RateLimiter::new_bounded(
|
||||
config.task_read_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
git_bin: "git".to_string(),
|
||||
};
|
||||
if config.ipfs_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled");
|
||||
}
|
||||
if config.task_read_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_TASK_READ_RATE_LIMIT=0 — per-IP task read rate limiting disabled");
|
||||
}
|
||||
|
||||
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
|
||||
// we still set the gauge to 0 so dashboards don't show "no data".
|
||||
@@ -869,11 +888,12 @@ async fn run_degraded_server(
|
||||
listener: TcpListener,
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
mut db_ready_rx: watch::Receiver<bool>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
let addr = listener.local_addr().ok();
|
||||
let router = build_degraded_router(node_did, db_startup);
|
||||
let router = build_degraded_router(node_did, db_startup, web_url);
|
||||
info!(?addr, "degraded HTTP server ready");
|
||||
|
||||
axum::serve(listener, router)
|
||||
@@ -890,10 +910,15 @@ async fn run_degraded_server(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
|
||||
fn build_degraded_router(
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
) -> Router {
|
||||
let state = DegradedState {
|
||||
node_did,
|
||||
db_startup,
|
||||
web_url,
|
||||
};
|
||||
// Everything answers 503 with the same body — including /health and
|
||||
// /ready, so peer readiness probes and uptime monitors correctly see a
|
||||
@@ -925,6 +950,9 @@ async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResp
|
||||
obj.insert("name".into(), "gitlawb-node".into());
|
||||
obj.insert("version".into(), env!("CARGO_PKG_VERSION").into());
|
||||
obj.insert("did".into(), state.node_did.clone().into());
|
||||
if let Some(web_url) = &state.web_url {
|
||||
obj.insert("web_url".into(), web_url.clone().into());
|
||||
}
|
||||
}
|
||||
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
|
||||
}
|
||||
@@ -1171,7 +1199,10 @@ mod rate_limiter_sweep_tests {
|
||||
state.close_issue_rate_limiter = RateLimiter::new(10, window);
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
|
||||
state.peer_write_rate_limiter = RateLimiter::new(10, window);
|
||||
state.federated_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_work_rate_limiter = RateLimiter::new(10, window);
|
||||
state.task_read_rate_limiter = RateLimiter::new(10, window);
|
||||
|
||||
let limiters = |s: &crate::state::AppState| {
|
||||
[
|
||||
@@ -1181,7 +1212,10 @@ mod rate_limiter_sweep_tests {
|
||||
s.close_issue_rate_limiter.clone(),
|
||||
s.sync_trigger_rate_limiter.clone(),
|
||||
s.peer_write_rate_limiter.clone(),
|
||||
s.federated_rate_limiter.clone(),
|
||||
s.ipfs_rate_limiter.clone(),
|
||||
s.ipfs_work_rate_limiter.clone(),
|
||||
s.task_read_rate_limiter.clone(),
|
||||
]
|
||||
};
|
||||
for l in limiters(&state) {
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! The node's PeerId comes from an Ed25519 keypair loaded from a persistent
|
||||
//! key file, so the PeerId is stable across restarts.
|
||||
|
||||
use std::collections::{hash_map::DefaultHasher, HashMap};
|
||||
use std::collections::{hash_map::DefaultHasher, HashMap, HashSet, VecDeque};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -315,6 +315,17 @@ fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
// Identify is an untrusted address source. Keep enough entries for normal
|
||||
// multi-homed peers while bounding both one peer and the whole routing table.
|
||||
const IDENTIFY_ADDRESS_LIMIT: usize = 8;
|
||||
// Bound canonicalization and sorting too, including rejected/duplicate entries.
|
||||
const IDENTIFY_REPORT_ADDRESS_LIMIT: usize = 64;
|
||||
const IDENTIFY_NEW_ADDRESS_LIMIT: usize = 8;
|
||||
const IDENTIFY_GLOBAL_ADDRESS_LIMIT: usize = 1024;
|
||||
const IDENTIFY_ADDRESS_WINDOW: Duration = Duration::from_secs(60);
|
||||
const IDENTIFY_ADDRESS_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const IDENTIFY_ADDRESS_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// A ref-update event published to Gossipsub when a push lands.
|
||||
///
|
||||
/// The signing bytes are this struct serialized with `sig` set to None (see
|
||||
@@ -1631,6 +1642,200 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
|
||||
kad::RecordKey::new(&format!("/gitlawb/did/{did}").as_bytes())
|
||||
}
|
||||
|
||||
/// Returns true when `address` was explicitly configured for `peer` through
|
||||
/// AddKnownPeer. Explicit addresses are retained in Kademlia even when an
|
||||
/// overlapping Identify lease expires or is evicted.
|
||||
fn is_explicit_address(
|
||||
explicit_addresses: &HashMap<PeerId, HashSet<Multiaddr>>,
|
||||
peer: &PeerId,
|
||||
address: &Multiaddr,
|
||||
) -> bool {
|
||||
explicit_addresses
|
||||
.get(peer)
|
||||
.is_some_and(|addresses| addresses.contains(address))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdentifyAddress {
|
||||
address: Multiaddr,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdentifyPeerAddresses {
|
||||
addresses: VecDeque<IdentifyAddress>,
|
||||
window_started: Instant,
|
||||
new_addresses: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct IdentifyAddressChanges {
|
||||
added: Vec<Multiaddr>,
|
||||
refreshed: Vec<Multiaddr>,
|
||||
removed: Vec<(PeerId, Multiaddr)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct IdentifyAddressBook {
|
||||
peers: HashMap<PeerId, IdentifyPeerAddresses>,
|
||||
address_count: usize,
|
||||
}
|
||||
|
||||
impl IdentifyAddressBook {
|
||||
fn update(
|
||||
&mut self,
|
||||
peer_id: PeerId,
|
||||
now: Instant,
|
||||
addresses: &[Multiaddr],
|
||||
) -> IdentifyAddressChanges {
|
||||
let mut changes = IdentifyAddressChanges::default();
|
||||
if self.peers.contains_key(&peer_id) {
|
||||
changes.removed.extend(self.expire_peer(peer_id, now));
|
||||
}
|
||||
let reported = addresses
|
||||
.iter()
|
||||
.take(IDENTIFY_REPORT_ADDRESS_LIMIT)
|
||||
.filter_map(|address| address.clone().with_p2p(peer_id).ok())
|
||||
.collect::<HashSet<_>>();
|
||||
if reported.is_empty() && !self.peers.contains_key(&peer_id) {
|
||||
return changes;
|
||||
}
|
||||
let state = self
|
||||
.peers
|
||||
.entry(peer_id)
|
||||
.or_insert_with(|| IdentifyPeerAddresses {
|
||||
addresses: VecDeque::new(),
|
||||
window_started: now,
|
||||
new_addresses: 0,
|
||||
});
|
||||
|
||||
if now.saturating_duration_since(state.window_started) >= IDENTIFY_ADDRESS_WINDOW {
|
||||
state.window_started = now;
|
||||
state.new_addresses = 0;
|
||||
}
|
||||
|
||||
// Refresh every retained address in the bounded report before admission.
|
||||
// Refreshed addresses are reported so the Identify handler can re-admit
|
||||
// them to Kademlia. Kademlia may have dropped a retained address
|
||||
// after a failed dial or never kept it for a full k-bucket, and a
|
||||
// TTL refresh alone would never re-add it.
|
||||
for existing in &mut state.addresses {
|
||||
if reported.contains(&existing.address) {
|
||||
existing.expires_at = now + IDENTIFY_ADDRESS_TTL;
|
||||
changes.refreshed.push(existing.address.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_addresses: Vec<Multiaddr> = reported
|
||||
.iter()
|
||||
.filter(|address| {
|
||||
!state
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|existing| &existing.address == *address)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// HashSet iteration is deliberately unordered. Sort new candidates so
|
||||
// an oversized Identify report has deterministic admission behavior.
|
||||
new_addresses.sort();
|
||||
|
||||
for address in new_addresses {
|
||||
if state.new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT {
|
||||
break;
|
||||
}
|
||||
|
||||
let evicted = if state.addresses.len() < IDENTIFY_ADDRESS_LIMIT {
|
||||
None
|
||||
} else {
|
||||
state
|
||||
.addresses
|
||||
.iter()
|
||||
.position(|existing| !reported.contains(&existing.address))
|
||||
.and_then(|index| state.addresses.remove(index))
|
||||
};
|
||||
if let Some(evicted) = evicted {
|
||||
self.address_count -= 1;
|
||||
changes.removed.push((peer_id, evicted.address));
|
||||
} else if state.addresses.len() >= IDENTIFY_ADDRESS_LIMIT {
|
||||
// Every retained address was refreshed in this report. Keep
|
||||
// that stable subset and drop surplus new candidates.
|
||||
break;
|
||||
}
|
||||
|
||||
if self.address_count >= IDENTIFY_GLOBAL_ADDRESS_LIMIT {
|
||||
// Reject the admission instead of evicting another peer's
|
||||
// entry: cross-peer eviction lets a flood of new identities
|
||||
// push honest peers' addresses out of the book and, when the
|
||||
// evicted address is their last one, out of the Kademlia
|
||||
// routing table. Per-peer eviction above still applies to the
|
||||
// reporting peer's own addresses.
|
||||
break;
|
||||
}
|
||||
|
||||
state.new_addresses += 1;
|
||||
state.addresses.push_back(IdentifyAddress {
|
||||
address: address.clone(),
|
||||
expires_at: now + IDENTIFY_ADDRESS_TTL,
|
||||
});
|
||||
self.address_count += 1;
|
||||
changes.added.push(address);
|
||||
}
|
||||
|
||||
// Do not keep a row when every candidate was rejected: a zero-address
|
||||
// row is invisible to address_count and would otherwise linger until
|
||||
// the expiry tick. This matches expire_peer, which drops empty rows.
|
||||
if state.addresses.is_empty() {
|
||||
self.peers.remove(&peer_id);
|
||||
}
|
||||
|
||||
changes
|
||||
}
|
||||
|
||||
fn expire(&mut self, now: Instant) -> Vec<(PeerId, Multiaddr)> {
|
||||
let mut removed = Vec::new();
|
||||
let peers = self.peers.keys().copied().collect::<Vec<_>>();
|
||||
for peer_id in peers {
|
||||
removed.extend(self.expire_peer(peer_id, now));
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn expire_peer(&mut self, peer_id: PeerId, now: Instant) -> Vec<(PeerId, Multiaddr)> {
|
||||
let expired = self
|
||||
.peers
|
||||
.get_mut(&peer_id)
|
||||
.map(|state| Self::expire_state(state, now))
|
||||
.unwrap_or_default();
|
||||
self.address_count -= expired.len();
|
||||
if self
|
||||
.peers
|
||||
.get(&peer_id)
|
||||
.is_some_and(|state| state.addresses.is_empty())
|
||||
{
|
||||
self.peers.remove(&peer_id);
|
||||
}
|
||||
expired
|
||||
.into_iter()
|
||||
.map(|address| (peer_id, address))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expire_state(state: &mut IdentifyPeerAddresses, now: Instant) -> Vec<Multiaddr> {
|
||||
let mut expired = Vec::new();
|
||||
let mut retained = VecDeque::with_capacity(state.addresses.len());
|
||||
while let Some(address) = state.addresses.pop_front() {
|
||||
if address.expires_at <= now {
|
||||
expired.push(address.address);
|
||||
} else {
|
||||
retained.push_back(address);
|
||||
}
|
||||
}
|
||||
state.addresses = retained;
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined libp2p behaviour.
|
||||
#[derive(NetworkBehaviour)]
|
||||
#[behaviour(prelude = "libp2p_swarm::derive_prelude")]
|
||||
@@ -3960,6 +4165,14 @@ pub async fn start(
|
||||
// Track in-flight GetRecord queries → reply channels
|
||||
let mut pending_get_did: HashMap<kad::QueryId, oneshot::Sender<Option<DidRecord>>> =
|
||||
HashMap::new();
|
||||
// Keep Identify-derived entries across disconnects so a bootstrap address
|
||||
// learned through Identify remains usable for redial. The TTL cleanup below
|
||||
// removes entries that are no longer refreshed without touching explicit
|
||||
// AddKnownPeer addresses.
|
||||
let mut identify_addresses = IdentifyAddressBook::default();
|
||||
let mut explicit_addresses: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
|
||||
let mut identify_cleanup = tokio::time::interval(IDENTIFY_ADDRESS_CLEANUP_INTERVAL);
|
||||
identify_cleanup.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
// Start the event loop as a background task
|
||||
tokio::spawn(async move {
|
||||
@@ -3979,6 +4192,13 @@ pub async fn start(
|
||||
_ = ingest_sweep.tick() => {
|
||||
run_ingest_sweep(&ingest_limiters, &replay_guard, ingest_now()).await;
|
||||
}
|
||||
_ = identify_cleanup.tick() => {
|
||||
for (peer_id, address) in identify_addresses.expire(Instant::now()) {
|
||||
if !is_explicit_address(&explicit_addresses, &peer_id, &address) {
|
||||
swarm.behaviour_mut().kademlia.remove_address(&peer_id, &address);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Graceful shutdown: exit the swarm loop when the
|
||||
// process-wide signal flips. This drops the Swarm
|
||||
// which closes all libp2p connections cleanly.
|
||||
@@ -4113,7 +4333,21 @@ pub async fn start(
|
||||
identify::Event::Received { peer_id, info, .. }
|
||||
)) => {
|
||||
debug!(peer = %peer_id, "identify received");
|
||||
for addr in info.listen_addrs {
|
||||
let changes = identify_addresses.update(
|
||||
peer_id,
|
||||
Instant::now(),
|
||||
&info.listen_addrs,
|
||||
);
|
||||
for (removed_peer, addr) in changes.removed {
|
||||
if !is_explicit_address(&explicit_addresses, &removed_peer, &addr)
|
||||
{
|
||||
swarm
|
||||
.behaviour_mut()
|
||||
.kademlia
|
||||
.remove_address(&removed_peer, &addr);
|
||||
}
|
||||
}
|
||||
for addr in changes.refreshed.into_iter().chain(changes.added) {
|
||||
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
|
||||
}
|
||||
}
|
||||
@@ -4150,6 +4384,14 @@ pub async fn start(
|
||||
}
|
||||
}
|
||||
P2pCommand::AddKnownPeer { peer_id, addr } => {
|
||||
let Ok(addr) = addr.clone().with_p2p(peer_id) else {
|
||||
warn!(%peer_id, %addr, "dropping known-peer address with a foreign peer suffix");
|
||||
continue;
|
||||
};
|
||||
explicit_addresses
|
||||
.entry(peer_id)
|
||||
.or_default()
|
||||
.insert(addr.clone());
|
||||
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
|
||||
}
|
||||
P2pCommand::Dial(addr) => {
|
||||
@@ -4201,6 +4443,453 @@ pub async fn start(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identify_address(peer_id: PeerId, octet: u8, port: u16) -> Multiaddr {
|
||||
format!("/ip4/192.0.2.{octet}/udp/{port}/quic-v1")
|
||||
.parse::<Multiaddr>()
|
||||
.unwrap()
|
||||
.with_p2p(peer_id)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn identify_address_without_peer(octet: u8, port: u16) -> Multiaddr {
|
||||
format!("/ip4/192.0.2.{octet}/udp/{port}/quic-v1")
|
||||
.parse::<Multiaddr>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_empty_reports_do_not_retain_peer_entries() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let foreign = identify_address(PeerId::random(), 1, 10_000);
|
||||
for _ in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT + 1 {
|
||||
let peer = PeerId::random();
|
||||
assert_eq!(
|
||||
book.update(peer, now, &[]),
|
||||
IdentifyAddressChanges::default()
|
||||
);
|
||||
assert_eq!(
|
||||
book.update(peer, now, std::slice::from_ref(&foreign)),
|
||||
IdentifyAddressChanges::default()
|
||||
);
|
||||
}
|
||||
assert!(book.peers.is_empty());
|
||||
assert_eq!(book.address_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_fully_rejected_peer_leaves_no_row_at_full_budget() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
// Fill the global budget with one-address peers.
|
||||
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT {
|
||||
let peer = PeerId::random();
|
||||
let address = identify_address(peer, 1, 10_000 + index as u16);
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
}
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
// A fresh peer reporting at saturation has every candidate rejected
|
||||
// by the global-budget check. The row created by or_insert_with must
|
||||
// be dropped: a zero-address row is invisible to address_count and
|
||||
// would otherwise linger until the expiry tick.
|
||||
let fresh = PeerId::random();
|
||||
let fresh_address = identify_address(fresh, 2, 30_000);
|
||||
let changes = book.update(fresh, now, std::slice::from_ref(&fresh_address));
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(!book.peers.contains_key(&fresh));
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_update_reports_expired_addresses_as_removed() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let address = identify_address(peer, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
// An update past the TTL expires the stale address through
|
||||
// expire_peer and reports it in changes.removed so the Identify
|
||||
// handler can drop it from Kademlia.
|
||||
let changes = book.update(
|
||||
peer,
|
||||
now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1),
|
||||
std::slice::from_ref(&address),
|
||||
);
|
||||
assert_eq!(changes.removed, vec![(peer, address.clone())]);
|
||||
assert_eq!(changes.added, vec![address]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_expire_drops_rows_emptied_by_expiry() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let address = identify_address(peer, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
assert!(book.peers.contains_key(&peer));
|
||||
// Once every address in the row has expired, expire() must drop the
|
||||
// row itself, matching the empty-row drop in update().
|
||||
let removed = book.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1));
|
||||
assert_eq!(removed, vec![(peer, address)]);
|
||||
assert!(!book.peers.contains_key(&peer));
|
||||
assert_eq!(book.address_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_report_processing_stops_at_the_input_limit() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let foreign = identify_address(PeerId::random(), 1, 10_000);
|
||||
let accepted = identify_address(peer, 2, 10_001);
|
||||
let ignored = identify_address(peer, 3, 10_002);
|
||||
let mut report = vec![foreign; IDENTIFY_REPORT_ADDRESS_LIMIT - 1];
|
||||
report.push(accepted.clone());
|
||||
report.push(ignored);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let changes = book.update(peer, now, &report);
|
||||
assert_eq!(changes.added, vec![accepted]);
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(book.address_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_grow_without_eviction_below_the_peer_cap() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let first = identify_address(peer, 1, 10_000);
|
||||
let second = identify_address(peer, 2, 10_001);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
book.update(peer, now, std::slice::from_ref(&first));
|
||||
let changes = book.update(
|
||||
peer,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&second),
|
||||
);
|
||||
assert_eq!(changes.added, vec![second]);
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(book.peers[&peer].addresses.len(), 2);
|
||||
assert_eq!(book.address_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_global_budget_rejects_new_admissions() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let peers = (0..IDENTIFY_GLOBAL_ADDRESS_LIMIT)
|
||||
.map(|index| {
|
||||
let peer = PeerId::random();
|
||||
let address = identify_address(peer, 1, 10_000 + index as u16);
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
(peer, address)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (peer, refreshed) = &peers[0];
|
||||
let new_address = identify_address(*peer, 2, 30_000);
|
||||
let changes = book.update(
|
||||
*peer,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
&[new_address.clone(), refreshed.clone()],
|
||||
);
|
||||
// The budget is full: the new address is rejected rather than
|
||||
// evicting another peer's entry, and the refreshed address keeps its
|
||||
// slot and is reported for re-admission to Kademlia.
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(changes.refreshed, vec![(*refreshed).clone()]);
|
||||
assert_eq!(book.peers.len(), IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
assert_eq!(
|
||||
book.peers[peer]
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| &a.address)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![refreshed]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_per_peer_eviction_applies_at_full_global_budget() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
// One filler peer goes in before the victim so the evicted address
|
||||
// is not also the globally-oldest insertion. If saturation evicted
|
||||
// the globally-oldest entry instead of the victim's own oldest,
|
||||
// the sentinel's address would be the one removed.
|
||||
let sentinel = PeerId::random();
|
||||
let sentinel_address = identify_address(sentinel, 200, 19_999);
|
||||
book.update(sentinel, now, std::slice::from_ref(&sentinel_address));
|
||||
// The victim peer holds a full per-peer row while the rest of the
|
||||
// global budget is filled by one-address peers.
|
||||
let victim = PeerId::random();
|
||||
let victim_addresses: Vec<Multiaddr> = (0..IDENTIFY_ADDRESS_LIMIT)
|
||||
.map(|index| identify_address(victim, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect();
|
||||
book.update(victim, now, &victim_addresses);
|
||||
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT - IDENTIFY_ADDRESS_LIMIT - 1 {
|
||||
let peer = PeerId::random();
|
||||
let address = identify_address(peer, 200, 20_000 + index as u16);
|
||||
book.update(peer, now, &[address]);
|
||||
}
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
|
||||
// The victim reports a ninth address after the rate window rolls over.
|
||||
// Per-peer eviction must still apply at global saturation: the oldest
|
||||
// non-reported address is evicted to make room, and the global count
|
||||
// is unchanged. This ordering only holds because eviction runs before
|
||||
// the global-budget check; hoisting that check above the eviction
|
||||
// block makes this test fail.
|
||||
let ninth = identify_address(victim, 100, 30_000);
|
||||
let changes = book.update(
|
||||
victim,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&ninth),
|
||||
);
|
||||
assert_eq!(changes.added, vec![ninth]);
|
||||
assert_eq!(changes.removed, vec![(victim, victim_addresses[0].clone())]);
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
assert_eq!(book.peers[&victim].addresses.len(), IDENTIFY_ADDRESS_LIMIT);
|
||||
// The sentinel was inserted first; per-peer eviction must leave it
|
||||
// alone. A global-oldest eviction policy would have removed it.
|
||||
assert_eq!(
|
||||
book.peers[&sentinel]
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| &a.address)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![&sentinel_address]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_address_helper_detects_retained_addresses() {
|
||||
let peer = PeerId::random();
|
||||
let other = PeerId::random();
|
||||
let address = identify_address(peer, 1, 10_000);
|
||||
let mut explicit: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
|
||||
// Unknown peer: nothing is explicit.
|
||||
assert!(!is_explicit_address(&explicit, &peer, &address));
|
||||
explicit.entry(peer).or_default().insert(address.clone());
|
||||
assert!(is_explicit_address(&explicit, &peer, &address));
|
||||
// The same address under a different peer is not retained.
|
||||
assert!(!is_explicit_address(&explicit, &other, &address));
|
||||
// A different address for the same peer is not retained.
|
||||
assert!(!is_explicit_address(
|
||||
&explicit,
|
||||
&peer,
|
||||
&identify_address(peer, 2, 10_001)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_are_capped_with_fifo_eviction() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let initial = (0..IDENTIFY_ADDRESS_LIMIT)
|
||||
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect::<Vec<_>>();
|
||||
let replacement = identify_address(peer_id, 200, 20_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
let first = book.update(peer_id, now, &initial);
|
||||
assert_eq!(first.added, initial);
|
||||
assert!(first.removed.is_empty());
|
||||
|
||||
let second = book.update(
|
||||
peer_id,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&replacement),
|
||||
);
|
||||
assert_eq!(second.added, vec![replacement]);
|
||||
assert_eq!(second.removed, vec![(peer_id, initial[0].clone())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_oversized_reports_keep_the_same_refreshed_subset() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let mut report = (0..IDENTIFY_ADDRESS_LIMIT + 2)
|
||||
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect::<Vec<_>>();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
let first = book.update(peer_id, now, &report);
|
||||
assert_eq!(first.added.len(), IDENTIFY_ADDRESS_LIMIT);
|
||||
assert_eq!(first.removed.len(), 0);
|
||||
let retained = first.added.clone();
|
||||
|
||||
report.reverse();
|
||||
let second = book.update(peer_id, now + IDENTIFY_ADDRESS_WINDOW, &report);
|
||||
assert!(second.added.is_empty());
|
||||
assert!(second.removed.is_empty());
|
||||
let current = book
|
||||
.peers
|
||||
.get(&peer_id)
|
||||
.unwrap()
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.address.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(current, retained);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_rate_limit_new_entries_across_updates() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let initial = (0..IDENTIFY_NEW_ADDRESS_LIMIT)
|
||||
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect::<Vec<_>>();
|
||||
let replacement = identify_address(peer_id, 200, 20_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert_eq!(book.update(peer_id, now, &initial).added, initial);
|
||||
assert!(book
|
||||
.update(peer_id, now, std::slice::from_ref(&replacement))
|
||||
.added
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
book.update(
|
||||
peer_id,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&replacement),
|
||||
)
|
||||
.added,
|
||||
vec![replacement]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_have_a_global_budget_across_peers() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let mut first = None;
|
||||
|
||||
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT {
|
||||
let peer_id = PeerId::random();
|
||||
let address = identify_address(peer_id, (index % 254) as u8 + 1, 10_000 + index as u16);
|
||||
if first.is_none() {
|
||||
first = Some((peer_id, address.clone()));
|
||||
}
|
||||
assert!(book.update(peer_id, now, &[address]).removed.is_empty());
|
||||
}
|
||||
|
||||
let (peer_id, first_address) = first.unwrap();
|
||||
let address = identify_address(peer_id, 250, 30_000);
|
||||
let changes = book.update(
|
||||
peer_id,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&address),
|
||||
);
|
||||
// The global budget is full, so the admission is rejected and the
|
||||
// peer's existing entry is left alone.
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(
|
||||
book.peers[&peer_id]
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| &a.address)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![&first_address]
|
||||
);
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_refresh_and_expire() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let address = identify_address(peer_id, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert_eq!(
|
||||
book.update(peer_id, now, std::slice::from_ref(&address))
|
||||
.added,
|
||||
vec![address.clone()]
|
||||
);
|
||||
let refreshed = now + IDENTIFY_ADDRESS_TTL - Duration::from_secs(1);
|
||||
let refresh = book.update(peer_id, refreshed, std::slice::from_ref(&address));
|
||||
assert!(refresh.added.is_empty());
|
||||
assert!(refresh.removed.is_empty());
|
||||
// Refreshed addresses are reported so the Identify handler re-adds
|
||||
// them to Kademlia even though they were not newly admitted.
|
||||
assert_eq!(refresh.refreshed, vec![address.clone()]);
|
||||
assert!(book
|
||||
.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1))
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
book.expire(refreshed + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
|
||||
vec![(peer_id, address)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_refresh_reports_only_addresses_in_the_report() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let reported = identify_address(peer_id, 1, 10_000);
|
||||
let unreported = identify_address(peer_id, 2, 20_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
// Admit both addresses up front so the peer holds two.
|
||||
assert_eq!(
|
||||
book.update(peer_id, now, &[reported.clone(), unreported.clone()])
|
||||
.added
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
// A later report carrying only one of the two addresses must refresh
|
||||
// just that one: pins the negative direction of the refresh
|
||||
// predicate, so an address the peer stopped advertising keeps its
|
||||
// original TTL instead of being refreshed back onto the Kademlia
|
||||
// re-admit path on every report.
|
||||
let report_at = now + IDENTIFY_ADDRESS_TTL - Duration::from_secs(1);
|
||||
let changes = book.update(peer_id, report_at, std::slice::from_ref(&reported));
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(changes.refreshed, vec![reported.clone()]);
|
||||
|
||||
// The unreported address still expires at its original TTL, while
|
||||
// the refreshed address survives until its refreshed TTL.
|
||||
assert_eq!(
|
||||
book.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
|
||||
vec![(peer_id, unreported)]
|
||||
);
|
||||
assert!(book.expire(report_at + Duration::from_secs(1)).is_empty());
|
||||
assert_eq!(
|
||||
book.expire(report_at + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
|
||||
vec![(peer_id, reported)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_reject_foreign_peer_suffixes() {
|
||||
let peer_id = PeerId::random();
|
||||
let other_peer_id = PeerId::random();
|
||||
let address = identify_address(other_peer_id, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert!(book
|
||||
.update(peer_id, Instant::now(), &[address])
|
||||
.added
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_canonicalize_the_peer_suffix_once() {
|
||||
let peer_id = PeerId::random();
|
||||
let raw = identify_address_without_peer(1, 10_000);
|
||||
let canonical = identify_address(peer_id, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert_eq!(
|
||||
book.update(peer_id, Instant::now(), &[raw]).added,
|
||||
vec![canonical]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_mode_replace_authority_is_the_write_bits() {
|
||||
assert!(!dir_mode_allows_untrusted_replace(0o755));
|
||||
|
||||
@@ -296,7 +296,7 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
RATE_LIMIT_MESSAGE,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -401,6 +401,11 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The message every per-IP brake answers with, shared so the GraphQL surface
|
||||
/// (which cannot return a 429 status inside a 200 GraphQL envelope) says the
|
||||
/// same thing as the REST routes.
|
||||
pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later";
|
||||
|
||||
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
|
||||
/// middleware now serves the push path AND the peer-sync routes, so the message
|
||||
/// stays generic (the offending path is recorded in the warn log below).
|
||||
@@ -408,11 +413,60 @@ pub fn too_many_requests() -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
RATE_LIMIT_MESSAGE,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// The per-IP task-read brake carried as GraphQL request data: the limiter plus
|
||||
/// the client key the transport already resolved.
|
||||
///
|
||||
/// `/graphql` is a single POST endpoint, so this brake cannot sit in middleware
|
||||
/// the way `task_read_routes` mounts `rate_limit_by_ip`: that would charge every
|
||||
/// unrelated query and every mutation against the task-read bucket. The two task
|
||||
/// resolvers debit it instead, which also prices an aliased query honestly — ten
|
||||
/// aliased `tasks` fields run the visibility gate ten times and pay ten slots,
|
||||
/// where one request-scoped debit would pay one (#327 review).
|
||||
/// Default cap on the number of task read fields (across all aliases) permitted
|
||||
/// in a single GraphQL request before rejecting further field executions (#327 review).
|
||||
pub const MAX_GRAPHQL_TASK_READS_PER_REQUEST: usize = 5;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TaskReadBrake {
|
||||
pub limiter: RateLimiter,
|
||||
/// `None` when no key could be resolved at all (no trusted header and no
|
||||
/// `ConnectInfo`, e.g. a synthetic test request). Never braked, matching
|
||||
/// [`rate_limit_by_ip`].
|
||||
pub key: Option<String>,
|
||||
pub request_count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TaskReadBrake {
|
||||
/// Debit one slot for one task-read field. `true` = proceed.
|
||||
pub async fn check(&self, field: &str) -> bool {
|
||||
let Some(key) = self.key.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
let count = self
|
||||
.request_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if count >= MAX_GRAPHQL_TASK_READS_PER_REQUEST {
|
||||
tracing::warn!(
|
||||
key = %key,
|
||||
field = %field,
|
||||
count = count + 1,
|
||||
"per-request GraphQL task field limit exceeded"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if self.limiter.check(key).await {
|
||||
return true;
|
||||
}
|
||||
tracing::warn!(key = %key, field = %field, "per-IP rate limit exceeded");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Throttle the git push path by resolved client IP. The socket peer address is
|
||||
/// read from `ConnectInfo` (see `into_make_service_with_connect_info` in
|
||||
/// `main`). Only skips the limiter when no key can be resolved at all.
|
||||
|
||||
+1130
-30
File diff suppressed because it is too large.
Load diff
@@ -59,6 +59,10 @@ pub struct AppState {
|
||||
pub task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
/// GraphQL schema (queries + mutations + subscriptions)
|
||||
pub graphql_schema: Arc<crate::graphql::GitlawbSchema>,
|
||||
/// MAC key for task-list continuation tokens, derived from the node
|
||||
/// keypair seed. Held here (and handed to the GraphQL schema) so REST and
|
||||
/// GraphQL mint and accept the same tokens.
|
||||
pub task_cursor_key: crate::api::task_cursor::TaskCursorKey,
|
||||
/// Fly.io machine ID — used for fly-replay routing in multi-machine deployments
|
||||
pub machine_id: Option<String>,
|
||||
/// Centralized repo storage: local disk cache + optional Tigris backend
|
||||
@@ -190,6 +194,9 @@ pub struct AppState {
|
||||
/// sink as trigger and accepts unsigned requests from known peers, so it is
|
||||
/// braked too; each peer's distinct IP gets its own bucket.
|
||||
pub peer_write_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP federation budget: 12 requests per minute, with at most
|
||||
/// 10,000 tracked source keys. Shared by router instances and swept below.
|
||||
pub federated_rate_limiter: RateLimiter,
|
||||
/// Process-wide graceful-shutdown signal. Sending `true` causes every
|
||||
/// task that holds a `watch::Receiver` to exit at its next await point.
|
||||
/// Used by:
|
||||
@@ -323,6 +330,16 @@ pub struct AppState {
|
||||
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
|
||||
/// it (INV-15).
|
||||
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// Per-client-IP rate limiter for the task read routes. `GET /api/v1/tasks`
|
||||
/// and `GET /api/v1/tasks/{id}` are anonymously reachable and run the #268
|
||||
/// visibility gate (a task lookup plus deduped-repo and visibility-rule
|
||||
/// queries) before returning the opaque 404, so an unauthenticated prober
|
||||
/// costs the node real work per request whether or not anything is visible.
|
||||
/// Keyed on the resolved client IP via `push_limiter_trust`. Layered on
|
||||
/// `task_read_routes` via `rate_limit_by_ip`.
|
||||
/// The same per-IP hourly budget applies to GraphQL `tasks` / `task`
|
||||
/// queries and WebSocket task queries via `TaskReadBrake`.
|
||||
pub task_read_rate_limiter: RateLimiter,
|
||||
/// The `git` executable the served-git withheld-blob walk spawns. Production is
|
||||
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
|
||||
/// process-group teardown in handler tests without mutating the process-global
|
||||
@@ -351,6 +368,8 @@ impl AppState {
|
||||
self.ipfs_work_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
self.peer_write_rate_limiter.cleanup().await;
|
||||
self.task_read_rate_limiter.cleanup().await;
|
||||
self.federated_rate_limiter.cleanup().await;
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown. Idempotent — calling more than once
|
||||
|
||||
@@ -701,6 +701,7 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
if mode == MirrorMode::Promisor {
|
||||
args.push("--filter=blob:limit=10g");
|
||||
}
|
||||
args.push("--");
|
||||
args.push(remote_url);
|
||||
args.push(local_str);
|
||||
|
||||
@@ -729,7 +730,10 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
|
||||
git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?;
|
||||
git_run(&[
|
||||
"-C", local_str, "remote", "set-url", "origin", "--", remote_url,
|
||||
])
|
||||
.await?;
|
||||
|
||||
match mode {
|
||||
MirrorMode::Promisor => {
|
||||
@@ -1006,6 +1010,82 @@ mod tests {
|
||||
assert_eq!(probe, PromisorProbe::NotPromisor);
|
||||
}
|
||||
|
||||
/// #374: `clone_repo` interpolates the peer's origin URL into a `git
|
||||
/// clone --mirror` argument list. Without a `--` delimiter git parses a
|
||||
/// remote beginning with `-` as an option instead of a repository.
|
||||
///
|
||||
/// The delimiter is what forces the same string to be a repository, and
|
||||
/// git says so in its own words: it reports the whole argument as a
|
||||
/// repository it cannot find. Undelimited, git consumes it as an option
|
||||
/// and fails against the *destination* instead (`fatal: repository '<dest>' does not exist`),
|
||||
/// never naming the injected string — which is why this assertion is RED
|
||||
/// before the fix and GREEN after.
|
||||
///
|
||||
/// The payload is deliberately free of `:` and spaces: a colon would make
|
||||
/// git read the argument as an ssh-style `host:path` URL and report a
|
||||
/// hostname rather than a repository, which is a different code path.
|
||||
#[tokio::test]
|
||||
async fn clone_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = clone_repo(injected, &dest, MirrorMode::Plain)
|
||||
.await
|
||||
.expect_err("a remote that is not a repository must fail the clone")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(injected),
|
||||
"git must report the argument as a repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #374: `fetch_repo` interpolates the peer's origin URL into
|
||||
/// `git remote set-url`, a second argv construction with the same contract as
|
||||
/// the clone. It needs its own proof: the clone test cannot reach this path.
|
||||
///
|
||||
/// The assertion is on stored state rather than on the error string, and that
|
||||
/// is deliberate. `git_run` formats its failure as `git {args:?} failed:
|
||||
/// {stderr}`, so the injected value is in the message via the debug-printed
|
||||
/// argv whether or not the delimiter is present — asserting on containment
|
||||
/// would pass with `--` deleted. Delimited, `set-url` succeeds and stores the
|
||||
/// value verbatim; undelimited, git exits 129 with `unknown option` and leaves
|
||||
/// the previous URL in place, so this goes red without the fix.
|
||||
///
|
||||
/// The fetch that follows the rewrite fails, because the stored URL is not a
|
||||
/// repository. That is expected and ignored: the property under test is what
|
||||
/// `set-url` wrote, not whether the subsequent fetch could succeed.
|
||||
#[tokio::test]
|
||||
async fn fetch_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let local = td.path().join("mirror.git");
|
||||
let local_str = local.to_str().unwrap();
|
||||
let placeholder = "http://example.invalid/x.git";
|
||||
|
||||
assert!(Command::new("git")
|
||||
.args(["init", "-q", "--bare", local_str])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", local_str, "remote", "add", "origin", placeholder])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
|
||||
let injected = "--upload-pack=false";
|
||||
let _ = fetch_repo(&local, injected, MirrorMode::Plain).await;
|
||||
|
||||
assert_eq!(
|
||||
git_config_get(local_str, "remote.origin.url")
|
||||
.await
|
||||
.as_deref(),
|
||||
Some(injected),
|
||||
"set-url must store an option-shaped remote verbatim, not reject it as an option"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_unknown_on_git_error() {
|
||||
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,177 @@
|
||||
//! #26 Split PR 4 — `GITLAWB_ENFORCE_OWNER_PUSH` default is consistent across surfaces.
|
||||
//!
|
||||
//! Reviewer 2 (2026-08-28) closed PR #224 with a P2 finding: SECURITY.md and
|
||||
//! README.md both stated the default was `false`, while `Config::enforce_owner_push`
|
||||
//! declared `default_value_t = true` and the runtime, .env.example, and the env
|
||||
//! var table on the README all agreed with the code. Two operator-facing
|
||||
//! surfaces lied about the authorization policy. PR #330 flipped the code
|
||||
//! default and updated .env.example / README env table / docs/RUN-A-NODE.md,
|
||||
//! but missed these two prose mentions.
|
||||
//!
|
||||
//! This guard pins the documentation and example surfaces together. Each one
|
||||
//! is read off the working tree at test time, not a copy in the binary, so a
|
||||
//! revert of the doc edit turns it red:
|
||||
//!
|
||||
//! 1. `SECURITY.md` says the default is `true`.
|
||||
//! 2. `README.md` says the default is `true` in the known-limitations prose.
|
||||
//! 3. `README.md` says the default is `true` in the env-var table row.
|
||||
//! 4. `.env.example` sets `GITLAWB_ENFORCE_OWNER_PUSH=true`.
|
||||
//!
|
||||
//! The runtime declared default (`Config::enforce_owner_push`'s
|
||||
//! `default_value_t = true`) is protected by the
|
||||
//! `enforce_owner_push_is_declared_true_independent_of_the_environment`
|
||||
//! unit test in `config.rs`, which reads the parser directly and is
|
||||
//! env-independent. The two checks together cover the surfaces that
|
||||
//! produced the original doc-vs-code drift; this guard cannot reach into
|
||||
//! the binary crate's `Config` type (it is private to the bin target,
|
||||
//! which has no `lib.rs`) without a larger refactor, so the contract is
|
||||
//! stated as the four documentation/example surfaces above.
|
||||
//!
|
||||
//! The four are checked independently. A check that just verified "at least
|
||||
//! one surface says true" would pass while a stale SECURITY.md lied; a check
|
||||
//! that only verified the binary would not catch the doc drift that
|
||||
//! produced this finding in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn read(rel: &str) -> String {
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join(rel);
|
||||
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// Pin the prose mentions of `GITLAWB_ENFORCE_OWNER_PUSH` so a future doc
|
||||
/// edit cannot reintroduce the "defaults to false" wording the reviewer
|
||||
/// flagged. The wording in the test is what the docs MUST say about the
|
||||
/// default; absence of any prose mention is also a fail because operators
|
||||
/// rely on the docs to learn the default.
|
||||
#[test]
|
||||
fn security_md_states_owner_push_default_is_true() {
|
||||
let security = read("SECURITY.md");
|
||||
let section = section_after(&security, "### Repository write authorization defaults")
|
||||
.unwrap_or_else(|| panic!("SECURITY.md is missing the owner-push section"));
|
||||
|
||||
assert!(
|
||||
section.contains("defaults to `true`"),
|
||||
"SECURITY.md owner-push section must state the default is true. \
|
||||
Found:\n{section}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(§ion),
|
||||
"SECURITY.md owner-push section must not claim the default is false. \
|
||||
Found:\n{section}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_known_limitations_states_owner_push_default_is_true() {
|
||||
let readme = read("README.md");
|
||||
let limitations = section_after(&readme, "Known limitations:")
|
||||
.unwrap_or_else(|| panic!("README.md is missing the Known limitations: section"));
|
||||
|
||||
// The owner-push line is one bullet in the limitations list. Pull the
|
||||
// single line that mentions the env var so the assertion is local to
|
||||
// the claim, not a substring of unrelated text.
|
||||
let line = limitations
|
||||
.lines()
|
||||
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"README.md Known limitations must mention GITLAWB_ENFORCE_OWNER_PUSH.\n\
|
||||
Got:\n{limitations}"
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
line.contains("defaults to `true`") || line.contains("defaults to true"),
|
||||
"README Known-limitations owner-push bullet must state the default is true. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(line),
|
||||
"README Known-limitations owner-push bullet must not claim the default is false. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_env_var_table_states_owner_push_default_is_true() {
|
||||
let readme = read("README.md");
|
||||
let line = readme
|
||||
.lines()
|
||||
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH") && l.contains("|"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("README.md is missing the env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH")
|
||||
});
|
||||
|
||||
assert!(
|
||||
line.contains("Defaults to `true`") || line.contains("defaults to `true`"),
|
||||
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must state the default is true. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(line),
|
||||
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must not claim the default is false. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_example_sets_owner_push_true() {
|
||||
let env = read(".env.example");
|
||||
let line = env
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("GITLAWB_ENFORCE_OWNER_PUSH"))
|
||||
.unwrap_or_else(|| panic!(".env.example is missing GITLAWB_ENFORCE_OWNER_PUSH"));
|
||||
|
||||
assert!(
|
||||
line.contains("=true"),
|
||||
".env.example GITLAWB_ENFORCE_OWNER_PUSH must be set to true. Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A claim that the default is false can hide in many phrasings. Match the
|
||||
/// forms that have appeared in the wild on this branch: "defaults to `false`",
|
||||
/// "defaults to false", "default is false", "is `false` for compatibility",
|
||||
/// "is false for compatibility". The match is intentionally narrow — false
|
||||
/// positives would suppress a real doc edit.
|
||||
fn section_claims_default_is_false(section: &str) -> bool {
|
||||
let lower = section.to_ascii_lowercase();
|
||||
lower.contains("defaults to `false`")
|
||||
|| lower.contains("defaults to false")
|
||||
|| lower.contains("default is `false`")
|
||||
|| lower.contains("default is false")
|
||||
|| (lower.contains("`false`") && lower.contains("for compatibility"))
|
||||
}
|
||||
|
||||
/// Return the prose after a marker up to the next heading boundary. The
|
||||
/// marker is normally a markdown heading (e.g. `### Section`); if it has
|
||||
/// no `#` prefix it is treated as a plain-text marker and the slice is
|
||||
/// bounded by the next heading of any level. This matters because a
|
||||
/// plain-text marker would otherwise produce `header_level = 0`, making
|
||||
/// the same-or-higher-level stop condition unsatisfiable and letting the
|
||||
/// slice run to EOF.
|
||||
fn section_after(doc: &str, heading: &str) -> Option<String> {
|
||||
let start = doc.find(heading)?;
|
||||
let after = &doc[start..];
|
||||
let header_level = heading.chars().take_while(|c| *c == '#').count();
|
||||
|
||||
// Skip past the heading line itself.
|
||||
let body_start = after.find('\n')? + 1;
|
||||
let body = &after[body_start..];
|
||||
|
||||
// Stop at the next heading of the same or higher level, or at the next
|
||||
// heading of any level when the marker had no `#` prefix.
|
||||
let mut end = body.len();
|
||||
for line in body.lines() {
|
||||
let level = line.chars().take_while(|c| *c == '#').count();
|
||||
if level > 0 && (header_level == 0 || level <= header_level) {
|
||||
end = line.as_ptr() as usize - body.as_ptr() as usize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(body[..end].to_string())
|
||||
}
|
||||
@@ -26,6 +26,8 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
dirs = "5"
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
urlencoding = "2"
|
||||
alloy = { version = "1", default-features = false, features = [
|
||||
"contract",
|
||||
|
||||
+541
-21
@@ -4,6 +4,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -34,7 +35,12 @@ pub enum CertCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show a specific ref certificate and verify its signature
|
||||
/// Show a specific ref certificate and verify its signature.
|
||||
///
|
||||
/// `--verify` requires `--expect-node <did>`: the queried node's
|
||||
/// self-reported DID is not a trust anchor, because a node serving a
|
||||
/// forged certificate also controls its own self-report. Callers
|
||||
/// must name the issuer they trust.
|
||||
Show {
|
||||
/// Repository in <owner>/<repo> or <repo> format
|
||||
repo: String,
|
||||
@@ -49,13 +55,17 @@ pub enum CertCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Exit non-zero unless the Ed25519 signature verifies AND the
|
||||
/// issuing node matches the queried node (or --expect-node)
|
||||
/// issuing node matches --expect-node (which --verify requires:
|
||||
/// without a caller-supplied anchor there is no trusted issuer to
|
||||
/// verify against)
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
/// Expected issuing node DID for --verify. A valid signature alone
|
||||
/// only proves the cert is internally consistent — signed by whatever
|
||||
/// key it names — so --verify also anchors the issuer to a DID you
|
||||
/// trust: this value when given, else the queried node's DID.
|
||||
/// trust. The queried node's self-reported DID is deliberately NOT
|
||||
/// accepted as a fallback anchor: the node that served the cert can
|
||||
/// serve a matching self-report.
|
||||
#[arg(long, requires = "verify")]
|
||||
expect_node: Option<String>,
|
||||
},
|
||||
@@ -152,6 +162,24 @@ async fn cmd_show(
|
||||
let node_did = cert["node_did"].as_str().unwrap_or("?");
|
||||
let signature = cert["signature"].as_str().unwrap_or("?");
|
||||
let issued_at = cert["issued_at"].as_str().unwrap_or("?");
|
||||
// #26 Split PR 3: read the wire-format version. A MISSING key
|
||||
// selects legacy v1 (an old server that predates the field).
|
||||
// A PRESENT value must be a JSON integer that fits in u32 and
|
||||
// names a version this client supports — otherwise the response
|
||||
// declares a format we cannot represent and we refuse to verify
|
||||
// rather than guess the payload shape. Reviewer 2 finding:
|
||||
// collapsing `null`, a string, a float, or an overflow integer
|
||||
// onto v1 would let a well-signed v1 signature pass `--verify`
|
||||
// against a server that explicitly said "version 2 (or 99, or
|
||||
// 4294967297)" — that is the exact mismatch the field exists
|
||||
// to prevent. parse_cert_version is the load-bearing parser
|
||||
// that distinguishes missing-key (legacy v1) from invalid-value
|
||||
// (unsupported).
|
||||
let parsed_version = parse_cert_version(cert.get("version"));
|
||||
let version_display: String = match &parsed_version {
|
||||
Ok(v) => v.to_string(),
|
||||
Err(reason) => format!("unsupported ({reason})"),
|
||||
};
|
||||
|
||||
println!("Ref Certificate: {cert_id}");
|
||||
println!(" Ref: {ref_name}");
|
||||
@@ -160,6 +188,7 @@ async fn cmd_show(
|
||||
println!(" Pusher: {pusher}");
|
||||
println!(" Node DID: {node_did}");
|
||||
println!(" Issued at: {issued_at}");
|
||||
println!(" Version: {version_display}");
|
||||
println!(" Signature: {signature}");
|
||||
println!();
|
||||
|
||||
@@ -169,9 +198,31 @@ async fn cmd_show(
|
||||
// This proves the cert is internally authentic — signed by the key it
|
||||
// names; the node-DID comparison below covers *which* node that is.
|
||||
let repo_id = cert["repo_id"].as_str().unwrap_or("");
|
||||
let verdict = verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
);
|
||||
let verdict = match parsed_version {
|
||||
// v1 is the only version this client verifies. The v1
|
||||
// signed payload is the 7-field canonical form with no
|
||||
// `version` key — see gitlawb-node/src/cert.rs. A future
|
||||
// v2+ cert has a different signed payload shape (the
|
||||
// version field becomes part of the signed JSON per
|
||||
// `v2_signing_payload`), so a v2+ cert from a server this
|
||||
// client does not know about must NOT be silently verified
|
||||
// as v1.
|
||||
// Reviewer 2: refuse rather than guess; the client and
|
||||
// server must agree on the version.
|
||||
// parse_cert_version admits exactly one Ok value: 1 (missing key and
|
||||
// integer 1 both land there; 2, floats, strings, overflow and every
|
||||
// other spelling come back Err with their own reason). A separate
|
||||
// `Ok(v != 1)` arm existed here and was DEAD CODE — a test aimed at it
|
||||
// could never catch drift in this match (review round 2) — so the
|
||||
// match now mirrors the parser's real contract, which the
|
||||
// `parse_cert_version_truth_table` test pins.
|
||||
Ok(_) => verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
),
|
||||
Err(ref reason) => Err(format!(
|
||||
"cert declared a version this client cannot represent ({reason}); refusing to verify"
|
||||
)),
|
||||
};
|
||||
|
||||
println!("Signature verification:");
|
||||
match &verdict {
|
||||
@@ -181,7 +232,11 @@ async fn cmd_show(
|
||||
);
|
||||
}
|
||||
Err(reason) => {
|
||||
println!(" INVALID — {reason}");
|
||||
// A version refusal means no cryptographic check ran, so it must
|
||||
// not print as an INVALID signature: operators and log scrapers
|
||||
// would read that as a failed Ed25519 verification. INVALID is
|
||||
// reserved for failures out of verify_signature itself.
|
||||
println!(" {} — {reason}", signature_verdict_label(&parsed_version));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,29 +253,39 @@ async fn cmd_show(
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
let current_node_did = current.as_ref().ok().cloned();
|
||||
for line in did_check_report(¤t, node_did) {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
if require_valid {
|
||||
if let Err(reason) = verdict {
|
||||
anyhow::bail!("certificate signature did not verify: {reason}");
|
||||
// Round 3: do not claim the signature failed when it was
|
||||
// never checked. A version refusal means the payload
|
||||
// shape is unknown, so no signature verification ran.
|
||||
anyhow::bail!("certificate could not be verified: {reason}");
|
||||
}
|
||||
// A valid signature proves internal consistency only: the payload was
|
||||
// signed by whatever key the certificate itself names. A hostile
|
||||
// source can mint a keypair, put its DID in node_did, and self-sign.
|
||||
// --verify therefore also anchors the issuer to a trusted DID:
|
||||
// --expect-node when given, else the DID of the node being queried.
|
||||
let expected = expect_node.as_deref().or(current_node_did.as_deref());
|
||||
match expected {
|
||||
// --verify therefore also anchors the issuer — and the anchor must be
|
||||
// a DID the CALLER trusts, which is exactly what --expect-node is.
|
||||
// The DID the queried node reports at `/` is NOT an anchor: the same
|
||||
// node that served a forged certificate can serve a matching
|
||||
// self-report, and with that fallback a wholly forged cert printed
|
||||
// VALID and exited 0 (review round 2, demonstrated by execution). So
|
||||
// --verify without --expect-node refuses to claim a trusted issuer at
|
||||
// all, rather than laundering the server's self-assertion into one.
|
||||
match expect_node.as_deref() {
|
||||
Some(expected) if expected == node_did => {}
|
||||
Some(expected) => anyhow::bail!(
|
||||
"certificate is signed by {node_did}, but the expected issuer is {expected} — \
|
||||
a valid signature alone proves internal consistency, not a trusted issuer"
|
||||
),
|
||||
None => anyhow::bail!(
|
||||
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
|
||||
"--verify needs --expect-node <did> to anchor the issuer: the signature is \
|
||||
valid, but it only proves the cert is self-consistent with the DID it \
|
||||
names ({node_did}); the queried node's self-reported DID is not a trust \
|
||||
anchor"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -268,6 +333,18 @@ fn did_check_report(current: &std::result::Result<String, String>, node_did: &st
|
||||
}
|
||||
}
|
||||
|
||||
/// The stdout label for a failed verification verdict. A `parsed_version`
|
||||
/// error means `verify_signature` never ran, so the refusal must print as
|
||||
/// UNSUPPORTED VERSION rather than INVALID, which operators and log
|
||||
/// scrapers would read as a failed Ed25519 check.
|
||||
fn signature_verdict_label(parsed_version: &std::result::Result<u32, String>) -> &'static str {
|
||||
if parsed_version.is_err() {
|
||||
"UNSUPPORTED VERSION"
|
||||
} else {
|
||||
"INVALID"
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the node's canonical signing payload (field order must match
|
||||
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
|
||||
/// certificate's Ed25519 signature against the key embedded in `node_did`.
|
||||
@@ -314,6 +391,65 @@ fn verify_signature(
|
||||
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
|
||||
}
|
||||
|
||||
/// Parse the wire-format `version` field of a certificate response.
|
||||
///
|
||||
/// Semantics (Reviewer 2):
|
||||
/// - MISSING key (`None`) → `Ok(1)`. An old server predates the field, so
|
||||
/// the cert is by definition the v1 (pre-versioning) shape; the v1
|
||||
/// verify path is the only path that can possibly match the bytes.
|
||||
/// - PRESENT key must be a JSON integer that fits losslessly in `u32`
|
||||
/// and equals a version this client supports. `null`, a string,
|
||||
/// a float, an overflow integer, or any version other than 1 all
|
||||
/// yield `Err`. Collapsing any of these onto v1 would let a
|
||||
/// well-signed v1 signature pass `--verify` against a response
|
||||
/// that explicitly declared a format this client cannot represent
|
||||
/// — which is exactly the mismatch the version field exists to
|
||||
/// prevent.
|
||||
///
|
||||
/// The supported-version set is hard-coded to `{1}` because PR 3 ships
|
||||
/// only v1 verification; bump this set (and the verdict branch) when
|
||||
/// v2 verification lands.
|
||||
fn parse_cert_version(value: Option<&Value>) -> Result<u32, String> {
|
||||
let v = match value {
|
||||
None => return Ok(1),
|
||||
Some(v) => v,
|
||||
};
|
||||
|
||||
// serde_json::Value::as_u64 rejects strings, floats, booleans,
|
||||
// nulls, arrays, and objects — but it silently truncates floats
|
||||
// that are whole numbers (e.g. 2.0 → 2). We must reject floats
|
||||
// explicitly so a server cannot smuggle a v2 cert through the
|
||||
// float shape.
|
||||
if v.is_f64() {
|
||||
return Err(format!(
|
||||
"version is a JSON number with a fractional part ({v}); expected an integer"
|
||||
));
|
||||
}
|
||||
|
||||
let n = v.as_u64().ok_or_else(|| {
|
||||
// null / string / bool / array / object — anything that is
|
||||
// not a JSON integer.
|
||||
format!("version is {v}; expected a JSON integer")
|
||||
})?;
|
||||
|
||||
// Lossy narrowing: refuse anything that does not fit in u32.
|
||||
// u32::MAX is 4_294_967_295; serde_json only goes up to u64.
|
||||
if n > u32::MAX as u64 {
|
||||
return Err(format!(
|
||||
"version {n} does not fit in u32 (max {})",
|
||||
u32::MAX
|
||||
));
|
||||
}
|
||||
|
||||
let n = n as u32;
|
||||
if n != 1 {
|
||||
return Err(format!(
|
||||
"this client supports cert version 1 only; server returned {n}"
|
||||
));
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
|
||||
if id.len() >= 36 {
|
||||
return Ok(id.to_string());
|
||||
@@ -811,7 +947,15 @@ mod tests {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let got = cmd_show("alice/r".to_string(), id, server.url(), None, true, None).await;
|
||||
let got = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(kp.did().as_str().to_string()),
|
||||
)
|
||||
.await;
|
||||
assert!(got.is_ok(), "valid sig + matching issuer must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
@@ -827,10 +971,17 @@ mod tests {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
let err = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(other.did().as_str().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("expected issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
@@ -848,7 +999,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("cannot anchor the issuer"), "got: {err}");
|
||||
assert!(err.contains("--expect-node"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -889,7 +1040,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("signature did not verify"), "got: {err}");
|
||||
assert!(err.contains("does not match"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -973,4 +1124,373 @@ mod tests {
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
}
|
||||
|
||||
/// #26 Split PR 3: a missing `version` field on a cert defaults
|
||||
/// to 1, so an old server's response is forward-compatible with
|
||||
/// a new client. The v1 verify path is taken, and a
|
||||
/// well-signed v1 cert round-trips through `verify_signature`.
|
||||
///
|
||||
/// Reviewer 1 finding: the previous shape only parsed the field
|
||||
/// and never called `verify_signature`, so the test could not
|
||||
/// fail if the verdict branch was wired to the wrong path. This
|
||||
/// version signs and verifies the full round-trip.
|
||||
#[test]
|
||||
fn missing_version_defaults_to_1_and_verifies() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
// Simulate the JSON a v1 server returns: no `version` key.
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "test-id",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig.clone(),
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
// no `version` field
|
||||
});
|
||||
|
||||
// Forward-compat: the parse yields v1 (the only version this
|
||||
// client verifies), and the verdict arm matching Ok(1) feeds
|
||||
// the v1 payload through verify_signature end to end.
|
||||
assert_eq!(parse_cert_version(cert_json.get("version")).unwrap(), 1);
|
||||
let verdict = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
&sig,
|
||||
);
|
||||
assert!(
|
||||
verdict.is_ok(),
|
||||
"missing version must take the v1 verify path: {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3: an explicit `version: 1` on a cert is the v1
|
||||
/// verify path. A v1 cert with an explicit version verifies the
|
||||
/// same as a v1 cert without one — round-trip through
|
||||
/// `verify_signature`.
|
||||
#[test]
|
||||
fn explicit_version_1_takes_v1_path() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
// Simulate a v1 cert with an explicit `version: 1` field.
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "test-id",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig.clone(),
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
"version": 1,
|
||||
});
|
||||
|
||||
// The parse arms the Ok(1) verdict branch, which calls
|
||||
// verify_signature on the v1 payload. A signature mismatch
|
||||
// here would mean the verdict branch was wired to the
|
||||
// wrong path or the payload drifted.
|
||||
assert_eq!(parse_cert_version(cert_json.get("version")).unwrap(), 1);
|
||||
let verdict = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
&sig,
|
||||
);
|
||||
assert!(
|
||||
verdict.is_ok(),
|
||||
"explicit version: 1 must take the v1 verify path: {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3 + Reviewer 2: parse_cert_version is the
|
||||
/// load-bearing gate that distinguishes a missing-key (legacy
|
||||
/// server) from a present-but-malformed value. The four cases
|
||||
/// below pin every cell of that truth table; collapsing any
|
||||
/// pair would let a server advertise "version 2" (or 99, or
|
||||
/// 4294967297, or "two") while the client runs the v1
|
||||
/// signature path on a different signed payload — a hostile
|
||||
/// misconfiguration that `--verify` must not silently accept.
|
||||
#[test]
|
||||
fn parse_cert_version_truth_table() {
|
||||
// MISSING key → legacy v1 (the v1 path is the only one that
|
||||
// could match pre-versioning bytes).
|
||||
assert_eq!(
|
||||
parse_cert_version(None).unwrap(),
|
||||
1,
|
||||
"a missing version field is the legacy v1 path"
|
||||
);
|
||||
|
||||
// Explicit 1 → v1.
|
||||
assert_eq!(
|
||||
parse_cert_version(Some(&serde_json::json!(1))).unwrap(),
|
||||
1,
|
||||
"explicit version 1 is the v1 path"
|
||||
);
|
||||
|
||||
// Explicit 2 → Err, NOT Ok(1). Reviewer 2: a v1 signature
|
||||
// must not verify against a server that said "version 2".
|
||||
assert!(
|
||||
parse_cert_version(Some(&serde_json::json!(2))).is_err(),
|
||||
"explicit version 2 must not collapse to v1"
|
||||
);
|
||||
|
||||
// u32 overflow (2^32 + 1) → Err, NOT Ok(1). The previous
|
||||
// `.map(|v| v as u32)` silently truncated this to 1.
|
||||
let overflow = serde_json::json!(u64::from(u32::MAX) + 1);
|
||||
assert!(
|
||||
parse_cert_version(Some(&overflow)).is_err(),
|
||||
"a version that overflows u32 must not truncate to 1"
|
||||
);
|
||||
|
||||
// Non-numeric: a string, a float, null, bool — every shape
|
||||
// that is not a JSON integer is rejected. None of these
|
||||
// may collapse to v1.
|
||||
for bad in [
|
||||
serde_json::json!("two"),
|
||||
serde_json::json!(2.0), // serde_json::Value::is_f64 is true for this
|
||||
serde_json::json!(2.5), // obviously fractional
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([2]),
|
||||
serde_json::json!({"v": 2}),
|
||||
] {
|
||||
assert!(
|
||||
parse_cert_version(Some(&bad)).is_err(),
|
||||
"non-integer version {bad} must not collapse to v1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A version refusal must not print as INVALID — no cryptographic
|
||||
/// check ran, so labelling it INVALID would tell operators an Ed25519
|
||||
/// verification failed when the payload shape was the disqualifier.
|
||||
/// Removing the label branch fails this test.
|
||||
#[test]
|
||||
fn signature_verdict_label_distinguishes_refusal_from_bad_signature() {
|
||||
assert_eq!(
|
||||
signature_verdict_label(&Err("version 2 not supported".to_string())),
|
||||
"UNSUPPORTED VERSION"
|
||||
);
|
||||
assert_eq!(signature_verdict_label(&Ok(1)), "INVALID");
|
||||
}
|
||||
|
||||
/// #26 Split PR 3, round 2: a `version: 2` certificate must fail
|
||||
/// `--verify` THROUGH `cmd_show` itself, not through a copy of its
|
||||
/// match. The prior form of this test duplicated the verdict match in
|
||||
/// the test body and aimed at an `Ok(v != 1)` arm the real parser can
|
||||
/// never produce, so gutting `cmd_show`'s rejection kept every test
|
||||
/// green (demonstrated by the reviewer by execution). Driving the
|
||||
/// command end to end is what makes this a guard: the mock serves a
|
||||
/// cert whose SIGNATURE IS VALID for v1 bytes but which declares
|
||||
/// version 2, and the command must exit Err with the version named —
|
||||
/// the v1 verify path must not launder it through.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_verify_rejects_v2_even_with_valid_v1_signature() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
|
||||
// Sign a v1-shaped payload so the ONLY disqualifier is the version.
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "0123456789abcdef0123456789abcdef0123",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
"version": 2,
|
||||
});
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// 36-char id short-circuits resolve_cert_id; "owner/repo" input
|
||||
// short-circuits resolve_repo — the cert GET is the only required
|
||||
// route. `/` may be probed for the contextual DID line; serving it
|
||||
// keeps the test about the verdict, not about network noise.
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
"/api/v1/repos/o/r/certs/0123456789abcdef0123456789abcdef0123",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_json.to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::json!({ "did": node_did }).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(node_did.clone()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a version-2 cert must fail --verify even with a valid v1 signature");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("version"),
|
||||
"the failure must name the version mismatch, not a generic invalid signature: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Round 2's second demonstrated escape: with no --expect-node, the old
|
||||
/// fallback anchored the issuer to the DID the QUERIED NODE reports at
|
||||
/// `/` — which a forger controls along with the cert, so a wholly forged
|
||||
/// self-signed certificate passed --verify with exit 0. `cmd_show` with
|
||||
/// --verify and no --expect-node must now refuse to claim a trusted
|
||||
/// issuer, even when the served cert's signature is valid and the node's
|
||||
/// self-report matches it.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_verify_without_expect_node_refuses_self_asserted_anchor() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "0123456789abcdef0123456789abcdef0123",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// P3 (reviewer round 2): both mocks are required to be
|
||||
// hit for the test to mean anything. `mockito 1.7`'s
|
||||
// `assert_on_drop` is off by default, so the previous
|
||||
// `_cert` and `_root` bindings would silently let a
|
||||
// regression that skipped the root fetch pass.
|
||||
// `.expect(2)` pins each mock's call count (the cert mock
|
||||
// is hit twice because the test runs `cmd_show` twice) and
|
||||
// `.assert_async()` at the end fires if any are unmet.
|
||||
let cert_mock = server
|
||||
.mock(
|
||||
"GET",
|
||||
"/api/v1/repos/o/r/certs/0123456789abcdef0123456789abcdef0123",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_json.to_string())
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
// The forger's matching self-report — the exact shape
|
||||
// that used to convert into a trust anchor. The guard's
|
||||
// whole point is to make the test FAIL if a future
|
||||
// regression stops calling `/` here: `.expect(2)` +
|
||||
// `.assert_async()` makes that dependency visible.
|
||||
let root_mock = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::json!({ "did": node_did }).to_string())
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("--verify without --expect-node must not mint a trust anchor from the node's self-report");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("--expect-node"),
|
||||
"the refusal must tell the caller how to anchor trust: {msg}"
|
||||
);
|
||||
|
||||
// Positive control: the same cert with the DID explicitly trusted
|
||||
// passes, so the guard is about the anchor, not the signature.
|
||||
cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(node_did),
|
||||
)
|
||||
.await
|
||||
.expect("explicitly anchored --verify of a valid v1 cert must pass");
|
||||
|
||||
// P3 (reviewer round 2): confirm the mocks were actually
|
||||
// hit. Without this, a regression that skipped the cert or
|
||||
// root fetches would still produce a passing test (the
|
||||
// `expect_err` arm succeeds, the positive control succeeds)
|
||||
// and the test would mean nothing.
|
||||
cert_mock.assert_async().await;
|
||||
root_mock.assert_async().await;
|
||||
}
|
||||
}
|
||||
+89
-5
@@ -163,8 +163,8 @@ pub fn setup_partial_clone(
|
||||
|
||||
if withheld_globs.is_empty() {
|
||||
match branch {
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", remote_url, dest_str])?,
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, "--", remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", "--", remote_url, dest_str])?,
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -174,6 +174,7 @@ pub fn setup_partial_clone(
|
||||
"-q",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
"--",
|
||||
remote_url,
|
||||
dest_str,
|
||||
])?;
|
||||
@@ -388,7 +389,7 @@ async fn recover_encrypted_blobs(
|
||||
let plaintext = match open_blob(&envelope, keypair) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("warning: could not decrypt {oid}: {e}");
|
||||
emit_warning(&format!("warning: could not decrypt {oid}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -406,7 +407,9 @@ async fn recover_encrypted_blobs(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -804,7 +807,9 @@ async fn recover_from_arweave(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -1090,6 +1095,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_plain_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], None).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_with_branch_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let branch_out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
td.path().join("origin").to_str().unwrap(),
|
||||
"branch",
|
||||
"--show-current",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
let branch = String::from_utf8(branch_out.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], Some(&branch)).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
/// #374: every `setup_partial_clone` shape interpolates `remote_url` into a
|
||||
/// `git clone` argv. The `--` delimiter is what forces an option-shaped
|
||||
/// remote to be read as a repository rather than parsed as an option.
|
||||
///
|
||||
/// The assertion is on git's own words — `repository '<value>'`, single-quoted
|
||||
/// — and not merely on the value appearing somewhere in the error. `git_global`
|
||||
/// formats its failure as `git {args:?} failed: {stderr}`, so the injected
|
||||
/// string is already present in the debug-printed argv whether or not the
|
||||
/// delimiter is there; asserting on containment alone would pass with `--`
|
||||
/// deleted. Undelimited, git consumes the value as an option, leaving the
|
||||
/// destination as the only positional, and names *that* instead
|
||||
/// (`repository '<dest>' does not exist`) — so this goes red without the fix.
|
||||
///
|
||||
/// The payload carries no `:` or space: a colon would make git read the
|
||||
/// argument as an ssh-style `host:path` URL and report a hostname, which is a
|
||||
/// different parse.
|
||||
fn assert_option_shaped_remote_is_a_repository(withheld: &[String], branch: Option<&str>) {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("dest");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = setup_partial_clone(&dest, injected, withheld, &[], branch)
|
||||
.expect_err("an option-shaped remote is not a repository and must fail")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(&format!("repository '{injected}'")),
|
||||
"git must name the argument as the repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_clone_never_parses_a_remote_as_a_git_option() {
|
||||
assert_option_shaped_remote_is_a_repository(&[], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// Pins that `--branch <b>` stays ahead of the delimiter: if `--` were moved
|
||||
// before `--branch`, git would read the branch name as a positional instead.
|
||||
assert_option_shaped_remote_is_a_repository(&[], Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// The withheld-globs arm is a separate argv construction
|
||||
// (`--filter=blob:none --no-checkout`) and needs its own proof.
|
||||
assert_option_shaped_remote_is_a_repository(&["/secret/**".to_string()], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_patterns_subtree_and_exact() {
|
||||
assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]);
|
||||
|
||||
+177
-36
@@ -3,7 +3,7 @@
|
||||
//! Checks:
|
||||
//! 1. Identity — ~/.gitlawb/identity.pem exists, DID parseable
|
||||
//! 2. Registration — ~/.gitlawb/ucan.json exists (registered with a node)
|
||||
//! 3. GITLAWB_NODE — env var is set to a non-localhost URL
|
||||
//! 3. GITLAWB_NODE — env var points at a public node (unset is advisory)
|
||||
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.twigpine.com)
|
||||
//! 5. git helper — git-remote-gitlawb is in PATH
|
||||
//! 6. git — git is installed
|
||||
@@ -80,7 +80,6 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
});
|
||||
|
||||
let mut checks = Vec::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
// ── 1. Identity ───────────────────────────────────────────────────────
|
||||
let pem_path = dir.join("identity.pem");
|
||||
@@ -141,32 +140,16 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
|
||||
match std::env::var("GITLAWB_NODE") {
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Ok(v) if is_loopback_url(&v) => {
|
||||
checks.push(Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(v) if !v.is_empty() => {
|
||||
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
|
||||
}
|
||||
_ => {
|
||||
checks.push(Check::fail(
|
||||
"GITLAWB_NODE",
|
||||
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
|
||||
"export GITLAWB_NODE=https://node.twigpine.com",
|
||||
));
|
||||
}
|
||||
}
|
||||
checks.push(gitlawb_node_env_check(
|
||||
std::env::var("GITLAWB_NODE").ok().as_deref(),
|
||||
));
|
||||
|
||||
// ── 4. Node connectivity ──────────────────────────────────────────────
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
// GITLAWB_NODE="" binds args.node to "" through clap, and check 3 groups
|
||||
// empty with unset. The probe must apply the same normalization or it
|
||||
// would dial "" and Fail while the env row only Warns.
|
||||
let node_url = effective_node_url(&args.node);
|
||||
let client = NodeClient::new(node_url, None);
|
||||
match client.get("/").await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let info = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
@@ -175,7 +158,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
let short_did = did.chars().take(30).collect::<String>();
|
||||
checks.push(Check::pass(
|
||||
"node",
|
||||
format!("{} — v{version} ({short_did}…)", args.node),
|
||||
format!("{} — v{version} ({short_did}…)", node_url),
|
||||
));
|
||||
// Capability drift: a node newer than this CLI may require features
|
||||
// (RFC 9421 signing, iCaptcha) the CLI doesn't speak.
|
||||
@@ -194,14 +177,14 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
Ok(resp) => {
|
||||
checks.push(Check::fail(
|
||||
"node",
|
||||
format!("{} returned HTTP {}", args.node, resp.status()),
|
||||
format!("{} returned HTTP {}", node_url, resp.status()),
|
||||
"check GITLAWB_NODE env var or try gl register --node <url>",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(Check::fail(
|
||||
"node",
|
||||
format!("{} unreachable: {e}", args.node),
|
||||
format!("{} unreachable: {e}", node_url),
|
||||
"check your internet connection or set GITLAWB_NODE",
|
||||
));
|
||||
}
|
||||
@@ -285,23 +268,21 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
CheckState::Fail => "✗",
|
||||
};
|
||||
println!(" {icon} {:<24} {}", check.label, check.detail);
|
||||
if matches!(check.state, CheckState::Fail) {
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
let has_failures_now = has_failures(&checks);
|
||||
let has_issues = checks
|
||||
.iter()
|
||||
.any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
|
||||
if !has_issues {
|
||||
println!("Everything looks good. Run `gl quickstart` to create your first repo.");
|
||||
} else {
|
||||
if all_ok {
|
||||
println!("Setup looks good with some warnings:");
|
||||
} else {
|
||||
if has_failures_now {
|
||||
println!("Some checks failed. Suggested fixes:");
|
||||
} else {
|
||||
println!("Setup looks good with some warnings:");
|
||||
}
|
||||
for check in &checks {
|
||||
if matches!(check.state, CheckState::Fail | CheckState::Warn) {
|
||||
@@ -311,14 +292,75 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if !all_ok {
|
||||
if has_failures_now {
|
||||
println!("Run `gl quickstart` for a guided setup.");
|
||||
}
|
||||
}
|
||||
|
||||
// #357: a non-zero exit is the only signal scripting and CI have. Warn-only
|
||||
// rows stay exit 0 (iCaptcha offline, version drift, shell-alias shadowing
|
||||
// are all non-fatal); Fail-class rows (identity, registration, node
|
||||
// reachable, git-remote-gitlawb present, git present) flip to exit 1.
|
||||
// Direct `process::exit` rather than returning Err: the prose above is the
|
||||
// user-facing summary, and an anyhow Error frame would just duplicate it.
|
||||
if has_failures(&checks) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify the `GITLAWB_NODE` env var for `gl doctor`. Extracted so unit
|
||||
/// tests can pin the Warn tier directly (#357): an unset env is advisory, not
|
||||
/// a failure, because `--node` defaults to `https://node.twigpine.com` and the
|
||||
/// CLI works without it. Flipping this back to `Check::fail` must break a
|
||||
/// test, not just change prose.
|
||||
fn gitlawb_node_env_check(env_val: Option<&str>) -> Check {
|
||||
match env_val {
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Some(v) if is_loopback_url(v) => Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
),
|
||||
Some(v) if !v.is_empty() => Check::pass("GITLAWB_NODE", v.to_string()),
|
||||
// `--node` defaults to `https://node.twigpine.com` and the CLI works
|
||||
// fine without the variable, so an unset env is advisory, not a
|
||||
// failure. Warn-class keeps the exit code 0 for a stock working
|
||||
// install; this used to be Fail, which the obvious "return non-zero
|
||||
// on any failure" change would have flipped to exit 1 on every
|
||||
// default-config install (#357).
|
||||
_ => Check::warn(
|
||||
"GITLAWB_NODE",
|
||||
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
|
||||
format!("export GITLAWB_NODE={PUBLIC_NODE}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL the connectivity probe should dial. `GITLAWB_NODE` set but empty
|
||||
/// (or whitespace-only) binds `args.node` to `""` through clap rather than
|
||||
/// to the `default_value`, and check 3 already groups empty with unset, so
|
||||
/// the probe applies the same normalization: an empty effective URL means
|
||||
/// the public node, same as unset.
|
||||
fn effective_node_url(node: &str) -> &str {
|
||||
if node.trim().is_empty() {
|
||||
PUBLIC_NODE
|
||||
} else {
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one check is Fail-class. Warn-only runs (iCaptcha offline,
|
||||
/// version drift, shell-alias shadowing, GITLAWB_NODE unset) keep exit 0;
|
||||
/// this is the gating predicate for #357's exit-status fix.
|
||||
fn has_failures(checks: &[Check]) -> bool {
|
||||
checks.iter().any(|c| matches!(c.state, CheckState::Fail))
|
||||
}
|
||||
|
||||
/// Check if a binary name exists anywhere on PATH.
|
||||
/// True when the rc file contains a real `unalias` command naming `gl` —
|
||||
/// not a comment, and not a longer word like `unalias global`. Ordering
|
||||
@@ -644,4 +686,103 @@ mod tests {
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("GitHub API returned HTTP 403"));
|
||||
}
|
||||
|
||||
/// #357: an all-Ok run does not flip the exit code to non-zero — a healthy
|
||||
/// install must keep returning 0 so scripts like `gl doctor && gl push`
|
||||
/// keep working.
|
||||
#[test]
|
||||
fn exit_predicate_is_false_for_all_ok() {
|
||||
let checks = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::pass("registration", "ok"),
|
||||
Check::pass("git", "ok"),
|
||||
];
|
||||
assert!(!has_failures(&checks));
|
||||
}
|
||||
|
||||
/// #357: Warn-class rows (iCaptcha offline, version drift, shell-alias
|
||||
/// shadowing, GITLAWB_NODE unset on a default-config install) are NOT
|
||||
/// exit-status failures — they are still printed to the user but the
|
||||
/// process exits 0. This is the rule that the previous "any
|
||||
/// Fail-or-Warn row → exit 1" change would have broken for stock
|
||||
/// installs that simply have not set the env var.
|
||||
#[test]
|
||||
fn exit_predicate_is_false_for_warn_only() {
|
||||
let checks = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::warn("GITLAWB_NODE", "unset", "export GITLAWB_NODE=..."),
|
||||
Check::warn("iCaptcha", "offline", "check connectivity"),
|
||||
Check::warn("version", "drift", "upgrade"),
|
||||
Check::warn("shell alias", "omz git plugin", "unalias gl"),
|
||||
];
|
||||
assert!(!has_failures(&checks));
|
||||
}
|
||||
|
||||
/// #357: a single Fail-class row trips the exit code. The set of Fail
|
||||
/// rows is whatever `run` writes through `Check::fail`: identity missing
|
||||
/// or unparseable, registration missing/malformed, node unreachable,
|
||||
/// git-remote-gitlawb absent, git absent.
|
||||
#[test]
|
||||
fn exit_predicate_is_true_when_a_fail_row_is_present() {
|
||||
let checks = vec![
|
||||
Check::pass("registration", "ok"),
|
||||
Check::fail("identity", "missing", "gl identity new"),
|
||||
Check::pass("git", "ok"),
|
||||
];
|
||||
assert!(has_failures(&checks));
|
||||
|
||||
let mixed_warns = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::warn("GITLAWB_NODE", "unset", "export"),
|
||||
Check::fail("node", "unreachable", "check network"),
|
||||
Check::warn("iCaptcha", "offline", "check"),
|
||||
];
|
||||
assert!(has_failures(&mixed_warns));
|
||||
}
|
||||
|
||||
/// #357: `GITLAWB_NODE` unset must be Warn, not Fail. This pins the
|
||||
/// re-tier so flipping it back to `Check::fail` breaks the suite — the
|
||||
/// previous `has_failures`-only tests built their own `Check::warn` rows
|
||||
/// and could not see the re-tier at all.
|
||||
#[test]
|
||||
fn gitlawb_node_env_unset_is_warn() {
|
||||
let check = gitlawb_node_env_check(None);
|
||||
assert!(matches!(check.state, CheckState::Warn));
|
||||
assert!(check.detail.contains("not set"));
|
||||
let empty = gitlawb_node_env_check(Some(""));
|
||||
assert!(matches!(empty.state, CheckState::Warn));
|
||||
}
|
||||
|
||||
/// `GITLAWB_NODE=""` binds `args.node` to `""` through clap, and check 3
|
||||
/// groups empty with unset. The probe must dial the same URL unset
|
||||
/// would — removing the normalization makes check 4 probe `""` and Fail
|
||||
/// while the env row only Warns, so an empty env install exits 1.
|
||||
#[test]
|
||||
fn effective_node_url_treats_empty_and_blank_as_unset() {
|
||||
assert_eq!(effective_node_url(""), PUBLIC_NODE);
|
||||
assert_eq!(effective_node_url(" "), PUBLIC_NODE);
|
||||
assert_eq!(effective_node_url("\t\n"), PUBLIC_NODE);
|
||||
assert_eq!(
|
||||
effective_node_url("http://127.0.0.1:7545"),
|
||||
"http://127.0.0.1:7545"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlawb_node_env_set_is_pass() {
|
||||
let check = gitlawb_node_env_check(Some("https://node.twigpine.com"));
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("https://node.twigpine.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlawb_node_env_loopback_is_pass() {
|
||||
for url in ["http://127.0.0.1:7545", "http://localhost:7545"] {
|
||||
let check = gitlawb_node_env_check(Some(url));
|
||||
assert!(
|
||||
matches!(check.state, CheckState::Ok),
|
||||
"loopback {url} must be Ok, not Warn/Fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,27 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
|
||||
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
|
||||
}
|
||||
|
||||
/// Load keypair from an optional directory override, distinguishing missing default
|
||||
/// identity (clean anonymous) from an explicit directory error or corrupt PEM.
|
||||
pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Keypair>> {
|
||||
if let Some(d) = dir {
|
||||
return load_keypair_from_dir(Some(d)).map(Some);
|
||||
}
|
||||
let base = match dirs::home_dir() {
|
||||
Some(h) => h.join(".gitlawb"),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let path = key_path(&base);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(pem) => Keypair::from_pem(&pem)
|
||||
.context("failed to load keypair from PEM")
|
||||
.map(Some),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(anyhow::Error::from(e)
|
||||
.context(format!("failed to read identity from {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_new(dir: Option<PathBuf>, force: bool) -> Result<()> {
|
||||
cmd_new_with_reader(dir, force, &mut std::io::stdin().lock()).await
|
||||
}
|
||||
|
||||
+384
-13
@@ -43,7 +43,6 @@ use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct McpArgs {
|
||||
@@ -516,7 +515,8 @@ fn tool_definitions() -> Value {
|
||||
"properties": {
|
||||
"status": { "type": "string", "description": "Filter by status: pending, claimed, completed, failed" },
|
||||
"assignee_did": { "type": "string", "description": "Filter by assignee DID" },
|
||||
"limit": { "type": "integer", "description": "Max results (default: 50)", "default": 50 }
|
||||
"limit": { "type": "integer", "minimum": 1, "description": "Total results to return (default: 50). Must be positive; a non-positive limit is rejected rather than answered with an empty list. Values above the node's 200-row page cap are gathered by following continuation tokens.", "default": 50 },
|
||||
"cursor": { "type": "string", "description": "Resume from a previous call's next_cursor. Must be paired with the same status/assignee_did filter that produced it." }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -640,7 +640,14 @@ async fn call_tool(
|
||||
node: &str,
|
||||
dir: Option<&std::path::Path>,
|
||||
) -> Result<String> {
|
||||
let keypair = load_keypair_from_dir(dir).ok();
|
||||
// An explicit dir with no identity file is a clean anonymous caller
|
||||
// (main's unsigned `webhook_list` path lets the node answer the denial);
|
||||
// a present but unloadable identity is an error, never a silent
|
||||
// downgrade to anonymous (this PR's `load_optional_keypair` contract).
|
||||
let keypair = match dir {
|
||||
Some(d) if !d.join("identity.pem").exists() => None,
|
||||
_ => crate::identity::load_optional_keypair(dir)?,
|
||||
};
|
||||
let client = NodeClient::new(node, keypair.clone());
|
||||
|
||||
match name {
|
||||
@@ -1114,16 +1121,31 @@ async fn call_tool(
|
||||
|
||||
// ── Task tools ────────────────────────────────────────────────────
|
||||
"task_list" => {
|
||||
let limit = args["limit"].as_i64().unwrap_or(50);
|
||||
let mut path = format!("/api/v1/tasks?limit={limit}");
|
||||
if let Some(s) = args.get("status").and_then(|v| v.as_str()) {
|
||||
path.push_str(&format!("&status={}", urlencoding::encode(s)));
|
||||
// Follows the node's opaque `next_cursor` so a limit above the
|
||||
// 200-row server page cap returns what was asked for instead of a
|
||||
// silently truncated page, and reports an explicit incomplete
|
||||
// result when a guard or the node's scan ceiling stops it (#327).
|
||||
let limit = match args.get("limit") {
|
||||
None | Some(Value::Null) => 50,
|
||||
Some(val) => val.as_i64().context("invalid limit: expected an integer")?,
|
||||
};
|
||||
let cursor = match args.get("cursor") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(val) => Some(val.as_str().context("invalid cursor: expected a string")?),
|
||||
};
|
||||
let result = crate::task::fetch_tasks(
|
||||
&client,
|
||||
args.get("status").and_then(|v| v.as_str()),
|
||||
args.get("assignee_did").and_then(|v| v.as_str()),
|
||||
limit,
|
||||
cursor,
|
||||
)
|
||||
.await?;
|
||||
let mut out = result.to_json();
|
||||
if let Some(warning) = result.truncation_warning() {
|
||||
out["warning"] = Value::String(warning);
|
||||
}
|
||||
if let Some(a) = args.get("assignee_did").and_then(|v| v.as_str()) {
|
||||
path.push_str(&format!("&assignee_did={}", urlencoding::encode(a)));
|
||||
}
|
||||
let resp = crate::http::read_json(client.get(&path).await?, "list tasks").await?;
|
||||
Ok(serde_json::to_string_pretty(&resp)?)
|
||||
Ok(serde_json::to_string_pretty(&out)?)
|
||||
}
|
||||
|
||||
"task_create" => {
|
||||
@@ -1726,7 +1748,7 @@ mod tests {
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}]}"#)
|
||||
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1740,6 +1762,275 @@ mod tests {
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["tasks"][0]["id"], "t1");
|
||||
assert_eq!(parsed["complete"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_uses_loaded_identity() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"tasks":[{"id":"t1"}],"has_more":false,"incomplete":false,"next_cursor":null}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({}), &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["tasks"][0]["id"], "t1");
|
||||
assert_eq!(parsed["complete"], json!(true));
|
||||
}
|
||||
|
||||
/// #327 review: MCP `task_list` issued one request and exposed no
|
||||
/// continuation, so a limit above the node's 200-row page cap returned a
|
||||
/// silently truncated result the model had no way to detect. It now
|
||||
/// follows cursors and, when a guard stops it, says so in the payload.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_follows_cursors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let first = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/tasks\?limit=200$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"tasks":[{"id":"t1"}],"has_more":true,"incomplete":false,"next_cursor":"c1"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let second = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"cursor=c1".to_string()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[{"id":"t2"}],"has_more":false,"next_cursor":null}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 500}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
first.assert_async().await;
|
||||
second.assert_async().await;
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["count"], 2);
|
||||
assert_eq!(parsed["tasks"][1]["id"], "t2");
|
||||
assert_eq!(parsed["complete"], json!(true));
|
||||
assert!(parsed.get("warning").is_none());
|
||||
}
|
||||
|
||||
/// A truncated MCP result must carry an explicit warning and a resume
|
||||
/// cursor, so the model cannot read it as the whole answer.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_flags_incomplete_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"tasks":[{"id":"t1"}],"has_more":true,"incomplete":true,"next_cursor":"resume-me"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 1}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["complete"], json!(false));
|
||||
assert_eq!(parsed["incomplete"], json!(true));
|
||||
assert_eq!(parsed["next_cursor"], "resume-me");
|
||||
assert!(
|
||||
parsed["warning"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("result incomplete"),
|
||||
"{parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A legacy response without pagination metadata is reported as incomplete via MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_legacy_response_is_incomplete() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[{"id":"t1"}],"count":1}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 50}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["complete"], json!(false));
|
||||
assert_eq!(parsed["incomplete"], json!(true));
|
||||
assert!(parsed["next_cursor"].is_null());
|
||||
assert!(
|
||||
parsed["warning"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("node does not support pagination metadata"),
|
||||
"{parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A legacy response with limit > 200 stops after 1 page and does not claim completeness via MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_legacy_limit_above_page_cap() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ids: Vec<String> = (0..200).map(|i| format!("t{i}")).collect();
|
||||
let tasks_json: Vec<Value> = ids.iter().map(|id| json!({ "id": id })).collect();
|
||||
let body = json!({ "tasks": tasks_json, "count": 200 }).to_string();
|
||||
|
||||
let m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/tasks\?limit=200$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 500}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
m.assert_async().await;
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["count"], 200);
|
||||
assert_eq!(parsed["complete"], json!(false));
|
||||
assert_eq!(parsed["incomplete"], json!(true));
|
||||
assert!(parsed["next_cursor"].is_null());
|
||||
assert!(
|
||||
parsed["warning"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("node does not support pagination metadata"),
|
||||
"{parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor the model passes back must reach the node.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_forwards_cursor_argument() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"cursor=given-cursor".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
call_tool(
|
||||
"task_list",
|
||||
json!({"cursor": "given-cursor"}),
|
||||
&server.url(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_returns_http_errors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"failed"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool("task_list", json!({}), &server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("500"));
|
||||
}
|
||||
|
||||
/// Malformed server responses fail visibly in MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_malformed_response_errors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":"not-an-array"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool("task_list", json!({}), &server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("malformed") || err.to_string().contains("invalid JSON"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #327 review: a model that sends `limit: 0` used to get an empty list
|
||||
/// marked complete, which reads as "this node has no tasks". The shared
|
||||
/// helper rejects it, so the model sees an invalid argument instead.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_rejects_non_positive_limit() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool("task_list", json!({"limit": 0}), &server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("limit must be a positive"),
|
||||
"{err}"
|
||||
);
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2208,6 +2499,86 @@ mod tests {
|
||||
assert!(err.to_string().contains("no identity found"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_invalid_limit_rejected() {
|
||||
let server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = call_tool(
|
||||
"task_list",
|
||||
json!({"limit": "50"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("string limit must be rejected");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid limit: expected an integer"),
|
||||
"got: {err}"
|
||||
);
|
||||
|
||||
let err_float = call_tool(
|
||||
"task_list",
|
||||
json!({"limit": 50.5}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("float limit must be rejected");
|
||||
assert!(
|
||||
err_float
|
||||
.to_string()
|
||||
.contains("invalid limit: expected an integer"),
|
||||
"got: {err_float}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_invalid_cursor_rejected() {
|
||||
let server = mockito::Server::new_async().await;
|
||||
for invalid in [json!(123), json!({}), json!([]), json!(true)] {
|
||||
let err = call_tool("task_list", json!({"cursor": invalid}), &server.url(), None)
|
||||
.await
|
||||
.expect_err("non-string cursor must be rejected");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid cursor: expected a string"),
|
||||
"for {invalid}: got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_null_or_absent_cursor_accepted() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
call_tool("task_list", json!({}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
call_tool("task_list", json!({"cursor": null}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_count_is_42() {
|
||||
let tools = tool_definitions();
|
||||
|
||||
+56
-3
@@ -41,6 +41,17 @@ pub struct MirrorArgs {
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Argv for the mirror clone, with `--` immediately before the first positional
|
||||
/// so an option-shaped `source` cannot be parsed as a git option (#374).
|
||||
///
|
||||
/// This is a separate function purely so the delimiter's placement can be driven
|
||||
/// through real git by a test. The production call keeps `.status()` rather than
|
||||
/// `.output()`: a mirror clone of a large repo streams git's progress to the
|
||||
/// user's terminal, and capturing it to assert on stderr here would swallow that.
|
||||
fn mirror_clone_args(source: &str) -> [&str; 4] {
|
||||
["clone", "--mirror", "--", source]
|
||||
}
|
||||
|
||||
pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
let source = args.source.trim_end_matches('/').to_string();
|
||||
|
||||
@@ -131,7 +142,11 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
println!();
|
||||
println!("✓ Mirror complete: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
// Only print View: when the node advertises a web_url — self-hosted nodes
|
||||
// without a web front-end would otherwise produce a 404 link (#370).
|
||||
if let Some(web_url) = crate::repo::fetch_node_web_url(&args.node).await {
|
||||
println!(" View: {web_url}/{owner_short}/{name}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -145,7 +160,7 @@ fn clone_mirror(source: &str, dest: &std::path::Path) -> Result<()> {
|
||||
// bounded-runner rule governs node request handlers. Same spawn as before,
|
||||
// only relocated into this helper.
|
||||
let status = Command::new("git")
|
||||
.args(["clone", "--mirror", source])
|
||||
.args(mirror_clone_args(source))
|
||||
.arg(dest)
|
||||
.status()
|
||||
.context("failed to run git clone — is git installed?")?;
|
||||
@@ -186,6 +201,43 @@ pub fn extract_repo_name(url: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #374: `gl mirror` interpolates the user-supplied source into a
|
||||
/// `git clone --mirror` argv. `--` must sit immediately before the first
|
||||
/// positional so an option-shaped source is read as a repository.
|
||||
///
|
||||
/// This drives the production argv through real git rather than asserting on
|
||||
/// the array's contents, so it fails on a delimiter that is missing *or*
|
||||
/// misplaced. It cannot go through `run()`: that loads an identity keypair and
|
||||
/// contacts a node before reaching the clone, and its bail message interpolates
|
||||
/// `{source}` unconditionally, so an assertion there would pass with `--` gone.
|
||||
///
|
||||
/// Asserting on git's single-quoted `repository '<value>'` is what makes this
|
||||
/// red without the delimiter — undelimited, git consumes the value as an option
|
||||
/// and names the destination as the repository instead.
|
||||
#[test]
|
||||
fn mirror_clone_never_parses_the_source_as_a_git_option() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let out = Command::new("git")
|
||||
.env("LC_ALL", "C")
|
||||
.args(mirror_clone_args(injected))
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an option-shaped source is not a repository and must fail the clone"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("repository '{injected}'")),
|
||||
"git must name the source as the repository, not consume it as an option: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_url() {
|
||||
assert_eq!(
|
||||
@@ -330,7 +382,8 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// We can't easily test the git subprocess calls, but we can test the
|
||||
// The git subprocess argv is pinned separately by
|
||||
// `mirror_clone_never_parses_the_source_as_a_git_option`; this covers the
|
||||
// API error path by calling the create step directly via NodeClient.
|
||||
let kp2 = gitlawb_core::identity::Keypair::generate();
|
||||
let client = NodeClient::new(server.url(), Some(kp2));
|
||||
|
||||
+405
-2
@@ -5,7 +5,7 @@ use clap::{Args, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -274,6 +274,126 @@ async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result
|
||||
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
|
||||
}
|
||||
|
||||
/// Fetch `GET /` from the node and return the `web_url` if the node advertises one.
|
||||
///
|
||||
/// The View: link is a nice-to-have, so every failure mode degrades to `None`
|
||||
/// rather than failing the enclosing command — but failures are surfaced as
|
||||
/// stderr warnings (request error, non-success HTTP status with the status,
|
||||
/// malformed advertised value), since they all mean the node itself is
|
||||
/// misbehaving or misconfigured, which the user would otherwise never learn.
|
||||
/// A successful response that lacks a usable `web_url` (field missing or
|
||||
/// blank) omits the link silently: self-hosted nodes without a web front-end
|
||||
/// are expected to not advertise one (#370).
|
||||
///
|
||||
/// The body is treated like every other caller-chosen node reply (INV-6): the
|
||||
/// read is capped and an accepted `web_url` must be free of control/bidi bytes
|
||||
/// — it reaches the terminal verbatim through the View: line.
|
||||
pub(crate) async fn fetch_node_web_url(node: &str) -> Option<String> {
|
||||
let info_client = NodeClient::new(node, None);
|
||||
let info_resp = match info_client.get("/").await {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
eprintln!("warning: node info request failed ({err}); skipping View link");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let status = info_resp.status();
|
||||
if !status.is_success() {
|
||||
eprintln!("warning: node info request returned {status}; skipping View link");
|
||||
return None;
|
||||
}
|
||||
// An info reply is a DID, a few URLs and counts — 8 KiB is well past what
|
||||
// the shape needs; anything longer is hostile or broken (peer.rs precedent).
|
||||
let raw_body = crate::http::read_body_capped(info_resp, 8 * 1024).await;
|
||||
match web_url_from_info_body(&raw_body) {
|
||||
Ok(url) => url,
|
||||
Err(reason) => {
|
||||
eprintln!("warning: {reason}; skipping View link");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a capped `GET /` body for the `View:` advertisement. Split out of
|
||||
/// `fetch_node_web_url` so tests can pin the classification contract directly:
|
||||
/// a truncated or half-read body is indistinguishable from a node that
|
||||
/// advertised nothing, so it must warn rather than silently omit the link.
|
||||
fn web_url_from_info_body(
|
||||
raw_body: &crate::http::CappedBody,
|
||||
) -> std::result::Result<Option<String>, String> {
|
||||
if raw_body.truncated || raw_body.read_failed {
|
||||
return Err("node info body was oversized or could not be read in full".to_string());
|
||||
}
|
||||
let info: Value = match serde_json::from_str(&raw_body.text) {
|
||||
Ok(json) => json,
|
||||
Err(_) => return Err("node info body was not valid JSON".to_string()),
|
||||
};
|
||||
// Missing field / non-string degrade to None without warning — same contract
|
||||
// as before; only transport- and advertisement-level failures warn there.
|
||||
let raw = match info["web_url"].as_str() {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// A present-but-malformed value means the node is misconfigured; say so
|
||||
// instead of quietly dropping the link. Mirrors the node-side boot
|
||||
// validation: must be an absolute http(s) URL with no query or fragment —
|
||||
// the link is built by string-appending `/{owner}/{repo}` to it. Control
|
||||
// and bidi-format bytes are rejected outright (not stripped): they have no
|
||||
// legitimate place in a base URL and would reach the terminal through the
|
||||
// View: line — including when they arrive percent-encoded (INV-6).
|
||||
let malformed_reason: Option<String> = if trimmed
|
||||
.chars()
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
|
||||
{
|
||||
Some("contains control or bidi characters".to_string())
|
||||
} else {
|
||||
match trimmed.parse::<url::Url>() {
|
||||
Err(_) => Some("not an absolute URL".to_string()),
|
||||
Ok(parsed) if !matches!(parsed.scheme(), "http" | "https") => {
|
||||
Some("not http(s)".to_string())
|
||||
}
|
||||
Ok(parsed) if parsed.query().is_some() || parsed.fragment().is_some() => {
|
||||
Some("contains a query or fragment".to_string())
|
||||
}
|
||||
// userinfo is rejected because the `View:` line prints the
|
||||
// value verbatim; a remote advertisement carrying
|
||||
// `user:pass@host` would land that credential in terminal
|
||||
// scrollback and CI logs.
|
||||
Ok(parsed) if !parsed.username().is_empty() || parsed.password().is_some() => {
|
||||
Some("contains a username or password".to_string())
|
||||
}
|
||||
Ok(parsed)
|
||||
if parsed
|
||||
.host_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.chain(
|
||||
percent_encoding::percent_decode_str(parsed.path())
|
||||
.decode_utf8_lossy()
|
||||
.chars(),
|
||||
)
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c)) =>
|
||||
{
|
||||
Some("contains control or bidi characters, even percent-encoded".to_string())
|
||||
}
|
||||
Ok(_) => None,
|
||||
}
|
||||
};
|
||||
match malformed_reason {
|
||||
// The reason string is ours; the advertised value is not — defang it
|
||||
// exactly as it would have been defanged had it been accepted.
|
||||
Some(reason) => Err(format!(
|
||||
"node advertised a malformed web_url ({:?}, {reason})",
|
||||
sanitize_node_msg(trimmed)
|
||||
)),
|
||||
None => Ok(Some(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_create(
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
@@ -311,7 +431,11 @@ async fn cmd_create(
|
||||
println!("✓ Created repository: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
println!(" HTTP: {clone_url}");
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
// Only print View: when the node advertises a web_url — self-hosted nodes
|
||||
// without a web front-end would otherwise produce a 404 link (#370).
|
||||
if let Some(web_url) = fetch_node_web_url(&node).await {
|
||||
println!(" View: {web_url}/{owner_short}/{name}");
|
||||
}
|
||||
if let Some(desc) = payload["description"].as_str().filter(|s| !s.is_empty()) {
|
||||
println!(" Desc: {desc}");
|
||||
}
|
||||
@@ -848,6 +972,285 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_with_web_url() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://example.com","did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_trims_trailing_slash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://example.com/"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_without_web_url() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_empty_string() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":""}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_whitespace_only() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":" "}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_server_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(500)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
/// A node advertising a present-but-malformed web_url (not an absolute
|
||||
/// http(s) URL) must not yield a View link — and must warn, since a
|
||||
/// malformed advertisement means the node is misconfigured (#370).
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_malformed_value_is_rejected() {
|
||||
for bad in [
|
||||
"gitlawb.com",
|
||||
"not a url",
|
||||
"ftp://files.example.com",
|
||||
// Query/fragment corrupt the appended /{owner}/{repo} path.
|
||||
"https://example.com?a=1",
|
||||
"https://example.com/#faq",
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(r#"{{"web_url":"{bad}"}}"#))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"malformed web_url {bad:?} must not render a View link"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport-level failure (connection refused) degrades to None but is
|
||||
/// no longer silent: the request error is surfaced on stderr so an
|
||||
/// unreachable node isn't indistinguishable from one without a web_url.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_connection_refused_warns() {
|
||||
// Port 1 on localhost is reserved (tcpmux) and refuses connections.
|
||||
let web_url = fetch_node_web_url("http://127.0.0.1:1").await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
/// A hostile node can smuggle ANSI/bell/bidi controls inside a web_url that
|
||||
/// still passes http(s) URL parsing — those bytes would reach the terminal
|
||||
/// verbatim through the View: line. The advertised value must be rejected
|
||||
/// outright: no control byte may survive into the returned string.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_rejects_control_bytes() {
|
||||
for bad in [
|
||||
"https://example.com/\x1b[31mred", // ANSI CSI escape in path
|
||||
"https://example.com\x07", // bell
|
||||
"https://ex\u{202e}ample.com", // bidi override (RLO)
|
||||
"https://example.com/\u{200f}", // RLM format char
|
||||
"\x1b]0;title\x07https://evil.example", // OSC title-set prefix
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
// serde_json escapes the control bytes so the body is VALID
|
||||
// JSON — a raw literal would make parse fail and the test
|
||||
// would pass without reaching the control check at all.
|
||||
.with_body(serde_json::json!({"web_url": bad}).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"web_url with control bytes {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An oversized or half-read `GET /` body must warn, not silently
|
||||
/// classify as "no web front-end": a cut body fails JSON parse, which is
|
||||
/// indistinguishable from a node that advertised nothing without the
|
||||
/// truncated flag. Removing the flag check makes this test pass
|
||||
/// vacuously through the parse-fail arm.
|
||||
#[test]
|
||||
fn web_url_from_info_body_warns_on_truncated_reads() {
|
||||
for (body, label) in [
|
||||
(
|
||||
crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://x"#.repeat(2000),
|
||||
truncated: true,
|
||||
read_failed: false,
|
||||
},
|
||||
"truncated",
|
||||
),
|
||||
(
|
||||
crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://x"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: true,
|
||||
},
|
||||
"read_failed",
|
||||
),
|
||||
] {
|
||||
let err = web_url_from_info_body(&body)
|
||||
.expect_err(&format!("{label} body must warn, not silently classify"));
|
||||
assert!(
|
||||
err.contains("oversized or could not be read"),
|
||||
"{label}: got {err}"
|
||||
);
|
||||
}
|
||||
// A clean body missing web_url still classifies as a silent None.
|
||||
let clean = crate::http::CappedBody {
|
||||
text: r#"{"did":"did:key:x"}"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: false,
|
||||
};
|
||||
assert_eq!(web_url_from_info_body(&clean).unwrap(), None);
|
||||
}
|
||||
|
||||
/// Percent-encoded bidi must not slip past the literal scan: a node
|
||||
/// advertising `https://example.com/%E2%80%AE` serves wire bytes with no
|
||||
/// Cf code point, but a browser decodes it into a reordering character
|
||||
/// on the rendered View: link (INV-6).
|
||||
#[test]
|
||||
fn web_url_from_info_body_rejects_percent_encoded_bidi() {
|
||||
let body = crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://example.com/%E2%80%AE"}"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: false,
|
||||
};
|
||||
let err = web_url_from_info_body(&body).expect_err("percent-encoded bidi must be rejected");
|
||||
assert!(err.contains("bidi"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_validates_after_trimming() {
|
||||
// Trailing slash is trimmed before validation; result stays usable.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"http://localhost:8080/ui/"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("http://localhost:8080/ui"));
|
||||
}
|
||||
|
||||
/// A remote node that advertises `web_url` with userinfo (`user@`,
|
||||
/// `user:pass@`) must be dropped: the value is reprinted on the `View:`
|
||||
/// line, and a credential embedded in the URL would land in terminal
|
||||
/// scrollback and CI logs. Mirrors the node-side boot validation.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_rejects_userinfo() {
|
||||
for bad in [
|
||||
"https://[email protected]",
|
||||
"https://user:[email protected]",
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(r#"{{"web_url":"{}"}}"#, bad))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"web_url with userinfo {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-success status must still return None (View link is cosmetic) but
|
||||
/// the status surfaces as a user-visible stderr warning instead of being
|
||||
/// swallowed silently (#370 review).
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_non_success_warns_with_status() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(503)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
// The warning itself goes to stderr; mockito can't capture it here, so
|
||||
// this asserts only the None contract. Manual check: run against a
|
||||
// 503-ing node and observe "node info request returned 503" on stderr.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_create_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
+1364
-34
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,138 @@
|
||||
//! End-to-end test for the `View:` line printed by `gl repo create` (#370).
|
||||
//!
|
||||
//! `cmd_create` is the load-bearing #370 path: it is the one place the CLI
|
||||
//! prints a `View:` line, and the only thing keeping it from being a
|
||||
//! hardcoded `https://gitlawb.com/...` 404 is `fetch_node_web_url`. The
|
||||
//! helper-level tests in `repo.rs` cover the helper, not the command path —
|
||||
//! if `cmd_create` went back to a hardcoded constant, every helper test
|
||||
//! would still pass.
|
||||
//!
|
||||
//! This integration test drives the real `gl` binary against a mockito
|
||||
//! server that answers both `POST /api/v1/repos` and `GET /`. Asserting on
|
||||
//! the subprocess's stdout is straightforward: bytes are bytes, with no
|
||||
//! libtest / gag / in-process capture race.
|
||||
//!
|
||||
//! CARGO_BIN_EXE_gl is set by Cargo for integration tests of a `[[bin]]`
|
||||
//! in the same package, so the test always picks up the in-tree build.
|
||||
|
||||
use std::process::Stdio;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
fn gl_bin() -> String {
|
||||
std::env::var("CARGO_BIN_EXE_gl").expect("CARGO_BIN_EXE_gl is unset for this test")
|
||||
}
|
||||
|
||||
async fn write_identity(dir: &TempDir) {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
let path = dir.path().join("identity.pem");
|
||||
let mut f = tokio::fs::File::create(&path).await.unwrap();
|
||||
f.write_all(pem.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
/// The trailing key segment of the freshly-generated identity. Mirrors the
|
||||
/// in-source `resolve_owner_did` test helper but lives in this integration
|
||||
/// file because that helper is `pub(crate)`.
|
||||
async fn owner_short(dir: &TempDir) -> String {
|
||||
let pem = tokio::fs::read_to_string(dir.path().join("identity.pem"))
|
||||
.await
|
||||
.unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::from_pem(&pem).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
}
|
||||
|
||||
async fn run_gl_create(node_url: &str, dir: &TempDir) -> String {
|
||||
let output = Command::new(gl_bin())
|
||||
.arg("repo")
|
||||
.arg("create")
|
||||
.arg("myrepo")
|
||||
.arg("--private")
|
||||
.arg("--branch")
|
||||
.arg("main")
|
||||
.arg("--node")
|
||||
.arg(node_url)
|
||||
.arg("--dir")
|
||||
.arg(dir.path())
|
||||
.env("NO_COLOR", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.expect("failed to spawn `gl repo create`");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"`gl repo create` failed: stderr:\n{}\nstdout:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
String::from_utf8(output.stdout).expect("`gl` wrote non-UTF-8 to stdout")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_create_view_url_tracks_node_advertisement() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir).await;
|
||||
let owner = owner_short(&dir).await;
|
||||
|
||||
// Case 1: node advertises a web_url. The `View:` line must use the
|
||||
// advertised origin and the resolved owner/short name.
|
||||
{
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m_create = server
|
||||
.mock("POST", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"name":"myrepo","clone_url":"gitlawb://did:key:z6Mk/myrepo"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let _m_info = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://git.example","did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let server_url = server.url();
|
||||
|
||||
let out = run_gl_create(&server_url, &dir).await;
|
||||
let view_line = out
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("View:"))
|
||||
.unwrap_or_else(|| panic!("View: line missing from stdout:\n{out}"));
|
||||
assert!(
|
||||
view_line.contains(&format!("https://git.example/{owner}/myrepo")),
|
||||
"View: line must use the advertised origin, got: {view_line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: node does NOT advertise a web_url. The `View:` line must be
|
||||
// absent, so a self-hosted node without a web front-end never produces
|
||||
// a 404 link (#370).
|
||||
{
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m_create = server
|
||||
.mock("POST", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"name":"myrepo","clone_url":"gitlawb://did:key:z6Mk/myrepo"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let _m_info = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let server_url = server.url();
|
||||
|
||||
let out = run_gl_create(&server_url, &dir).await;
|
||||
assert!(
|
||||
!out.lines().any(|l| l.trim_start().starts_with("View:")),
|
||||
"View: line must be absent when node omits web_url; got:\n{out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! #357: pin the `gl doctor` exit-code wiring, not just the predicate.
|
||||
//!
|
||||
//! The unit tests for `has_failures` never touch `std::process::exit(1)` or
|
||||
//! the `GITLAWB_NODE` Warn tier. Deleting the exit gate outright kept
|
||||
//! `cargo test -p gl doctor::` green, so this binary probe is the only thing
|
||||
//! that breaks when the wiring is gutted.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn doctor_exits_nonzero_when_fail_rows_present() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
// Use an unroutable node so the "node unreachable" check is Fail-class.
|
||||
// The temp dir has no identity.pem / ucan.json, so identity + registration
|
||||
// are also Fail — at least one Fail must flip the process to exit 1.
|
||||
let bin = env!("CARGO_BIN_EXE_gl");
|
||||
let output = Command::new(bin)
|
||||
.args([
|
||||
"doctor",
|
||||
"--dir",
|
||||
dir.path().to_str().unwrap(),
|
||||
"--node",
|
||||
"http://127.0.0.1:1",
|
||||
])
|
||||
// Inherit PATH but neutralize GITLAWB_NODE so the test is deterministic
|
||||
// across machines that have it set (otherwise it would be Pass).
|
||||
.env_remove("GITLAWB_NODE")
|
||||
// iCaptcha probes a real URL by default; keep it unreachable too so
|
||||
// it stays Warn and does not affect the Fail gating.
|
||||
.env("GITLAWB_ICAPTCHA_URL", "http://127.0.0.1:1")
|
||||
.output()
|
||||
.expect("run gl doctor");
|
||||
|
||||
// The command should have exited 1 because at least one Fail row exists.
|
||||
// If the `std::process::exit(1)` gate is removed, this becomes 0 and the
|
||||
// test fails — that is the regression it pins.
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"gl doctor must exit non-zero when Fail rows are present; stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(1),
|
||||
"exit code must be 1, not some other non-zero"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user