mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f63f28215 | ||
|
|
06388ec26a | ||
|
|
2df6ff9d30 | ||
|
|
2620e973e3 | ||
|
|
e3a20317d5 | ||
|
|
c296e23678 | ||
|
|
20d6848846 | ||
|
|
4891db3889 | ||
|
|
6f36fc07b8 | ||
|
|
563c456803 | ||
|
|
6c95592d18 | ||
|
|
466a550915 | ||
|
|
09a0cb23b9 | ||
|
|
cd4680ebeb | ||
|
|
8d96cab4f7 |
No files matched your search
@@ -90,6 +90,21 @@ GITLAWB_PUBLIC_READ=true
|
||||
# Maximum git smart-HTTP pack request size, in bytes.
|
||||
GITLAWB_MAX_PACK_BYTES=2147483648
|
||||
|
||||
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
|
||||
# Max receive-pack requests (info/refs advertisement + push POST) per client
|
||||
# IP per hour. 0 disables. Default 600.
|
||||
GITLAWB_PUSH_RATE_LIMIT=600
|
||||
|
||||
# Which forwarded header the edge is trusted to set, used to resolve the real
|
||||
# client IP for the push limiter. One of:
|
||||
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
|
||||
# fly — behind Fly's edge (trust Fly-Client-IP).
|
||||
# x-forwarded-for — behind a single reverse proxy like Caddy/NGINX (trust the
|
||||
# rightmost X-Forwarded-For hop).
|
||||
# Only set this when a proxy you control actually fronts the node; trusting a
|
||||
# forwarded header on a directly-exposed node lets clients spoof the key.
|
||||
GITLAWB_TRUSTED_PROXY=
|
||||
|
||||
# ── Sync ─────────────────────────────────────────────────────────────────
|
||||
# Enable automatic background sync from known peers
|
||||
GITLAWB_AUTO_SYNC=false
|
||||
|
||||
@@ -37,7 +37,18 @@ jobs:
|
||||
with:
|
||||
key: audit-schedule
|
||||
|
||||
- name: Install cargo-audit
|
||||
- name: Install cargo-audit (floating; drift canary)
|
||||
# Deliberately UNPINNED, unlike the blocking gate in pr-checks.yml. This
|
||||
# job is cron-only (weekly); it is never pull_request- or push-triggered,
|
||||
# so it NEVER gates a merge no matter what it does. Floating the scanner
|
||||
# here is the drift canary: if a newer cargo-audit regresses, at worst
|
||||
# this weekly run reds (the install step below is unguarded) or its
|
||||
# visibility report changes, and that is exactly the signal that latest
|
||||
# has moved and the pinned gate should not be bumped yet. The scan result
|
||||
# itself never fails the run (the "Full audit report" step is `|| true`
|
||||
# and the only other hard-fail is a Cargo.lock grep). The point is that
|
||||
# drift surfaces here without ever blocking a PR. Do NOT pin to match
|
||||
# pr-checks.yml.
|
||||
run: cargo install --locked cargo-audit
|
||||
|
||||
# Full advisory report with NO suppressions. Run from a scratch dir that
|
||||
|
||||
@@ -133,10 +133,30 @@ jobs:
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: audit
|
||||
key: audit-0.22.2
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install --locked cargo-audit
|
||||
- name: Install cargo-audit (pinned)
|
||||
# Pin the scanner on this BLOCKING gate: an unpinned `cargo install`
|
||||
# floats to the latest release, so a regressed cargo-audit could red CI
|
||||
# repo-wide with no code change. Advisory-DB freshness is fetched at scan
|
||||
# time, independent of the binary, so this does not weaken what the gate
|
||||
# catches. The weekly Scheduled Audit (audit-schedule.yml) deliberately
|
||||
# floats to latest as the drift canary; when it shows a newer cargo-audit
|
||||
# behaving differently, bump this version (and the cache key above) as a
|
||||
# deliberate maintainer action. If 0.22.2 is ever yanked from crates.io
|
||||
# this step fails loud until the pin is bumped.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo install --locked --version 0.22.2 cargo-audit
|
||||
# Assert the pin took effect so an accidental future unpin (or a drift
|
||||
# off 0.22.2) fails loudly here instead of silently running a different
|
||||
# scanner. `cargo audit --version` prints "cargo-audit-audit 0.22.2".
|
||||
installed="$(cargo audit --version)"
|
||||
echo "cargo-audit installed: $installed"
|
||||
echo "$installed" | grep -qE '(^| )0\.22\.2($| )' || {
|
||||
echo "::error::cargo-audit is not the pinned 0.22.2 (got: $installed)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Hard-fail gate. Suppressions live in .cargo/audit.toml (read automatically
|
||||
# from the repo root), each with no available upstream fix. A green check
|
||||
|
||||
@@ -23,3 +23,6 @@ keys/
|
||||
# Logs
|
||||
*.log
|
||||
.openclaude-profile.json
|
||||
|
||||
# Per-operator Fly config (node1 prod config is pulled from Fly, not tracked)
|
||||
infra/fly/node1.fly.toml
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.4.0"
|
||||
".": "0.5.0"
|
||||
}
|
||||
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gl:** sanctioned iCaptcha client flow + secure git lifecycle ([#138](https://github.com/Gitlawb/node/issues/138)) ([06388ec](https://github.com/Gitlawb/node/commit/06388ec26aa29d356ae311276fdb91be054e9ecc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gl:** sign the CLI's /ipfs/pins reads under the [#134](https://github.com/Gitlawb/node/issues/134) auth gate ([#146](https://github.com/Gitlawb/node/issues/146)) ([20d6848](https://github.com/Gitlawb/node/commit/20d6848846b3a988d604208833167a528b7d8820))
|
||||
* **node,git-remote:** gate receive-pack advertisement, sign client fetch/push ([#119](https://github.com/Gitlawb/node/issues/119)) ([6f36fc0](https://github.com/Gitlawb/node/commit/6f36fc07b8e10a650c5948b269feac1cb25cae2a))
|
||||
* **node,gossip:** route gossip HTTP through the no-redirect client ([#93](https://github.com/Gitlawb/node/issues/93)) ([#140](https://github.com/Gitlawb/node/issues/140)) ([563c456](https://github.com/Gitlawb/node/commit/563c456803bf3e958d63869db424b3940472bc3d))
|
||||
* **node:** close two spam-vector root causes (trust upsert + ungated push) ([#152](https://github.com/Gitlawb/node/issues/152)) ([2df6ff9](https://github.com/Gitlawb/node/commit/2df6ff9d30de62f754fa41473e85db316021718e))
|
||||
* **node:** gate GET /ipfs/{cid} on reachable allowed-set, not deny-set ([#126](https://github.com/Gitlawb/node/issues/126)) ([#133](https://github.com/Gitlawb/node/issues/133)) ([466a550](https://github.com/Gitlawb/node/commit/466a550915edd711856ef32035f9f474e2577c4f))
|
||||
* **node:** gate the ref-updates feeds on read visibility ([#112](https://github.com/Gitlawb/node/issues/112), [#114](https://github.com/Gitlawb/node/issues/114)) ([#143](https://github.com/Gitlawb/node/issues/143)) ([4891db3](https://github.com/Gitlawb/node/commit/4891db38892663326ee0c1417a2db931988be4b5))
|
||||
* **node:** prefer canonical repo row over mirror row in get_repo ([#124](https://github.com/Gitlawb/node/issues/124)) ([#141](https://github.com/Gitlawb/node/issues/141)) ([6c95592](https://github.com/Gitlawb/node/commit/6c95592d188222ac3446dc23ef8d9befbf82ad6f))
|
||||
* **remote:** include HTTP error response body ([#137](https://github.com/Gitlawb/node/issues/137)) ([09a0cb2](https://github.com/Gitlawb/node/commit/09a0cb23b9f284ccbd69aca6958b70671f3bfb46))
|
||||
* **repos:** log the cause when repo create fails ([#103](https://github.com/Gitlawb/node/issues/103)) ([2620e97](https://github.com/Gitlawb/node/commit/2620e973e3cd4835ed42ddd4adcd8183b5b3080e))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/node/compare/v0.3.9...v0.4.0) (2026-06-30)
|
||||
|
||||
|
||||
|
||||
Generated
+20
-5
@@ -3300,10 +3300,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.3.9"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3311,7 +3312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.3.9"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3328,7 +3329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.3.9"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3355,7 +3356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.3.9"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3411,7 +3412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.3.9"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3419,6 +3420,7 @@ dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3813,6 +3815,19 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icaptcha-client"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"crates/gl",
|
||||
"crates/git-remote-gitlawb",
|
||||
"crates/gitlawb-attest",
|
||||
"crates/icaptcha-client",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -185,6 +185,45 @@ git clone gitlawb://did:key:z6Mk.../my-repo
|
||||
|
||||
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
|
||||
|
||||
### Full lifecycle against an iCaptcha-enforcing node
|
||||
|
||||
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
|
||||
|
||||
1. **RFC 9421 HTTP Signatures** — every write is signed by your identity key. `gl`
|
||||
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
|
||||
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
|
||||
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
|
||||
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
|
||||
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
|
||||
locally (arithmetic / algebra / sequence), and **retries the same signed request**
|
||||
with the `x-icaptcha-proof` header — no manual steps, no env vars.
|
||||
|
||||
```bash
|
||||
gl identity new # create did:key identity
|
||||
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
|
||||
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
|
||||
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
|
||||
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
|
||||
gl doctor # preflight: identity, node, version, iCaptcha
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **`requesterId` is always your DID.** The proof's `sub` claim must equal the
|
||||
authenticated signer; `gl`/helper set this automatically and the node enforces
|
||||
`sub == authenticated DID` (so a proof minted for another identity is rejected).
|
||||
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
|
||||
solving and use, the client transparently solves a fresh one and retries.
|
||||
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
|
||||
`git push` is **signed-only** (owner signature is the gate — no per-push challenge);
|
||||
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
|
||||
clear `404`, never a placeholder.
|
||||
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
|
||||
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
|
||||
`https` origin whose host is allowlisted (that URL or the public default), and
|
||||
sends the bearer token **only** to your configured origin — never to a URL a
|
||||
node advertises — so a hostile node can't capture the key or redirect the solve.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
+26
-2
@@ -1,16 +1,40 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-05-16",
|
||||
"updated": "2026-06-30",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"operator": "Gitlawb (Kevin)",
|
||||
"operator": "Gitlawb",
|
||||
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
|
||||
"http_url": "https://node.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-04-29"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node2",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node2.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node3",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node3.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-manila",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://manila.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "rapybus-gitlawb",
|
||||
"operator": "RapyBus S.A.C.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version = "0.5.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -16,3 +16,6 @@ anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
@@ -14,7 +14,13 @@
|
||||
//! # Protocol flow (connect capability)
|
||||
//! capabilities → "connect\n\n"
|
||||
//! connect git-upload-pack → GET /info/refs | POST /git-upload-pack
|
||||
//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack (+ auth header)
|
||||
//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack
|
||||
//! Push (git-receive-pack) is RFC-9421 signed from the first request when an
|
||||
//! identity keypair is present, since a push can never be anonymous. Fetch
|
||||
//! (git-upload-pack) stays anonymous and is signed only on a single retry, after
|
||||
//! the node denies the anonymous advertisement with 404 — so a public clone never
|
||||
//! discloses the caller's DID, while a private repo's owner (or an authorized
|
||||
//! reader) still authenticates when the node demands it.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
@@ -63,7 +69,10 @@ fn main() -> Result<()> {
|
||||
let repo_base = format!("{}/{}/{}", node_base, short_owner, repo_name);
|
||||
tracing::debug!("repo_base: {repo_base}");
|
||||
|
||||
// Load keypair for signing push requests (optional — push still works unsigned in v0.1)
|
||||
// Load keypair for signing requests (optional). Push always signs when a key is
|
||||
// present; fetch signs only if the node 404s the anonymous advertisement, so
|
||||
// public fetches stay anonymous. Absent a key, public fetch still works and push
|
||||
// falls back to unsigned (v0.1 local alpha only).
|
||||
let keypair = load_keypair();
|
||||
|
||||
run_helper(&repo_base, keypair.as_ref())
|
||||
@@ -115,7 +124,7 @@ fn help_text() -> String {
|
||||
\n\
|
||||
ENVIRONMENT:\n\
|
||||
\x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\
|
||||
\x20 GITLAWB_KEY Identity PEM path for signed pushes (default: ~/.gitlawb/identity.pem)\n\
|
||||
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\
|
||||
\x20 GITLAWB_LOG Log filter (default: warn)\n\
|
||||
\n\
|
||||
FLAGS:\n\
|
||||
@@ -179,11 +188,11 @@ fn run_helper(repo_base: &str, keypair: Option<&Keypair>) -> Result<()> {
|
||||
|
||||
// ── HTTP proxy for git smart protocol ────────────────────────────────────────
|
||||
|
||||
fn handle_connect(
|
||||
fn handle_connect<R: Read>(
|
||||
repo_base: &str,
|
||||
service: &str,
|
||||
keypair: Option<&Keypair>,
|
||||
stdin: &mut io::BufReader<io::Stdin>,
|
||||
stdin: &mut R,
|
||||
) -> Result<()> {
|
||||
match service {
|
||||
"git-upload-pack" | "git-receive-pack" => {}
|
||||
@@ -198,19 +207,52 @@ fn handle_connect(
|
||||
//
|
||||
// The server advertises its refs. git reads this to decide what to fetch/push.
|
||||
|
||||
// Signing policy for this exchange, carried through to the Phase-2 POST:
|
||||
// - git-receive-pack (push): sign from the first request; a push is
|
||||
// signature-gated and can never be anonymous.
|
||||
// - git-upload-pack (fetch): start anonymous so a public clone never discloses
|
||||
// the caller's DID, then escalate to a single signed retry only if the node
|
||||
// denies the anonymous advertisement with 404 (how it withholds a private
|
||||
// repo from an unauthenticated reader).
|
||||
let mut signing_key = if service == "git-receive-pack" {
|
||||
keypair
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let refs_url = format!("{}/info/refs?service={}", repo_base, service);
|
||||
tracing::debug!("GET {refs_url}");
|
||||
|
||||
let refs_resp = client
|
||||
.get(&refs_url)
|
||||
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
|
||||
let mut refs_resp = build_advertisement_request(&client, &refs_url, signing_key)
|
||||
.send()
|
||||
.with_context(|| format!("GET {refs_url}"))?;
|
||||
|
||||
// Fetch escalation: retry the advertisement signed once when the anonymous try
|
||||
// is denied and we hold an identity. A public repo answers 200 on the first
|
||||
// (anonymous) request and never reaches here, so it stays DID-private.
|
||||
if service == "git-upload-pack"
|
||||
&& refs_resp.status() == reqwest::StatusCode::NOT_FOUND
|
||||
&& keypair.is_some()
|
||||
{
|
||||
tracing::debug!("anonymous info/refs returned 404; retrying signed");
|
||||
signing_key = keypair;
|
||||
refs_resp = build_advertisement_request(&client, &refs_url, signing_key)
|
||||
.send()
|
||||
.with_context(|| format!("GET {refs_url} (signed retry)"))?;
|
||||
}
|
||||
|
||||
if !refs_resp.status().is_success() {
|
||||
let status = refs_resp.status();
|
||||
let body = read_error_body(refs_resp);
|
||||
bail!(
|
||||
"GET /info/refs returned {} — is the repo registered on this node?",
|
||||
refs_resp.status()
|
||||
"{}",
|
||||
http_error_message(
|
||||
"GET",
|
||||
"/info/refs",
|
||||
status,
|
||||
&body,
|
||||
Some("— is the repo registered on this node?")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,29 +308,7 @@ fn handle_connect(
|
||||
let post_url = format!("{}/{}", repo_base, service);
|
||||
tracing::debug!("POST {post_url} ({} bytes)", request_body.len());
|
||||
|
||||
// Extract the URL path for signing (e.g., "/z6Mk.../my-repo/git-receive-pack")
|
||||
let path_for_sig = url_path(&post_url);
|
||||
|
||||
let mut req = client
|
||||
.post(&post_url)
|
||||
.header("Content-Type", format!("application/x-{}-request", service))
|
||||
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0");
|
||||
|
||||
// Add RFC 9421 HTTP Signature auth on push operations
|
||||
if service == "git-receive-pack" {
|
||||
if let Some(kp) = keypair {
|
||||
let signed = sign_request(kp, "POST", &path_for_sig, &request_body);
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
tracing::debug!("attached RFC 9421 HTTP Signature (DID: {})", kp.did());
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"no identity keypair found — push will be unsigned (v0.1 local alpha only)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let req = build_pack_post_request(&client, &post_url, service, &request_body, signing_key);
|
||||
|
||||
// Attach the body after signing so the pack bytes are moved, not cloned —
|
||||
// packs can be large and the clone doubled peak memory on push.
|
||||
@@ -298,7 +318,10 @@ fn handle_connect(
|
||||
.with_context(|| format!("POST {post_url}"))?;
|
||||
|
||||
if !pack_resp.status().is_success() {
|
||||
bail!("POST /{} returned {}", service, pack_resp.status());
|
||||
let status = pack_resp.status();
|
||||
let body = read_error_body(pack_resp);
|
||||
let path = format!("/{service}");
|
||||
bail!("{}", http_error_message("POST", &path, status, &body, None));
|
||||
}
|
||||
|
||||
let pack_bytes = pack_resp.bytes().context("reading pack response")?;
|
||||
@@ -310,6 +333,68 @@ fn handle_connect(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Smart-protocol request builders ───────────────────────────────────────────
|
||||
|
||||
const USER_AGENT: &str = "git/2.0 git-remote-gitlawb/0.1.0";
|
||||
|
||||
/// Build the Phase-1 ref-advertisement GET, signing it when an identity is
|
||||
/// present. The node gates the ref advertisement on read visibility for BOTH
|
||||
/// services, so a private repo's advertisement is denied (404) to an
|
||||
/// unauthenticated caller; without this signature the repo's own owner can
|
||||
/// neither fetch (upload-pack) nor push (receive-pack) it. Public repos still
|
||||
/// work anonymously (no keypair present, or the gate admits anonymous). Sign over
|
||||
/// the path *and* query (?service=...) because the node verifies the signature
|
||||
/// over its path_and_query.
|
||||
fn build_advertisement_request(
|
||||
client: &reqwest::blocking::Client,
|
||||
refs_url: &str,
|
||||
keypair: Option<&Keypair>,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
let mut req = client.get(refs_url).header("User-Agent", USER_AGENT);
|
||||
if let Some(kp) = keypair {
|
||||
let signed = sign_request(kp, "GET", &url_path(refs_url), b"");
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
tracing::debug!("signed info/refs advertisement (DID: {})", kp.did());
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
/// Build the Phase-2 pack POST, signing it when an identity is present, for BOTH
|
||||
/// services. The node read-gates the git-upload-pack POST with visibility_check
|
||||
/// at "/", and separately owner-gates the git-receive-pack POST (signature
|
||||
/// required, enforced by middleware). The Phase-1 advertisement signature does
|
||||
/// not carry to this separate request, so a private repo's owner must
|
||||
/// authenticate here too or their fetch/push is denied.
|
||||
/// Public-repo fetch still works anonymously when no keypair is present. The body
|
||||
/// is signed (content-digest) but NOT attached here, so the caller can move the
|
||||
/// (possibly large) pack bytes into `.body()` rather than clone them.
|
||||
fn build_pack_post_request(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
service: &str,
|
||||
body: &[u8],
|
||||
keypair: Option<&Keypair>,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
let mut req = client
|
||||
.post(post_url)
|
||||
.header("Content-Type", format!("application/x-{}-request", service))
|
||||
.header("User-Agent", USER_AGENT);
|
||||
if let Some(kp) = keypair {
|
||||
let signed = sign_request(kp, "POST", &url_path(post_url), body);
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
tracing::debug!("signed {service} POST (DID: {})", kp.did());
|
||||
} else if service == "git-receive-pack" {
|
||||
tracing::warn!("no identity keypair found, push will be unsigned (v0.1 local alpha only)");
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
// ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a `gitlawb://did:key:z6Mk.../repo-name` URL.
|
||||
@@ -347,7 +432,7 @@ fn parse_gitlawb_url(url: &str) -> Result<(String, String, String)> {
|
||||
///
|
||||
/// We also handle the flush-only case (`"0000"`) that git sends when it already
|
||||
/// has everything it needs (up-to-date clone).
|
||||
fn read_upload_pack_request(stdin: &mut io::BufReader<io::Stdin>) -> Result<Vec<u8>> {
|
||||
fn read_upload_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
loop {
|
||||
@@ -433,6 +518,78 @@ fn url_path(url: &str) -> String {
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
|
||||
const MAX_ERROR_BODY_BYTES: u64 = 4096;
|
||||
const MAX_ERROR_BODY_CHARS: usize = 2000;
|
||||
|
||||
fn read_error_body(mut response: reqwest::blocking::Response) -> String {
|
||||
let mut body = Vec::new();
|
||||
let mut limited = (&mut response).take(MAX_ERROR_BODY_BYTES);
|
||||
|
||||
if limited.read_to_end(&mut body).is_err() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&body).into_owned()
|
||||
}
|
||||
|
||||
fn http_error_message(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: reqwest::StatusCode,
|
||||
body: &str,
|
||||
empty_body_hint: Option<&str>,
|
||||
) -> String {
|
||||
let mut message = format!("{method} {path} returned {status}");
|
||||
let body = safe_error_body_excerpt(body);
|
||||
|
||||
if !body.is_empty() {
|
||||
message.push_str(": ");
|
||||
message.push_str(&body);
|
||||
} else if let Some(hint) = empty_body_hint {
|
||||
message.push(' ');
|
||||
message.push_str(hint);
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
fn safe_error_body_excerpt(body: &str) -> String {
|
||||
let mut excerpt = String::new();
|
||||
let mut pending_space = false;
|
||||
let mut kept_chars = 0;
|
||||
|
||||
for ch in body.trim().chars() {
|
||||
if kept_chars >= MAX_ERROR_BODY_CHARS {
|
||||
break;
|
||||
}
|
||||
|
||||
if ch.is_control() {
|
||||
if matches!(ch, '\n' | '\r' | '\t') {
|
||||
pending_space = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch.is_whitespace() {
|
||||
pending_space = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if pending_space && !excerpt.is_empty() {
|
||||
excerpt.push(' ');
|
||||
kept_chars += 1;
|
||||
if kept_chars >= MAX_ERROR_BODY_CHARS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
excerpt.push(ch);
|
||||
kept_chars += 1;
|
||||
pending_space = false;
|
||||
}
|
||||
|
||||
excerpt
|
||||
}
|
||||
|
||||
// ── Keypair loading ───────────────────────────────────────────────────────────
|
||||
|
||||
fn load_keypair() -> Option<Keypair> {
|
||||
@@ -476,6 +633,467 @@ fn resolve_key_path() -> std::path::PathBuf {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
struct TestResponse {
|
||||
request_line: &'static str,
|
||||
status: &'static str,
|
||||
body: &'static str,
|
||||
}
|
||||
|
||||
fn serve_http(responses: Vec<TestResponse>) -> (String, JoinHandle<Vec<String>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut requests = Vec::new();
|
||||
|
||||
for response in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = read_http_request(&mut stream);
|
||||
assert!(
|
||||
request.starts_with(response.request_line),
|
||||
"unexpected request:\n{request}"
|
||||
);
|
||||
|
||||
let response_text = format!(
|
||||
"HTTP/1.1 {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response.status,
|
||||
response.body.len(),
|
||||
response.body
|
||||
);
|
||||
stream.write_all(response_text.as_bytes()).unwrap();
|
||||
requests.push(request);
|
||||
}
|
||||
|
||||
requests
|
||||
});
|
||||
|
||||
(base_url, handle)
|
||||
}
|
||||
|
||||
fn read_http_request(stream: &mut TcpStream) -> String {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 512];
|
||||
let mut header_len = None;
|
||||
|
||||
loop {
|
||||
let n = stream.read(&mut chunk).unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
|
||||
if header_len.is_none() {
|
||||
header_len = buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4);
|
||||
}
|
||||
|
||||
if let Some(header_len) = header_len {
|
||||
let header = String::from_utf8_lossy(&buf[..header_len]);
|
||||
let content_len = header
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if buf.len() >= header_len + content_len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
/// The regression that round-1 missed: the Phase-2 `git-upload-pack` POST was
|
||||
/// left unsigned, so an owner's fetch of a private repo cleared the (now signed)
|
||||
/// advertisement and then 404'd on the pack POST. Drive BOTH request builders
|
||||
/// against a mock node and assert the RFC-9421 headers actually go out on the
|
||||
/// wire for BOTH services when a keypair is present (the mock only matches when
|
||||
/// the headers exist, so `.assert()` fails if any are dropped).
|
||||
#[test]
|
||||
fn advertisement_and_pack_post_are_signed_for_both_services_with_keypair() {
|
||||
let kp = Keypair::generate();
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let body = b"0009done\n".to_vec();
|
||||
|
||||
for service in ["git-upload-pack", "git-receive-pack"] {
|
||||
let mut server = mockito::Server::new();
|
||||
let repo_base = format!("{}/zOwner/myrepo", server.url());
|
||||
|
||||
let get_mock = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.match_header("content-digest", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.create();
|
||||
let refs_url = format!("{repo_base}/info/refs?service={service}");
|
||||
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.send()
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
get_mock.assert();
|
||||
|
||||
let post_mock = server
|
||||
.mock("POST", mockito::Matcher::Regex(format!("/{service}$")))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.match_header("content-digest", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.create();
|
||||
let post_url = format!("{repo_base}/{service}");
|
||||
let resp = build_pack_post_request(&client, &post_url, service, &body, Some(&kp))
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
post_mock.assert();
|
||||
}
|
||||
}
|
||||
|
||||
/// Without an identity, the pack POST must go out UNSIGNED so public fetch (and
|
||||
/// alpha unsigned push) still work. `Matcher::Missing` matches only when the
|
||||
/// header is absent, so the mock is hit only if NO signature was attached, for
|
||||
/// BOTH services (the receive-pack iteration also exercises the no-keypair warn
|
||||
/// branch and confirms it does not block the request).
|
||||
#[test]
|
||||
fn pack_post_is_unsigned_without_keypair_for_both_services() {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let body = b"0009done\n".to_vec();
|
||||
|
||||
for service in ["git-upload-pack", "git-receive-pack"] {
|
||||
let mut server = mockito::Server::new();
|
||||
let post_url = format!("{}/zOwner/myrepo/{service}", server.url());
|
||||
|
||||
let post_mock = server
|
||||
.mock("POST", mockito::Matcher::Regex(format!("/{service}$")))
|
||||
.match_header("signature", mockito::Matcher::Missing)
|
||||
.match_header("signature-input", mockito::Matcher::Missing)
|
||||
.match_header("content-digest", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.create();
|
||||
let resp = build_pack_post_request(&client, &post_url, service, &body, None)
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
post_mock.assert();
|
||||
}
|
||||
}
|
||||
|
||||
/// Symmetric negative case for Phase 1: with no identity the advertisement GET
|
||||
/// carries no signature, so public clones stay anonymous.
|
||||
#[test]
|
||||
fn advertisement_get_is_unsigned_without_keypair() {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut server = mockito::Server::new();
|
||||
let refs_url = format!(
|
||||
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
server.url()
|
||||
);
|
||||
|
||||
let get_mock = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Missing)
|
||||
.match_header("signature-input", mockito::Matcher::Missing)
|
||||
.match_header("content-digest", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.create();
|
||||
let resp = build_advertisement_request(&client, &refs_url, None)
|
||||
.send()
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
get_mock.assert();
|
||||
}
|
||||
|
||||
// ── Phase-1 retry-on-404 (P2, #119): fetch stays anonymous until the node
|
||||
// denies it, then escalates to ONE signed retry. Drives handle_connect end to
|
||||
// end against the recording mock, asserting what actually goes on the wire.
|
||||
// A request is "signed" iff it carries the `signature-input` header.
|
||||
|
||||
/// Public fetch: the node serves the anonymous advertisement (200), so the
|
||||
/// client never signs — not the advertisement GET, not the upload-pack POST —
|
||||
/// even though an identity is present. This is the privacy fix: a public clone
|
||||
/// must not disclose the caller's DID.
|
||||
#[test]
|
||||
fn fetch_public_stays_anonymous_even_with_keypair() {
|
||||
let kp = Keypair::generate();
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(b"0009done\n".to_vec());
|
||||
handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin)
|
||||
.expect("public fetch should succeed");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 2, "public fetch must not retry");
|
||||
assert!(
|
||||
!requests[0].to_lowercase().contains("signature-input"),
|
||||
"the advertisement GET must be anonymous for a public fetch"
|
||||
);
|
||||
assert!(
|
||||
!requests[1].to_lowercase().contains("signature-input"),
|
||||
"the upload-pack POST must be anonymous for a public fetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Private fetch: the anonymous advertisement is denied (404); with an identity
|
||||
/// present the client retries the GET signed and — because Phase 1 needed
|
||||
/// signing — also signs the Phase-2 upload-pack POST. The FIRST request is still
|
||||
/// anonymous: the DID is not disclosed until the node demands authentication.
|
||||
#[test]
|
||||
fn fetch_private_retries_signed_on_404_and_signs_the_pack_post() {
|
||||
let kp = Keypair::generate();
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"repository is not registered on this node"}"#,
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-upload-pack",
|
||||
status: "200 OK",
|
||||
body: "",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(b"0009done\n".to_vec());
|
||||
handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin)
|
||||
.expect("signed retry should succeed");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
3,
|
||||
"expected anon GET, signed GET retry, signed POST"
|
||||
);
|
||||
assert!(
|
||||
!requests[0].to_lowercase().contains("signature-input"),
|
||||
"the first advertisement GET must be anonymous"
|
||||
);
|
||||
assert!(
|
||||
requests[1].to_lowercase().contains("signature-input"),
|
||||
"the retry advertisement GET must be signed"
|
||||
);
|
||||
assert!(
|
||||
requests[2].to_lowercase().contains("signature-input"),
|
||||
"the upload-pack POST must be signed once the fetch needed auth"
|
||||
);
|
||||
}
|
||||
|
||||
/// Private fetch with no identity: the anonymous advertisement 404s and there is
|
||||
/// no keypair to retry with, so the client surfaces the error after exactly one
|
||||
/// request — no retry, no hang.
|
||||
#[test]
|
||||
fn fetch_without_keypair_does_not_retry_on_404() {
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"not found"}"#,
|
||||
}]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
|
||||
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
|
||||
assert!(err.to_string().contains("returned 404 Not Found"));
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 1, "no keypair means no signed retry");
|
||||
assert!(!requests[0].to_lowercase().contains("signature-input"));
|
||||
}
|
||||
|
||||
/// Private fetch where the SIGNED retry is also denied (caller is not a reader):
|
||||
/// the client retries exactly once, then surfaces the 404 rather than looping.
|
||||
/// Bounds the escalation to a single signed attempt.
|
||||
#[test]
|
||||
fn fetch_signed_retry_still_denied_surfaces_error_once() {
|
||||
let kp = Keypair::generate();
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"not found"}"#,
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"not found"}"#,
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
|
||||
let err = handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin).unwrap_err();
|
||||
assert!(err.to_string().contains("returned 404 Not Found"));
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 2, "exactly one signed retry, then surface");
|
||||
assert!(!requests[0].to_lowercase().contains("signature-input"));
|
||||
assert!(requests[1].to_lowercase().contains("signature-input"));
|
||||
}
|
||||
|
||||
/// Push (git-receive-pack) is unchanged: it signs from the FIRST request — you
|
||||
/// cannot push anonymously — so there is no anonymous-first probe for push.
|
||||
#[test]
|
||||
fn push_advertisement_is_signed_from_the_first_request() {
|
||||
let kp = Keypair::generate();
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /zOwner/myrepo/info/refs?service=git-receive-pack",
|
||||
status: "200 OK",
|
||||
body: "0000",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /zOwner/myrepo/git-receive-pack",
|
||||
status: "200 OK",
|
||||
body: "",
|
||||
},
|
||||
]);
|
||||
let repo_base = format!("{base_url}/zOwner/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(b"0000".to_vec());
|
||||
handle_connect(&repo_base, "git-receive-pack", Some(&kp), &mut stdin)
|
||||
.expect("push should succeed");
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
requests[0].to_lowercase().contains("signature-input"),
|
||||
"push advertisement must be signed from the first request"
|
||||
);
|
||||
assert!(
|
||||
requests[1].to_lowercase().contains("signature-input"),
|
||||
"push POST must be signed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-crate seam: the signature the CLIENT actually emits (via the real
|
||||
/// request builders) verifies under the SAME `gitlawb_core::http_sig`
|
||||
/// primitives the node's `require_signature` uses, over the `@path` the client
|
||||
/// genuinely transmits (derived from the built reqwest request, exactly as the
|
||||
/// node derives it from `uri.path_and_query()`). A tampered `@path` must fail:
|
||||
/// this is the byte-match the whole A1 client fix depends on, now executed end
|
||||
/// to end (sign here, verify with the node's verifier), not reasoned.
|
||||
#[test]
|
||||
fn client_signature_verifies_under_node_verification_for_both_services() {
|
||||
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
|
||||
use gitlawb_core::identity::verify;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let body = b"0009done\n".to_vec();
|
||||
|
||||
// Re-implements the node's require_signature verification (auth/mod.rs):
|
||||
// parse headers, recompute content-digest from the body, rebuild the signing
|
||||
// string over @method/@path/content-digest, Ed25519-verify. Ok iff the node
|
||||
// would accept it.
|
||||
let node_verifies = |method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
sig_input: &str,
|
||||
sig_header: &str,
|
||||
content_digest: &str|
|
||||
-> anyhow::Result<()> {
|
||||
let sig = HttpSignature::parse(sig_input, sig_header)?;
|
||||
sig.check_created()?;
|
||||
assert!(
|
||||
sig.missing_components().is_empty(),
|
||||
"signature must cover all required components"
|
||||
);
|
||||
assert_eq!(sig.alg, "ed25519");
|
||||
assert_eq!(
|
||||
content_digest,
|
||||
compute_content_digest(body),
|
||||
"content-digest must match the body"
|
||||
);
|
||||
let vk = sig.key_id.to_verifying_key()?;
|
||||
let mut values = HashMap::new();
|
||||
values.insert("@method".to_string(), method.to_uppercase());
|
||||
values.insert("@path".to_string(), path_and_query.to_string());
|
||||
values.insert("content-digest".to_string(), content_digest.to_string());
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
|
||||
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
|
||||
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
|
||||
verify(&vk, signing_string.as_bytes(), &sig_array)?;
|
||||
Ok(())
|
||||
};
|
||||
// @path exactly as the node reconstructs it from the request it receives.
|
||||
let path_and_query = |req: &reqwest::blocking::Request| match req.url().query() {
|
||||
Some(q) => format!("{}?{}", req.url().path(), q),
|
||||
None => req.url().path().to_string(),
|
||||
};
|
||||
let header = |req: &reqwest::blocking::Request, name: &str| {
|
||||
req.headers()
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("missing {name}"))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
for service in ["git-upload-pack", "git-receive-pack"] {
|
||||
// Phase-1 advertisement GET (empty body).
|
||||
let refs_url = format!("http://node.example/zOwner/myrepo/info/refs?service={service}");
|
||||
let req = build_advertisement_request(&client, &refs_url, Some(&kp))
|
||||
.build()
|
||||
.unwrap();
|
||||
node_verifies(
|
||||
"GET",
|
||||
&path_and_query(&req),
|
||||
b"",
|
||||
&header(&req, "signature-input"),
|
||||
&header(&req, "signature"),
|
||||
&header(&req, "content-digest"),
|
||||
)
|
||||
.expect("client GET signature must verify under the node's verifier");
|
||||
|
||||
// Phase-2 pack POST (real body).
|
||||
let post_url = format!("http://node.example/zOwner/myrepo/{service}");
|
||||
let req = build_pack_post_request(&client, &post_url, service, &body, Some(&kp))
|
||||
.body(body.clone())
|
||||
.build()
|
||||
.unwrap();
|
||||
let pq = path_and_query(&req);
|
||||
node_verifies(
|
||||
"POST",
|
||||
&pq,
|
||||
&body,
|
||||
&header(&req, "signature-input"),
|
||||
&header(&req, "signature"),
|
||||
&header(&req, "content-digest"),
|
||||
)
|
||||
.expect("client POST signature must verify under the node's verifier");
|
||||
|
||||
// Negative: a tampered @path (the A1 attack surface) must NOT verify.
|
||||
let tampered = pq.replace(service, "git-evil");
|
||||
assert!(
|
||||
node_verifies(
|
||||
"POST",
|
||||
&tampered,
|
||||
&body,
|
||||
&header(&req, "signature-input"),
|
||||
&header(&req, "signature"),
|
||||
&header(&req, "content-digest"),
|
||||
)
|
||||
.is_err(),
|
||||
"a tampered @path must fail verification"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_standard_url() {
|
||||
@@ -524,6 +1142,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_message_sanitizes_response_body_controls() {
|
||||
let message = http_error_message(
|
||||
"GET",
|
||||
"/info/refs",
|
||||
reqwest::StatusCode::BAD_GATEWAY,
|
||||
"\u{1b}[2Jrepository\r\nis\tblocked\u{7}",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(message.contains("repository is blocked"));
|
||||
assert!(!message.contains('\u{1b}'));
|
||||
assert!(!message.contains('\u{7}'));
|
||||
assert!(!message.contains('\r'));
|
||||
assert!(!message.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_message_truncates_long_response_body() {
|
||||
let body = "x".repeat(MAX_ERROR_BODY_CHARS + 100);
|
||||
let message = http_error_message(
|
||||
"POST",
|
||||
"/git-receive-pack",
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
&body,
|
||||
None,
|
||||
);
|
||||
let prefix = "POST /git-receive-pack returned 403 Forbidden: ";
|
||||
|
||||
assert_eq!(message.len(), prefix.len() + MAX_ERROR_BODY_CHARS);
|
||||
assert!(message.starts_with(prefix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_get_error_includes_response_body() {
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /z6Mk/myrepo/info/refs?service=git-upload-pack HTTP/1.1",
|
||||
status: "404 Not Found",
|
||||
body: r#"{"message":"repository is not registered on this node"}"#,
|
||||
}]);
|
||||
|
||||
let repo_base = format!("{base_url}/z6Mk/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
|
||||
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("GET /info/refs returned 404 Not Found"));
|
||||
assert!(message.contains("repository is not registered on this node"));
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_post_error_includes_response_body() {
|
||||
let (base_url, server) = serve_http(vec![
|
||||
TestResponse {
|
||||
request_line: "GET /z6Mk/myrepo/info/refs?service=git-receive-pack HTTP/1.1",
|
||||
status: "200 OK",
|
||||
body: "",
|
||||
},
|
||||
TestResponse {
|
||||
request_line: "POST /z6Mk/myrepo/git-receive-pack HTTP/1.1",
|
||||
status: "403 Forbidden",
|
||||
body: r#"{"error":"forbidden","message":"push rejected: only the repo owner may push"}"#,
|
||||
},
|
||||
]);
|
||||
|
||||
let repo_base = format!("{base_url}/z6Mk/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(b"0000".to_vec());
|
||||
let err = handle_connect(&repo_base, "git-receive-pack", None, &mut stdin).unwrap_err();
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("POST /git-receive-pack returned 403 Forbidden"));
|
||||
assert!(message.contains("push rejected: only the repo owner may push"));
|
||||
let requests = server.join().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests[1].contains("\r\n\r\n0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_path_preserves_query_for_signed_advertisement() {
|
||||
// The Phase-1 advertisement GET is signed over url_path(refs_url), and the
|
||||
// node verifies the signature over its path_and_query, so the ?service=
|
||||
// query MUST survive verbatim or the signatures will not byte-match.
|
||||
assert_eq!(
|
||||
url_path("http://127.0.0.1:7545/z6Mk/myrepo/info/refs?service=git-upload-pack"),
|
||||
"/z6Mk/myrepo/info/refs?service=git-upload-pack"
|
||||
);
|
||||
assert_eq!(
|
||||
url_path("http://127.0.0.1:7545/z6Mk/myrepo/info/refs?service=git-receive-pack"),
|
||||
"/z6Mk/myrepo/info/refs?service=git-receive-pack"
|
||||
);
|
||||
}
|
||||
|
||||
fn args(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version = "0.5.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version = "0.5.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version = "0.5.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,7 +27,7 @@ use std::str::FromStr;
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::git::store;
|
||||
use crate::git::visibility_pack::{has_path_scoped_rule, withheld_blob_oids};
|
||||
use crate::git::visibility_pack::{allowed_blob_set_for_caller, has_path_scoped_rule};
|
||||
use crate::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
@@ -36,16 +36,24 @@ use crate::visibility::{visibility_check, Decision};
|
||||
/// Search all repos on the node for a git object whose SHA-256 hash matches
|
||||
/// the given CIDv1, returning its raw content if the caller may read it.
|
||||
///
|
||||
/// Visibility (#110): the object is served only from a repo row the caller
|
||||
/// passes. For each iterated row we gate against that row's OWN rules
|
||||
/// Visibility (#110, #126): the object is served only from a repo row the
|
||||
/// caller passes. For each iterated row we gate against that row's OWN rules
|
||||
/// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read`
|
||||
/// — `get_repo`'s fuzzy match could otherwise authorize a different physical
|
||||
/// row than the one read (KTD2a). When the row carries path-scoped rules, a
|
||||
/// blob withheld from the caller (`withheld_blob_oids`) is skipped. Denial and
|
||||
/// genuine not-found both fall through to an opaque 404.
|
||||
/// row than the one read (KTD2a). We check object existence via
|
||||
/// `store::object_type` *before* the expensive reachability walk so random-CID
|
||||
/// spray cannot trigger full-history git walks on repos that don't carry the
|
||||
/// object. When the row carries path-scoped rules (KTD4) the served object
|
||||
/// must be either a non-blob (trees/commits are structural; KTD3) OR a blob
|
||||
/// in the caller's *reachable* allowed-set (`allowed_blob_set_for_caller`).
|
||||
/// The reachable allowed-set excludes dangling blobs — a blob written via
|
||||
/// `git hash-object -w` and never committed has no path to gate, so it is
|
||||
/// fail-closed 404'd under path-scoped rules (#126). Denial and genuine
|
||||
/// not-found both fall through to an opaque 404.
|
||||
///
|
||||
/// Scope: this closes the direct unauthenticated scan. A stale-public mirror
|
||||
/// row still serves withheld content (tracked separately, #124).
|
||||
/// Scope: this closes the direct unauthenticated scan, including the dangling
|
||||
/// case. A stale-public mirror row still serves withheld content (tracked
|
||||
/// separately, #124).
|
||||
pub async fn get_by_cid(
|
||||
Path(cid_str): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
@@ -85,11 +93,16 @@ pub async fn get_by_cid(
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Request-scoped memo of the per-repo withheld set (KTD1). The caller is
|
||||
// constant for one request, so `repo.id` alone is a safe, sufficient key —
|
||||
// never a coarse caller "class", which `visibility_check`'s exact full-DID
|
||||
// reader match would make unsafe.
|
||||
let mut withheld_memo: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
// Request-scoped memo of the per-repo allowed-blob set (KTD1, #126). The
|
||||
// caller is constant for one request, so `repo.id` alone is a safe,
|
||||
// sufficient key — never a coarse caller "class", which
|
||||
// `visibility_check`'s exact full-DID reader match would make unsafe.
|
||||
//
|
||||
// We flipped from a deny-set (`withheld_blob_oids`) to an allowed-set
|
||||
// (`allowed_blob_set_for_caller`) so dangling blobs — never enumerated by
|
||||
// the reachable walk — fail closed instead of slipping through an empty
|
||||
// deny entry (#126).
|
||||
let mut allowed_memo: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
|
||||
for repo in &repos {
|
||||
// Repo-level read gate against THIS row's own rules (KTD2a).
|
||||
@@ -106,9 +119,24 @@ pub async fn get_by_cid(
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Per-blob withholding only applies when a path-scoped rule exists (KTD4).
|
||||
if has_path_scoped_rule(rules) {
|
||||
if !withheld_memo.contains_key(&repo.id) {
|
||||
// Check whether the object exists in this repo before any expensive
|
||||
// reachability walk. This prevents random-CID spray from triggering
|
||||
// full-history git walks on repos that don't carry the object.
|
||||
let obj_type = match store::object_type(&repo_path, &sha256_hex) {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error checking git object type");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-blob gating only applies when a path-scoped rule exists (KTD4).
|
||||
// Without any path-scoped rule, the "/" gate above is the whole story.
|
||||
// Trees/commits are always served under path-scoped rules (KTD3).
|
||||
let path_scoped = has_path_scoped_rule(rules);
|
||||
if path_scoped && obj_type == "blob" {
|
||||
if !allowed_memo.contains_key(&repo.id) {
|
||||
let rp = repo_path.clone();
|
||||
let r = rules.to_vec();
|
||||
let is_public = repo.is_public;
|
||||
@@ -116,7 +144,13 @@ pub async fn get_by_cid(
|
||||
let caller_for_walk = caller_owned.clone();
|
||||
// Full-history walk shells out to git — keep it off the async runtime.
|
||||
let walk = tokio::task::spawn_blocking(move || {
|
||||
withheld_blob_oids(&rp, &r, is_public, &owner, caller_for_walk.as_deref())
|
||||
allowed_blob_set_for_caller(
|
||||
&rp,
|
||||
&r,
|
||||
is_public,
|
||||
&owner,
|
||||
caller_for_walk.as_deref(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
// Fail closed on EITHER a task panic (JoinError) or a walk error:
|
||||
@@ -125,51 +159,50 @@ pub async fn get_by_cid(
|
||||
let set = match walk {
|
||||
Ok(Ok(set)) => set,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "withheld walk failed; skipping repo");
|
||||
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "withheld walk task panicked; skipping repo");
|
||||
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
withheld_memo.insert(repo.id.clone(), set);
|
||||
allowed_memo.insert(repo.id.clone(), set);
|
||||
}
|
||||
if withheld_memo
|
||||
let in_allowed = allowed_memo
|
||||
.get(&repo.id)
|
||||
.is_some_and(|set| set.contains(&sha256_hex))
|
||||
{
|
||||
.is_some_and(|set| set.contains(&sha256_hex));
|
||||
if !in_allowed {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match store::read_object(&repo_path, &sha256_hex) {
|
||||
Ok(Some((_obj_type, content))) => {
|
||||
// 3. Return the content with IPFS-style headers
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-content-cid"),
|
||||
HeaderValue::from_str(&cid_str)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-git-hash"),
|
||||
HeaderValue::from_str(&sha256_hex)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
|
||||
return Ok((StatusCode::OK, headers, content).into_response());
|
||||
}
|
||||
Ok(None) => continue,
|
||||
// Now that we've passed the gate, read the content.
|
||||
let content = match store::read_object_content(&repo_path, &sha256_hex, &obj_type) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error reading git object");
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error reading git object content");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Return the content with IPFS-style headers
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-content-cid"),
|
||||
HeaderValue::from_str(&cid_str).unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-git-hash"),
|
||||
HeaderValue::from_str(&sha256_hex)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
|
||||
return Ok((StatusCode::OK, headers, content).into_response());
|
||||
}
|
||||
|
||||
// Not found in any repo
|
||||
|
||||
@@ -324,6 +324,118 @@ mod authz_guard {
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect `.rs` source files under `dir`. Recursive so the completeness scan
|
||||
/// covers nested API modules (`api/<module>/mod.rs` and deeper), not only the
|
||||
/// immediate `api/*.rs` children.
|
||||
fn collect_rs_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).expect("read api dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
out.extend(collect_rs_files(&path));
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.rs` files under `api_root` (as paths RELATIVE to it) that the
|
||||
/// completeness scan must inspect: everything except the top-level `mod.rs` (the
|
||||
/// guard file itself) and the top-level files already covered by the per-handler
|
||||
/// `sources` loop. A nested `api/<module>/<name>.rs` is a distinct source file
|
||||
/// even when its basename matches a listed top-level file, so it stays in scope.
|
||||
fn unlisted_source_files(
|
||||
api_root: &std::path::Path,
|
||||
listed: &std::collections::HashSet<&str>,
|
||||
) -> Vec<String> {
|
||||
collect_rs_files(api_root)
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let rel = path
|
||||
.strip_prefix(api_root)
|
||||
.ok()?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if rel == "mod.rs" || listed.contains(rel.as_str()) {
|
||||
None
|
||||
} else {
|
||||
Some(rel)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_rs_files_recurses_subdirs() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("a.rs"), "").unwrap();
|
||||
std::fs::write(root.join("note.txt"), "").unwrap();
|
||||
std::fs::create_dir_all(root.join("sub/deep")).unwrap();
|
||||
std::fs::write(root.join("sub/mod.rs"), "").unwrap();
|
||||
std::fs::write(root.join("sub/deep/c.rs"), "").unwrap();
|
||||
let names: std::collections::HashSet<String> = collect_rs_files(root)
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
})
|
||||
.collect();
|
||||
assert!(names.contains("a.rs"));
|
||||
assert!(
|
||||
names.contains("sub/mod.rs"),
|
||||
"nested module file must be collected"
|
||||
);
|
||||
assert!(
|
||||
names.contains("sub/deep/c.rs"),
|
||||
"deeply nested file must be collected"
|
||||
);
|
||||
assert!(
|
||||
!names.iter().any(|n| n.ends_with(".txt")),
|
||||
"non-rs files excluded"
|
||||
);
|
||||
assert_eq!(names.len(), 3);
|
||||
}
|
||||
|
||||
// P3 (#119): the completeness scan must skip already-covered files by their path
|
||||
// RELATIVE to api_root, not by basename. A nested api/<module>/repos.rs is a
|
||||
// distinct source file from the covered top-level repos.rs and must still be
|
||||
// scanned, or a new nested module could smuggle in an ungated repo-scoped handler
|
||||
// behind a colliding filename.
|
||||
#[test]
|
||||
fn unlisted_source_files_scans_nested_file_with_colliding_basename() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("repos.rs"), "").unwrap();
|
||||
std::fs::write(root.join("mod.rs"), "").unwrap();
|
||||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||||
std::fs::write(root.join("sub/repos.rs"), "").unwrap();
|
||||
std::fs::write(root.join("sub/fresh.rs"), "").unwrap();
|
||||
let listed: std::collections::HashSet<&str> = ["repos.rs"].into_iter().collect();
|
||||
|
||||
let unlisted = unlisted_source_files(root, &listed);
|
||||
|
||||
assert!(
|
||||
unlisted.contains(&"sub/repos.rs".to_string()),
|
||||
"a nested file whose basename matches a listed top-level file must still be scanned"
|
||||
);
|
||||
assert!(
|
||||
unlisted.contains(&"sub/fresh.rs".to_string()),
|
||||
"a nested file with a unique name must be scanned"
|
||||
);
|
||||
assert!(
|
||||
!unlisted.contains(&"repos.rs".to_string()),
|
||||
"the listed top-level file is covered by the per-handler loop"
|
||||
);
|
||||
assert!(
|
||||
!unlisted.contains(&"mod.rs".to_string()),
|
||||
"the top-level guard file is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/// Egress gate guard: every repo-scoped handler (`Path<(String, String..)>`)
|
||||
/// must carry an authz marker — a read gate (`authorize_repo_read` /
|
||||
/// `visibility_check`), or a write gate (`require_repo_owner` / `require_owner`
|
||||
@@ -376,19 +488,15 @@ mod authz_guard {
|
||||
"read-guard `sources` lists {f} but the file does not exist"
|
||||
);
|
||||
}
|
||||
for entry in std::fs::read_dir(api_dir).expect("read src/api") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
let fname = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
if !fname.ends_with(".rs") || fname == "mod.rs" || listed.contains(fname.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let src = std::fs::read_to_string(&path).expect("read api file");
|
||||
let api_root = std::path::Path::new(api_dir);
|
||||
for rel in unlisted_source_files(api_root, &listed) {
|
||||
let src = std::fs::read_to_string(api_root.join(&rel)).expect("read api file");
|
||||
let has_repo_handler = handler_names(&src)
|
||||
.iter()
|
||||
.any(|n| is_repo_scoped(&fn_body(&src, n)));
|
||||
assert!(
|
||||
!has_repo_handler,
|
||||
"api/{fname} declares a repo-scoped handler but is not in the egress \
|
||||
"api/{rel} declares a repo-scoped handler but is not in the egress \
|
||||
guard `sources` list — add it so its handlers are gate-checked"
|
||||
);
|
||||
}
|
||||
@@ -396,9 +504,6 @@ mod authz_guard {
|
||||
// Repo-scoped reads known to be ungated today, each tracked by an issue.
|
||||
// Remove an entry the moment its gate lands (the staleness assert enforces it).
|
||||
let known_ungated: &[(&str, &str)] = &[
|
||||
// info/refs gates only git-upload-pack today; git-receive-pack
|
||||
// advertisement is ungated until #119 makes the gate unconditional.
|
||||
("git_info_refs", "#119"),
|
||||
("list_certs", "#120"),
|
||||
("get_cert", "#120"),
|
||||
("list_issues", "#120"),
|
||||
@@ -407,7 +512,6 @@ mod authz_guard {
|
||||
("list_labels", "#120"),
|
||||
("list_repo_bounties", "#120"),
|
||||
("get_star_status", "#120"),
|
||||
("list_repo_events", "#94 (PR #113)"),
|
||||
("list_webhooks", "#94 (PR #113)"),
|
||||
("list_replicas", "PR #113"),
|
||||
("list_protected_branches", "PR #113"),
|
||||
|
||||
@@ -296,8 +296,8 @@ pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_js
|
||||
repo.get("name").and_then(|v| v.as_str()),
|
||||
) {
|
||||
(Some(owner), Some(name)) => {
|
||||
// Use short owner (last colon segment) matching DB convention
|
||||
let short = owner.split(':').next_back().unwrap_or(owner);
|
||||
// Use short owner (did:key-only normalization) matching DB convention
|
||||
let short = crate::db::normalize_owner_key(owner);
|
||||
format!("{short}/{name}")
|
||||
}
|
||||
_ => continue,
|
||||
|
||||
@@ -211,7 +211,12 @@ pub async fn create_repo(
|
||||
.repo_store
|
||||
.init(&owner_did, &req.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
// `{:#}` walks the anyhow chain to the leaf cause; the other git
|
||||
// handlers log their failures, this one didn't.
|
||||
tracing::error!(owner = %owner_did, repo = %req.name, err = %format!("{e:#}"), "repo create failed");
|
||||
AppError::Git(e.to_string())
|
||||
})?;
|
||||
|
||||
let now = Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
@@ -482,11 +487,13 @@ pub async fn get_tree(
|
||||
|
||||
// ── Git smart HTTP endpoints ──────────────────────────────────────────────
|
||||
|
||||
/// GET /:owner/:repo.git/info/refs?service=git-upload-pack
|
||||
/// GET /:owner/:repo.git/info/refs?service=git-upload-pack|git-receive-pack
|
||||
pub async fn git_info_refs(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Query(query): Query<InfoRefsQuery>,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
headers: axum::http::HeaderMap,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Response> {
|
||||
let name = repo.trim_end_matches(".git");
|
||||
@@ -508,10 +515,14 @@ pub async fn git_info_refs(
|
||||
.ok_or_else(|| AppError::BadRequest("missing ?service= parameter".into()))?;
|
||||
tracing::debug!(service = %service, repo = %name, "info/refs service");
|
||||
|
||||
// Enforce read (clone/fetch) visibility. The push advertisement
|
||||
// (service=git-receive-pack) is authorized separately on the
|
||||
// git-receive-pack POST, so leave it untouched here.
|
||||
if service == "git-upload-pack" {
|
||||
// Enforce read visibility on the ref advertisement, for BOTH services. The
|
||||
// upload-pack (clone/fetch) and receive-pack (push) advertisements expose the
|
||||
// same ref metadata (branch/tag names and commit tips), so a private repo's
|
||||
// advertisement must be withheld from a non-reader regardless of which service
|
||||
// is requested. The push itself stays separately owner-gated on the
|
||||
// git-receive-pack POST; push access implies read access here, so a
|
||||
// legitimate pusher (the owner) always clears this gate.
|
||||
{
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
// Subtree (mode B) rules do not gate the advertisement: refs expose commit
|
||||
@@ -519,11 +530,28 @@ pub async fn git_info_refs(
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
|
||||
== Decision::Deny
|
||||
{
|
||||
tracing::debug!(repo = %name, caller = ?caller, "info/refs read denied by visibility");
|
||||
tracing::debug!(repo = %name, caller = ?caller, service = %service, "info/refs read denied by visibility");
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
|
||||
}
|
||||
}
|
||||
|
||||
// Push flood brake on the advertisement phase. A push always hits this
|
||||
// GET first, and for receive-pack it forces a fresh Tigris download below;
|
||||
// throttling only the receive-pack POST would leave the expensive
|
||||
// fresh-acquire reachable unauthenticated and unlimited. Applied before the
|
||||
// acquire so a rejected request does no Tigris work. Same per-IP limiter and
|
||||
// trusted-proxy policy as the POST middleware (shared buckets).
|
||||
if service == "git-receive-pack" {
|
||||
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
|
||||
if !state.push_rate_limiter.check(&key).await {
|
||||
tracing::warn!(repo = %name, key = %key, "receive-pack advertisement rate limited");
|
||||
return Err(AppError::TooManyRequests(
|
||||
"push rate limit exceeded — try again later".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For receive-pack (push), download the latest from Tigris so the client
|
||||
// sees the same refs that acquire_write() will operate on.
|
||||
let disk_path = if service == "git-receive-pack" {
|
||||
@@ -945,11 +973,7 @@ pub async fn git_receive_pack(
|
||||
.as_deref()
|
||||
.unwrap_or("http://127.0.0.1:7545")
|
||||
.trim_end_matches('/');
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
let owner_short = crate::db::normalize_owner_key(&record.owner_did);
|
||||
let clone_url = format!("{}/{}/{}.git", base_url, owner_short, record.name);
|
||||
|
||||
for update in &ref_updates {
|
||||
@@ -1096,7 +1120,7 @@ pub async fn git_receive_pack(
|
||||
// this push to Arweave, so the oid->cid index survives total
|
||||
// node loss. Best-effort; never fails the push.
|
||||
if !delta.is_empty() && !irys_url.is_empty() {
|
||||
let owner_short = owner_did.split(':').next_back().unwrap_or(&owner_did);
|
||||
let owner_short = crate::db::normalize_owner_key(&owner_did);
|
||||
let repo_slug = format!("{owner_short}/{repo_name}");
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
let manifest = crate::arweave::EncryptedManifest {
|
||||
@@ -1141,11 +1165,7 @@ pub async fn git_receive_pack(
|
||||
let node_did_str = state.node_did.to_string();
|
||||
let repo_slug = format!(
|
||||
"{}/{}",
|
||||
record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did),
|
||||
crate::db::normalize_owner_key(&record.owner_did),
|
||||
record.name
|
||||
);
|
||||
let ref_updates_clone = ref_updates
|
||||
@@ -1213,17 +1233,26 @@ pub async fn git_receive_pack(
|
||||
}
|
||||
|
||||
// Broadcast ref update to GraphQL subscription listeners — one per ref.
|
||||
// Gated on `announce`: /graphql/ws is unauthenticated (mounted after
|
||||
// the optional_signature layer), and the subscription resolver has no
|
||||
// caller to gate against, so only publicly-readable ref updates may
|
||||
// reach anonymous subscribers. Mirrors the gossip (above) and Arweave
|
||||
// (below) sends, which are already `announce`-gated. Without this a
|
||||
// private-repo push would leak live ref metadata over the socket —
|
||||
// the subscription analog of #112/#114.
|
||||
let now_ts = chrono::Utc::now().to_rfc3339();
|
||||
for (ref_name, old_sha, new_sha) in &ref_updates_clone {
|
||||
let _ = ref_update_tx.send(crate::state::RefUpdateBroadcast {
|
||||
repo: repo_slug.clone(),
|
||||
ref_name: ref_name.clone(),
|
||||
old_sha: old_sha.clone(),
|
||||
new_sha: new_sha.clone(),
|
||||
pusher_did: pusher_did_clone.clone(),
|
||||
node_did: node_did_str.clone(),
|
||||
timestamp: now_ts.clone(),
|
||||
});
|
||||
if announce {
|
||||
for (ref_name, old_sha, new_sha) in &ref_updates_clone {
|
||||
let _ = ref_update_tx.send(crate::state::RefUpdateBroadcast {
|
||||
repo: repo_slug.clone(),
|
||||
ref_name: ref_name.clone(),
|
||||
old_sha: old_sha.clone(),
|
||||
new_sha: new_sha.clone(),
|
||||
pusher_did: pusher_did_clone.clone(),
|
||||
node_did: node_did_str.clone(),
|
||||
timestamp: now_ts.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Arweave permanent anchoring — fire for each ref update.
|
||||
@@ -1474,7 +1503,7 @@ pub async fn fork_repo(
|
||||
}
|
||||
|
||||
// Check no name conflict under the forker's ownership
|
||||
let forker_short = forker_did.split(':').next_back().unwrap_or(&forker_did);
|
||||
let forker_short = crate::db::normalize_owner_key(&forker_did);
|
||||
if state.db.get_repo(forker_short, &fork_name).await?.is_some() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"you already have a repo named {fork_name}"
|
||||
@@ -1630,13 +1659,17 @@ fn parse_ref_updates(body: &[u8]) -> Vec<RefUpdate> {
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// For a non-key DID owner, `normalize_owner_key` returns the full DID, so
|
||||
// `clone_url` becomes `/did:gitlawb:z6.../repo.git`. That resolves through
|
||||
// `get_repo`, but the colon-bearing path segment would break the `sync.rs`
|
||||
// disk-path join (`owner_slug/repo`). Not reachable today (auth is
|
||||
// did:key-only), so this is a forward constraint to handle before non-key
|
||||
// ownership lands: the owner-first disk layout must either reject colons or
|
||||
// encode them.
|
||||
|
||||
fn to_response(record: &crate::db::RepoRecord, state: &AppState, star_count: i64) -> RepoResponse {
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
let owner_short = crate::db::normalize_owner_key(&record.owner_did);
|
||||
|
||||
let base_url = state
|
||||
.config
|
||||
@@ -1779,6 +1812,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `announce` is the single boolean that gates every network-facing emission
|
||||
/// of a push: gossip, Arweave anchoring, and the GraphQL subscription
|
||||
/// broadcast (the last one added in this change). It must be false for a repo
|
||||
/// the anonymous public cannot read, or the unauthenticated `/graphql/ws`
|
||||
/// subscription leaks live private-repo ref metadata. Pin both directions of
|
||||
/// the decision the broadcast now sits behind. No disk access: a non-announce
|
||||
/// repo returns early, and a public repo with no path-scoped rule skips the
|
||||
/// withheld walk.
|
||||
#[tokio::test]
|
||||
async fn replication_announce_false_for_private_true_for_public() {
|
||||
let dummy = std::path::PathBuf::from("/nonexistent");
|
||||
|
||||
// Private: no rules at all.
|
||||
let (announce, _) = replication_withheld_set(None, OWNER_DID, false, dummy.clone()).await;
|
||||
assert!(!announce, "private repo (no rules) must not announce");
|
||||
|
||||
// Private: empty rule set, is_public=false → still not listable at root.
|
||||
let (announce, _) =
|
||||
replication_withheld_set(Some(vec![]), OWNER_DID, false, dummy.clone()).await;
|
||||
assert!(!announce, "private repo (empty rules) must not announce");
|
||||
|
||||
// Public: empty rule set, is_public=true → listable at root, announces.
|
||||
let (announce, _) = replication_withheld_set(Some(vec![]), OWNER_DID, true, dummy).await;
|
||||
assert!(announce, "public repo must announce");
|
||||
}
|
||||
|
||||
/// A rejection must be a 403 Forbidden (authenticated but not authorized),
|
||||
/// not a 400 — some git/CI clients retry 400s.
|
||||
fn assert_forbidden(rejection: Option<AppError>) {
|
||||
@@ -2340,4 +2399,99 @@ mod tests {
|
||||
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_response_generates_correct_clone_url_slug() {
|
||||
let state = crate::test_support::test_state_lazy();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// 1. did:key owner (should strip did:key: prefix)
|
||||
let repo_key = crate::db::RepoRecord {
|
||||
id: "uuid-1".into(),
|
||||
name: "my-repo".into(),
|
||||
owner_did: "did:key:z6Mkwbud".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/my-repo".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
let response_key = to_response(&repo_key, &state, 5);
|
||||
assert!(
|
||||
response_key.clone_url.contains("/z6Mkwbud/my-repo.git"),
|
||||
"clone_url should use the bare did:key ID. got: {}",
|
||||
response_key.clone_url
|
||||
);
|
||||
|
||||
// 2. did:gitlawb owner (non-key DID method, should NOT strip)
|
||||
let repo_non_key = crate::db::RepoRecord {
|
||||
id: "uuid-2".into(),
|
||||
name: "other-repo".into(),
|
||||
owner_did: "did:gitlawb:z6Mkwbud".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/other-repo".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
let response_non_key = to_response(&repo_non_key, &state, 10);
|
||||
assert!(
|
||||
response_non_key
|
||||
.clone_url
|
||||
.contains("/did:gitlawb:z6Mkwbud/other-repo.git"),
|
||||
"clone_url should preserve the full non-key owner DID. got: {}",
|
||||
response_non_key.clone_url
|
||||
);
|
||||
}
|
||||
|
||||
/// The receive-pack *advertisement* (`GET info/refs?service=git-receive-pack`)
|
||||
/// must be throttled by the per-IP push limiter BEFORE it does the fresh
|
||||
/// Tigris acquire — otherwise the flood brake on the POST is bypassable via
|
||||
/// the cheaper unauthenticated GET (PR #152 review P1). Pre-filling the
|
||||
/// bucket makes the assertion deterministic and keeps the test off the
|
||||
/// acquire path entirely.
|
||||
#[sqlx::test]
|
||||
async fn receive_pack_advertisement_is_rate_limited(pool: sqlx::PgPool) {
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let mut state = crate::test_support::test_state(pool).await;
|
||||
// Tiny limit, keyed on the socket peer (no trusted proxy).
|
||||
state.push_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6advowner", "adv", "/tmp/adv", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let peer: SocketAddr = "203.0.113.55:6000".parse().unwrap();
|
||||
// Exhaust this peer's single-request budget up front.
|
||||
assert!(state.push_rate_limiter.check(&peer.ip().to_string()).await);
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/z6advowner/adv/info/refs?service=git-receive-pack")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
|
||||
let status = router.oneshot(req).await.unwrap().status();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"receive-pack advertisement must be throttled before the Tigris acquire"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -514,6 +514,8 @@ mod tests {
|
||||
machine_id: None,
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +778,7 @@ const MIGRATIONS: &[Migration] = &[
|
||||
// the matching expression index. The CASE must stay byte-for-byte in
|
||||
// sync with DEDUP_CTE / count_repos_deduped or Postgres won't use it.
|
||||
"DROP INDEX IF EXISTS idx_repos_owner_short_name",
|
||||
// Keep byte-identical to OWNER_KEY_CASE_SQL so Postgres uses the index.
|
||||
"CREATE INDEX IF NOT EXISTS idx_repos_owner_key_name ON repos ((CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END), name)",
|
||||
],
|
||||
},
|
||||
@@ -826,6 +827,85 @@ const MIGRATIONS: &[Migration] = &[
|
||||
|
||||
// ── Repos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn normalize_owner_key(did: &str) -> &str {
|
||||
match did.strip_prefix("did:key:") {
|
||||
Some(rest) if !rest.contains(':') => rest,
|
||||
_ => did,
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL CASE expression byte-identical to `normalize_owner_key`. All queries that
|
||||
/// filter or group by owner key use this const so the Rust and SQL sides cannot
|
||||
/// drift apart. If you change `normalize_owner_key`, update this const too.
|
||||
const OWNER_KEY_CASE_SQL: &str = "CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END";
|
||||
|
||||
#[cfg(test)]
|
||||
mod normalize_owner_key_tests {
|
||||
use super::normalize_owner_key;
|
||||
|
||||
// Boundary set matching the SQL CASE: did:key short/full, empty residual,
|
||||
// did:key:z:extra, non-key, bare, empty, uppercase.
|
||||
#[test]
|
||||
fn strips_did_key_prefix() {
|
||||
assert_eq!(normalize_owner_key("did:key:z6Mkfoo"), "z6Mkfoo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_full_did_key_unchanged_when_not_a_prefix() {
|
||||
assert_eq!(normalize_owner_key("z6Mkfoo"), "z6Mkfoo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_key_did_intact() {
|
||||
assert_eq!(
|
||||
normalize_owner_key("did:gitlawb:z6Mkfoo"),
|
||||
"did:gitlawb:z6Mkfoo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_web_did_intact() {
|
||||
assert_eq!(
|
||||
normalize_owner_key("did:web:example.com:alice"),
|
||||
"did:web:example.com:alice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_strip_did_key_with_extra_colon() {
|
||||
// did:key:did:gitlawb:z6... — the remainder contains ':', so it's left whole.
|
||||
assert_eq!(
|
||||
normalize_owner_key("did:key:did:gitlawb:z6Mkfoo"),
|
||||
"did:key:did:gitlawb:z6Mkfoo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_returns_empty() {
|
||||
assert_eq!(normalize_owner_key(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_did_key_colon_becomes_empty() {
|
||||
// did:key: with nothing after still has the prefix stripped.
|
||||
assert_eq!(normalize_owner_key("did:key:"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_prefix_is_untouched() {
|
||||
assert_eq!(normalize_owner_key("DID:KEY:z6Mkfoo"), "DID:KEY:z6Mkfoo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_did_key_even_with_trailing_slash() {
|
||||
// did:key:z6Mkfoo/extra has no ':' in the remainder, so it strips.
|
||||
assert_eq!(
|
||||
normalize_owner_key("did:key:z6Mkfoo/extra"),
|
||||
"z6Mkfoo/extra"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn create_repo(&self, repo: &RepoRecord) -> Result<()> {
|
||||
sqlx::query(
|
||||
@@ -887,16 +967,32 @@ impl Db {
|
||||
}
|
||||
|
||||
pub async fn get_repo(&self, owner_did: &str, name: &str) -> Result<Option<RepoRecord>> {
|
||||
let row = sqlx::query(
|
||||
// Normalize owner_did to its did:key short form, mirroring did_matches and
|
||||
// the DEDUP_CTE's owner-key CASE: strip `did:key:` only when the remainder
|
||||
// is a bare key id (no further `:`). This keeps `did:key:z...` and bare
|
||||
// `z...` interchangeable while `did:gitlawb:z...` / `did:web:z...` stay
|
||||
// distinct — the old LIKE '%:' || $1 || '%' was too broad (issue #124 P2).
|
||||
let owner_key = normalize_owner_key(owner_did);
|
||||
let sql = format!(
|
||||
"SELECT id, name, owner_did, description, is_public, default_branch,
|
||||
created_at, updated_at, disk_path, forked_from, machine_id
|
||||
FROM repos
|
||||
WHERE (owner_did = $1 OR owner_did LIKE '%:' || $1 || '%') AND name = $2",
|
||||
)
|
||||
.bind(owner_did)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
WHERE ({key}) = $1
|
||||
AND name = $2
|
||||
-- Prefer canonical rows (UUID id, no slash) over mirror rows (slash id).
|
||||
-- Mirror rows are inserted by upsert_mirror_repo with is_public=true and
|
||||
-- no visibility rules; using them for visibility checks would bypass the
|
||||
-- canonical row's gate (issue #124).
|
||||
ORDER BY CASE WHEN position('/' in id) > 0 THEN 1 ELSE 0 END,
|
||||
created_at ASC, id ASC
|
||||
LIMIT 1",
|
||||
key = OWNER_KEY_CASE_SQL
|
||||
);
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(owner_key)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(row_to_repo))
|
||||
}
|
||||
@@ -969,15 +1065,17 @@ impl Db {
|
||||
/// full tie still picks a deterministic survivor. `list_all_repos_deduped_with_stars`,
|
||||
/// `list_all_repos_deduped`, and the marker logic in
|
||||
/// `crate::api::repos::dedupe_canonical_repos` must stay in sync.
|
||||
const DEDUP_CTE: &'static str = "WITH deduped AS (
|
||||
SELECT DISTINCT ON (CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END, name)
|
||||
fn dedup_cte() -> String {
|
||||
format!(
|
||||
"WITH deduped AS (
|
||||
SELECT DISTINCT ON ({key}, name)
|
||||
id, name, owner_did, description, is_public, default_branch,
|
||||
created_at,
|
||||
-- group MAX, not the canonical row's own value: pushes that
|
||||
-- arrive via gossip touch only the mirror row, so the
|
||||
-- canonical updated_at goes stale
|
||||
MAX(updated_at) OVER (
|
||||
PARTITION BY CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END, name
|
||||
PARTITION BY {key}, name
|
||||
) AS updated_at,
|
||||
disk_path, forked_from, machine_id
|
||||
FROM repos
|
||||
@@ -987,15 +1085,18 @@ impl Db {
|
||||
-- does. Callers bind the already-normalized key ($1).
|
||||
-- Quarantined mirrors (admitted but unvalidated by the iCaptcha
|
||||
-- propagation gate) are withheld from every listing surface.
|
||||
WHERE quarantined = FALSE AND ($1::text IS NULL OR (CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END) = $1)
|
||||
ORDER BY CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END, name,
|
||||
-- mirror rows carry a slash-form id (\"{owner_short}/{name}\"),
|
||||
WHERE quarantined = FALSE AND ($1::text IS NULL OR ({key}) = $1)
|
||||
ORDER BY {key}, name,
|
||||
-- mirror rows carry a slash-form id (\"{{owner_short}}/{{name}}\"),
|
||||
-- written only by upsert_mirror_repo; canonical ids are UUIDs.
|
||||
-- Rank canonical (no slash) ahead of the mirror in each group,
|
||||
-- keyed on the structural id, not the user-settable description.
|
||||
CASE WHEN position('/' in id) > 0 THEN 1 ELSE 0 END,
|
||||
created_at ASC, id ASC
|
||||
)";
|
||||
)",
|
||||
key = OWNER_KEY_CASE_SQL
|
||||
)
|
||||
}
|
||||
|
||||
/// All repos with star counts, mirror-deduplicated via `DEDUP_CTE` and
|
||||
/// ordered newest-first, optionally filtered to one owner. Returns the full
|
||||
@@ -1015,10 +1116,7 @@ impl Db {
|
||||
// Mirror did_matches: strip `did:key:` only when the remainder is a bare
|
||||
// key id (no further `:`). The DEDUP_CTE applies the identical CASE to
|
||||
// owner_did, so the two compare on the same normalized key.
|
||||
let owner_key = owner_filter.map(|o| match o.strip_prefix("did:key:") {
|
||||
Some(rest) if !rest.contains(':') => rest,
|
||||
_ => o,
|
||||
});
|
||||
let owner_key = owner_filter.map(normalize_owner_key);
|
||||
let sql = format!(
|
||||
"{}
|
||||
SELECT
|
||||
@@ -1031,7 +1129,7 @@ impl Db {
|
||||
SELECT repo_id, COUNT(*) AS cnt FROM repo_stars GROUP BY repo_id
|
||||
) s ON s.repo_id = d.id
|
||||
ORDER BY d.updated_at DESC",
|
||||
Self::DEDUP_CTE
|
||||
Self::dedup_cte()
|
||||
);
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(owner_key)
|
||||
@@ -1049,7 +1147,7 @@ impl Db {
|
||||
|
||||
/// Deduped repo list (no stars, no paging) for the unfiltered read surfaces
|
||||
/// (GraphQL `repos`). One logical repo per mirror+canonical group, ordered by
|
||||
/// the group's most recent activity. Shares `DEDUP_CTE` with the paged path so
|
||||
/// the group's most recent activity. Shares `dedup_cte()` with the paged path so
|
||||
/// the dedup rule cannot drift; binds a NULL owner filter (all rows).
|
||||
pub async fn list_all_repos_deduped(&self) -> Result<Vec<RepoRecord>> {
|
||||
let sql = format!(
|
||||
@@ -1059,7 +1157,7 @@ impl Db {
|
||||
d.forked_from, d.machine_id
|
||||
FROM deduped d
|
||||
ORDER BY d.updated_at DESC",
|
||||
Self::DEDUP_CTE
|
||||
Self::dedup_cte()
|
||||
);
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(None::<&str>)
|
||||
@@ -1069,6 +1167,22 @@ impl Db {
|
||||
Ok(rows.into_iter().map(row_to_repo).collect())
|
||||
}
|
||||
|
||||
/// Repos currently quarantined (admitted as mirrors but withheld from every
|
||||
/// listing surface). `list_all_repos_deduped` excludes these (its `DEDUP_CTE`
|
||||
/// filters `quarantined = FALSE`), so a gate that resolves a slug against the
|
||||
/// deduped set must also match against these and fail closed, or a quarantined
|
||||
/// repo's row is misclassified as remote/gossip-only and served.
|
||||
pub async fn list_quarantined_repos(&self) -> Result<Vec<RepoRecord>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, owner_did, description, is_public, default_branch,
|
||||
created_at, updated_at, disk_path, forked_from, machine_id
|
||||
FROM repos WHERE quarantined = TRUE",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_repo).collect())
|
||||
}
|
||||
|
||||
/// Count of distinct logical repos (mirror + canonical collapsed). Uses the
|
||||
/// same did:key-aware owner-key grouping as `DEDUP_CTE` (the CASE must stay
|
||||
/// byte-identical); the marker/tiebreak only decide which row would survive,
|
||||
@@ -1080,11 +1194,11 @@ impl Db {
|
||||
/// CASE that the live list paths depend on. Allowed dead outside tests.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub async fn count_repos_deduped(&self) -> Result<i64> {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(DISTINCT (CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END, name)) AS cnt FROM repos",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let sql = format!(
|
||||
"SELECT COUNT(DISTINCT ({key}, name)) AS cnt FROM repos",
|
||||
key = OWNER_KEY_CASE_SQL
|
||||
);
|
||||
let row = sqlx::query(&sql).fetch_one(&self.pool).await?;
|
||||
Ok(row.get::<i64, _>("cnt"))
|
||||
}
|
||||
|
||||
@@ -1258,18 +1372,18 @@ impl Db {
|
||||
Ok(row.map(|r| r.get::<f64, _>("trust_score")).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Update an EXISTING agent's trust score; a no-op for unregistered DIDs.
|
||||
/// Deliberately never inserts: the only path into the agents table is
|
||||
/// `register_agent`, which sits behind the iCaptcha gate on /api/register.
|
||||
/// This used to be an upsert, which let any authenticated push/issue/PR
|
||||
/// re-create a deregistered DID's row with a fresh `registered_at`,
|
||||
/// bypassing the registration gate entirely.
|
||||
pub async fn update_trust_score(&self, agent_did: &str, score: f64) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO agents (did, trust_score, capabilities, registered_at)
|
||||
VALUES ($1, $2, '[]', $3)
|
||||
ON CONFLICT(did) DO UPDATE SET trust_score = $2",
|
||||
)
|
||||
.bind(agent_did)
|
||||
.bind(score)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query("UPDATE agents SET trust_score = $2 WHERE did = $1")
|
||||
.bind(agent_did)
|
||||
.bind(score)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2109,61 +2223,63 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_ref_updates(&self, limit: i64) -> Result<Vec<ReceivedRefUpdate>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp,
|
||||
cert_id, received_at, from_peer
|
||||
FROM received_ref_updates ORDER BY timestamp DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_ref_update).collect())
|
||||
}
|
||||
|
||||
pub async fn list_repo_ref_updates(
|
||||
&self,
|
||||
repo: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<ReceivedRefUpdate>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp,
|
||||
cert_id, received_at, from_peer
|
||||
FROM received_ref_updates WHERE repo = $1 ORDER BY timestamp DESC LIMIT $2",
|
||||
)
|
||||
.bind(repo)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_ref_update).collect())
|
||||
}
|
||||
|
||||
/// Filtered ref updates — optionally scoped to a specific repo.
|
||||
pub async fn list_ref_updates_filtered(
|
||||
/// One page of ref updates (newest first), optionally scoped to one repo.
|
||||
/// The `(timestamp DESC, id DESC)` order gives a stable tiebreak so offset
|
||||
/// paging does not skip or duplicate rows when timestamps collide. Used by
|
||||
/// the visibility-gated feed collector, which pages past dropped private rows
|
||||
/// so a small limit still returns the latest visible events (#114).
|
||||
/// One page of ref updates for the visibility collector, ordered
|
||||
/// `timestamp DESC, id DESC`, using a **keyset** cursor rather than
|
||||
/// `LIMIT/OFFSET`.
|
||||
///
|
||||
/// `after` is the `(timestamp, id)` of the last row of the previous page;
|
||||
/// the next page reads rows strictly older than it via the row-value
|
||||
/// predicate `(timestamp, id) < (after_ts, after_id)`, which matches the
|
||||
/// `ORDER BY` exactly (same id tie-break). Because a concurrently inserted
|
||||
/// row is newer (larger `timestamp`) and so sorts to the front, it lands
|
||||
/// *above* the window we are paging through and cannot shift it. That keeps
|
||||
/// a single multi-page scan free of the duplicate/skip that OFFSET paging
|
||||
/// suffers when `received_ref_updates` is written between page reads.
|
||||
pub async fn list_ref_updates_keyset(
|
||||
&self,
|
||||
repo: Option<&str>,
|
||||
limit: i64,
|
||||
after: Option<(&str, &str)>,
|
||||
) -> Result<Vec<ReceivedRefUpdate>> {
|
||||
let rows = if let Some(r) = repo {
|
||||
sqlx::query(
|
||||
"SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp,
|
||||
cert_id, received_at, from_peer
|
||||
FROM received_ref_updates WHERE repo=$1 ORDER BY timestamp DESC LIMIT $2",
|
||||
)
|
||||
.bind(r)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
const COLS: &str = "id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, \
|
||||
timestamp, cert_id, received_at, from_peer";
|
||||
|
||||
// Positional params in bind order: repo?, after_ts?, after_id?, limit.
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
let mut n = 0;
|
||||
if repo.is_some() {
|
||||
n += 1;
|
||||
conds.push(format!("repo = ${n}"));
|
||||
}
|
||||
if after.is_some() {
|
||||
let (a, b) = (n + 1, n + 2);
|
||||
n += 2;
|
||||
conds.push(format!("(timestamp, id) < (${a}, ${b})"));
|
||||
}
|
||||
let where_clause = if conds.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
sqlx::query(
|
||||
"SELECT id, node_did, pusher_did, repo, ref_name, old_sha, new_sha, timestamp,
|
||||
cert_id, received_at, from_peer
|
||||
FROM received_ref_updates ORDER BY timestamp DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
format!(" WHERE {}", conds.join(" AND "))
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM received_ref_updates{where_clause} \
|
||||
ORDER BY timestamp DESC, id DESC LIMIT ${}",
|
||||
n + 1
|
||||
);
|
||||
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(r) = repo {
|
||||
q = q.bind(r.to_string());
|
||||
}
|
||||
if let Some((ts, id)) = after {
|
||||
q = q.bind(ts.to_string()).bind(id.to_string());
|
||||
}
|
||||
let rows = q.bind(limit).fetch_all(&self.pool).await?;
|
||||
Ok(rows.into_iter().map(row_to_ref_update).collect())
|
||||
}
|
||||
}
|
||||
@@ -3346,6 +3462,42 @@ mod dedup_db_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A PRIVATE canonical repo and a PUBLIC mirror row for the same
|
||||
/// (owner, name) collapse to a single survivor whose `is_public` is the
|
||||
/// canonical `false`, not the mirror's `true`. `upsert_mirror_repo` always
|
||||
/// writes `is_public=true`, so without this the deduped set could carry a
|
||||
/// public flag for a locally-private repo and the ref-updates feed gate
|
||||
/// would over-serve. Pins the DEDUP_CTE tiebreak so a future regression
|
||||
/// that flips the survivor can't leak silently.
|
||||
#[sqlx::test]
|
||||
async fn deduped_private_canonical_beats_public_mirror(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
// Private canonical row (rec() forces is_public=true, so build inline).
|
||||
let mut canonical = rec(
|
||||
"uuid-private-canonical",
|
||||
"did:key:z6Mkwbud",
|
||||
"nipmod",
|
||||
"private canonical",
|
||||
"2026-01-15T00:00:00Z",
|
||||
"2026-01-15T00:00:00Z",
|
||||
);
|
||||
canonical.is_public = false;
|
||||
db.create_repo(&canonical).await.unwrap();
|
||||
// Public mirror row for the same (owner, name): id = "z6Mkwbud/nipmod",
|
||||
// is_public = true.
|
||||
db.upsert_mirror_repo("z6Mkwbud", "nipmod", "/srv/mirror", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let out = db.list_all_repos_deduped().await.unwrap();
|
||||
assert_eq!(out.len(), 1, "the pair collapses to one logical repo");
|
||||
assert_eq!(out[0].owner_did, "did:key:z6Mkwbud", "canonical row wins");
|
||||
assert!(
|
||||
!out[0].is_public,
|
||||
"survivor keeps the canonical private is_public=false, not the mirror's true"
|
||||
);
|
||||
}
|
||||
|
||||
/// upsert_mirror_repo's own rows dedupe against a canonical twin (proves the
|
||||
/// real mirror writer's row shape is classified correctly).
|
||||
#[sqlx::test]
|
||||
@@ -3700,6 +3852,248 @@ mod dedup_db_tests {
|
||||
assert_eq!(list_len, 2);
|
||||
assert_eq!(count, list_len, "count must equal the deduped list length");
|
||||
}
|
||||
|
||||
/// get_repo must prefer the canonical row over the mirror row when both match,
|
||||
/// so the visibility gate keys off the canonical row's rules and is_public flag
|
||||
/// rather than the mirror's hardcoded public-with-no-rules (issue #124).
|
||||
#[sqlx::test]
|
||||
async fn get_repo_prefers_canonical_over_mirror(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let short = "z6Mkwbud";
|
||||
let owner_did = "did:key:z6Mkwbud";
|
||||
|
||||
// Mirror row seeded FIRST — hardcoded is_public=true, no visibility rules.
|
||||
// Without the ORDER BY fix, fetch_optional returns this row by insertion order,
|
||||
// so the test fails (proving it locks in the fix).
|
||||
db.upsert_mirror_repo(short, "secret-repo", "/srv/mirror", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Canonical row with is_public=false.
|
||||
let canonical = RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "secret-repo".into(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: false,
|
||||
default_branch: "main".into(),
|
||||
// Date after the mirror (Utc::now()) so that created_at ASC alone
|
||||
// would pick the mirror; the CASE WHEN position('/' in id) > 0 term
|
||||
// is what makes the canonical row win.
|
||||
created_at: ts("2126-01-01T00:00:00Z"),
|
||||
updated_at: ts("2126-01-01T00:00:00Z"),
|
||||
disk_path: "/srv/secret".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
db.create_repo(&canonical).await.unwrap();
|
||||
|
||||
// Querying with bare short DID should return the canonical row.
|
||||
let got = db
|
||||
.get_repo(short, "secret-repo")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("get_repo should find the repo");
|
||||
|
||||
assert_eq!(
|
||||
got.owner_did, owner_did,
|
||||
"canonical row (did:key: form) must win over mirror row (bare short DID)"
|
||||
);
|
||||
assert!(
|
||||
!got.id.contains('/'),
|
||||
"canonical row id must not contain a slash"
|
||||
);
|
||||
assert!(
|
||||
!got.is_public,
|
||||
"canonical row's is_public must be preserved"
|
||||
);
|
||||
|
||||
// Querying with full did:key: form should also return the canonical row.
|
||||
let got_full = db
|
||||
.get_repo(owner_did, "secret-repo")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("get_repo should find the repo with full did:key");
|
||||
|
||||
assert_eq!(
|
||||
got_full.owner_did, owner_did,
|
||||
"canonical row must be found using full did:key: form"
|
||||
);
|
||||
assert!(
|
||||
!got_full.id.contains('/'),
|
||||
"canonical row id must not contain a slash"
|
||||
);
|
||||
assert!(
|
||||
!got_full.is_public,
|
||||
"canonical row's is_public must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed a private canonical plus a public mirror twin for the same owner+name
|
||||
/// (mirror inserted first), call authorize_repo_read with caller=None, and
|
||||
/// assert Err(RepoNotFound). That locks the property at the gate.
|
||||
#[sqlx::test]
|
||||
async fn authorize_repo_read_denies_private_canonical_even_with_public_mirror(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let short = "z6Mkwbud";
|
||||
let owner_did = "did:key:z6Mkwbud";
|
||||
|
||||
// Mirror row seeded FIRST — hardcoded is_public=true, no visibility rules.
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(short, "secret-repo", "/srv/mirror", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Canonical row with is_public=false.
|
||||
let canonical = RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "secret-repo".into(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: false,
|
||||
default_branch: "main".into(),
|
||||
created_at: ts("2126-01-01T00:00:00Z"),
|
||||
updated_at: ts("2126-01-01T00:00:00Z"),
|
||||
disk_path: "/srv/secret".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&canonical).await.unwrap();
|
||||
|
||||
// call authorize_repo_read with caller=None, and assert Err(RepoNotFound)
|
||||
let res = crate::api::authorize_repo_read(&state, short, "secret-repo", None, "/").await;
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::AppError::RepoNotFound(_))),
|
||||
"expected Err(RepoNotFound), got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// get_repo still returns the mirror row when no canonical row exists
|
||||
/// (mirror-only group), so sync and read paths remain functional.
|
||||
#[sqlx::test]
|
||||
async fn get_repo_returns_mirror_when_no_canonical(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.upsert_mirror_repo("z6Lonely", "orphan", "/srv/m", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = db
|
||||
.get_repo("z6Lonely", "orphan")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("get_repo should find the mirror");
|
||||
|
||||
assert_eq!(got.id, "z6Lonely/orphan", "mirror row is returned");
|
||||
assert!(got.is_public, "mirror row's is_public should be true");
|
||||
}
|
||||
|
||||
/// get_repo must NOT match a non-key DID row (e.g. did:gitlawb:) when queried
|
||||
/// with the bare short DID — the old LIKE '%:' || $1 || '%' was too broad and
|
||||
/// could rank a non-key canonical row ahead of the exact mirror.
|
||||
#[sqlx::test]
|
||||
async fn get_repo_does_not_match_non_key_did(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let short = "z6Mkwbud";
|
||||
|
||||
// Mirror row for the bare short DID.
|
||||
db.upsert_mirror_repo(short, "shared-name", "/srv/m", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Non-key DID row sharing the same trailing id — must stay distinct.
|
||||
let non_key = RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "shared-name".into(),
|
||||
owner_did: format!("did:gitlawb:{short}"),
|
||||
description: None,
|
||||
is_public: false,
|
||||
default_branch: "main".into(),
|
||||
created_at: ts("2126-01-01T00:00:00Z"),
|
||||
updated_at: ts("2126-01-01T00:00:00Z"),
|
||||
disk_path: "/srv/other".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
db.create_repo(&non_key).await.unwrap();
|
||||
|
||||
// Querying with the bare short DID must return the mirror, NOT the
|
||||
// did:gitlawb row (different DID method, separate owner).
|
||||
let got = db
|
||||
.get_repo(short, "shared-name")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("get_repo should find the mirror row");
|
||||
|
||||
assert!(
|
||||
got.id.contains('/'),
|
||||
"must return the mirror (slash id), not a non-key canonical row"
|
||||
);
|
||||
assert!(got.is_public, "mirror row's is_public should be true");
|
||||
|
||||
// Querying with the full non-key DID must return that exact row.
|
||||
let got = db
|
||||
.get_repo(&format!("did:gitlawb:{short}"), "shared-name")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("get_repo should find the non-key DID row");
|
||||
|
||||
assert!(
|
||||
!got.id.contains('/'),
|
||||
"must return the non-key canonical row (UUID id)"
|
||||
);
|
||||
assert!(!got.is_public, "non-key row's is_public must be preserved");
|
||||
}
|
||||
|
||||
/// Verify that the Rust `normalize_owner_key` and the `OWNER_KEY_CASE_SQL`
|
||||
/// expression agree on every boundary value in the owner-key normalization
|
||||
/// set. A mismatch would let the Rust code bind a different key than the SQL
|
||||
/// predicate filters on, silently breaking the did:key-only matching contract.
|
||||
#[sqlx::test]
|
||||
async fn normalize_owner_key_matches_sql_case(pool: PgPool) {
|
||||
// The full boundary set: did:key short/full, bare, non-key DIDs,
|
||||
// did:key with extra colon, empty, empty residual, uppercase.
|
||||
let boundary_values = [
|
||||
"did:key:z6Mkfoo",
|
||||
"z6Mkfoo",
|
||||
"did:gitlawb:z6Mkfoo",
|
||||
"did:web:example.com:alice",
|
||||
"did:key:did:gitlawb:z6Mkfoo",
|
||||
"",
|
||||
"did:key:",
|
||||
"DID:KEY:z6Mkfoo",
|
||||
];
|
||||
|
||||
// Build a VALUES list with the column aliased as `owner_did` so the
|
||||
// OWNER_KEY_CASE_SQL expression (which references `owner_did`) works
|
||||
// verbatim — no search-and-replace that could hide a drift.
|
||||
let values_sql: String = boundary_values
|
||||
.iter()
|
||||
.map(|v| format!("('{}'::text)", v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let sql = format!(
|
||||
"WITH data(owner_did) AS (VALUES {values_sql})
|
||||
SELECT owner_did, ({key}) AS normalized FROM data ORDER BY owner_did",
|
||||
key = super::OWNER_KEY_CASE_SQL
|
||||
);
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(&sql).fetch_all(&pool).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
boundary_values.len(),
|
||||
"every boundary value must produce a row"
|
||||
);
|
||||
|
||||
for (val, sql_result) in &rows {
|
||||
let rust_result = super::normalize_owner_key(val);
|
||||
assert_eq!(
|
||||
sql_result, rust_result,
|
||||
"normalize_owner_key(\"{val}\") mismatch: Rust = \"{rust_result}\", SQL CASE = \"{sql_result}\""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises the iCaptcha single-use proof ledger (`icaptcha_consumed_proofs`),
|
||||
@@ -3908,4 +4302,301 @@ mod icaptcha_quarantine_tests {
|
||||
let with_stars = db.list_all_repos_deduped_with_stars(None).await.unwrap();
|
||||
assert!(with_stars.iter().all(|(r, _)| r.name != "spam"));
|
||||
}
|
||||
|
||||
/// `update_trust_score` must never create an agent row. Registration (behind
|
||||
/// the iCaptcha gate) is the only way in; otherwise a push/issue/PR from a
|
||||
/// deregistered DID would silently re-register it and bypass the gate.
|
||||
#[sqlx::test]
|
||||
async fn update_trust_score_never_creates_agent(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let did = "did:key:zNeverRegistered";
|
||||
|
||||
// Unregistered DID: updating its score is a no-op, not an insert.
|
||||
db.update_trust_score(did, 0.9).await.unwrap();
|
||||
assert!(
|
||||
db.get_agent(did).await.unwrap().is_none(),
|
||||
"update_trust_score must not resurrect an unregistered DID"
|
||||
);
|
||||
|
||||
// Once genuinely registered, the score updates in place.
|
||||
db.register_agent(did, &[]).await.unwrap();
|
||||
db.update_trust_score(did, 0.9).await.unwrap();
|
||||
assert_eq!(db.get_trust_score(did).await.unwrap(), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ref_update_keyset_paging_tests {
|
||||
use super::{Db, ReceivedRefUpdate};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn rru(id: &str, ts: &str) -> ReceivedRefUpdate {
|
||||
ReceivedRefUpdate {
|
||||
id: id.into(),
|
||||
node_did: "did:key:zN".into(),
|
||||
pusher_did: "did:key:zP".into(),
|
||||
repo: "z6MkOwner/openrepo".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".into(),
|
||||
new_sha: "1".into(),
|
||||
timestamp: ts.into(),
|
||||
cert_id: None,
|
||||
received_at: ts.into(),
|
||||
from_peer: "peer".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_r1_to_r4(db: &Db) {
|
||||
for i in 1..=4 {
|
||||
db.insert_ref_update(&rru(
|
||||
&format!("r{i}"),
|
||||
&format!("2026-07-01T10:00:0{i}+00:00"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Documents the bug jatmn flagged: OFFSET paging re-reads a page-1 row when a
|
||||
// newer row is inserted between page reads (the newer row shifts every offset).
|
||||
#[sqlx::test]
|
||||
async fn offset_paging_duplicates_a_row_under_concurrent_insert(pool: PgPool) {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
seed_r1_to_r4(&db).await;
|
||||
|
||||
let offset_page = |limit: i64, offset: i64| {
|
||||
let pool = db.pool.clone();
|
||||
async move {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM received_ref_updates \
|
||||
ORDER BY timestamp DESC, id DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
let p1 = offset_page(2, 0).await;
|
||||
assert_eq!(p1, vec!["r4", "r3"], "offset page 1");
|
||||
// concurrent insert of a newer row shifts every later offset by one
|
||||
db.insert_ref_update(&rru("r5", "2026-07-01T10:00:09+00:00"))
|
||||
.await
|
||||
.unwrap();
|
||||
let p2 = offset_page(2, 2).await;
|
||||
assert!(
|
||||
p2.contains(&"r3".to_string()),
|
||||
"OFFSET paging re-reads r3 after the concurrent insert (the bug keyset fixes); got {p2:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The fix: keyset paging on (timestamp, id) reads strictly older rows each
|
||||
// page, so a concurrent newer insert cannot duplicate or skip a row.
|
||||
#[sqlx::test]
|
||||
async fn keyset_paging_is_stable_under_concurrent_insert(pool: PgPool) {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
seed_r1_to_r4(&db).await;
|
||||
|
||||
let p1 = db.list_ref_updates_keyset(None, 2, None).await.unwrap();
|
||||
let p1_ids: Vec<String> = p1.iter().map(|u| u.id.clone()).collect();
|
||||
assert_eq!(p1_ids, vec!["r4", "r3"], "keyset page 1");
|
||||
let last = p1.last().unwrap();
|
||||
let cursor = (last.timestamp.clone(), last.id.clone());
|
||||
|
||||
// concurrent insert of a newer row between page reads
|
||||
db.insert_ref_update(&rru("r5", "2026-07-01T10:00:09+00:00"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let p2 = db
|
||||
.list_ref_updates_keyset(None, 2, Some((cursor.0.as_str(), cursor.1.as_str())))
|
||||
.await
|
||||
.unwrap();
|
||||
let p2_ids: Vec<String> = p2.iter().map(|u| u.id.clone()).collect();
|
||||
assert_eq!(
|
||||
p2_ids,
|
||||
vec!["r2", "r1"],
|
||||
"keyset page 2 reads strictly older rows, unaffected by the concurrent insert"
|
||||
);
|
||||
|
||||
let all: Vec<String> = p1_ids.into_iter().chain(p2_ids).collect();
|
||||
let uniq: std::collections::HashSet<&String> = all.iter().collect();
|
||||
assert_eq!(uniq.len(), 4, "no row appears twice across pages");
|
||||
assert!(
|
||||
!all.iter().any(|id| id == "r5"),
|
||||
"a row inserted above the scan window is not folded in mid-scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ref_update_keyset_repo_filtered_tests {
|
||||
use super::{Db, ReceivedRefUpdate};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn rru_repo(id: &str, ts: &str, repo: &str) -> ReceivedRefUpdate {
|
||||
ReceivedRefUpdate {
|
||||
id: id.into(),
|
||||
node_did: "did:key:zN".into(),
|
||||
pusher_did: "did:key:zP".into(),
|
||||
repo: repo.into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".into(),
|
||||
new_sha: "1".into(),
|
||||
timestamp: ts.into(),
|
||||
cert_id: None,
|
||||
received_at: ts.into(),
|
||||
from_peer: "peer".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// Exercises the (Some(repo), Some(after)) keyset branch: a repo-filtered
|
||||
// multi-page continuation that emits `WHERE repo=$1 AND (timestamp,id)<($2,$3)`
|
||||
// with four binds. Also confirms the repo filter holds across pages, noise
|
||||
// rows are excluded, and a concurrent insert does not duplicate or skip.
|
||||
#[sqlx::test]
|
||||
async fn keyset_repo_filtered_multipage_stable_under_concurrent_insert(pool: PgPool) {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
let target = "z6MkOwner/target";
|
||||
let other = "z6MkOwner/other";
|
||||
|
||||
// 3 target rows (older) + 2 noise rows for another repo (newer, so they
|
||||
// sort to the front of the global order and must be filtered out).
|
||||
for (id, ts) in [("T1", "01"), ("T2", "02"), ("T3", "03")] {
|
||||
db.insert_ref_update(&rru_repo(
|
||||
id,
|
||||
&format!("2026-07-01T10:00:{ts}+00:00"),
|
||||
target,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for (id, ts) in [("O1", "04"), ("O2", "05")] {
|
||||
db.insert_ref_update(&rru_repo(
|
||||
id,
|
||||
&format!("2026-07-01T10:00:{ts}+00:00"),
|
||||
other,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// page 1: (Some(repo), None) -> two newest TARGET rows only
|
||||
let p1 = db
|
||||
.list_ref_updates_keyset(Some(target), 2, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let p1_ids: Vec<String> = p1.iter().map(|u| u.id.clone()).collect();
|
||||
assert_eq!(
|
||||
p1_ids,
|
||||
vec!["T3", "T2"],
|
||||
"repo-filtered page 1 excludes other-repo rows"
|
||||
);
|
||||
assert!(
|
||||
p1.iter().all(|u| u.repo == target),
|
||||
"no noise rows on page 1"
|
||||
);
|
||||
let last = p1.last().unwrap();
|
||||
let cursor = (last.timestamp.clone(), last.id.clone());
|
||||
|
||||
// concurrent insert of a newer TARGET row between page reads
|
||||
db.insert_ref_update(&rru_repo("T4", "2026-07-01T10:00:06+00:00", target))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// page 2: (Some(repo), Some(after)) -> the four-bind continuation branch
|
||||
let p2 = db
|
||||
.list_ref_updates_keyset(
|
||||
Some(target),
|
||||
2,
|
||||
Some((cursor.0.as_str(), cursor.1.as_str())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let p2_ids: Vec<String> = p2.iter().map(|u| u.id.clone()).collect();
|
||||
assert_eq!(
|
||||
p2_ids,
|
||||
vec!["T1"],
|
||||
"repo-filtered keyset page 2 reads only older target rows"
|
||||
);
|
||||
assert!(
|
||||
p2.iter().all(|u| u.repo == target),
|
||||
"no noise rows on page 2"
|
||||
);
|
||||
|
||||
let all: Vec<String> = p1_ids.into_iter().chain(p2_ids).collect();
|
||||
let uniq: std::collections::HashSet<&String> = all.iter().collect();
|
||||
assert_eq!(uniq.len(), 3, "each target row exactly once across pages");
|
||||
assert!(
|
||||
!all.iter().any(|id| id == "T4"),
|
||||
"concurrent newer row not folded in mid-scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ref_update_keyset_same_timestamp_tests {
|
||||
use super::{Db, ReceivedRefUpdate};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn row(id: &str, ts: &str) -> ReceivedRefUpdate {
|
||||
ReceivedRefUpdate {
|
||||
id: id.into(),
|
||||
node_did: "did:key:zN".into(),
|
||||
pusher_did: "did:key:zP".into(),
|
||||
repo: "z6MkOwner/openrepo".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".into(),
|
||||
new_sha: "1".into(),
|
||||
timestamp: ts.into(),
|
||||
cert_id: None,
|
||||
received_at: ts.into(),
|
||||
from_peer: "peer".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// Load-bearing for the `id` half of the (timestamp, id) keyset cursor: all
|
||||
// rows share ONE timestamp, so ordering and the page boundary fall entirely
|
||||
// on `id DESC`. A timestamp-only cursor would return nothing for page 2 and
|
||||
// silently skip b and a; the (timestamp, id) tie-break must advance instead.
|
||||
#[sqlx::test]
|
||||
async fn keyset_advances_within_an_equal_timestamp_run(pool: PgPool) {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
let ts = "2026-07-01T10:00:00+00:00";
|
||||
for id in ["a", "b", "c", "d"] {
|
||||
db.insert_ref_update(&row(id, ts)).await.unwrap();
|
||||
}
|
||||
|
||||
// id DESC over equal timestamps: d, c, b, a
|
||||
let p1 = db.list_ref_updates_keyset(None, 2, None).await.unwrap();
|
||||
let p1_ids: Vec<String> = p1.iter().map(|u| u.id.clone()).collect();
|
||||
assert_eq!(p1_ids, vec!["d", "c"], "page 1 by id DESC");
|
||||
let last = p1.last().unwrap();
|
||||
|
||||
// page boundary lands INSIDE the equal-timestamp group (cursor = (ts, "c"))
|
||||
let p2 = db
|
||||
.list_ref_updates_keyset(None, 2, Some((last.timestamp.as_str(), last.id.as_str())))
|
||||
.await
|
||||
.unwrap();
|
||||
let p2_ids: Vec<String> = p2.iter().map(|u| u.id.clone()).collect();
|
||||
assert_eq!(
|
||||
p2_ids,
|
||||
vec!["b", "a"],
|
||||
"keyset must advance by id within an equal-timestamp run (a timestamp-only cursor would skip these)"
|
||||
);
|
||||
|
||||
let all: Vec<String> = p1_ids.into_iter().chain(p2_ids).collect();
|
||||
let uniq: std::collections::HashSet<&String> = all.iter().collect();
|
||||
assert_eq!(
|
||||
uniq.len(),
|
||||
4,
|
||||
"no dup or skip across a same-timestamp page boundary"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,21 @@ pub enum AppError {
|
||||
#[allow(dead_code)]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("icaptcha proof required: {0}")]
|
||||
IcaptchaProofRequired(String),
|
||||
#[error("icaptcha proof required: {message}")]
|
||||
IcaptchaProofRequired {
|
||||
message: String,
|
||||
/// iCaptcha service base URL the client should solve against.
|
||||
url: String,
|
||||
/// Minimum proof level this node requires.
|
||||
level: u32,
|
||||
},
|
||||
|
||||
#[error("invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
|
||||
@@ -41,6 +50,34 @@ pub enum AppError {
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
// iCaptcha challenges carry structured discovery so clients don't have to
|
||||
// scrape the message: the service URL and required level are returned as
|
||||
// both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers
|
||||
// (mirroring the header-bearing `human_detected` response in auth/mod.rs).
|
||||
if let AppError::IcaptchaProofRequired {
|
||||
message,
|
||||
url,
|
||||
level,
|
||||
} = &self
|
||||
{
|
||||
use axum::http::HeaderValue;
|
||||
let body = Json(json!({
|
||||
"error": "icaptcha_proof_required",
|
||||
"message": message,
|
||||
"icaptcha_url": url,
|
||||
"required_level": level,
|
||||
}));
|
||||
let mut resp = (StatusCode::FORBIDDEN, body).into_response();
|
||||
let headers = resp.headers_mut();
|
||||
if let Ok(v) = HeaderValue::from_str(url) {
|
||||
headers.insert("x-icaptcha-url", v);
|
||||
}
|
||||
if let Ok(v) = HeaderValue::from_str(&level.to_string()) {
|
||||
headers.insert("x-icaptcha-level", v);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
let (status, code, message) = match &self {
|
||||
AppError::RepoNotFound(r) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -55,16 +92,12 @@ impl IntoResponse for AppError {
|
||||
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()),
|
||||
// 403, not 401: the caller IS an authenticated agent (credentials are
|
||||
// valid) but is forbidden from this action without a valid, fresh
|
||||
// iCaptcha proof. The distinct `icaptcha_proof_required` code — which
|
||||
// clients branch on — keeps it separable from a plain `forbidden`.
|
||||
AppError::IcaptchaProofRequired(msg) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
"icaptcha_proof_required",
|
||||
msg.clone(),
|
||||
),
|
||||
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
|
||||
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
|
||||
}
|
||||
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
AppError::Internal(e) => (
|
||||
|
||||
@@ -52,7 +52,7 @@ pub async fn info_refs(repo_path: &Path, service: &str) -> Result<Response> {
|
||||
/// Serves pack data for a clone or fetch. This is stateless — the entire
|
||||
/// negotiation happens in a single request/response.
|
||||
pub async fn upload_pack(repo_path: &Path, request_body: Bytes) -> Result<Response> {
|
||||
let output = run_git_service("git-upload-pack", repo_path, request_body).await?;
|
||||
let output = run_git_service("git", "git-upload-pack", repo_path, request_body).await?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -66,7 +66,7 @@ pub async fn upload_pack(repo_path: &Path, request_body: Bytes) -> Result<Respon
|
||||
/// Accepts a push. The caller MUST verify HTTP Signature auth before
|
||||
/// calling this function.
|
||||
pub async fn receive_pack(repo_path: &Path, request_body: Bytes) -> Result<Response> {
|
||||
let output = run_git_service("git-receive-pack", repo_path, request_body).await?;
|
||||
let output = run_git_service("git", "git-receive-pack", repo_path, request_body).await?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -112,8 +112,19 @@ impl Drop for KillGroupOnDrop {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_git_service(service: &str, repo_path: &Path, input: Bytes) -> Result<Vec<u8>> {
|
||||
let mut command = Command::new("git");
|
||||
/// Run a stateless-rpc git service and return its stdout.
|
||||
///
|
||||
/// `git_bin` is the git executable to spawn; production callers pass `"git"`
|
||||
/// (resolved via `PATH`). It is injectable purely so the process-group teardown
|
||||
/// wiring (`process_group(0)` + [`KillGroupOnDrop`]) can be driven end-to-end by
|
||||
/// a fake `git` in tests without mutating the process-global `PATH`.
|
||||
async fn run_git_service(
|
||||
git_bin: &str,
|
||||
service: &str,
|
||||
repo_path: &Path,
|
||||
input: Bytes,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut command = Command::new(git_bin);
|
||||
command
|
||||
.arg(service_to_command(service))
|
||||
.arg("--stateless-rpc")
|
||||
@@ -876,4 +887,214 @@ mod tests {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
|
||||
// ── #62 PR1: end-to-end teardown wiring through run_git_service ─────────
|
||||
//
|
||||
// The kill_group_guard_* tests above build a KillGroupOnDrop by hand and
|
||||
// never call run_git_service, so deleting `process_group(0)` (spawn site) or
|
||||
// the guard-arming, or the post-reap disarm, would leave them green. These
|
||||
// drive the REAL run_git_service via an injected fake `git` (the `git_bin`
|
||||
// seam) and assert the production spawn path actually wires the teardown up.
|
||||
// Faithful to the real invocation: run_git_service spawns
|
||||
// `<git_bin> <cmd> --stateless-rpc <repo_path>` in its own process group.
|
||||
|
||||
/// SIGKILLs the given pids on drop (ignoring all kill errors, e.g. ESRCH for
|
||||
/// an already-dead pid) so a panicking assertion can't leak the fake `git` or
|
||||
/// its grandchild onto the test runner.
|
||||
#[cfg(unix)]
|
||||
struct ReapOnPanic(Vec<i32>);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for ReapOnPanic {
|
||||
fn drop(&mut self) {
|
||||
for &pid in &self.0 {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an executable fake `git` (named `git`) into `dir`; return its path.
|
||||
#[cfg(unix)]
|
||||
fn write_fake_git(dir: &std::path::Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let path = dir.join("git");
|
||||
std::fs::write(&path, body).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Read a two-line `leader\ngrandchild` pidfile once both lines are present.
|
||||
#[cfg(unix)]
|
||||
fn read_two_pids(pidfile: &std::path::Path) -> Option<(i32, i32)> {
|
||||
let s = std::fs::read_to_string(pidfile).ok()?;
|
||||
let mut lines = s.lines();
|
||||
let leader: i32 = lines.next()?.trim().parse().ok()?;
|
||||
let grandchild: i32 = lines.next()?.trim().parse().ok()?;
|
||||
Some((leader, grandchild))
|
||||
}
|
||||
|
||||
// Dropping the request future mid-flight (client disconnect) must SIGTERM the
|
||||
// whole group so git AND its pack-objects grandchild die together. Goes RED
|
||||
// if `process_group(0)` or the guard-arming is removed: without its own
|
||||
// group, kill(-pgid) hits no group and the grandchild survives.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn run_git_service_tears_down_group_when_future_dropped() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pidfile = tmp.path().join("pids");
|
||||
// Fork a grandchild (stands in for pack-objects), record leader+grandchild
|
||||
// pids, then hang so run_git_service parks in wait_with_output.
|
||||
let body = format!(
|
||||
"#!/bin/sh\nsleep 300 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nwait\n",
|
||||
pidfile.display()
|
||||
);
|
||||
let git_bin = write_fake_git(tmp.path(), &body);
|
||||
|
||||
let mut fut = Box::pin(run_git_service(
|
||||
git_bin.to_str().unwrap(),
|
||||
"git-upload-pack",
|
||||
tmp.path(),
|
||||
Bytes::new(),
|
||||
));
|
||||
|
||||
// Advance the future until the fake has spawned its grandchild.
|
||||
let mut pids = None;
|
||||
for _ in 0..500 {
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_millis(10), &mut fut).await;
|
||||
if let Some(p) = read_two_pids(&pidfile) {
|
||||
pids = Some(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Dropping the future (below or on the None arm) makes tokio reap the
|
||||
// fake leader itself, so only the grandchild needs panic-cleanup —
|
||||
// carrying the already-reaped leader pid risks SIGKILLing a recycled pid
|
||||
// under parallel test load.
|
||||
let (_leader, grandchild) = match pids {
|
||||
Some(p) => p,
|
||||
// Drop the still-armed future first so its guard reaps the fake
|
||||
// group, then fail — otherwise a spawn hiccup orphans the processes.
|
||||
None => {
|
||||
drop(fut);
|
||||
panic!("fake git should spawn a grandchild and write its pids");
|
||||
}
|
||||
};
|
||||
let _cleanup = ReapOnPanic(vec![grandchild]);
|
||||
assert!(
|
||||
alive(grandchild),
|
||||
"grandchild should be running before the drop"
|
||||
);
|
||||
|
||||
// Client disconnect: drop the request future. The armed KillGroupOnDrop
|
||||
// must SIGTERM the whole group.
|
||||
drop(fut);
|
||||
|
||||
let mut gone = false;
|
||||
for _ in 0..500 {
|
||||
if !alive(grandchild) {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(
|
||||
gone,
|
||||
"grandchild must be torn down via the process group on future-drop \
|
||||
(proves run_git_service sets process_group(0) and arms the guard)"
|
||||
);
|
||||
}
|
||||
|
||||
// A request that runs to completion must DISARM the guard after reaping, so
|
||||
// no stray group SIGTERM fires. The fake exits non-zero (surfacing as Err)
|
||||
// but leaves a grandchild alive; the grandchild must survive. Goes RED if the
|
||||
// post-reap disarm is removed: the guard would then fire on return and, since
|
||||
// the grandchild still holds the group open, sweep it.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn run_git_service_disarms_on_completion_leaving_group_alive() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pidfile = tmp.path().join("pids");
|
||||
// The grandchild is long-lived with stdio redirected to /dev/null:
|
||||
// /dev/null so it doesn't inherit (and hold open) run_git_service's stdout
|
||||
// pipe (which would block wait_with_output until it exits); long-lived so
|
||||
// the "still alive" assertion below can't race the sleep exiting on its own
|
||||
// under a starved scheduler. ReapOnPanic cleans it up.
|
||||
let body = format!(
|
||||
"#!/bin/sh\nsleep 300 >/dev/null 2>&1 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nexit 1\n",
|
||||
pidfile.display()
|
||||
);
|
||||
let git_bin = write_fake_git(tmp.path(), &body);
|
||||
|
||||
let result = run_git_service(
|
||||
git_bin.to_str().unwrap(),
|
||||
"git-upload-pack",
|
||||
tmp.path(),
|
||||
Bytes::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// wait_with_output already reaped the fake leader, so only the grandchild
|
||||
// needs panic-cleanup — SIGKILLing the reaped leader pid could hit a
|
||||
// recycled pid under parallel test load.
|
||||
let (_leader, grandchild) =
|
||||
read_two_pids(&pidfile).expect("fake git should have written its pids");
|
||||
let _cleanup = ReapOnPanic(vec![grandchild]);
|
||||
|
||||
assert!(result.is_err(), "non-zero git exit must surface as Err");
|
||||
// A mutant that left the guard armed fires SIGTERM on return; poll a short
|
||||
// window so a killed grandchild is reliably observed dead — a single
|
||||
// alive() check can race the SIGTERM+reap and miss it.
|
||||
for _ in 0..30 {
|
||||
assert!(
|
||||
alive(grandchild),
|
||||
"grandchild must survive: run_git_service must disarm the guard after \
|
||||
reaping, not fire a group SIGTERM on the completion path"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The SUCCESS path (exit 0, Ok) must disarm too — the disarm test above only
|
||||
// exercises the exit-1/Err branch. Goes RED if disarm is gated on failure
|
||||
// (e.g. moved inside the `!status.success()` branch): the guard would then
|
||||
// fire after a clean completion and sweep the still-live grandchild.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn run_git_service_disarms_on_success_leaving_group_alive() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let pidfile = tmp.path().join("pids");
|
||||
// Long-lived, stdio to /dev/null (same reasons as the exit-1 test).
|
||||
let body = format!(
|
||||
"#!/bin/sh\nsleep 300 >/dev/null 2>&1 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nexit 0\n",
|
||||
pidfile.display()
|
||||
);
|
||||
let git_bin = write_fake_git(tmp.path(), &body);
|
||||
|
||||
let result = run_git_service(
|
||||
git_bin.to_str().unwrap(),
|
||||
"git-upload-pack",
|
||||
tmp.path(),
|
||||
Bytes::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (_leader, grandchild) =
|
||||
read_two_pids(&pidfile).expect("fake git should have written its pids");
|
||||
let _cleanup = ReapOnPanic(vec![grandchild]);
|
||||
|
||||
assert!(result.is_ok(), "zero-exit git must surface as Ok");
|
||||
// Poll a short window (see the exit-1 test): a mutant that disarms only on
|
||||
// failure fires the guard on this success path, and a single alive() check
|
||||
// can race the SIGTERM+reap.
|
||||
for _ in 0..30 {
|
||||
assert!(
|
||||
alive(grandchild),
|
||||
"grandchild must survive: run_git_service must disarm on the success \
|
||||
path too, not fire a group SIGTERM after a clean completion"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,9 +271,8 @@ pub struct TreeEntry {
|
||||
/// `/ipfs/<cid>` is computed from these same content bytes via
|
||||
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
|
||||
///
|
||||
/// Returns `None` if the object does not exist in this repo.
|
||||
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||
// First check if the object exists and get its type
|
||||
/// Get just the object type. Returns `None` if the object doesn't exist.
|
||||
pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result<Option<String>> {
|
||||
let type_output = Command::new("git")
|
||||
.args(["cat-file", "-t", sha256_hex])
|
||||
.current_dir(repo_path)
|
||||
@@ -284,13 +283,17 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let obj_type = String::from_utf8_lossy(&type_output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(Some(
|
||||
String::from_utf8_lossy(&type_output.stdout)
|
||||
.trim()
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
// Read the raw content bytes
|
||||
/// Read an object's content if its type is already known.
|
||||
pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -> Result<Vec<u8>> {
|
||||
let content_output = Command::new("git")
|
||||
.args(["cat-file", &obj_type, sha256_hex])
|
||||
.args(["cat-file", obj_type, sha256_hex])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file <type>")?;
|
||||
@@ -300,7 +303,24 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(Some((obj_type, content_output.stdout)))
|
||||
Ok(content_output.stdout)
|
||||
}
|
||||
|
||||
/// Read a git object by its SHA-256 hex object ID.
|
||||
///
|
||||
/// Returns `(object_type, content_bytes)` where `content_bytes` is the raw
|
||||
/// object content (without the git framing header). The CID served over
|
||||
/// `/ipfs/<cid>` is computed from these same content bytes via
|
||||
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
|
||||
///
|
||||
/// Returns `None` if the object does not exist in this repo.
|
||||
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let obj_type = match object_type(repo_path, sha256_hex)? {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let content = read_object_content(repo_path, sha256_hex, &obj_type)?;
|
||||
Ok(Some((obj_type, content)))
|
||||
}
|
||||
|
||||
/// Get the diff between two branches: changes on source_branch not in target_branch.
|
||||
|
||||
@@ -309,11 +309,33 @@ pub fn replicable_blob_set(
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
) -> Result<HashSet<String>> {
|
||||
allowed_blob_set_for_caller(repo_path, rules, is_public, owner_did, None)
|
||||
}
|
||||
|
||||
/// Reachable blob OIDs that visibility ALLOWS `caller` at some path. The
|
||||
/// caller-aware generalization of `replicable_blob_set` (which is the anonymous
|
||||
/// `caller = None` case). Used by `GET /ipfs/{cid}` to gate fail-closed against
|
||||
/// dangling/unreachable blobs (#126): a blob written via `git hash-object -w`
|
||||
/// but unreferenced is absent from the reachable walk, so it is never in this
|
||||
/// set and the IPFS serve path drops it — even from the owner, who has no path
|
||||
/// to authorize the blob at.
|
||||
///
|
||||
/// A blob reachable at an allowed path is included even when also denied
|
||||
/// elsewhere (its content is readable to this caller elsewhere). Trees and
|
||||
/// commits are NOT included here; the caller decides per object type whether
|
||||
/// the allow-set applies (it does not for trees/commits — KTD3).
|
||||
pub fn allowed_blob_set_for_caller(
|
||||
repo_path: &Path,
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> Result<HashSet<String>> {
|
||||
let pairs = blob_paths(repo_path)?;
|
||||
let mut allowed = HashSet::new();
|
||||
for (oid, path) in &pairs {
|
||||
if visibility_check(rules, is_public, owner_did, None, path) == Decision::Allow {
|
||||
if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow {
|
||||
allowed.insert(oid.clone());
|
||||
}
|
||||
}
|
||||
@@ -743,6 +765,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_set_excludes_dangling_blob_for_every_caller() {
|
||||
// #126: a blob written via `git hash-object -w` but never referenced has
|
||||
// no path to gate on, so it is absent from the reachable allowed-set —
|
||||
// for anonymous callers, listed readers, AND the owner. The IPFS serve
|
||||
// path relies on this fail-closed property to drop dangling withheld
|
||||
// blobs that the deny-set model leaked.
|
||||
let td = TempDir::new().unwrap();
|
||||
let work = td.path().join("work");
|
||||
std::fs::create_dir_all(work.join("public")).unwrap();
|
||||
std::fs::write(work.join("public/a.txt"), b"public bytes\n").unwrap();
|
||||
let run = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
run(&["init", "-q"]);
|
||||
run(&["config", "user.email", "t@t"]);
|
||||
run(&["config", "user.name", "t"]);
|
||||
run(&["add", "."]);
|
||||
run(&["commit", "-qm", "init"]);
|
||||
let oid_of = |rev: &str| {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", rev])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
let public_oid = oid_of("HEAD:public/a.txt");
|
||||
|
||||
std::fs::write(work.join("orphan.bin"), b"DANGLING SECRET\n").unwrap();
|
||||
let dangling_oid = {
|
||||
let out = Command::new("git")
|
||||
.args(["hash-object", "-w", "orphan.bin"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
assert!(
|
||||
matches!(dangling_oid.len(), 40 | 64),
|
||||
"precondition: hash-object stored the dangling blob"
|
||||
);
|
||||
|
||||
// Path-scoped rule: /secret/** denied to anon, allowed to a listed reader.
|
||||
let reader = "did:key:zReader";
|
||||
let rules = [rule("/secret/**", &[reader])];
|
||||
|
||||
// Every gate-relevant caller: anonymous, listed reader, owner. None of
|
||||
// them can put the dangling blob in the allowed set — it has no path.
|
||||
for caller in [None, Some(reader), Some(OWNER)] {
|
||||
let allowed = allowed_blob_set_for_caller(&work, &rules, true, OWNER, caller).unwrap();
|
||||
assert!(
|
||||
!allowed.contains(&dangling_oid),
|
||||
"dangling blob must be absent from allowed-set (caller={caller:?})"
|
||||
);
|
||||
// Sanity: the reachable public blob is still in the set for every
|
||||
// caller (the rule does not deny /public/**).
|
||||
assert!(
|
||||
allowed.contains(&public_oid),
|
||||
"reachable public blob must be in allowed-set (caller={caller:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipients_are_owner_plus_allowed_readers_only() {
|
||||
let (_td, repo, secret_oid, public_oid) = fixture();
|
||||
|
||||
@@ -49,13 +49,30 @@ impl QueryRoot {
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
repo: Option<String>,
|
||||
#[graphql(default = 20)] limit: i64,
|
||||
#[graphql(
|
||||
default = 20,
|
||||
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<RefUpdateType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let updates = db
|
||||
.list_ref_updates_filtered(repo.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
// Gate each row on the same "/" visibility decision the repos resolver
|
||||
// uses, so anonymous callers get no row for a local repo they can't read
|
||||
// (#112). The shared collector applies the fail-closed gate *before* the
|
||||
// limit (paging past dropped private rows) so a small limit still returns
|
||||
// the latest visible events, and keeps this surface byte-identical to the
|
||||
// REST feed (#114). The row slug is peer-supplied, so the pure filter
|
||||
// treats it as untrusted input; remote (no local match) rows pass.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let updates =
|
||||
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(updates
|
||||
.into_iter()
|
||||
.map(|u| RefUpdateType {
|
||||
@@ -94,3 +111,288 @@ impl QueryRoot {
|
||||
Ok(t.map(AgentTaskType::from))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OWNER: &str = "did:key:z6MkOwner";
|
||||
|
||||
async fn db(pool: PgPool) -> Arc<Db> {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
Arc::new(db)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
|
||||
RepoRecord {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
owner_did: owner_did.into(),
|
||||
description: None,
|
||||
is_public,
|
||||
default_branch: "main".into(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
disk_path: format!("/srv/{id}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ref_row(id: &str, slug: &str) -> ReceivedRefUpdate {
|
||||
ReceivedRefUpdate {
|
||||
id: id.into(),
|
||||
node_did: "did:key:z6MkNode".into(),
|
||||
pusher_did: "did:key:z6MkPusher".into(),
|
||||
repo: slug.into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a".repeat(40),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
cert_id: None,
|
||||
received_at: Utc::now().to_rfc3339(),
|
||||
from_peer: "peer1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Count `refUpdates` rows in a GraphQL response.
|
||||
fn count(resp: &async_graphql::Response) -> usize {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("refUpdates").expect("refUpdates key")
|
||||
else {
|
||||
panic!("refUpdates not a list");
|
||||
};
|
||||
rows.len()
|
||||
}
|
||||
|
||||
async fn anon(schema: &super::super::GitlawbSchema, query: &str) -> async_graphql::Response {
|
||||
schema.execute(async_graphql::Request::new(query)).await
|
||||
}
|
||||
|
||||
async fn authed(
|
||||
schema: &super::super::GitlawbSchema,
|
||||
query: &str,
|
||||
did: &str,
|
||||
) -> async_graphql::Response {
|
||||
schema
|
||||
.execute(
|
||||
async_graphql::Request::new(query)
|
||||
.data(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Scenario 1 — anon must not get a private local repo's row on the
|
||||
// repo:Some branch. This is the load-bearing RED→GREEN case.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_private_repo_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
// The GraphQL `repo` arg is the raw slug DB filter, so it must equal the
|
||||
// stored slug to select the row at all — this is the exact leak path.
|
||||
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName newSha pusherDid } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 2 — owner still sees their own private repo's row.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_private_repo_kept_for_owner(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName } }"#;
|
||||
assert_eq!(count(&authed(&schema, q, OWNER).await), 1);
|
||||
}
|
||||
|
||||
// Scenario 3 — unfiltered (repo:None): anon gets only the public row.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_unfiltered_anon_gets_only_public(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("pub", OWNER, "openrepo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("priv", OWNER, "secret", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u_pub", "z6MkOwner/openrepo"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u_priv", "z6MkOwner/secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo refName } }"#;
|
||||
let resp = anon(&schema, q).await;
|
||||
assert_eq!(count(&resp), 1);
|
||||
// The one row returned must be the public repo's.
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
unreachable!()
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let async_graphql::Value::Object(row) = &rows[0] else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
row.get("repo").unwrap(),
|
||||
&async_graphql::Value::from("z6MkOwner/openrepo")
|
||||
);
|
||||
}
|
||||
|
||||
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_full_did_slug_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", "did:key:zABC", "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "did:key:zABC/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
// repo:None so the slug is not the DB filter key (which is verbatim);
|
||||
// the gate must still drop it.
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 5 — truncated-key fail-closed: 8-char-prefix owner form.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_truncated_key_slug_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", "did:key:zABCDEFGH", "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "zABCDEF/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 6 — remote slug (no local match) is returned to anon.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_remote_slug_kept_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
// Row whose slug matches no local repo (different owner + name).
|
||||
db.insert_ref_update(&ref_row("u1", "zZZZOTHER/gadget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 1);
|
||||
}
|
||||
|
||||
// Scenario 7 (#114 P2) — a small limit must page past the newest rows when
|
||||
// they are private, so the older public rows are still returned. Before the
|
||||
// gate moved ahead of the limit this returned 0 (the newest `limit` rows were
|
||||
// all private and got filtered after the SQL LIMIT). RED→GREEN.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_small_limit_pages_past_newest_private(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("pub", OWNER, "openrepo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("priv", OWNER, "secret", false))
|
||||
.await
|
||||
.unwrap();
|
||||
// 3 older PUBLIC rows …
|
||||
for i in 0..3 {
|
||||
let mut r = ref_row(&format!("pub{i}"), "z6MkOwner/openrepo");
|
||||
r.timestamp = format!("2026-07-01T10:00:0{i}+00:00");
|
||||
db.insert_ref_update(&r).await.unwrap();
|
||||
}
|
||||
// … then 5 NEWER PRIVATE rows (the newest in the feed).
|
||||
for i in 0..5 {
|
||||
let mut r = ref_row(&format!("priv{i}"), "z6MkOwner/secret");
|
||||
r.timestamp = format!("2026-07-01T10:00:1{i}+00:00");
|
||||
db.insert_ref_update(&r).await.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
// limit 3 < the 5 newest (all private): anon must still get 3 public rows.
|
||||
let q = r#"{ refUpdates(limit: 3) { repo } }"#;
|
||||
let resp = anon(&schema, q).await;
|
||||
assert_eq!(count(&resp), 3, "paging must reach the older public rows");
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
unreachable!()
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
for row in rows {
|
||||
let async_graphql::Value::Object(r) = row else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
r.get("repo").unwrap(),
|
||||
&async_graphql::Value::from("z6MkOwner/openrepo"),
|
||||
"every returned row must be the public repo's"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 8 — a quarantined mirror is withheld on the GraphQL surface too.
|
||||
// Guards that the resolver keeps delegating to the shared collector (where the
|
||||
// quarantine fold lives); a REST-only test would miss a resolver that stopped.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_quarantined_mirror_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.upsert_mirror_repo("z6MkQuar", "secret", "/tmp/q", None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 8b — the GraphQL surface also withholds a quarantined repo from an
|
||||
// authenticated OWNER, not just anon. Without the collector's quarantine drop
|
||||
// the owner short-circuit in visibility_check keeps the row on this surface
|
||||
// too, so the REST owner test alone would not guard the resolver.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_quarantined_repo_dropped_for_owner(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("q1", "did:key:z6MkQuar", "secret", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_repo_quarantine("q1", true).await.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&authed(&schema, q, "did:key:z6MkQuar").await), 0);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,15 @@ 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
|
||||
/// 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
|
||||
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
|
||||
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
|
||||
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -237,9 +237,13 @@ impl ProofGuard {
|
||||
match self.0 {
|
||||
Some(p) => {
|
||||
if !db.consume_proof_jti(&p.jti, p.exp).await? {
|
||||
return Err(AppError::IcaptchaProofRequired(
|
||||
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
|
||||
));
|
||||
let (url, level) = url_and_level();
|
||||
return Err(AppError::IcaptchaProofRequired {
|
||||
message: "iCaptcha proof already used (replay); solve a fresh challenge"
|
||||
.to_string(),
|
||||
url,
|
||||
level,
|
||||
});
|
||||
}
|
||||
Ok(Some(p))
|
||||
}
|
||||
@@ -265,10 +269,23 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppE
|
||||
}
|
||||
|
||||
fn reject_error(v: &Verifier, reason: &str) -> AppError {
|
||||
AppError::IcaptchaProofRequired(format!(
|
||||
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
|
||||
v.url, v.required_level, PROOF_HEADER
|
||||
))
|
||||
AppError::IcaptchaProofRequired {
|
||||
message: format!(
|
||||
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
|
||||
v.url, v.required_level, PROOF_HEADER
|
||||
),
|
||||
url: v.url.clone(),
|
||||
level: v.required_level,
|
||||
}
|
||||
}
|
||||
|
||||
/// iCaptcha service url + required level for error responses, read from the
|
||||
/// initialized verifier (falls back to defaults if somehow uninitialized).
|
||||
fn url_and_level() -> (String, u32) {
|
||||
VERIFIER
|
||||
.get()
|
||||
.map(|v| (v.url.clone(), v.required_level))
|
||||
.unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3))
|
||||
}
|
||||
|
||||
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
|
||||
|
||||
+154
-31
@@ -145,16 +145,8 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
// Do not follow redirects: this client fans out to peer-supplied URLs
|
||||
// (sync trigger, profile/repo fetches). A peer answering `302 Location:
|
||||
// http://127.0.0.1/` would otherwise bypass the announce-time public-URL
|
||||
// check and turn the redirect into an SSRF.
|
||||
let http_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?,
|
||||
);
|
||||
// Shared no-redirect HTTP client. See build_http_client for the SSRF rationale.
|
||||
let http_client = Arc::new(build_http_client()?);
|
||||
|
||||
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
|
||||
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
|
||||
@@ -190,7 +182,36 @@ async fn main() -> Result<()> {
|
||||
let repo_store =
|
||||
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
|
||||
|
||||
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
let rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
|
||||
|
||||
// Push-path flood brake: max git-receive-pack requests per client IP per
|
||||
// hour (counts both the info/refs advertisement and the push POST). Sized
|
||||
// for heavy agent automation while still stopping flood traffic (the June
|
||||
// 2026 attack pushed several times per second per IP). GITLAWB_PUSH_RATE_LIMIT
|
||||
// overrides; 0 disables. Bounded key set — the key is a client-influenced IP.
|
||||
let push_limit = std::env::var("GITLAWB_PUSH_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(600);
|
||||
let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
push_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if push_limit == 0 {
|
||||
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
|
||||
}
|
||||
|
||||
// Which forwarded header the edge is trusted to set. Default None (trust
|
||||
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
|
||||
// a node behind Caddy/NGINX sets it to x-forwarded-for.
|
||||
let push_limiter_trust = rate_limit::TrustedProxy::from_env_value(
|
||||
&std::env::var("GITLAWB_TRUSTED_PROXY").unwrap_or_default(),
|
||||
);
|
||||
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
|
||||
|
||||
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
|
||||
icaptcha::init().await;
|
||||
@@ -208,6 +229,8 @@ async fn main() -> Result<()> {
|
||||
machine_id,
|
||||
repo_store,
|
||||
rate_limiter,
|
||||
push_rate_limiter,
|
||||
push_limiter_trust,
|
||||
shutdown_tx: shutdown_tx.clone(),
|
||||
};
|
||||
|
||||
@@ -268,6 +291,7 @@ async fn main() -> Result<()> {
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let rl = state.rate_limiter.clone();
|
||||
let push_rl = state.push_rate_limiter.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
@@ -275,6 +299,7 @@ async fn main() -> Result<()> {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
rl.cleanup().await;
|
||||
push_rl.cleanup().await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
@@ -399,19 +424,25 @@ async fn main() -> Result<()> {
|
||||
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
|
||||
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
|
||||
|
||||
let serve_result = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
let mut rx = shutdown_signal_for_axum;
|
||||
// Wait until the watcher flips to true, then return so axum
|
||||
// can begin draining.
|
||||
while !*rx.borrow_and_update() {
|
||||
if rx.changed().await.is_err() {
|
||||
// Sender dropped — treat as shutdown.
|
||||
break;
|
||||
}
|
||||
// `into_make_service_with_connect_info` exposes the socket peer address as
|
||||
// `ConnectInfo<SocketAddr>` so the push limiter can key on the real client
|
||||
// when no trusted proxy header applies (see `rate_limit::client_key`).
|
||||
let serve_result = axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move {
|
||||
let mut rx = shutdown_signal_for_axum;
|
||||
// Wait until the watcher flips to true, then return so axum
|
||||
// can begin draining.
|
||||
while !*rx.borrow_and_update() {
|
||||
if rx.changed().await.is_err() {
|
||||
// Sender dropped — treat as shutdown.
|
||||
break;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// Server has stopped accepting new connections and drained in-flight
|
||||
// requests. Tear the rest of the system down.
|
||||
@@ -515,7 +546,12 @@ async fn gossip_task(
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
// Reuse the shared no-redirect client for every gossip outbound call (the
|
||||
// bootstrap announce POST and the periodic peer /health ping). Peer URLs are
|
||||
// attacker-influenceable, so a 3xx to a private address must not be followed.
|
||||
// Do NOT fall back to reqwest::Client::new(): its default follows redirects
|
||||
// and would reintroduce the SSRF closed here (#93).
|
||||
let client = state.http_client.clone();
|
||||
let my_did = state.node_did.to_string();
|
||||
let my_url = state.config.public_url.clone().unwrap_or_default();
|
||||
|
||||
@@ -590,13 +626,7 @@ async fn gossip_task(
|
||||
Err(_) => continue,
|
||||
};
|
||||
for peer in peers {
|
||||
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
|
||||
let ok = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
let ok = ping_peer_health(&client, &peer.http_url).await;
|
||||
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
|
||||
}
|
||||
}
|
||||
@@ -610,6 +640,38 @@ async fn gossip_task(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shared node HTTP client used for every outbound fan-out (sync
|
||||
/// trigger, profile/repo fetches, gossip announce + peer pings).
|
||||
///
|
||||
/// No redirects: peer URLs are attacker-influenceable, so a `3xx` to a private
|
||||
/// address must not be followed (SSRF guard, #78/#93). Do NOT replace with
|
||||
/// `reqwest::Client::new()` — its default follows redirects. Kept as a named
|
||||
/// builder so tests bind the redirect guarantee to the real client the node
|
||||
/// runs, not a hand-rolled equivalent.
|
||||
fn build_http_client() -> reqwest::Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Ping a peer's `/health` endpoint and report whether it answered 2xx.
|
||||
///
|
||||
/// Takes the client by reference so callers supply the shared, no-redirect
|
||||
/// `state.http_client`. Peer URLs are attacker-influenceable, so a `3xx` to a
|
||||
/// private address must not be followed. Do NOT call this with a bare
|
||||
/// `reqwest::Client::new()`: its default follows redirects and would
|
||||
/// reintroduce the SSRF this guards against (#93).
|
||||
async fn ping_peer_health(client: &reqwest::Client, http_url: &str) -> bool {
|
||||
let url = format!("{}/health", http_url.trim_end_matches('/'));
|
||||
client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
let key_path = config.resolved_key_path();
|
||||
|
||||
@@ -642,3 +704,64 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
Ok(kp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::ping_peer_health;
|
||||
|
||||
// Build the client exactly as production does (super::build_http_client) so
|
||||
// these tests bind the redirect guarantee to the real shared client the
|
||||
// node runs. A regression that makes build_http_client follow redirects
|
||||
// fails ping_peer_health_does_not_follow_redirect.
|
||||
fn production_http_client() -> reqwest::Client {
|
||||
super::build_http_client().expect("failed to build production http client")
|
||||
}
|
||||
|
||||
// A peer answering `/health` with a 302 toward an internal address must not
|
||||
// be followed: the redirect target must never be requested (#93).
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_does_not_follow_redirect() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let internal = server
|
||||
.mock("GET", "/internal-metadata")
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let _health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/internal-metadata", server.url()))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(!ok, "a 302 must not count as a healthy peer");
|
||||
// expect(0) is enforced only at assert time; this fails if the redirect
|
||||
// was followed to the internal target.
|
||||
internal.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_reports_success_on_200() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(ok, "a 200 /health must count as a healthy peer");
|
||||
}
|
||||
|
||||
// A transport error (nothing listening) must map to unhealthy, never a
|
||||
// spurious healthy — the .unwrap_or(false) arm.
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_reports_unhealthy_on_connection_error() {
|
||||
let ok = ping_peer_health(&production_http_client(), "http://127.0.0.1:1").await;
|
||||
assert!(!ok, "a connection error must count as an unhealthy peer");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::extract::{ConnectInfo, Request};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
|
||||
/// Default ceiling on the number of distinct keys a limiter tracks. Bounds the
|
||||
/// limiter's own memory so a caller that can vary the key (many DIDs, or a
|
||||
/// spoofable IP header) cannot grow the map without limit.
|
||||
const DEFAULT_MAX_KEYS: usize = 100_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Window {
|
||||
timestamps: Vec<Instant>,
|
||||
@@ -20,38 +26,98 @@ pub struct RateLimiter {
|
||||
state: Arc<Mutex<HashMap<String, Window>>>,
|
||||
max_requests: usize,
|
||||
window: Duration,
|
||||
/// Hard cap on tracked keys. When full, expired keys are evicted (at most
|
||||
/// once per [`sweep_interval`](Self::sweep_interval), see below); if still
|
||||
/// full, a request under a *new* key is rejected rather than inserted — so
|
||||
/// the map can never exceed this bound and a rejected request never
|
||||
/// allocates a new entry.
|
||||
max_keys: usize,
|
||||
/// Timestamp of the last inline capacity sweep. The eviction scan is
|
||||
/// O(max_keys), so under a distinct-key flood (when the map sits at the
|
||||
/// cap) running it on every miss would serialize all traffic behind a full
|
||||
/// scan. Gating it to at most once per interval amortizes that cost; the
|
||||
/// background [`cleanup`](Self::cleanup) loop still reclaims on its own.
|
||||
last_sweep: Arc<Mutex<Instant>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
// Retained for tests and callers that don't need a tight key bound;
|
||||
// production limiters use `new_bounded`.
|
||||
#[allow(dead_code)]
|
||||
pub fn new(max_requests: usize, window: Duration) -> Self {
|
||||
Self::new_bounded(max_requests, window, DEFAULT_MAX_KEYS)
|
||||
}
|
||||
|
||||
/// Like [`new`] but with an explicit cap on the number of distinct keys.
|
||||
/// Production limiters keyed on client-influenced values (per-DID, per-IP)
|
||||
/// set this so the limiter's own state cannot be turned into a
|
||||
/// memory-exhaustion vector.
|
||||
pub fn new_bounded(max_requests: usize, window: Duration, max_keys: usize) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(HashMap::new())),
|
||||
max_requests,
|
||||
window,
|
||||
max_keys: max_keys.max(1),
|
||||
last_sweep: Arc::new(Mutex::new(Instant::now())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, key: &str) -> bool {
|
||||
/// How often the inline capacity sweep may run: at most once per second, or
|
||||
/// once per window when the window is shorter. Bounds post-flood staleness
|
||||
/// (a full map self-heals within one interval) without paying the O(max_keys)
|
||||
/// scan on every capacity miss.
|
||||
fn sweep_interval(&self) -> Duration {
|
||||
self.window.min(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
pub(crate) async fn check(&self, key: &str) -> bool {
|
||||
// max_requests == 0 means the limiter is disabled, not "block all".
|
||||
if self.max_requests == 0 {
|
||||
return true;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
// Look up before inserting so the common case (key already tracked)
|
||||
// doesn't allocate a String per request.
|
||||
if !state.contains_key(key) {
|
||||
state.insert(
|
||||
key.to_string(),
|
||||
Window {
|
||||
timestamps: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
// Fast path: an already-tracked key never allocates and never grows the
|
||||
// map, so it is unaffected by the key cap.
|
||||
if let Some(window) = state.get_mut(key) {
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
if window.timestamps.len() >= self.max_requests {
|
||||
return false;
|
||||
}
|
||||
window.timestamps.push(now);
|
||||
return true;
|
||||
}
|
||||
let window = state.get_mut(key).expect("window just ensured");
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
if window.timestamps.len() >= self.max_requests {
|
||||
return false;
|
||||
|
||||
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
|
||||
// cannot grow the map, and a rejected request never allocates an entry.
|
||||
if state.len() >= self.max_keys {
|
||||
// Reclaim expired keys, but at most once per sweep interval — the
|
||||
// scan is O(max_keys) and the map sits at the cap precisely during a
|
||||
// distinct-key flood, so sweeping per miss would serialize traffic
|
||||
// behind a full scan. Between sweeps a new key is simply rejected.
|
||||
let mut last_sweep = self.last_sweep.lock().await;
|
||||
if now.duration_since(*last_sweep) >= self.sweep_interval() {
|
||||
state.retain(|_, w| {
|
||||
w.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
!w.timestamps.is_empty()
|
||||
});
|
||||
*last_sweep = now;
|
||||
}
|
||||
drop(last_sweep);
|
||||
if state.len() >= self.max_keys {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
window.timestamps.push(now);
|
||||
state.insert(
|
||||
key.to_string(),
|
||||
Window {
|
||||
timestamps: vec![now],
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -88,6 +154,134 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Which forwarded header (if any) the operator's edge is trusted to set. Only
|
||||
/// a proxy the operator controls may be believed; a raw client can put any
|
||||
/// value in `Fly-Client-IP` / `X-Forwarded-For`, so trusting them unconditionally
|
||||
/// lets a flooder rotate the header and never fill a bucket. Configured via
|
||||
/// `GITLAWB_TRUSTED_PROXY`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum TrustedProxy {
|
||||
/// No trusted proxy: ignore forwarded headers, key on the socket peer IP.
|
||||
/// Safe default for direct/self-hosted nodes.
|
||||
None,
|
||||
/// Behind Fly's edge, which sets (and overwrites any client-supplied)
|
||||
/// `Fly-Client-IP`.
|
||||
Fly,
|
||||
/// Behind a single reverse proxy (e.g. Caddy on the AWS image) that appends
|
||||
/// the real client as the rightmost `X-Forwarded-For` hop.
|
||||
XForwardedFor,
|
||||
}
|
||||
|
||||
impl TrustedProxy {
|
||||
/// Parse `GITLAWB_TRUSTED_PROXY`. Unknown/empty → `None` (trust nothing).
|
||||
pub fn from_env_value(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"fly" | "fly-client-ip" => TrustedProxy::Fly,
|
||||
"xff" | "x-forwarded-for" | "caddy" => TrustedProxy::XForwardedFor,
|
||||
_ => TrustedProxy::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trimmed_nonempty(v: &str) -> Option<String> {
|
||||
let t = v.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the rate-limit key for a request. In a trusted-proxy mode the
|
||||
/// operator's edge header is preferred; when that header is missing or empty we
|
||||
/// fall back to the socket peer address rather than skipping the limiter (a
|
||||
/// malformed header must never disable the brake). With no trusted proxy the
|
||||
/// socket peer is always used and forwarded headers are ignored entirely.
|
||||
/// Returns `None` only when neither a trusted header nor a peer address is
|
||||
/// available (e.g. a synthetic test request with no `ConnectInfo`).
|
||||
pub fn client_key(
|
||||
headers: &HeaderMap,
|
||||
peer: Option<SocketAddr>,
|
||||
trust: TrustedProxy,
|
||||
) -> Option<String> {
|
||||
let from_header = match trust {
|
||||
TrustedProxy::None => None,
|
||||
TrustedProxy::Fly => headers
|
||||
.get("fly-client-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(trimmed_nonempty),
|
||||
// Rightmost hop = the value appended by the immediately-upstream trusted
|
||||
// proxy. The leftmost hop is client-controlled and must not be trusted.
|
||||
TrustedProxy::XForwardedFor => headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.rsplit(',').next())
|
||||
.and_then(trimmed_nonempty),
|
||||
};
|
||||
from_header.or_else(|| peer.map(|p| p.ip().to_string()))
|
||||
}
|
||||
|
||||
/// Per-client-IP limiter for the git push path, carrying the trusted-proxy
|
||||
/// policy. A newtype so it can live in request extensions (keyed by type)
|
||||
/// alongside the per-DID [`RateLimiter`]. Per-DID limits are useless against a
|
||||
/// push flood from a DID farm (one throwaway DID per repo), so the push path
|
||||
/// throttles on the resolved client IP instead.
|
||||
#[derive(Clone)]
|
||||
pub struct IpRateLimiter {
|
||||
pub limiter: RateLimiter,
|
||||
pub trust: TrustedProxy,
|
||||
}
|
||||
|
||||
/// Infallible extractor for the socket peer address from `ConnectInfo`. Yields
|
||||
/// `None` when the server was started without connect-info (e.g. `oneshot` in
|
||||
/// tests), so a handler never 500s on its absence — the limiter simply falls
|
||||
/// back per [`client_key`].
|
||||
pub struct PeerAddr(pub Option<SocketAddr>);
|
||||
|
||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Ok(PeerAddr(
|
||||
parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared 429 response for the push flood brake.
|
||||
pub fn too_many_requests() -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"push rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
let limiter = request.extensions().get::<IpRateLimiter>().cloned();
|
||||
let peer = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0);
|
||||
|
||||
if let Some(limiter) = limiter {
|
||||
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
|
||||
if !limiter.limiter.check(&key).await {
|
||||
tracing::warn!(key = %key, path = %request.uri().path(), "push rate limit exceeded");
|
||||
return too_many_requests();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -134,4 +328,217 @@ mod tests {
|
||||
let state = limiter.state.lock().await;
|
||||
assert!(state.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_limit_disables() {
|
||||
let limiter = RateLimiter::new(0, Duration::from_secs(60));
|
||||
for _ in 0..1000 {
|
||||
assert!(limiter.check("k").await);
|
||||
}
|
||||
}
|
||||
|
||||
// ── key-cap / memory-bound (P2) ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn caps_tracked_keys_and_rejects_new_ones_when_full() {
|
||||
// Cap of 2 distinct keys; generous request budget so rejection is due to
|
||||
// the key cap, not the per-key limit.
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 2);
|
||||
assert!(limiter.check("a").await);
|
||||
assert!(limiter.check("b").await);
|
||||
// Third distinct key would grow the map past the cap → rejected.
|
||||
assert!(!limiter.check("c").await);
|
||||
// The map never exceeded the cap, and the rejected key was NOT inserted.
|
||||
let state = limiter.state.lock().await;
|
||||
assert_eq!(state.len(), 2);
|
||||
assert!(!state.contains_key("c"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_key_unaffected_by_cap() {
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 1);
|
||||
assert!(limiter.check("a").await); // fills the single slot
|
||||
assert!(limiter.check("a").await); // same key still served
|
||||
assert!(!limiter.check("b").await); // new key rejected — cap full
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_keys_evicted_to_admit_new_when_full() {
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_millis(40), 1);
|
||||
assert!(limiter.check("old").await);
|
||||
tokio::time::sleep(Duration::from_millis(55)).await;
|
||||
// "old" is now expired; a new key triggers inline eviction and is admitted.
|
||||
assert!(limiter.check("new").await);
|
||||
let state = limiter.state.lock().await;
|
||||
assert!(state.contains_key("new"));
|
||||
assert!(!state.contains_key("old"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_sweep_is_amortized_within_interval() {
|
||||
// The O(max_keys) eviction scan must not run on every capacity miss.
|
||||
// With a 1s sweep interval, the resident (unexpired) key is never
|
||||
// evicted by a burst of misses, so repeated new keys are all rejected
|
||||
// without disturbing existing state.
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(1), 1);
|
||||
assert!(limiter.check("resident").await);
|
||||
for i in 0..100 {
|
||||
assert!(
|
||||
!limiter.check(&format!("flood-{i}")).await,
|
||||
"new key must be rejected while the single slot is held"
|
||||
);
|
||||
}
|
||||
let state = limiter.state.lock().await;
|
||||
assert_eq!(state.len(), 1, "no flood key was ever inserted");
|
||||
assert!(state.contains_key("resident"));
|
||||
}
|
||||
|
||||
// ── client_key / trusted-proxy resolution (P1 + P2) ─────────────────
|
||||
|
||||
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
v.parse().unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn peer(s: &str) -> Option<SocketAddr> {
|
||||
Some(s.parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_mode_ignores_headers_and_uses_peer() {
|
||||
// Even a well-formed Fly header is ignored without a trusted proxy.
|
||||
let h = headers(&[("fly-client-ip", "203.0.113.7")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::None).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fly_mode_trusts_fly_header() {
|
||||
let h = headers(&[
|
||||
("fly-client-ip", "203.0.113.7"),
|
||||
("x-forwarded-for", "1.2.3.4"),
|
||||
]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::Fly).as_deref(),
|
||||
Some("203.0.113.7")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fly_mode_empty_header_falls_back_to_peer_not_skip() {
|
||||
// Empty Fly-Client-IP must NOT collapse traffic onto Some("") nor skip
|
||||
// the limiter — it falls back to the real peer.
|
||||
let h = headers(&[("fly-client-ip", "")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::Fly).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xff_mode_uses_rightmost_hop_not_client_controlled_first() {
|
||||
// Client prepends spoofed hops; only the rightmost (proxy-appended) is trusted.
|
||||
let h = headers(&[("x-forwarded-for", "9.9.9.9, 8.8.8.8, 198.51.100.9")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xff_mode_empty_leading_hop_does_not_disable_brake() {
|
||||
// "X-Forwarded-For: ,1.2.3.4" — rightmost hop is used; never None-skips.
|
||||
let h = headers(&[("x-forwarded-for", ",1.2.3.4")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
|
||||
Some("1.2.3.4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_xff_falls_back_to_peer() {
|
||||
let h = headers(&[("x-forwarded-for", " , ")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::XForwardedFor).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_proxy_parsing() {
|
||||
assert_eq!(TrustedProxy::from_env_value("fly"), TrustedProxy::Fly);
|
||||
assert_eq!(
|
||||
TrustedProxy::from_env_value("X-Forwarded-For"),
|
||||
TrustedProxy::XForwardedFor
|
||||
);
|
||||
assert_eq!(
|
||||
TrustedProxy::from_env_value("caddy"),
|
||||
TrustedProxy::XForwardedFor
|
||||
);
|
||||
assert_eq!(TrustedProxy::from_env_value(""), TrustedProxy::None);
|
||||
assert_eq!(TrustedProxy::from_env_value("garbage"), TrustedProxy::None);
|
||||
}
|
||||
|
||||
// ── middleware 429 path ─────────────────────────────────────────────
|
||||
|
||||
/// A minimal router with the push limiter layered over an OK handler,
|
||||
/// driven via `oneshot`. `ConnectInfo` is attached to each request directly
|
||||
/// (as the production make-service does) so the middleware resolves a peer.
|
||||
fn ip_limited_router(limiter: IpRateLimiter) -> axum::Router {
|
||||
axum::Router::new()
|
||||
.route(
|
||||
"/o/r/git-receive-pack",
|
||||
axum::routing::post(|| async { StatusCode::OK }),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(rate_limit_by_ip))
|
||||
.layer(axum::Extension(limiter))
|
||||
}
|
||||
|
||||
async fn post_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
|
||||
use tower::ServiceExt;
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method(axum::http::Method::POST)
|
||||
.uri("/o/r/git-receive-pack")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
router.clone().oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_returns_429_over_limit() {
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(2, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let peer: SocketAddr = "203.0.113.7:5000".parse().unwrap();
|
||||
// Two requests inside the budget, the third over it (shared Arc state).
|
||||
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
|
||||
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
|
||||
assert_eq!(
|
||||
post_from(&router, peer).await,
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_isolates_distinct_peers() {
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let a: SocketAddr = "203.0.113.1:1".parse().unwrap();
|
||||
let b: SocketAddr = "203.0.113.2:1".parse().unwrap();
|
||||
assert_eq!(post_from(&router, a).await, StatusCode::OK);
|
||||
assert_eq!(post_from(&router, b).await, StatusCode::OK); // independent bucket
|
||||
assert_eq!(post_from(&router, a).await, StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
|
||||
// helper signs requests with RFC 9421 signatures using the agent's keypair.
|
||||
let pack_limit = state.config.max_pack_bytes;
|
||||
// Per-IP throttle wraps the auth layer (outermost = runs first): flood
|
||||
// traffic is rejected before signature verification burns CPU. Per-DID
|
||||
// limiting is deliberately NOT used here — a DID farm (one throwaway
|
||||
// identity per repo, as in the June 2026 push flood) never trips it.
|
||||
let push_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.push_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let git_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -184,7 +192,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit)),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(push_limiter));
|
||||
|
||||
// ── IPFS content-addressed retrieval and pin listing ──────────────────
|
||||
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
|
||||
|
||||
@@ -51,6 +51,13 @@ pub struct AppState {
|
||||
pub repo_store: RepoStore,
|
||||
/// Per-DID rate limiter for creation endpoints (repos, issues, PRs)
|
||||
pub rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
pub push_rate_limiter: RateLimiter,
|
||||
/// Which forwarded header (if any) the edge is trusted to set, for
|
||||
/// resolving the push limiter's client-IP key. See `GITLAWB_TRUSTED_PROXY`.
|
||||
pub push_limiter_trust: crate::rate_limit::TrustedProxy,
|
||||
/// Process-wide graceful-shutdown signal. Sending `true` causes every
|
||||
/// task that holds a `watch::Receiver` to exit at its next await point.
|
||||
/// Used by:
|
||||
|
||||
@@ -76,6 +76,8 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
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)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
}
|
||||
}
|
||||
@@ -669,6 +671,436 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── #119: git-info-refs advertisement gate + client signing ──────────────
|
||||
|
||||
/// A1 read-gate bypass + its client remedy. `git_info_refs` serves BOTH the
|
||||
/// `git-upload-pack` (clone/fetch) and `git-receive-pack` (push) ref
|
||||
/// advertisement off one route, but the visibility gate was wrapped in
|
||||
/// `if service == "git-upload-pack"`, so a private repo's ref advertisement
|
||||
/// (branch/tag names + commit tips) leaked to any anonymous caller who asked
|
||||
/// for `?service=git-receive-pack`. The fix gates the advertisement for both
|
||||
/// services. Because the gate now denies an *unauthenticated* advertisement
|
||||
/// of a private repo for both services, `git-remote-gitlawb` signs its
|
||||
/// Phase-1 advertisement GET (over path_and_query) so the owner can still
|
||||
/// fetch and push; this test exercises that exact request with a REAL
|
||||
/// RFC-9421 signature through the production `optional_signature` middleware.
|
||||
///
|
||||
/// Denied → 404 (`RepoNotFound`, existence-hiding) at the gate, before disk
|
||||
/// access. Allowed → the handler clears the gate and falls through to
|
||||
/// `acquire` + real `git ... --advertise-refs` against a repo absent from the
|
||||
/// test disk, returning 500; that 500 (anything but 404) is the signal the
|
||||
/// caller cleared the gate.
|
||||
#[sqlx::test]
|
||||
async fn git_info_refs_gates_advertisement_for_both_services(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
// Short owner form in the URL so the signed @path and the node's
|
||||
// path_and_query() match byte-for-byte; get_repo's owner LIKE + did_matches
|
||||
// still authorize the full-DID signer as the owner.
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut priv_repo = seed_repo(&owner_did, "ir-priv");
|
||||
priv_repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&priv_repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
// A public repo to guard against the unconditional gate accidentally
|
||||
// denying public, anonymous clones.
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "ir-pub"))
|
||||
.await
|
||||
.expect("seed public repo");
|
||||
|
||||
// Production-shaped router: the real optional_signature middleware, so a
|
||||
// signed request is genuinely verified (not the injected-DID shortcut).
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let path = |service: &str| format!("/{short}/ir-priv.git/info/refs?service={service}");
|
||||
let anon = |service: &str| {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(path(service))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
// The advertisement GET exactly as git-remote-gitlawb now builds it: a
|
||||
// real signature over the path_and_query, empty body.
|
||||
let signed = |service: &str| {
|
||||
let p = path(service);
|
||||
let s = sign_request(&kp, "GET", &p, b"");
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&p)
|
||||
.header("content-digest", s.content_digest)
|
||||
.header("signature-input", s.signature_input)
|
||||
.header("signature", s.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Leak fix: anonymous advertisement of a private repo is denied (404) for
|
||||
// BOTH services. Pre-fix the receive-pack case returned 500 (gate skipped).
|
||||
for service in ["git-upload-pack", "git-receive-pack"] {
|
||||
let resp = router().oneshot(anon(service)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"anonymous {service} advertisement of a private repo must be denied"
|
||||
);
|
||||
}
|
||||
|
||||
// No-regression: a PUBLIC repo's advertisement stays anonymous for BOTH
|
||||
// services. The gate admits the anonymous caller, so the handler clears it
|
||||
// and 500s on the missing test-disk repo; anything but 404 (a gate denial)
|
||||
// proves the unconditional gate did not accidentally lock out public reads.
|
||||
for service in ["git-upload-pack", "git-receive-pack"] {
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/{short}/ir-pub.git/info/refs?service={service}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router().oneshot(req).await.unwrap();
|
||||
// 500 (not just non-404): the gate admits the public anonymous caller,
|
||||
// so the handler reaches acquire + git advertise-refs on the missing
|
||||
// test-disk repo. Pinning the exact 500 rules out a 401/403 regression
|
||||
// masquerading as "not gated".
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"anonymous {service} advertisement of a PUBLIC repo must not be gated"
|
||||
);
|
||||
}
|
||||
|
||||
// Client remedy: the owner's SIGNED advertisement GET clears the gate for
|
||||
// BOTH services (so fetch and push of a private repo keep working). It
|
||||
// 500s on the missing test-disk repo; anything but 404 means cleared.
|
||||
for service in ["git-upload-pack", "git-receive-pack"] {
|
||||
let resp = router().oneshot(signed(service)).await.unwrap();
|
||||
// INTERNAL_SERVER_ERROR specifically: the signature VERIFIED (passed
|
||||
// require_signature, not 401/403) and the owner cleared the read gate
|
||||
// (not 404), so the handler proceeded to acquire + git on a repo absent
|
||||
// from the test disk. Asserting the exact 500 (rather than merely
|
||||
// "not 404") proves the request got PAST auth, not rejected by it.
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the owner's signed {service} advertisement must verify and clear the gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push is signature-gated, not merely owner-gated: an UNSIGNED
|
||||
/// git-receive-pack POST is rejected by `require_signature` (401) before
|
||||
/// reaching `git_receive_pack`. 401 (not the handler's 404/500) is the
|
||||
/// discriminator that proves the request never reached the handler.
|
||||
#[sqlx::test]
|
||||
async fn unsigned_receive_pack_post_is_rejected(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner_did = Keypair::generate().did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "rp-repo"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
// Production wiring: the receive-pack POST sits behind require_signature
|
||||
// (server.rs add_auth_layers); apply that same layer here.
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::require_signature))
|
||||
.with_state(state);
|
||||
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/{short}/rp-repo.git/git-receive-pack"))
|
||||
.body(Body::from(&b"0000"[..]))
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"an unsigned receive-pack POST must be rejected by require_signature, \
|
||||
not reach the handler"
|
||||
);
|
||||
}
|
||||
|
||||
/// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after
|
||||
/// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack
|
||||
/// POST against a private repo is denied (404), so signing only the Phase-1
|
||||
/// advertisement GET is NOT enough; `git-remote-gitlawb` must also sign this
|
||||
/// POST, or an owner's fetch of their own private repo clears the advertisement
|
||||
/// and then dies on the pack POST. A real owner signature clears the gate
|
||||
/// (non-404; the missing test-disk repo then errors downstream).
|
||||
#[sqlx::test]
|
||||
async fn git_upload_pack_post_is_read_gated_on_private_repo(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut priv_repo = seed_repo(&owner_did, "up-priv");
|
||||
priv_repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&priv_repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-upload-pack",
|
||||
axum::routing::post(crate::api::repos::git_upload_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
// A non-empty body (git-remote-gitlawb skips the POST when the body is empty).
|
||||
let body = b"0032want 0000000000000000000000000000000000000000\n".to_vec();
|
||||
let path = format!("/{short}/up-priv.git/git-upload-pack");
|
||||
|
||||
// Anonymous Phase-2 fetch of a private repo: denied at the gate (404). This
|
||||
// is exactly the request git-remote-gitlawb sends today for upload-pack
|
||||
// (the unsigned POST), which is why fetch breaks for the owner.
|
||||
let anon = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-upload-pack-request")
|
||||
.body(Body::from(body.clone()))
|
||||
.unwrap();
|
||||
let resp = router().oneshot(anon).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"an anonymous upload-pack POST against a private repo must be denied"
|
||||
);
|
||||
|
||||
// The same POST signed by the owner clears the read gate (non-404). This is
|
||||
// the request the client must send once it signs the upload-pack POST.
|
||||
let signed = sign_request(&kp, "POST", &path, &body);
|
||||
let signed_req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-upload-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let resp = router().oneshot(signed_req).await.unwrap();
|
||||
// 500 (not merely non-404): the signature VERIFIED (passed require_signature,
|
||||
// not 401/403) AND the owner cleared the read gate (not 404), so the handler
|
||||
// reached git on the missing test-disk repo. Pinning 500 proves the request
|
||||
// got past auth; a 401 regression would slip through a bare `!= 404`.
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the owner's signed upload-pack POST must verify and clear the read gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Served-content seam: with a REAL on-disk bare repo (branch
|
||||
/// `topsecret-branch`), the advertisement serves the actual ref names to
|
||||
/// authorized callers and withholds them from denied ones, proving real
|
||||
/// content egress + withholding, not just the gate decision (the other tests
|
||||
/// land on a 500 from a missing-disk repo). Asserts the branch name appears for
|
||||
/// allowed callers and never appears in a denied 404 body.
|
||||
#[sqlx::test]
|
||||
async fn advertisement_serves_real_refs_only_to_authorized_callers(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use std::process::Command;
|
||||
|
||||
// repo_store::for_testing fixes the on-disk layout (/tmp/<slug>/<name>.git
|
||||
// and /tmp/gl-seam-src-<short>), so tempfile::TempDir's random paths don't
|
||||
// fit. Wrap each known path in a Drop guard so the dirs are removed even if
|
||||
// an assertion below panics.
|
||||
struct DirGuard(std::path::PathBuf);
|
||||
impl Drop for DirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
// repo_store::for_testing uses /tmp; local_path = /tmp/<slug>/<name>.git
|
||||
// with slug = owner_did with ':' and '/' replaced by '_'.
|
||||
let slug = owner_did.replace([':', '/'], "_");
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let run = |args: &[&str], cwd: &std::path::Path| {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
|
||||
// Source repo with a recognizable branch + one commit.
|
||||
let src = std::env::temp_dir().join(format!("gl-seam-src-{short}"));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let _src_guard = DirGuard(src.clone());
|
||||
run(&["init", "-q", "-b", "topsecret-branch"], &src);
|
||||
run(&["config", "user.email", "t@t"], &src);
|
||||
run(&["config", "user.name", "t"], &src);
|
||||
std::fs::write(src.join("f.txt"), b"hi").unwrap();
|
||||
run(&["add", "f.txt"], &src);
|
||||
run(&["commit", "-q", "-m", "seed"], &src);
|
||||
|
||||
// Bare-clone into the exact path repo_store.acquire() will read.
|
||||
let bare_for = |name: &str| {
|
||||
let dir = std::path::PathBuf::from("/tmp")
|
||||
.join(&slug)
|
||||
.join(format!("{name}.git"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
src.to_str().unwrap(),
|
||||
dir.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("git clone runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"bare clone failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
dir
|
||||
};
|
||||
let pub_dir = bare_for("served-pub");
|
||||
let _pub_guard = DirGuard(pub_dir.clone());
|
||||
let priv_dir = bare_for("served-priv");
|
||||
let _priv_guard = DirGuard(priv_dir.clone());
|
||||
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "served-pub"))
|
||||
.await
|
||||
.expect("seed public repo");
|
||||
let mut priv_repo = seed_repo(&owner_did, "served-priv");
|
||||
priv_repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&priv_repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
async fn body_of(resp: axum::response::Response) -> String {
|
||||
let b = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&b).to_string()
|
||||
}
|
||||
|
||||
// Public repo, anonymous → 200 and the real ref name is served.
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!(
|
||||
"/{short}/served-pub.git/info/refs?service=git-upload-pack"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(
|
||||
body_of(resp).await.contains("topsecret-branch"),
|
||||
"public advertisement must serve the real ref name"
|
||||
);
|
||||
|
||||
// Private repo, anonymous → 404 and the ref name is withheld.
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!(
|
||||
"/{short}/served-priv.git/info/refs?service=git-upload-pack"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert!(
|
||||
!body_of(resp).await.contains("topsecret-branch"),
|
||||
"a denied 404 must not leak the real ref name"
|
||||
);
|
||||
|
||||
// Private repo, owner's REAL signature → 200 and the real ref is served.
|
||||
let path = format!("/{short}/served-priv.git/info/refs?service=git-upload-pack");
|
||||
let s = sign_request(&kp, "GET", &path, b"");
|
||||
let resp = router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&path)
|
||||
.header("content-digest", s.content_digest)
|
||||
.header("signature-input", s.signature_input)
|
||||
.header("signature", s.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the owner's signed request reads the private advertisement"
|
||||
);
|
||||
assert!(
|
||||
body_of(resp).await.contains("topsecret-branch"),
|
||||
"the verified owner gets the real ref name"
|
||||
);
|
||||
|
||||
// Cleanup runs via the DirGuard Drop impls above, on success or panic.
|
||||
}
|
||||
|
||||
// ── #97: repo-listing surfaces are visibility-gated ──────────────────────
|
||||
|
||||
fn seed_private_repo(owner_did: &str, name: &str) -> RepoRecord {
|
||||
@@ -1842,4 +2274,116 @@ mod tests {
|
||||
"walk error fails closed: repo skipped, even the public blob is not served"
|
||||
);
|
||||
}
|
||||
|
||||
/// #126: a dangling blob (written via `git hash-object -w`, never referenced
|
||||
/// by any commit/tree) must 404 through `GET /ipfs/{cid}` under path-scoped
|
||||
/// rules — for anon AND the owner. The pre-#126 deny-set was fail-open by
|
||||
/// construction: dangling oids were absent from the reachable enumeration
|
||||
/// and thus absent from the deny-set, so the handler served 200. The
|
||||
/// allowed-set is fail-closed: dangling oids are absent from the reachable
|
||||
/// allowed-set, so the handler 404s (per team memory: the owner shift to
|
||||
/// 404 is the accepted fail-closed default — owners can still
|
||||
/// `git cat-file` directly).
|
||||
#[sqlx::test]
|
||||
async fn ipfs_cid_dangling_blob_fails_closed_under_path_rules(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let slug = owner_did.replace([':', '/'], "_");
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
// Seed a normal repo with `secret/b.txt` reachable from HEAD, so the
|
||||
// path-scoped rule has something to match — without this the rule has
|
||||
// no anchor and we'd be testing nothing.
|
||||
let _fx = seed_cid_repos(&slug, &short, &["dangling"]);
|
||||
let bare = std::path::PathBuf::from("/tmp")
|
||||
.join(&slug)
|
||||
.join("dangling.git");
|
||||
|
||||
// Write a dangling blob: `git hash-object -w --stdin` adds it to the
|
||||
// object DB but nothing references it, so the reachable walk never
|
||||
// enumerates it.
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(["hash-object", "-w", "--stdin"])
|
||||
.current_dir(&bare)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn git hash-object");
|
||||
{
|
||||
use std::io::Write;
|
||||
let stdin = child.stdin.as_mut().expect("stdin");
|
||||
stdin.write_all(b"DANGLING SECRET\n").expect("write stdin");
|
||||
}
|
||||
let out = child.wait_with_output().expect("hash-object output");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git hash-object: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let dangling_oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
// Sanity: must be a 64-hex sha256 oid, since the repo is sha256-format.
|
||||
assert_eq!(
|
||||
dangling_oid.len(),
|
||||
64,
|
||||
"expected sha256 oid: {dangling_oid}"
|
||||
);
|
||||
let dangling_cid = cid_for_oid(&dangling_oid);
|
||||
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "dangling"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
let rec = state
|
||||
.db
|
||||
.get_repo(&owner_did, "dangling")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// Path-scoped rule triggers the per-blob allowed-set gate (KTD4).
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(&rec.id, "/secret/**", VisibilityMode::B, &[], &owner_did)
|
||||
.await
|
||||
.expect("deny rule");
|
||||
|
||||
// anon: the dangling blob is absent from the reachable allowed-set →
|
||||
// 404, no leak. Pre-#126 (deny-set) would serve 200.
|
||||
let (st, body) = cid_parts(
|
||||
cid_router(&state)
|
||||
.oneshot(cid_anon(&dangling_cid))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"dangling blob must 404 under path-scoped rules"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("DANGLING SECRET"),
|
||||
"404 body must not leak the dangling content"
|
||||
);
|
||||
|
||||
// owner (signed): same 404. The dangling blob has no path, so it's
|
||||
// never visibility-checked → never in the allowed set, even for the
|
||||
// owner. This is the accepted fail-closed shift documented in the PR.
|
||||
let (st, body) = cid_parts(
|
||||
cid_router(&state)
|
||||
.oneshot(cid_signed(&owner, &dangling_cid))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner also 404s on dangling blobs under path-scoped rules (fail-closed default)"
|
||||
);
|
||||
assert!(!body.contains("DANGLING SECRET"));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
//! based on the repo's visibility rules with a fallback to the legacy
|
||||
//! `is_public` flag. It performs no I/O so it is exhaustively unit tested.
|
||||
|
||||
use crate::db::VisibilityRule;
|
||||
use crate::db::{RepoRecord, VisibilityRule};
|
||||
use std::collections::HashMap;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -24,10 +25,14 @@ fn nfc(s: &str) -> String {
|
||||
s.nfc().collect()
|
||||
}
|
||||
|
||||
/// True if `caller` is the repo owner (matches full did:key or its short form),
|
||||
/// mirroring the owner-match idiom in `api/protect.rs`.
|
||||
/// True if `caller` is the repo owner. Matches exactly or against the
|
||||
/// did:key short form — a non-key DID (did:gitlawb:/did:web:) in `owner_did`
|
||||
/// never matches a bare short segment, and a non-key DID in `caller` never
|
||||
/// matches a did:key owner's short form. Uses `normalize_owner_key` (not
|
||||
/// `did_matches`) so the did:key-only rule is stricter: cross-method matching
|
||||
/// would let a non-key canonical row bypass the #124 visibility gate.
|
||||
fn is_owner(owner_did: &str, caller: &str) -> bool {
|
||||
let owner_short = owner_did.split(':').next_back().unwrap_or(owner_did);
|
||||
let owner_short = crate::db::normalize_owner_key(owner_did);
|
||||
caller == owner_did || caller == owner_short
|
||||
}
|
||||
|
||||
@@ -127,6 +132,105 @@ pub fn listable_at_root(
|
||||
visibility_check(rules, is_public, owner_did, caller, "/") == Decision::Allow
|
||||
}
|
||||
|
||||
/// Whether a single `received_ref_updates` row (identified by its peer-supplied
|
||||
/// `row_repo` slug) should be shown to `caller` (None = anonymous) on the
|
||||
/// cross-repo ref-updates feeds (#112 GraphQL, #114 REST).
|
||||
///
|
||||
/// Pure and I/O-free: both call sites load the deduped local repo set and its
|
||||
/// visibility rules once per request and pass them in, so the gate logic lives
|
||||
/// here and visibility.rs keeps its "no I/O" property.
|
||||
///
|
||||
/// The slug is written verbatim from the inbound gossip/notify message, so it is
|
||||
/// untrusted input, not a canonical key. The decision is fail-closed by
|
||||
/// construction: the only KEEP paths are (a) a slug with no `/` (cannot name a
|
||||
/// local `owner/name` pair, so remote by definition), (b) all matched local
|
||||
/// records are readable, and (c) no local record matches (remote/gossip-only).
|
||||
/// Any local match the caller cannot read at root DROPs the row. There is no
|
||||
/// catch-all keep on unexpected state.
|
||||
///
|
||||
/// Slug/record owner keys are matched prefix-tolerantly (one is a prefix of the
|
||||
/// other), covering the exact short-key, the full `did:key:` form, and the
|
||||
/// URL-truncated 8-char form. Prefix over-match can only over-drop a genuinely
|
||||
/// remote row (fail-safe), never over-serve a private local one.
|
||||
///
|
||||
/// The live call sites are the #112 (GraphQL) and #114 (REST) feed handlers,
|
||||
/// added in the following units; exercised by the unit tests below meanwhile.
|
||||
pub fn ref_update_row_visible(
|
||||
deduped: &[RepoRecord],
|
||||
rules_by_repo: &HashMap<String, Vec<VisibilityRule>>,
|
||||
caller: Option<&str>,
|
||||
row_repo: &str,
|
||||
) -> bool {
|
||||
// A slug with no '/' cannot name a local owner/name pair — remote by
|
||||
// definition (same branch as "matches nothing local") → KEEP.
|
||||
if row_repo.rsplit_once('/').is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match each local record with the shared slug predicate (one matcher, so
|
||||
// this gate and the collector's quarantine drop cannot disagree about which
|
||||
// rows a repo owns), then fail closed on any matched record the caller
|
||||
// cannot read at root.
|
||||
for record in deduped {
|
||||
if !ref_update_row_names_repo(record, row_repo) {
|
||||
continue;
|
||||
}
|
||||
let rules = rules_by_repo
|
||||
.get(&record.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
if !listable_at_root(rules, record.is_public, &record.owner_did, caller) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reached only if every matched local record is readable, or nothing local
|
||||
// matched (remote/gossip-only). Both are the KEEP paths; there is no
|
||||
// default-keep on unexpected state — an unreadable match already returned.
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `row_repo`'s peer-supplied slug names the local `record`. The single
|
||||
/// match predicate shared by the feed gate ([`ref_update_row_visible`]) and the
|
||||
/// quarantine hard-drop in the ref-update collector, so the two cannot diverge
|
||||
/// about which rows a repo owns — a second, drifting matcher is exactly the #134
|
||||
/// slug-collision class.
|
||||
///
|
||||
/// A record matches when the slug's `name` half is equal and one owner key is a
|
||||
/// prefix of the other (prefix-tolerant: exact short key, full `did:key:` form,
|
||||
/// and the URL-truncated 8-char form). Both the slug owner and the record owner
|
||||
/// are reduced to their last ':'-delimited segment before comparing. The node's
|
||||
/// own emitter builds the wire slug via `normalize_owner_key` (bare short key for
|
||||
/// a single-segment `did:key:`, the full DID for every other method; see
|
||||
/// api/repos.rs), but the stored slug is UNTRUSTED: a peer can broadcast a row
|
||||
/// under any owner form, including a method-stripped `user/name`. Reducing both
|
||||
/// sides to the trailing segment matches a repo's own rows whichever form keyed
|
||||
/// them, and still catches an attacker-planted short slug.
|
||||
///
|
||||
/// This intentionally diverges from `did_matches` / `DEDUP_CTE`, which strip only
|
||||
/// bare `did:key:` and keep other DID methods whole: those compare trusted,
|
||||
/// canonical DIDs, while this slug is untrusted. Applying the keep-whole rule here
|
||||
/// would fail open: a hostile `user/name` slug would not prefix-match a private
|
||||
/// `did:web:host:user` record and would leak. The price is a fail-SAFE over-match
|
||||
/// when a remote owner shares both a trailing segment and a repo name with a local
|
||||
/// private repo (negligible for full did:key ids; only did:web / truncated forms
|
||||
/// collide): it can hide a genuinely remote row, never serve a private one.
|
||||
pub fn ref_update_row_names_repo(record: &RepoRecord, row_repo: &str) -> bool {
|
||||
let Some((owner_part, name)) = row_repo.rsplit_once('/') else {
|
||||
return false;
|
||||
};
|
||||
if record.name != name {
|
||||
return false;
|
||||
}
|
||||
let row_key = owner_part.split(':').next_back().unwrap_or(owner_part);
|
||||
let record_key = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
record_key.starts_with(row_key) || row_key.starts_with(record_key)
|
||||
}
|
||||
|
||||
/// The subtree path globs that `caller` (None = anonymous) may NOT read, given
|
||||
/// the repo's rules. Whole-repo ("/") rules are excluded: a denied whole-repo
|
||||
/// read is handled by the 404 gate before a clone ever starts. Each remaining
|
||||
@@ -299,6 +403,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_key_owner_bare_short_does_not_match() {
|
||||
// A non-key DID owner (did:gitlawb:z6MkFoo) probed with the bare last
|
||||
// segment (z6MkFoo) must NOT match — the old is_owner would return true
|
||||
// via split(':').next_back(), which this PR tightens.
|
||||
let rules = [rule("/", VisibilityMode::A, &[])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, false, "did:gitlawb:z6MkFoo", Some("z6MkFoo"), "/"),
|
||||
Decision::Deny,
|
||||
"bare short must not match a non-key DID owner"
|
||||
);
|
||||
// The full non-key DID still matches itself.
|
||||
assert_eq!(
|
||||
visibility_check(
|
||||
&rules,
|
||||
false,
|
||||
"did:gitlawb:z6MkFoo",
|
||||
Some("did:gitlawb:z6MkFoo"),
|
||||
"/"
|
||||
),
|
||||
Decision::Allow,
|
||||
"full non-key DID still matches itself"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_rule_allows_listed_reader() {
|
||||
let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
|
||||
@@ -403,6 +532,260 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── ref_update_row_visible (feed gate) ──────────────────────────────────
|
||||
|
||||
fn rec(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
|
||||
RepoRecord {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
owner_did: owner_did.into(),
|
||||
description: None,
|
||||
is_public,
|
||||
default_branch: "main".into(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
disk_path: format!("/srv/{id}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rules_for(entries: &[(&str, &[VisibilityRule])]) -> HashMap<String, Vec<VisibilityRule>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(id, rs)| (id.to_string(), rs.to_vec()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_public_local_repo_kept_for_anon() {
|
||||
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", true)];
|
||||
let rules = HashMap::new();
|
||||
assert!(ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_private_local_repo_dropped_for_anon_kept_for_owner() {
|
||||
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
// Anonymous → drop.
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
// Owner (full DID) → keep.
|
||||
assert!(ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
Some("did:key:z6MkOwner"),
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_root_rule_reader_reincluded() {
|
||||
// Private repo (is_public=false) with a root rule granting a named
|
||||
// reader. Delegates to listable_at_root: anon and non-reader denied,
|
||||
// named reader allowed.
|
||||
let deduped = [rec("r1", OWNER, "widget", false)];
|
||||
let root = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
|
||||
let rules = rules_for(&[("r1", &root)]);
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
Some("did:key:z6MkStranger"),
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
assert!(ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
Some("did:key:z6MkFriend"),
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_alias_full_did_slug_dropped_for_anon() {
|
||||
// Owner stored full-DID; slug also carries the full-DID form. Still
|
||||
// matches (row_key normalizes to the short key) → drop. Round 1's
|
||||
// string-match would have leaked this.
|
||||
let deduped = [rec("r1", "did:key:zABC", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"did:key:zABC/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_truncated_key_slug_dropped_for_anon() {
|
||||
// Slug carries an 8-char URL-truncated prefix of the owner key; still
|
||||
// matches via prefix tolerance → drop. Round 2's get_repo path leaked.
|
||||
let deduped = [rec("r1", "did:key:zABCDEFGH", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"zABCDEF/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_mirror_coexistence_private_canonical_dropped_for_anon() {
|
||||
// Pure-level mirror-coexistence: the deduped set contains only the
|
||||
// private canonical record for (owner,name). A matching slug drops for
|
||||
// anon. (DB-level dedup survivor property is pinned separately.)
|
||||
let deduped = [rec("uuid-canonical", "did:key:z6Mkwbud", "nipmod", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"z6Mkwbud/nipmod"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_empty_owner_slug_matches_and_drops() {
|
||||
// Slug "/name": empty owner_part → row_key "" → starts_with("") matches
|
||||
// every same-named record. Fail-safe pin for a private repo.
|
||||
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(&deduped, &rules, None, "/widget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_one_char_owner_slug_matches_and_drops() {
|
||||
// 1-char owner prefix that the private repo's key starts with → match.
|
||||
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(&deduped, &rules, None, "z/widget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_remote_slug_no_match_kept() {
|
||||
// Different owner key, no prefix relation → no local match → keep.
|
||||
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"zZZZOTHER/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_private_didweb_short_slug_dropped_for_anon() {
|
||||
// A private did:web repo must drop its row for anon when the slug arrives
|
||||
// in the short `{last-segment}/{name}` form. Post-#141 the emitter no longer
|
||||
// produces this form for did:web (normalize_owner_key keeps the full DID),
|
||||
// so this is the untrusted/crafted variant a peer can broadcast; the full-DID
|
||||
// variant the emitter now sends is covered by the next test. A did:key-aware
|
||||
// (keep-whole) rule would fail to match `alice` against `did:web:host:alice`
|
||||
// and leak.
|
||||
let deduped = [rec("r1", "did:web:host:alice", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"alice/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_multi_segment_did_slug_dropped_for_anon() {
|
||||
// A private repo owned by a multi-segment DID must also fail closed under
|
||||
// the full-DID slug form. Post-#141 this IS the form the emitter broadcasts
|
||||
// (normalize_owner_key keeps non-did:key methods whole), and a peer can craft
|
||||
// it too. The gate drops it: both sides reduce to the last ':' segment
|
||||
// ("user"), so they match. Fail-safe regardless of which form keyed the row.
|
||||
let deduped = [rec("r1", "did:web:host:user", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(!ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"did:web:host:user/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_multi_segment_did_slug_kept_for_public() {
|
||||
// Symmetric keep-side: a PUBLIC multi-segment-DID repo's row must still be
|
||||
// returned to anon after the last-':'-segment normalization — guards
|
||||
// against a regression that over-drops legitimate did:web rows.
|
||||
let deduped = [rec("r1", "did:web:host:user", "widget", true)];
|
||||
let rules = HashMap::new();
|
||||
assert!(ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"did:web:host:user/widget"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_malformed_slug_no_slash_kept_no_panic() {
|
||||
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
|
||||
let rules = HashMap::new();
|
||||
assert!(ref_update_row_visible(&deduped, &rules, None, "noslug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_empty_deduped_set_keeps_any_slug() {
|
||||
let deduped: [RepoRecord; 0] = [];
|
||||
let rules = HashMap::new();
|
||||
assert!(ref_update_row_visible(
|
||||
&deduped,
|
||||
&rules,
|
||||
None,
|
||||
"z6MkOwner/widget"
|
||||
));
|
||||
assert!(ref_update_row_visible(&deduped, &rules, None, "anything"));
|
||||
}
|
||||
|
||||
// The shared slug matcher underpinning both the feed gate and the collector's
|
||||
// quarantine drop: it must recognize a repo's own row across every owner-DID
|
||||
// form (bare short key, full did:key, URL-truncated prefix) and must not
|
||||
// over-match a different name or an unrelated owner. The quarantine drop calls
|
||||
// this directly, so its match contract is load-bearing for withholding a
|
||||
// quarantined mirror from a caller who matches the mirror's owner_did.
|
||||
#[test]
|
||||
fn names_repo_matches_owner_forms_and_rejects_mismatches() {
|
||||
// Bare short owner key (the form upsert_mirror_repo stores).
|
||||
let bare = rec("m", "z6MkQuar", "secret", false);
|
||||
assert!(ref_update_row_names_repo(&bare, "z6MkQuar/secret"));
|
||||
// Full did:key owner; the short-segment slug still matches via last-segment.
|
||||
let full = rec("c", "did:key:z6MkQuar", "secret", false);
|
||||
assert!(ref_update_row_names_repo(&full, "z6MkQuar/secret"));
|
||||
assert!(ref_update_row_names_repo(&full, "did:key:z6MkQuar/secret"));
|
||||
// URL-truncated 8-char prefix slug → prefix-tolerant match.
|
||||
let long = rec("l", "did:key:z6MkQuarLONGKEY", "secret", false);
|
||||
assert!(ref_update_row_names_repo(&long, "z6MkQuar/secret"));
|
||||
// Different name, different owner, and no-slash → no match.
|
||||
assert!(!ref_update_row_names_repo(&bare, "z6MkQuar/other"));
|
||||
assert!(!ref_update_row_names_repo(&bare, "z6MkOther/secret"));
|
||||
assert!(!ref_update_row_names_repo(&bare, "noslash"));
|
||||
}
|
||||
|
||||
// #101: a deny rule must withhold a path that denotes the same characters in
|
||||
// a different Unicode normalization form. Without NFC normalization at the
|
||||
// matcher seam, an NFC-authored rule byte-compares unequal to an NFD-stored
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version = "0.5.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
icaptcha-client = { path = "../icaptcha-client" }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -174,6 +174,19 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
"node",
|
||||
format!("{} — v{version} ({short_did}…)", args.node),
|
||||
));
|
||||
// Capability drift: a node newer than this CLI may require features
|
||||
// (RFC 9421 signing, iCaptcha) the CLI doesn't speak.
|
||||
let gl_ver = env!("CARGO_PKG_VERSION");
|
||||
if version != "?" && is_newer(version, gl_ver) {
|
||||
checks.push(Check::warn(
|
||||
"gl version",
|
||||
format!(
|
||||
"node is v{version} but gl is v{gl_ver} — your CLI may be missing \
|
||||
features (signing / iCaptcha) this node requires"
|
||||
),
|
||||
"upgrade gl: curl -sSf https://gitlawb.com/install.sh | sh",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
checks.push(Check::fail(
|
||||
@@ -191,6 +204,31 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4b. iCaptcha capability ───────────────────────────────────────────
|
||||
// Gated writes (repo create / register / fork) auto-solve a challenge at the
|
||||
// iCaptcha service; check it's reachable so the failure mode is obvious.
|
||||
let icaptcha_url = std::env::var("GITLAWB_ICAPTCHA_URL")
|
||||
.unwrap_or_else(|_| icaptcha_client::DEFAULT_URL.to_string());
|
||||
match NodeClient::new(&icaptcha_url, None).get("/v1/pubkey").await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
checks.push(Check::pass("iCaptcha", format!("{icaptcha_url} reachable")));
|
||||
}
|
||||
Ok(resp) => {
|
||||
checks.push(Check::warn(
|
||||
"iCaptcha",
|
||||
format!("{icaptcha_url} returned HTTP {}", resp.status()),
|
||||
"gated writes (repo create / register) may fail until iCaptcha is reachable",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(Check::warn(
|
||||
"iCaptcha",
|
||||
format!("{icaptcha_url} unreachable: {e}"),
|
||||
"set GITLAWB_ICAPTCHA_URL or check connectivity — repo create / register solve a challenge there",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. git-remote-gitlawb helper ──────────────────────────────────────
|
||||
// Use PATH lookup only — invoking the binary directly triggers git internals
|
||||
// that error with "fatal: not a git repository" outside of a git repo.
|
||||
|
||||
+102
-38
@@ -1,8 +1,19 @@
|
||||
//! Signed HTTP client for gitlawb API calls (async).
|
||||
//!
|
||||
//! Writes are signed with RFC 9421 HTTP Signatures. When the node gates a write
|
||||
//! behind iCaptcha (HTTP 403 `icaptcha_proof_required`, advertised via the
|
||||
//! `x-icaptcha-url` / `x-icaptcha-level` headers), the client transparently
|
||||
//! solves the challenge and retries the same signed request with the
|
||||
//! `x-icaptcha-proof` header — see `crates/icaptcha-client`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use icaptcha_client::IcaptchaCfg;
|
||||
|
||||
/// Max times we'll fetch a fresh proof and retry a 403-iCaptcha response
|
||||
/// (absorbs proof expiry / first-seen replay).
|
||||
const MAX_ICAPTCHA_RETRIES: usize = 2;
|
||||
|
||||
pub struct NodeClient {
|
||||
inner: reqwest::Client,
|
||||
@@ -52,63 +63,116 @@ impl NodeClient {
|
||||
req.send().await.with_context(|| format!("GET {url}"))
|
||||
}
|
||||
|
||||
/// POST with JSON body + RFC 9421 HTTP Signature auth.
|
||||
/// POST with JSON body + RFC 9421 signing + transparent iCaptcha solve/retry.
|
||||
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
|
||||
let url = format!("{}{}", self.node_url, path);
|
||||
let mut req = self
|
||||
.inner
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_vec());
|
||||
|
||||
if let Some(kp) = &self.keypair {
|
||||
let signed = sign_request(kp, "POST", path, body);
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
}
|
||||
|
||||
req.send().await.with_context(|| format!("POST {url}"))
|
||||
self.send_signed("POST", path, body).await
|
||||
}
|
||||
|
||||
/// PUT with RFC 9421 HTTP Signature auth (idempotent write).
|
||||
/// PUT with RFC 9421 signing + transparent iCaptcha solve/retry.
|
||||
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
|
||||
let url = format!("{}{}", self.node_url, path);
|
||||
let mut req = self
|
||||
.inner
|
||||
.put(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_vec());
|
||||
|
||||
if let Some(kp) = &self.keypair {
|
||||
let signed = sign_request(kp, "PUT", path, body);
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
}
|
||||
|
||||
req.send().await.with_context(|| format!("PUT {url}"))
|
||||
self.send_signed("PUT", path, body).await
|
||||
}
|
||||
|
||||
/// DELETE with RFC 9421 HTTP Signature auth.
|
||||
/// DELETE with RFC 9421 signing + transparent iCaptcha solve/retry.
|
||||
pub async fn delete(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
|
||||
self.send_signed("DELETE", path, body).await
|
||||
}
|
||||
|
||||
/// Sign + send a write. On a 403 iCaptcha challenge (detected via the
|
||||
/// `x-icaptcha-*` headers) solve it and retry the same signed request with
|
||||
/// the proof header, up to [`MAX_ICAPTCHA_RETRIES`]. Emits an actionable
|
||||
/// hint on a 401 "not an agent" (the old-CLI / unregistered failure mode).
|
||||
async fn send_signed(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: &[u8],
|
||||
) -> Result<reqwest::Response> {
|
||||
let mut proof: Option<String> = None;
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
let resp = self.send_once(method, path, body, proof.as_deref()).await?;
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& resp
|
||||
.headers()
|
||||
.get("x-gitlawb-error")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
== Some("human_detected")
|
||||
{
|
||||
eprintln!(
|
||||
"note: this node requires signed requests (RFC 9421). If writes keep \
|
||||
failing, your `gl` may be too old — upgrade it — or you're not registered: \
|
||||
run `gl register`."
|
||||
);
|
||||
}
|
||||
|
||||
if status == reqwest::StatusCode::FORBIDDEN && attempts < MAX_ICAPTCHA_RETRIES {
|
||||
if let Some(cfg) = self.icaptcha_cfg(resp.headers())? {
|
||||
attempts += 1;
|
||||
proof = Some(obtain_proof(cfg).await?);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build, sign, and send one request, optionally attaching a proof header.
|
||||
async fn send_once(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: &[u8],
|
||||
proof: Option<&str>,
|
||||
) -> Result<reqwest::Response> {
|
||||
let url = format!("{}{}", self.node_url, path);
|
||||
let mut req = self
|
||||
.inner
|
||||
.delete(&url)
|
||||
.request(method.parse().expect("valid HTTP method"), &url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_vec());
|
||||
|
||||
if let Some(kp) = &self.keypair {
|
||||
let signed = sign_request(kp, "DELETE", path, body);
|
||||
let signed = sign_request(kp, method, path, body);
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
}
|
||||
if let Some(p) = proof {
|
||||
req = req.header(icaptcha_client::PROOF_HEADER, p);
|
||||
}
|
||||
|
||||
req.send().await.with_context(|| format!("DELETE {url}"))
|
||||
req.send().await.with_context(|| format!("{method} {url}"))
|
||||
}
|
||||
|
||||
/// If `headers` describe an iCaptcha 403, build the solve config (binding the
|
||||
/// proof's `sub` to our DID). Returns `None` for a non-iCaptcha 403.
|
||||
fn icaptcha_cfg(&self, headers: &reqwest::header::HeaderMap) -> Result<Option<IcaptchaCfg>> {
|
||||
let url = headers.get("x-icaptcha-url").and_then(|v| v.to_str().ok());
|
||||
let level = headers
|
||||
.get("x-icaptcha-level")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
if url.is_none() && level.is_none() {
|
||||
return Ok(None); // not an iCaptcha challenge
|
||||
}
|
||||
let kp = self
|
||||
.keypair
|
||||
.as_ref()
|
||||
.context("iCaptcha challenge requires an identity keypair (run `gl identity new`)")?;
|
||||
Ok(Some(IcaptchaCfg::new(
|
||||
kp.did().to_string(),
|
||||
url.map(str::to_string),
|
||||
level.and_then(|l| l.parse().ok()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the (blocking) iCaptcha solve loop off the async runtime.
|
||||
async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
tokio::task::spawn_blocking(move || icaptcha_client::obtain_proof(&cfg, None))
|
||||
.await
|
||||
.context("iCaptcha solver task panicked")?
|
||||
}
|
||||
+137
-9
@@ -3,6 +3,8 @@
|
||||
//! Communicates with the gitlawb node to list pinned CIDs and retrieve git
|
||||
//! objects by their content-addressed CID.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
@@ -21,6 +23,9 @@ pub enum IpfsCmd {
|
||||
List {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Retrieve and display a git object from the node by its CIDv1
|
||||
Get {
|
||||
@@ -33,19 +38,24 @@ pub enum IpfsCmd {
|
||||
|
||||
pub async fn run(args: IpfsArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
IpfsCmd::List { node } => cmd_list(node).await,
|
||||
IpfsCmd::List { node, dir } => cmd_list(node, dir).await,
|
||||
IpfsCmd::Get { cid, node } => cmd_get(cid, node).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_list(node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let resp: Value = client
|
||||
.get("/api/v1/ipfs/pins")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse pins response")?;
|
||||
async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
// #134 gates /api/v1/ipfs/pins behind auth: sign the request with the
|
||||
// caller's identity. On no identity, propagate load_keypair_from_dir's
|
||||
// error (it already names `gl identity new`) rather than a bare 401.
|
||||
let keypair = crate::identity::load_keypair_from_dir(dir.as_deref())?;
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
let resp = client.get_signed("/api/v1/ipfs/pins").await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("node returned {status} for pins listing: {body}");
|
||||
}
|
||||
let resp: Value = resp.json().await.context("failed to parse pins response")?;
|
||||
|
||||
let pins = resp["pins"].as_array().cloned().unwrap_or_default();
|
||||
let count = resp["count"].as_u64().unwrap_or(pins.len() as u64);
|
||||
@@ -108,3 +118,121 @@ async fn cmd_get(cid: String, node: String) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Seed a keypair into a temp dir the way `load_keypair_from_dir` expects,
|
||||
/// then return the dir handle (keeps it alive for the test's duration).
|
||||
fn seed_keystore() -> tempfile::TempDir {
|
||||
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();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_signs_request_and_renders_pins() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let keystore = seed_keystore();
|
||||
|
||||
// Happy path: signed GET to /api/v1/ipfs/pins carrying the RFC 9421
|
||||
// signature headers, node returns a populated pins body.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.match_header("content-digest", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"pins":[{"cid":"bafyone","sha256_hex":"abc123","pinned_at":"2026-07-02T12:00:00.123456Z"}],"count":1}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(server.url(), Some(keystore.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_empty_pins() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let keystore = seed_keystore();
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"pins":[],"count":0}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(server.url(), Some(keystore.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_no_identity_errors_without_request() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Empty keystore dir: no identity.pem present.
|
||||
let empty = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// The endpoint must never be hit when there is no identity.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), Some(empty.path().to_path_buf()))
|
||||
.await
|
||||
.expect_err("no identity should be an error");
|
||||
assert!(
|
||||
err.to_string().contains("gl identity new")
|
||||
|| err.to_string().contains("no identity found"),
|
||||
"error should name `gl identity new`, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_non_success_status_is_error_not_empty() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let keystore = seed_keystore();
|
||||
|
||||
// A signed request the node rejects (401) must surface as an error,
|
||||
// not be silently parsed into an empty pin list.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"unauthorized"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), Some(keystore.path().to_path_buf()))
|
||||
.await
|
||||
.expect_err("non-2xx status should be an error");
|
||||
assert!(
|
||||
err.to_string().contains("401"),
|
||||
"error should mention the status, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+379
-12
@@ -2,10 +2,12 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
use crate::node_stake;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -20,6 +22,9 @@ pub enum NodeCmd {
|
||||
Status {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Check trust score for a DID
|
||||
Trust {
|
||||
@@ -121,7 +126,7 @@ pub enum NodeCmd {
|
||||
|
||||
pub async fn run(args: NodeArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
NodeCmd::Status { node } => cmd_status(node).await,
|
||||
NodeCmd::Status { node, dir } => cmd_status(node, dir).await,
|
||||
NodeCmd::Trust { did, node } => cmd_trust(did, node).await,
|
||||
NodeCmd::Resolve { did, node } => cmd_resolve(did, node).await,
|
||||
NodeCmd::Register {
|
||||
@@ -177,7 +182,101 @@ async fn try_get_json(client: &NodeClient, path: &str) -> Option<Value> {
|
||||
resp.json::<Value>().await.ok()
|
||||
}
|
||||
|
||||
async fn cmd_status(node: String) -> Result<()> {
|
||||
/// Outcome of fetching the IPFS pins panel for `gl node status`.
|
||||
///
|
||||
/// #134 gates `/api/v1/ipfs/pins` behind auth, so this panel signs its request
|
||||
/// when an identity is available and otherwise reports that the caller must
|
||||
/// sign in. A pins failure never aborts the dashboard.
|
||||
#[derive(Debug)]
|
||||
enum PinsPanel {
|
||||
/// Signed read succeeded and returned pins (carries the resolved count).
|
||||
Pins(u64),
|
||||
/// Signed read succeeded but the node has no pins recorded.
|
||||
Empty,
|
||||
/// Signed read was rejected (401/other) or errored.
|
||||
Unavailable,
|
||||
/// No identity available; no request was issued.
|
||||
NeedsIdentity,
|
||||
/// An explicit `--dir` was given but held no usable identity; carries the dir
|
||||
/// so the panel can name it instead of the misleading "sign in" prompt.
|
||||
IdentityError(PathBuf),
|
||||
}
|
||||
|
||||
/// Identity resolved for the signed pins read, decided before the request so the
|
||||
/// dashboard can distinguish "no identity at all" from "an explicit `--dir` that
|
||||
/// could not be loaded".
|
||||
enum PinsAuth {
|
||||
/// A usable keypair; the read will be signed.
|
||||
Keyed(Keypair),
|
||||
/// No identity requested and none in the default keystore.
|
||||
Anonymous,
|
||||
/// An explicit `--dir` was given but held no usable identity (carries the dir).
|
||||
DirUnusable(PathBuf),
|
||||
}
|
||||
|
||||
/// Resolve the pins-panel identity from an optional `--dir`. Mirrors `gl ipfs
|
||||
/// list`'s identity selection: an explicit `--dir` that fails to load is reported
|
||||
/// as `DirUnusable` (so the panel names the bad path) rather than collapsing to
|
||||
/// the misleading "sign in to view"; a missing default keystore (no `--dir`)
|
||||
/// degrades quietly to `Anonymous`.
|
||||
fn resolve_pins_auth(dir: Option<&std::path::Path>) -> PinsAuth {
|
||||
match load_keypair_from_dir(dir) {
|
||||
Ok(kp) => PinsAuth::Keyed(kp),
|
||||
Err(_) => match dir {
|
||||
Some(d) => PinsAuth::DirUnusable(d.to_path_buf()),
|
||||
None => PinsAuth::Anonymous,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the pins panel state. With a keypair, signs the `/api/v1/ipfs/pins`
|
||||
/// read and maps the outcome; with no identity it returns `NeedsIdentity`, and
|
||||
/// with an unusable explicit `--dir` it returns `IdentityError` — both without
|
||||
/// issuing a request. Injectable (node URL + resolved auth) so tests drive it
|
||||
/// with a mock server and never touch the default keystore.
|
||||
async fn fetch_pins(node: &str, auth: PinsAuth) -> PinsPanel {
|
||||
let kp = match auth {
|
||||
PinsAuth::Keyed(kp) => kp,
|
||||
PinsAuth::Anonymous => return PinsPanel::NeedsIdentity,
|
||||
PinsAuth::DirUnusable(dir) => return PinsPanel::IdentityError(dir),
|
||||
};
|
||||
let client = NodeClient::new(node, Some(kp));
|
||||
let resp = match client.get_signed("/api/v1/ipfs/pins").await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return PinsPanel::Unavailable,
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return PinsPanel::Unavailable;
|
||||
}
|
||||
let Ok(body) = resp.json::<Value>().await else {
|
||||
return PinsPanel::Unavailable;
|
||||
};
|
||||
let count = body["count"]
|
||||
.as_u64()
|
||||
.unwrap_or_else(|| body["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0));
|
||||
if count == 0 {
|
||||
PinsPanel::Empty
|
||||
} else {
|
||||
PinsPanel::Pins(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the one-line pins-panel status for the `gl node status` dashboard.
|
||||
fn pins_status_line(panel: &PinsPanel) -> String {
|
||||
match panel {
|
||||
PinsPanel::Pins(count) => format!(" Pinned CIDs: {count}"),
|
||||
PinsPanel::Empty => " Pinned CIDs: 0".to_string(),
|
||||
PinsPanel::Unavailable => " IPFS pins: unavailable".to_string(),
|
||||
PinsPanel::NeedsIdentity => {
|
||||
" IPFS pins: sign in to view (run `gl identity new`)".to_string()
|
||||
}
|
||||
PinsPanel::IdentityError(dir) => {
|
||||
format!(" IPFS pins: no usable identity in {}", dir.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
// ── Fetch node info (required — bail if unreachable) ──────────────────
|
||||
@@ -194,13 +293,21 @@ async fn cmd_status(node: String) -> Result<()> {
|
||||
let version = info["version"].as_str().unwrap_or("unknown");
|
||||
let network = info["network"].as_str().unwrap_or("unknown");
|
||||
|
||||
// The pins panel signs its read (#134 gates /api/v1/ipfs/pins behind auth).
|
||||
// `--dir` selects the same identity directory as `gl ipfs list` (#146); an
|
||||
// explicit --dir that can't be loaded is surfaced in the panel rather than
|
||||
// masquerading as "no identity", while a missing default keystore degrades
|
||||
// quietly. A pins failure never aborts the dashboard.
|
||||
let pins_auth = resolve_pins_auth(dir.as_deref());
|
||||
|
||||
// ── Fetch remaining endpoints in parallel ─────────────────────────────
|
||||
let (peers_val, repos_val, p2p_val, events_val, pins_val) = tokio::join!(
|
||||
// Peers/repos/p2p/events stay anonymous; only pins is signed.
|
||||
let (peers_val, repos_val, p2p_val, events_val, pins_panel) = tokio::join!(
|
||||
try_get_json(&client, "/api/v1/peers"),
|
||||
try_get_json(&client, "/api/v1/repos"),
|
||||
try_get_json(&client, "/api/v1/p2p/info"),
|
||||
try_get_json(&client, "/api/v1/events/ref-updates?limit=5"),
|
||||
try_get_json(&client, "/api/v1/ipfs/pins"),
|
||||
fetch_pins(&node, pins_auth),
|
||||
);
|
||||
|
||||
// ── Render dashboard ──────────────────────────────────────────────────
|
||||
@@ -307,14 +414,7 @@ async fn cmd_status(node: String) -> Result<()> {
|
||||
|
||||
// Pins
|
||||
println!("Pins");
|
||||
if let Some(ref pins) = pins_val {
|
||||
let count = pins["count"]
|
||||
.as_u64()
|
||||
.unwrap_or_else(|| pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0));
|
||||
println!(" Pinned CIDs: {count}");
|
||||
} else {
|
||||
println!(" IPFS not configured");
|
||||
}
|
||||
println!("{}", pins_status_line(&pins_panel));
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
@@ -409,3 +509,270 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_keyed_happy_signs_and_returns_pins() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let kp = Keypair::generate();
|
||||
|
||||
// A keyed fetch must sign the request (RFC 9421 headers) and, on a
|
||||
// populated 200 body, land in the Pins state carrying the pins.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.match_header("content-digest", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"pins":[{"cid":"bafyone","sha256_hex":"abc123","pinned_at":"2026-07-02T12:00:00Z"}],"count":1}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
|
||||
match panel {
|
||||
PinsPanel::Pins(count) => assert_eq!(count, 1),
|
||||
other => panic!("expected Pins, got {other:?}"),
|
||||
}
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_keyed_empty_returns_empty() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let kp = Keypair::generate();
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"pins":[],"count":0}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
|
||||
assert!(
|
||||
matches!(panel, PinsPanel::Empty),
|
||||
"expected Empty, got {panel:?}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_keyed_rejected_returns_unavailable() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let kp = Keypair::generate();
|
||||
|
||||
// Node rejects the signed read (401): the panel must degrade to
|
||||
// Unavailable without panicking, so cmd_status still completes.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"unauthorized"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
|
||||
assert!(
|
||||
matches!(panel, PinsPanel::Unavailable),
|
||||
"expected Unavailable, got {panel:?}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_unkeyed_needs_identity_without_request() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
// With no keypair the endpoint must never be hit.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let panel = fetch_pins(&server.url(), PinsAuth::Anonymous).await;
|
||||
assert!(
|
||||
matches!(panel, PinsPanel::NeedsIdentity),
|
||||
"expected NeedsIdentity, got {panel:?}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_malformed_body_returns_unavailable() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let kp = Keypair::generate();
|
||||
|
||||
// 2xx but the body is not valid JSON: must degrade to Unavailable,
|
||||
// never panic.
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("not json{{{")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
|
||||
assert!(
|
||||
matches!(panel, PinsPanel::Unavailable),
|
||||
"malformed body -> Unavailable, got {panel:?}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_transport_error_returns_unavailable() {
|
||||
// Bind then drop to obtain a definitely-closed port -> connection
|
||||
// refused -> get_signed Err -> Unavailable (no panic).
|
||||
let port = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let kp = Keypair::generate();
|
||||
|
||||
let panel = fetch_pins(&format!("http://127.0.0.1:{port}"), PinsAuth::Keyed(kp)).await;
|
||||
assert!(
|
||||
matches!(panel, PinsPanel::Unavailable),
|
||||
"transport error -> Unavailable, got {panel:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pins_status_line_renders_each_state() {
|
||||
assert_eq!(pins_status_line(&PinsPanel::Pins(3)), " Pinned CIDs: 3");
|
||||
assert_eq!(pins_status_line(&PinsPanel::Empty), " Pinned CIDs: 0");
|
||||
assert_eq!(
|
||||
pins_status_line(&PinsPanel::Unavailable),
|
||||
" IPFS pins: unavailable"
|
||||
);
|
||||
assert_eq!(
|
||||
pins_status_line(&PinsPanel::NeedsIdentity),
|
||||
" IPFS pins: sign in to view (run `gl identity new`)"
|
||||
);
|
||||
assert_eq!(
|
||||
pins_status_line(&PinsPanel::IdentityError(PathBuf::from("/tmp/id"))),
|
||||
" IPFS pins: no usable identity in /tmp/id"
|
||||
);
|
||||
}
|
||||
|
||||
// Adversarial follow-up to jatmn #146 P2: an explicit `--dir` that holds no
|
||||
// usable identity must resolve to DirUnusable (which names the path), NOT the
|
||||
// Anonymous "sign in to view / run `gl identity new`" case — otherwise a user
|
||||
// who did supply an identity dir is misdirected to create one they may have.
|
||||
#[test]
|
||||
fn resolve_pins_auth_reports_explicit_bad_dir_as_unusable() {
|
||||
let bad = PathBuf::from("/nonexistent/gl-id-xyz");
|
||||
match resolve_pins_auth(Some(&bad)) {
|
||||
PinsAuth::DirUnusable(d) => assert_eq!(d, bad),
|
||||
_ => panic!("explicit unusable --dir must be DirUnusable, not Anonymous"),
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of the above for the absent-`--dir` case: `None` must never resolve to
|
||||
// DirUnusable — it degrades to Anonymous (no default keystore) or Keyed (one
|
||||
// present). Env-independent: only the None->DirUnusable mapping is guarded, so it
|
||||
// holds whether or not the runner has a default `~/.gitlawb` identity.
|
||||
#[test]
|
||||
fn resolve_pins_auth_none_is_never_dir_unusable() {
|
||||
assert!(
|
||||
!matches!(resolve_pins_auth(None), PinsAuth::DirUnusable(_)),
|
||||
"absent --dir must degrade to Anonymous (or Keyed), never DirUnusable"
|
||||
);
|
||||
}
|
||||
|
||||
// The success wiring for #146: an explicit --dir with a valid identity must
|
||||
// load that key (dir -> load_keypair_from_dir -> Keyed), so the pins read is
|
||||
// signed with the selected identity rather than the default keystore.
|
||||
#[test]
|
||||
fn resolve_pins_auth_loads_keyed_identity_from_explicit_dir() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let PinsAuth::Keyed(loaded) = resolve_pins_auth(Some(dir.path())) else {
|
||||
panic!("an explicit --dir with a valid identity must resolve to Keyed");
|
||||
};
|
||||
// A bare `Keyed(_)` match would also pass if load fell through to the default
|
||||
// keystore; assert the loaded identity is the one written to --dir so the pins
|
||||
// read signs with the selected key, never the default.
|
||||
assert_eq!(
|
||||
loaded.did(),
|
||||
kp.did(),
|
||||
"must sign with the identity from --dir, not the default keystore"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_pins_dir_unusable_reports_identity_error_without_request() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// An unusable explicit --dir must render the identity error and never hit
|
||||
// the endpoint (no identity to sign with).
|
||||
let m = server
|
||||
.mock("GET", "/api/v1/ipfs/pins")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let bad = PathBuf::from("/nonexistent/gl-id-xyz");
|
||||
let panel = fetch_pins(&server.url(), PinsAuth::DirUnusable(bad.clone())).await;
|
||||
match panel {
|
||||
PinsPanel::IdentityError(d) => assert_eq!(d, bad),
|
||||
other => panic!("expected IdentityError, got {other:?}"),
|
||||
}
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
// jatmn #146 P2: `gl node status` must accept the same `--dir` identity
|
||||
// selector as `gl ipfs list`, so a user who selected an identity via --dir can
|
||||
// authenticate the status pins panel instead of seeing "sign in to view".
|
||||
#[test]
|
||||
fn status_accepts_dir_identity_selector() {
|
||||
use clap::Parser;
|
||||
#[derive(Parser)]
|
||||
struct TestCli {
|
||||
#[command(flatten)]
|
||||
args: NodeArgs,
|
||||
}
|
||||
let cli = TestCli::try_parse_from([
|
||||
"gl",
|
||||
"status",
|
||||
"--node",
|
||||
"http://example",
|
||||
"--dir",
|
||||
"/tmp/id",
|
||||
])
|
||||
.expect("`node status` must accept --dir");
|
||||
let NodeCmd::Status { dir, .. } = cli.args.cmd else {
|
||||
panic!("expected the Status subcommand");
|
||||
};
|
||||
assert_eq!(dir, Some(PathBuf::from("/tmp/id")));
|
||||
|
||||
// Absent --dir must stay None so the default keystore path is unchanged.
|
||||
let cli = TestCli::try_parse_from(["gl", "status"]).expect("status parses without --dir");
|
||||
let NodeCmd::Status { dir, .. } = cli.args.cmd else {
|
||||
panic!("expected the Status subcommand");
|
||||
};
|
||||
assert_eq!(dir, None);
|
||||
}
|
||||
}
|
||||
+41
-30
@@ -208,20 +208,16 @@ pub async fn run(args: RepoArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Derive the short DID key segment from a keypair, or fall back to the node's DID.
|
||||
async fn resolve_owner_did(node: &str, dir: Option<&std::path::Path>) -> Result<String> {
|
||||
if let Ok(kp) = load_keypair_from_dir(dir) {
|
||||
let did = kp.did().to_string();
|
||||
return Ok(did.split(':').next_back().unwrap_or(&did).to_string());
|
||||
}
|
||||
let client = NodeClient::new(node, None);
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let did = info["did"].as_str().context("node missing DID")?;
|
||||
Ok(did.split(':').next_back().unwrap_or(did).to_string())
|
||||
/// Resolve the owner short-DID for a bare repo name from the LOCAL identity.
|
||||
/// Never falls back to the node's own DID (that produced bogus "owned by the
|
||||
/// node" results for repos that don't exist) — if there's no local identity the
|
||||
/// caller must pass an explicit `owner/name`.
|
||||
async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result<String> {
|
||||
let kp = load_keypair_from_dir(dir).context(
|
||||
"no local identity to resolve the repo owner — pass `owner/name`, or run `gl identity new`",
|
||||
)?;
|
||||
let did = kp.did().to_string();
|
||||
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
|
||||
}
|
||||
|
||||
async fn cmd_create(
|
||||
@@ -306,25 +302,28 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn cmd_clone(name: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let did = if let Ok(kp) = load_keypair_from_dir(dir.as_deref()) {
|
||||
kp.did().to_string()
|
||||
// Owner is taken from an explicit `owner/name`, else the LOCAL identity —
|
||||
// never the node's own DID. A missing repo then surfaces as the helper's
|
||||
// clear 404 rather than a clone under an invented owner.
|
||||
let (did, repo_name) = if let Some((owner, rest)) = name.split_once('/') {
|
||||
(owner.to_string(), rest.to_string())
|
||||
} else {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let info: Value = client.get("/").await?.json().await?;
|
||||
info["did"]
|
||||
.as_str()
|
||||
.context("node missing DID")?
|
||||
.to_string()
|
||||
// Bare name: derive the SHORT owner key via the same helper the other
|
||||
// commands use, so the URL is `gitlawb://z6Mk.../name` — not the full
|
||||
// `did:key:z6Mk...` form, whose colons in the authority break the helper.
|
||||
(resolve_owner_did(&node, dir.as_deref()).await?, name)
|
||||
};
|
||||
let url = format!("gitlawb://{did}/{name}");
|
||||
let url = format!("gitlawb://{did}/{repo_name}");
|
||||
println!(" cloning {url}");
|
||||
let status = std::process::Command::new("git")
|
||||
.arg("clone")
|
||||
.arg(&url)
|
||||
// Point the remote helper at the same node the user selected.
|
||||
.env("GITLAWB_NODE", &node)
|
||||
.status()
|
||||
.context("failed to run git clone — is git installed?")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("git clone failed");
|
||||
anyhow::bail!("git clone failed — does the repo exist on this node?");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -340,12 +339,24 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
(short, repo)
|
||||
};
|
||||
|
||||
let r: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("repo not found")?;
|
||||
let resp = client.get(&format!("/api/v1/repos/{owner}/{name}")).await?;
|
||||
// A non-existent (or unreadable/quarantined) repo is a real 404 from the
|
||||
// node — surface it plainly instead of printing a stub card with `?` fields
|
||||
// and a placeholder owner DID.
|
||||
if !resp.status().is_success() {
|
||||
if resp.status().as_u16() == 404 {
|
||||
anyhow::bail!("repository '{owner}/{name}' not found");
|
||||
}
|
||||
let status = resp.status();
|
||||
let msg = resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v["message"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "request failed".to_string());
|
||||
anyhow::bail!("repo info failed ({status}): {msg}");
|
||||
}
|
||||
let r: Value = resp.json().await.context("parse repo info")?;
|
||||
|
||||
let owner_did = r["owner_did"].as_str().unwrap_or(&owner);
|
||||
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "icaptcha-client"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,406 @@
|
||||
//! Client for the iCaptcha proof-of-intelligence service.
|
||||
//!
|
||||
//! The node gates spam-prone writes (repo create / fork / register) behind an
|
||||
//! iCaptcha proof. This crate implements the *sanctioned client flow*: on a
|
||||
//! `403 icaptcha_proof_required`, request a challenge for the required level,
|
||||
//! solve the deterministic computational types locally, obtain the signed
|
||||
//! proof, and hand it back so the caller can retry the original signed request
|
||||
//! with the `x-icaptcha-proof` header.
|
||||
//!
|
||||
//! `requesterId` is always the caller's DID, so the proof's `sub` claim matches
|
||||
//! the authenticated signer (the node enforces `sub == authenticated DID`).
|
||||
//!
|
||||
//! Blocking HTTP (reqwest::blocking) so the git remote helper can use it
|
||||
//! directly; `gl` (async) calls it via `tokio::task::spawn_blocking`.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub mod solvers;
|
||||
|
||||
/// Default iCaptcha service base URL (used when the node doesn't advertise one).
|
||||
pub const DEFAULT_URL: &str = "https://icaptcha.gitlawb.com";
|
||||
/// Default required level (the node's default floor).
|
||||
pub const DEFAULT_LEVEL: u32 = 3;
|
||||
/// Header the gated write must echo the proof back in.
|
||||
pub const PROOF_HEADER: &str = "x-icaptcha-proof";
|
||||
|
||||
/// Cap on bytes read from an error response, and characters kept in the excerpt.
|
||||
/// The iCaptcha origin is only as trusted as the node that advertised it (a MITM
|
||||
/// on the default transport qualifies), so its error bodies are attacker-
|
||||
/// influenceable bytes that reach the TTY — they must be bounded and stripped of
|
||||
/// control characters before printing (CWE-150, same treatment as #137).
|
||||
const MAX_ERR_BYTES: u64 = 4096;
|
||||
const MAX_ERR_CHARS: usize = 500;
|
||||
|
||||
/// Read a bounded error body and return a control-char-stripped, length-capped
|
||||
/// single-line excerpt safe to print to a terminal.
|
||||
fn error_excerpt(resp: reqwest::blocking::Response) -> String {
|
||||
let mut buf = Vec::new();
|
||||
if resp.take(MAX_ERR_BYTES).read_to_end(&mut buf).is_err() {
|
||||
return String::new();
|
||||
}
|
||||
sanitize_excerpt(&String::from_utf8_lossy(&buf))
|
||||
}
|
||||
|
||||
/// Strip C0/C1 control characters, collapse whitespace runs to single spaces,
|
||||
/// and cap the length — so a hostile response body cannot inject terminal
|
||||
/// escapes or flood the TTY.
|
||||
fn sanitize_excerpt(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut pending_space = false;
|
||||
let mut kept = 0;
|
||||
for ch in s.trim().chars() {
|
||||
if kept >= MAX_ERR_CHARS {
|
||||
break;
|
||||
}
|
||||
if ch.is_control() {
|
||||
if matches!(ch, '\n' | '\r' | '\t') {
|
||||
pending_space = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch.is_whitespace() {
|
||||
pending_space = true;
|
||||
continue;
|
||||
}
|
||||
if pending_space && !out.is_empty() {
|
||||
out.push(' ');
|
||||
kept += 1;
|
||||
if kept >= MAX_ERR_CHARS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
kept += 1;
|
||||
pending_space = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `u` parses as an `https` URL.
|
||||
fn is_https(u: &str) -> bool {
|
||||
reqwest::Url::parse(u)
|
||||
.map(|p| p.scheme() == "https")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Lowercased host of a URL, if it parses and has one.
|
||||
fn host_of(u: &str) -> Option<String> {
|
||||
reqwest::Url::parse(u)
|
||||
.ok()
|
||||
.and_then(|p| p.host_str().map(|h| h.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Decide which iCaptcha origin to actually talk to, and whether it is trusted
|
||||
/// enough to receive the operator's API key.
|
||||
///
|
||||
/// The node-advertised `x-icaptcha-url` is untrusted: a hostile node could point
|
||||
/// it at an attacker origin (client-side SSRF) or capture a bearer token on the
|
||||
/// first hop. So an advertised URL is honored only when it is `https` AND its
|
||||
/// host is allowlisted — the public default host, or the operator's own
|
||||
/// `GITLAWB_ICAPTCHA_URL`. Otherwise we fall back to the operator/default origin.
|
||||
///
|
||||
/// The API key is attached only when the resolved origin is the operator's
|
||||
/// explicitly-configured host (never a node-discovered one), so a key is never
|
||||
/// exfiltrated to an origin the operator did not choose.
|
||||
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
|
||||
let operator = operator.filter(|u| is_https(u));
|
||||
let operator_host = operator.and_then(host_of);
|
||||
let default_host = host_of(DEFAULT_URL);
|
||||
|
||||
let allowed = |host: &Option<String>| -> bool {
|
||||
host.as_ref()
|
||||
.map(|h| Some(h) == default_host.as_ref() || Some(h) == operator_host.as_ref())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let fallback = || {
|
||||
operator
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| DEFAULT_URL.to_string())
|
||||
};
|
||||
|
||||
let chosen = match advertised {
|
||||
Some(a) if is_https(a) && allowed(&host_of(a)) => a.to_string(),
|
||||
Some(a) => {
|
||||
tracing::warn!(
|
||||
advertised = %a,
|
||||
"ignoring iCaptcha URL advertised by node (must be https with an allowlisted host); using trusted origin"
|
||||
);
|
||||
fallback()
|
||||
}
|
||||
None => fallback(),
|
||||
};
|
||||
|
||||
// Key goes only to the operator's own configured origin.
|
||||
let key_trusted = operator_host.is_some() && host_of(&chosen) == operator_host;
|
||||
(chosen, key_trusted)
|
||||
}
|
||||
|
||||
/// Computational challenge types this client solves locally. Restricting the
|
||||
/// request to these avoids dictionary (anagram/logic) and LLM (wordproblem/
|
||||
/// riddle) types, which can't be auto-solved.
|
||||
const SOLVABLE_TYPES: [&str; 3] = ["arithmetic", "algebra", "sequence"];
|
||||
|
||||
/// Bound on challenge/answer rounds (the service escalates difficulty on a miss;
|
||||
/// correct solvers shouldn't escalate, but cap it regardless).
|
||||
const MAX_ROUNDS: usize = 8;
|
||||
|
||||
/// Where + at what level to solve. `did` becomes the proof's `sub`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IcaptchaCfg {
|
||||
pub url: String,
|
||||
pub did: String,
|
||||
pub level: u32,
|
||||
/// Optional bearer token for an API-key-protected iCaptcha deployment.
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl IcaptchaCfg {
|
||||
/// Build config from the caller DID plus an optionally node-advertised
|
||||
/// url/level (e.g. the `x-icaptcha-url` / `x-icaptcha-level` headers) and
|
||||
/// optional level.
|
||||
///
|
||||
/// The advertised URL is validated and allowlisted (see [`resolve_solver_url`])
|
||||
/// so a hostile node cannot redirect the solve to an attacker origin. The
|
||||
/// `GITLAWB_ICAPTCHA_API_KEY` bearer token is attached only when the resolved
|
||||
/// origin is the operator's own `GITLAWB_ICAPTCHA_URL`, never a
|
||||
/// node-discovered one.
|
||||
pub fn new(did: impl Into<String>, url: Option<String>, level: Option<u32>) -> Self {
|
||||
let operator = std::env::var("GITLAWB_ICAPTCHA_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let (resolved_url, key_trusted) = resolve_solver_url(url.as_deref(), operator.as_deref());
|
||||
let api_key = if key_trusted {
|
||||
std::env::var("GITLAWB_ICAPTCHA_API_KEY")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
url: resolved_url,
|
||||
did: did.into(),
|
||||
level: level.unwrap_or(DEFAULT_LEVEL),
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A challenge handed back by the service (mirrors `icaptcha` `Challenge`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Challenge {
|
||||
pub challenge_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub difficulty: u32,
|
||||
pub prompt: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "lowercase")]
|
||||
enum AnswerResult {
|
||||
Passed { proof: String },
|
||||
Continue { challenge: Challenge },
|
||||
Failed { reason: String },
|
||||
}
|
||||
|
||||
/// Solver callback for types this crate can't solve deterministically.
|
||||
pub type Solver<'a> = dyn Fn(&Challenge) -> Option<String> + 'a;
|
||||
|
||||
/// Run the full challenge → solve → answer loop and return a fresh proof token.
|
||||
///
|
||||
/// `solver` is consulted for challenge types the built-in solvers don't handle
|
||||
/// (anagram/logic/LLM); pass `None` to fall back to an interactive stdin prompt.
|
||||
pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result<String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build iCaptcha http client")?;
|
||||
|
||||
let mut challenge = request_challenge(&client, cfg)?;
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let answer = solvers::solve(&challenge.kind, &challenge.prompt)
|
||||
.or_else(|| solver.and_then(|s| s(&challenge)))
|
||||
.or_else(|| interactive_prompt(&challenge))
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"cannot solve iCaptcha challenge type '{}' automatically; \
|
||||
set GITLAWB_ICAPTCHA_API_KEY/solver or solve interactively",
|
||||
challenge.kind
|
||||
)
|
||||
})?;
|
||||
|
||||
match submit_answer(&client, cfg, &challenge.token, &answer)? {
|
||||
AnswerResult::Passed { proof } => return Ok(proof),
|
||||
AnswerResult::Continue { challenge: next } => challenge = next,
|
||||
AnswerResult::Failed { reason } => {
|
||||
bail!("iCaptcha challenge failed: {}", sanitize_excerpt(&reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("iCaptcha not solved within {MAX_ROUNDS} rounds")
|
||||
}
|
||||
|
||||
fn request_challenge(client: &reqwest::blocking::Client, cfg: &IcaptchaCfg) -> Result<Challenge> {
|
||||
let url = format!("{}/v1/challenge", cfg.url.trim_end_matches('/'));
|
||||
let body = json!({
|
||||
"requesterId": cfg.did,
|
||||
"requiredLevel": cfg.level,
|
||||
"types": SOLVABLE_TYPES,
|
||||
});
|
||||
let mut req = client.post(&url).json(&body);
|
||||
if let Some(key) = &cfg.api_key {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
let resp = req.send().with_context(|| format!("POST {url}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = error_excerpt(resp);
|
||||
bail!("iCaptcha challenge request failed ({status}): {text}");
|
||||
}
|
||||
resp.json::<Challenge>().context("parse iCaptcha challenge")
|
||||
}
|
||||
|
||||
fn submit_answer(
|
||||
client: &reqwest::blocking::Client,
|
||||
cfg: &IcaptchaCfg,
|
||||
token: &str,
|
||||
answer: &str,
|
||||
) -> Result<AnswerResult> {
|
||||
let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/'));
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.json(&json!({ "token": token, "answer": answer }));
|
||||
if let Some(key) = &cfg.api_key {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
let resp = req.send().with_context(|| format!("POST {url}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = error_excerpt(resp);
|
||||
bail!("iCaptcha answer request failed ({status}): {text}");
|
||||
}
|
||||
resp.json::<AnswerResult>()
|
||||
.context("parse iCaptcha answer result")
|
||||
}
|
||||
|
||||
/// Last-resort fallback: show the prompt and read an answer from the terminal.
|
||||
/// Returns `None` when stdin isn't a usable interactive source (e.g. an agent),
|
||||
/// so the caller surfaces a clear "couldn't auto-solve" error instead.
|
||||
fn interactive_prompt(challenge: &Challenge) -> Option<String> {
|
||||
use std::io::{stderr, stdin, Write};
|
||||
let mut err = stderr();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"iCaptcha challenge ({}, level {}): {}\nAnswer: ",
|
||||
challenge.kind, challenge.difficulty, challenge.prompt
|
||||
);
|
||||
let _ = err.flush();
|
||||
let mut line = String::new();
|
||||
match stdin().read_line(&mut line) {
|
||||
Ok(0) | Err(_) => None,
|
||||
Ok(_) => {
|
||||
let a = line.trim().to_string();
|
||||
if a.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── P1: hostile error bodies must not reach the terminal raw ──────────
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_control_chars_and_escapes() {
|
||||
// ESC-based terminal escape + NUL + bell must all be removed.
|
||||
let hostile = "\x1b[2J\x1b[31mowned\x07\0 done";
|
||||
let clean = sanitize_excerpt(hostile);
|
||||
assert!(!clean.contains('\x1b'), "ESC survived: {clean:?}");
|
||||
assert!(
|
||||
!clean.chars().any(|c| c.is_control()),
|
||||
"control survived: {clean:?}"
|
||||
);
|
||||
assert!(clean.contains("owned") && clean.contains("done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_collapses_whitespace_and_caps_length() {
|
||||
let collapsed = sanitize_excerpt("a\n\n\t b");
|
||||
assert_eq!(collapsed, "a b");
|
||||
let long = "x".repeat(MAX_ERR_CHARS + 500);
|
||||
assert_eq!(sanitize_excerpt(&long).chars().count(), MAX_ERR_CHARS);
|
||||
}
|
||||
|
||||
// ── P2: never talk to (or hand a key to) an untrusted node-chosen URL ──
|
||||
|
||||
#[test]
|
||||
fn rejects_non_https_advertised_url() {
|
||||
// A plaintext node-advertised URL is ignored; we fall back to default.
|
||||
let (url, key_trusted) = resolve_solver_url(Some("http://evil.example/x"), None);
|
||||
assert_eq!(url, DEFAULT_URL);
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_https_url_with_non_allowlisted_host() {
|
||||
let (url, key_trusted) = resolve_solver_url(Some("https://evil.example"), None);
|
||||
assert_eq!(url, DEFAULT_URL, "attacker host must not be honored");
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honors_advertised_default_host_but_withholds_key_without_operator() {
|
||||
// Default public host is allowlisted for talking, but with no operator
|
||||
// origin configured the API key has no trusted destination.
|
||||
let (url, key_trusted) = resolve_solver_url(Some("https://icaptcha.gitlawb.com/v2"), None);
|
||||
assert_eq!(url, "https://icaptcha.gitlawb.com/v2");
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_trusted_only_for_operator_configured_origin() {
|
||||
let op = "https://icap.mynode.example";
|
||||
// Node advertises the operator's own origin → talk there, key allowed.
|
||||
let (url, key_trusted) =
|
||||
resolve_solver_url(Some("https://icap.mynode.example/v1"), Some(op));
|
||||
assert_eq!(url, "https://icap.mynode.example/v1");
|
||||
assert!(key_trusted);
|
||||
|
||||
// Node advertises an attacker origin while an operator is configured →
|
||||
// ignore the advert, fall back to the operator origin, key stays with it.
|
||||
let (url, key_trusted) = resolve_solver_url(Some("https://evil.example"), Some(op));
|
||||
assert_eq!(url, op);
|
||||
assert!(key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_advert_uses_operator_or_default() {
|
||||
let op = "https://icap.mynode.example";
|
||||
assert_eq!(resolve_solver_url(None, Some(op)), (op.to_string(), true));
|
||||
assert_eq!(
|
||||
resolve_solver_url(None, None),
|
||||
(DEFAULT_URL.to_string(), false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_https_operator_is_not_trusted() {
|
||||
// A misconfigured plaintext operator URL is not a valid key destination.
|
||||
let (url, key_trusted) = resolve_solver_url(None, Some("http://icap.mynode.example"));
|
||||
assert_eq!(url, DEFAULT_URL);
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Deterministic solvers for the computational iCaptcha challenge types.
|
||||
//!
|
||||
//! Prompt/answer formats mirror the iCaptcha service generators
|
||||
//! (`icaptcha/src/generators/{arithmetic,algebra,sequence}.ts`). The service
|
||||
//! grades numerics by value (`Number(a) === Number(b)`), so returning the plain
|
||||
//! integer string is sufficient. Anagram/logic (need a dictionary/parser) and
|
||||
//! the LLM types are intentionally NOT solved here — the client requests only
|
||||
//! these three types and falls back to a hook/interactive prompt otherwise.
|
||||
|
||||
/// Solve a challenge of the given `type` from its prompt. Returns `None` for
|
||||
/// types we don't solve locally or when the prompt can't be parsed.
|
||||
pub fn solve(challenge_type: &str, prompt: &str) -> Option<String> {
|
||||
match challenge_type {
|
||||
"arithmetic" => solve_arithmetic(prompt).map(|n| n.to_string()),
|
||||
"algebra" => solve_algebra(prompt).map(|n| n.to_string()),
|
||||
"sequence" => solve_sequence(prompt).map(|n| n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `What is 12 + 7 - 3?` -> evaluate the additive chain left to right.
|
||||
fn solve_arithmetic(prompt: &str) -> Option<i64> {
|
||||
let expr = prompt
|
||||
.trim()
|
||||
.strip_prefix("What is ")?
|
||||
.trim_end_matches('?')
|
||||
.trim();
|
||||
let mut tokens = expr.split_whitespace();
|
||||
let mut acc: i64 = tokens.next()?.parse().ok()?;
|
||||
while let Some(op) = tokens.next() {
|
||||
let n: i64 = tokens.next()?.parse().ok()?;
|
||||
match op {
|
||||
"+" => acc += n,
|
||||
"-" => acc -= n,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
/// `Solve for x: 3x + 4 = 19` (and the `=`-both-sides and `a(x + b)` variants).
|
||||
/// Parses each side into `coeff*x + const`, then x = (cR - cL) / (aL - aR).
|
||||
fn solve_algebra(prompt: &str) -> Option<i64> {
|
||||
let eq = prompt.trim().strip_prefix("Solve for x:")?.trim();
|
||||
let (lhs, rhs) = eq.split_once('=')?;
|
||||
let (al, cl) = parse_linear(lhs.trim())?;
|
||||
let (ar, cr) = parse_linear(rhs.trim())?;
|
||||
let denom = al - ar;
|
||||
if denom == 0 {
|
||||
return None;
|
||||
}
|
||||
let num = cr - cl;
|
||||
if num % denom != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(num / denom)
|
||||
}
|
||||
|
||||
/// Parse a linear expression in `x` into `(coeff_of_x, constant)`.
|
||||
/// Handles `Nx`, `x`, integer constants, and a single `N(x ± M)` product,
|
||||
/// with `+`/`-` separators (the formats the generator emits).
|
||||
fn parse_linear(s: &str) -> Option<(i64, i64)> {
|
||||
let s = s.trim();
|
||||
// Parenthesized product: a(x ± b) -> coeff a, const a*(±b).
|
||||
if let Some(open) = s.find('(') {
|
||||
let a: i64 = s[..open].trim().parse().ok()?;
|
||||
let close = s.find(')')?;
|
||||
let inner = &s[open + 1..close]; // "x + 4" / "x - 4"
|
||||
let mut it = inner.split_whitespace();
|
||||
if it.next()? != "x" {
|
||||
return None;
|
||||
}
|
||||
let (coeff_inner, const_inner) = match it.next() {
|
||||
None => (1, 0),
|
||||
Some(op) => {
|
||||
let m: i64 = it.next()?.parse().ok()?;
|
||||
match op {
|
||||
"+" => (1, m),
|
||||
"-" => (1, -m),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
};
|
||||
return Some((a * coeff_inner, a * const_inner));
|
||||
}
|
||||
|
||||
// Sum of `±`-separated terms.
|
||||
let mut coeff = 0i64;
|
||||
let mut konst = 0i64;
|
||||
let mut sign = 1i64;
|
||||
for tok in s.split_whitespace() {
|
||||
match tok {
|
||||
"+" => sign = 1,
|
||||
"-" => sign = -1,
|
||||
t => {
|
||||
if let Some(cpart) = t.strip_suffix('x') {
|
||||
let c: i64 = match cpart {
|
||||
"" | "+" => 1,
|
||||
"-" => -1,
|
||||
_ => cpart.parse().ok()?,
|
||||
};
|
||||
coeff += sign * c;
|
||||
} else {
|
||||
let n: i64 = t.parse().ok()?;
|
||||
konst += sign * n;
|
||||
}
|
||||
sign = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((coeff, konst))
|
||||
}
|
||||
|
||||
/// `What is the next number in this sequence? 2, 4, 6, 8, 10, ?`
|
||||
fn solve_sequence(prompt: &str) -> Option<i64> {
|
||||
let tail = prompt.split_once("sequence?")?.1;
|
||||
let nums: Vec<i64> = tail
|
||||
.split(',')
|
||||
.filter_map(|t| t.trim().parse::<i64>().ok())
|
||||
.collect();
|
||||
if nums.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
next_in_sequence(&nums)
|
||||
}
|
||||
|
||||
fn next_in_sequence(n: &[i64]) -> Option<i64> {
|
||||
let last = *n.last()?;
|
||||
|
||||
// Arithmetic: constant first difference.
|
||||
let d = n[1] - n[0];
|
||||
if n.windows(2).all(|w| w[1] - w[0] == d) {
|
||||
return Some(last + d);
|
||||
}
|
||||
|
||||
// Geometric: constant integer ratio.
|
||||
if n.iter().all(|&v| v != 0) && n[0] != 0 && n[1] % n[0] == 0 {
|
||||
let r = n[1] / n[0];
|
||||
if r != 0 && n.windows(2).all(|w| w[1] == w[0] * r) {
|
||||
return Some(last * r);
|
||||
}
|
||||
}
|
||||
|
||||
// Fibonacci-like: each term is the sum of the two before it.
|
||||
if n.len() >= 3 && (2..n.len()).all(|i| n[i] == n[i - 1] + n[i - 2]) {
|
||||
return Some(n[n.len() - 1] + n[n.len() - 2]);
|
||||
}
|
||||
|
||||
// Squares: all perfect squares with consecutive roots.
|
||||
let roots: Option<Vec<i64>> = n.iter().map(|&v| isqrt_exact(v)).collect();
|
||||
if let Some(roots) = roots {
|
||||
if roots.windows(2).all(|w| w[1] == w[0] + 1) {
|
||||
let nr = roots[roots.len() - 1] + 1;
|
||||
return Some(nr * nr);
|
||||
}
|
||||
}
|
||||
|
||||
// Alternating sign over an arithmetic magnitude (generator starts positive).
|
||||
let signs_alternate = n
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 });
|
||||
let mags: Vec<i64> = n.iter().map(|v| v.abs()).collect();
|
||||
let md = mags[1] - mags[0];
|
||||
if signs_alternate && mags.windows(2).all(|w| w[1] - w[0] == md) {
|
||||
let next_mag = mags[mags.len() - 1] + md;
|
||||
let next_sign = if last >= 0 { -1 } else { 1 };
|
||||
return Some(next_sign * next_mag);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Exact integer square root, or `None` if `v` isn't a perfect square.
|
||||
fn isqrt_exact(v: i64) -> Option<i64> {
|
||||
if v < 0 {
|
||||
return None;
|
||||
}
|
||||
let r = (v as f64).sqrt().round() as i64;
|
||||
[r - 1, r, r + 1]
|
||||
.into_iter()
|
||||
.find(|&cand| cand >= 0 && cand * cand == v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arithmetic_chains() {
|
||||
assert_eq!(
|
||||
solve("arithmetic", "What is 12 + 7 - 3?").as_deref(),
|
||||
Some("16")
|
||||
);
|
||||
assert_eq!(solve("arithmetic", "What is 5?").as_deref(), Some("5"));
|
||||
assert_eq!(
|
||||
solve("arithmetic", "What is 100 - 40 - 35 + 2?").as_deref(),
|
||||
Some("27")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algebra_linear() {
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: 3x + 4 = 19").as_deref(),
|
||||
Some("5")
|
||||
);
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: 5x - 8 = 12").as_deref(),
|
||||
Some("4")
|
||||
);
|
||||
// both sides: 2x + 16 = 5x - 8 -> 3x = 24 -> x=8
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: 2x + 16 = 5x - 8").as_deref(),
|
||||
Some("8")
|
||||
);
|
||||
// parenthesized: 3(x - 4) = 9 -> x-4=3 -> x=7
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: 3(x - 4) = 9").as_deref(),
|
||||
Some("7")
|
||||
);
|
||||
// negative solution: 2x + 10 = 4 -> x=-3
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: 2x + 10 = 4").as_deref(),
|
||||
Some("-3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_patterns() {
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 2, 4, 6, 8, 10, ?"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("12")
|
||||
);
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 3, 6, 12, 24, 48, ?"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("96")
|
||||
);
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 1, 4, 9, 16, 25, ?"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("36")
|
||||
);
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 1, 1, 2, 3, 5, ?"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_types_return_none() {
|
||||
assert_eq!(solve("anagram", "Unscramble: tca"), None);
|
||||
assert_eq!(solve("riddle", "What has keys but no locks?"), None);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,16 @@ services:
|
||||
GITLAWB_P2P_PORT: "${p2p_port}"
|
||||
GITLAWB_REPOS_DIR: /data/repos
|
||||
GITLAWB_KEY: /data/keys/identity.pem
|
||||
# Only trust X-Forwarded-For when the Caddy sidecar above is actually
|
||||
# deployed (domain_name != ""). Caddy appends the real client as the
|
||||
# rightmost hop, letting the push rate limiter key on the true client IP
|
||||
# rather than Caddy's container address. Without Caddy, this header is
|
||||
# fully client-controlled and must not be trusted.
|
||||
GITLAWB_TRUSTED_PROXY: "${domain_name != "" ? "x-forwarded-for" : ""}"
|
||||
# iCaptcha proof-of-intelligence gate (spam protection on create/fork/register)
|
||||
ICAPTCHA_MODE: ${icaptcha_mode}
|
||||
ICAPTCHA_PUBKEY: ${icaptcha_pubkey}
|
||||
ICAPTCHA_REQUIRED_LEVEL: "${icaptcha_required_level}"
|
||||
GITLAWB_PUBLIC_URL: $${GITLAWB_PUBLIC_URL}
|
||||
GITLAWB_BOOTSTRAP_PEERS: $${GITLAWB_BOOTSTRAP_PEERS}
|
||||
GITLAWB_AUTO_SYNC: $${GITLAWB_AUTO_SYNC}
|
||||
|
||||
+13
-10
@@ -290,16 +290,19 @@ locals {
|
||||
)
|
||||
|
||||
compose_yaml = templatefile("${path.module}/compose.yaml.tftpl", {
|
||||
image_repo = local.image_repo
|
||||
image_tag = var.image_tag
|
||||
domain_name = var.domain_name
|
||||
db_host = var.use_rds ? aws_db_instance.node[0].address : ""
|
||||
gitlawb_port = var.gitlawb_port
|
||||
p2p_port = var.gitlawb_p2p_port
|
||||
metrics_port = var.metrics_port
|
||||
expose_metrics = local.expose_metrics
|
||||
pg_user = var.postgres_user
|
||||
pg_db = var.postgres_db
|
||||
image_repo = local.image_repo
|
||||
image_tag = var.image_tag
|
||||
domain_name = var.domain_name
|
||||
db_host = var.use_rds ? aws_db_instance.node[0].address : ""
|
||||
gitlawb_port = var.gitlawb_port
|
||||
p2p_port = var.gitlawb_p2p_port
|
||||
metrics_port = var.metrics_port
|
||||
expose_metrics = local.expose_metrics
|
||||
pg_user = var.postgres_user
|
||||
pg_db = var.postgres_db
|
||||
icaptcha_mode = var.icaptcha_mode
|
||||
icaptcha_pubkey = var.icaptcha_pubkey
|
||||
icaptcha_required_level = var.icaptcha_required_level
|
||||
})
|
||||
|
||||
user_data = templatefile("${path.module}/user-data.sh.tftpl", {
|
||||
|
||||
@@ -81,6 +81,28 @@ variable "image_tag" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iCaptcha proof-of-intelligence gate (spam protection on writes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
variable "icaptcha_mode" {
|
||||
description = "iCaptcha gate mode: off | shadow | enforce. The live gitlawb network enforces."
|
||||
type = string
|
||||
default = "enforce"
|
||||
}
|
||||
|
||||
variable "icaptcha_pubkey" {
|
||||
description = "base64url Ed25519 public key of the iCaptcha service, pinned so startup doesn't depend on fetching it. This is the live icaptcha.gitlawb.com key."
|
||||
type = string
|
||||
default = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8"
|
||||
}
|
||||
|
||||
variable "icaptcha_required_level" {
|
||||
description = "Minimum iCaptcha proof level required to pass the gate."
|
||||
type = number
|
||||
default = 3
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Networking / ingress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,10 @@ primary_region = "iad"
|
||||
GITLAWB_BOOTSTRAP_PEERS = "https://node.gitlawb.com,https://node2.gitlawb.com,https://node3.gitlawb.com"
|
||||
GITLAWB_AUTO_SYNC = "true"
|
||||
GITLAWB_MAX_PACK_BYTES = "524288000"
|
||||
# On Fly: the edge sets Fly-Client-IP (overwriting any client-supplied value),
|
||||
# so the push rate limiter keys on the real client. Without this the limiter
|
||||
# would fall back to the socket peer (Fly's proxy) and over-throttle.
|
||||
GITLAWB_TRUSTED_PROXY = "fly"
|
||||
|
||||
[http_service]
|
||||
internal_port = 7545
|
||||
|
||||
Reference in new issue
Block a user