Compare commits

..
Author SHA1 Message Date
beardthelion 1b383c03f7 test(gl,node): pin LC_ALL=C on git-subprocess stderr assertions 2026-11-06 23:13:28 -06:00
beardthelion e2c52a9738 style(gl): elide the lifetime in mirror_clone_args 2026-11-06 23:12:52 -06:00
beardthelion 69665d8759 Merge remote-tracking branch 'origin/main' into recreate-376-fix-git-clone-positional-delimiter 2026-11-06 23:12:52 -06:00
euxarist 7abb7298e3 fix(gl): Route the last three clone warnings through emit_warning
`emit_warning` sanitizes a line before writing it, which strips ANSI
escape and bidi characters from untrusted text. Three warning sites in
the blob-recovery paths still used a bare `eprintln!` while interpolating
`oid`, which comes from node JSON or an Arweave gateway manifest, so
those escapes reached the user's terminal raw.

Refs #374
2026-08-26 23:23:18 -04:00
euxarist 20cd5c032f test(gl,node): Pin the argv delimiter on every git sink this PR changed
The delimiter contract is duplicated across five direct `git` argv
constructions, but only `sync::clone_repo` had a test that went red when
`--` was deleted. Removing or misplacing the delimiter in any other sink
left the suite green, so the hardening rested on review rather than on
the tests.

Each changed sink now has a subprocess-facing regression driven through
real git: the three `setup_partial_clone` shapes (plain, `--branch`, and
the sparse/promisor arm), `fetch_repo`'s `remote set-url`, `gl mirror`,
and the fork clone.

Two of these could not carry the obvious assertion:

- `git_global` and `git_run` both format failures as
  `git {args:?} failed: {stderr}`, so the injected value is already in
  the message via the debug-printed argv. Asserting that the error
  merely contains it passes with `--` deleted. The clone tests assert on
  git's own single-quoted `repository '<value>'` instead, which only
  appears when git read the value as a repository.
- `remote set-url` does not fail on a delimited option-shaped value, it
  stores it. The `fetch_repo` test asserts the stored URL rather than an
  error: undelimited, git exits 129 with `unknown option` and leaves the
  previous URL in place.

`gl mirror` and `fork_repo` needed their argv extracted to be testable.
`mirror::run` loads a keypair and contacts a node before reaching the
clone, and bails with a message that interpolates the source whether or
not the delimiter is present. The extraction keeps the production
`.status()` call, so a large mirror clone still streams git's progress
to the terminal instead of being captured.

Every one of the five was confirmed red with its delimiter removed and
green with it restored.

Refs #374
2026-08-26 23:23:12 -04:00
euxarist 641c8103fc fix(sync,gl): Delimit positional arguments in fetch_repo and gl clone
Carry the positional argument delimiter into fetch_repo when setting the
remote origin URL and into setup_partial_clone across all clone branches.
Correct the error string quoted in the clone regression test docstring.

Refs #374
2026-08-26 23:00:00 -04:00
euxarist c65722f5a5 fix(node): Pin the git clone delimiter against remote-argument injection
The delimiter change had no test, so nothing stopped it being reverted as
cosmetic. What it guards is real: git parses a remote beginning with a
dash as an option, and --upload-pack=<cmd> is one git hands to a shell.

No path reaches that today. Peer URLs are gated by is_public_http_url, so
the origin clone_repo composes can only begin with http:// or https://;
fork paths are joins under repos_dir; and clap rejects a leading-dash
positional in gl mirror. The delimiter is what removes git's dependence
on those three unrelated gates staying correct.

Assert git reports the whole argument as a repository it cannot find,
which is only true once the delimiter forces it to be a path.
Undelimited, git consumes it as an option and fails against the
destination instead, never naming the injected string, so the assertion
is red before the fix and green after.

Refs #374
2026-08-26 23:00:00 -04:00
euxarist ef0df582f6 style(gl): Format args array in mirror.rs
Fix cargo fmt line-length break for git clone --mirror arguments.

Refs #376
2026-08-26 23:00:00 -04:00
euxarist dfe8a17fa2 fix(api,sync): Delimit positional arguments in git clone mirror invocations
Pass "--" before positional path/URL arguments in Command invocations
for "git clone --mirror" across repos.rs, sync.rs, and gl mirror.rs.

Fixes #374
2026-08-26 23:00:00 -04:00
4 changed files with 27 additions and 949 deletions

No files matched your search

+9 -56
View File
@@ -1,13 +1,12 @@
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
use axum::{
extract::{Extension, Query, State},
extract::{Query, State},
Json,
};
use serde::Deserialize;
use crate::db::normalize_owner_key;
use crate::error::{AppError, Result};
use crate::error::Result;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
@@ -22,62 +21,17 @@ fn default_limit() -> i64 {
}
/// GET /api/v1/arweave/anchors
///
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
/// request to the canonical read-authorization path used by every other
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
/// identity attached. Missing repositories, quarantined mirrors, and signed
/// non-readers all collapse to the standard `404` (no existence oracle). The
/// unscoped listing is auth-only — the #121 contract — and does not admit
/// visibility filtering, which is the #136 stale-index class and explicitly
/// out of scope for this auth slice.
pub async fn list_anchors(
State(state): State<AppState>,
Query(q): Query<ListAnchorsQuery>,
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// The route's `optional_signature` layer is permissive (it admits unsigned
// legacy callers) so this in-handler check is the actual gate. A signed
// non-reader still passes this check and proceeds to the authz step below.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if caller.is_none() {
return Err(AppError::Unauthorized(
"authentication required for anchor listing".into(),
));
}
// Clamp before the SQL query. A negative or non-numeric limit must not
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
// contract is the same default ceiling as the listing page itself.
let limit = q.limit.clamp(0, 200);
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
// Parse the user-supplied "owner/name" through the same slug validator
// the sync path uses, so a malformed query is a 400, not a 500.
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
Ok(parts) => parts,
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
};
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
// missing repos, quarantined mirrors, and signed non-readers.
let (record, _rules) =
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
// Build the SQL filter from the canonical stored slug, NOT from the
// user-supplied `?repo=` string. Anchor rows are written as
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
// request that passes authz with the full `did:key:…/name` form would
// match zero rows and return a false empty page. The gate already
// verified the repo is readable; `record.owner_did` is the canonical
// identity and `record.name` is the stored name.
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
state
.db
.list_arweave_anchors(Some(&stored_slug), limit)
.await?
} else {
// No scope → no repo-read decision. Auth-only.
state.db.list_arweave_anchors(None, limit).await?
};
let limit = q.limit.min(200);
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let anchors = state
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await?;
Ok(Json(serde_json::json!({
"anchors": anchors,
@@ -106,7 +60,6 @@ mod closed_pool_tests {
.oneshot(
Request::builder()
.uri("/api/v1/arweave/anchors")
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
.body(axum::body::Body::empty())
.unwrap(),
)
+1 -10
View File
@@ -2133,15 +2133,7 @@ async fn gate_and_serve(
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(
State(state): State<AppState>,
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
if auth.is_none() {
return Err(crate::error::AppError::Unauthorized(
"authentication required for pin listing".into(),
));
}
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let pins = state.db.list_pinned_cids().await?;
@@ -2434,7 +2426,6 @@ mod closed_pool_tests {
.oneshot(
Request::builder()
.uri("/api/v1/ipfs/pins")
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
.body(axum::body::Body::empty())
.unwrap(),
)
+17 -708
View File
@@ -237,62 +237,35 @@ pub fn build_router(state: AppState) -> Router {
.layer(axum::Extension(push_limiter));
// ── IPFS content-addressed retrieval and pin listing ──────────────────
// Two independent sub-routers, then merged. They share a URL prefix family
// but have separate rate-limit policies and must not share a bucket.
//
// `/ipfs/{cid}` (CID resolver): carries `optional_signature` so `get_by_cid`
// sees the caller identity and can apply per-repo visibility (#110); anon
// callers stay anonymous and still read genuinely public content. The
// per-IP flood brake is layered on because the resolver is anon-reachable
// and each request can drive a full-history git walk — the brake is the
// outermost layer (rejects a flood before the walk-admission work), mirroring
// the push/create routers. The `IpRateLimiter` extension MUST be attached
// or `rate_limit_by_ip` is a silent no-op.
//
// `/api/v1/ipfs/pins` (pin listing): now carries `optional_signature` only
// (#121). The handler rejects requests without a verified `AuthenticatedDid`
// with 401. It does NOT carry the CID flood brake — `list_pins` is a single
// `list_pinned_cids()` call, no walk, and routing pins through the resolver's
// bucket would let `/ipfs/{cid}` traffic exhaust the bucket and 429 the
// pins endpoint, or let signed pin polling exhaust the bucket for legitimate
// CID reads. The two surfaces have separate availability contracts.
//
// Both sub-routers are built first with their own layer sets, then merged.
// This is the structure the prior routing guidance called for and the
// earlier 429 test for `/ipfs/{cid}` (test_support.rs) assumes.
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
// identity and can apply per-repo visibility (#110); anonymous callers stay
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
// stays unsigned — gating the pin index is tracked separately (#121).
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
// request can drive a full-history git walk, so the per-IP rate limiter is the
// outermost layer (rejects a flood before the walk-admission work), mirroring the
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
let ipfs_limiter = rate_limit::IpRateLimiter {
limiter: state.ipfs_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let ipfs_cid_routes = Router::new()
let ipfs_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(ipfs_limiter));
let ipfs_pins_routes = Router::new()
.route("/api/v1/ipfs/pins", get(ipfs::list_pins))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
let ipfs_routes = ipfs_cid_routes.merge(ipfs_pins_routes);
.layer(axum::Extension(ipfs_limiter))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
// ── Arweave permanent anchors ──────────────────────────────────────────
// `list_anchors` rejects callers without a verified `AuthenticatedDid`, so
// unsigned enumeration is denied. The same `optional_signature` layer used
// on the other read surfaces is applied here, closing the anonymous
// anchor-listing gap alongside `/api/v1/ipfs/pins`. The verify endpoint
// and its probe are deferred to the vertical slice with the uploader and
// a real provider contract.
let arweave_routes = Router::new()
.route("/api/v1/arweave/anchors", get(arweave::list_anchors))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
// List endpoint only (public; issue #134 tracks surfacing
// visibility rules on list). The verify endpoint and its probe
// are deferred to the vertical slice with the uploader and a
// real provider contract (reviewer 2, #26 split 2/4 round 5).
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
// ── Bounty routes (write — require HTTP Signature) ─────────────────
let bounty_write_routes = add_auth_layers(
@@ -699,667 +672,3 @@ async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
None => Json(json!({ "enabled": false })),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use sqlx::PgPool;
use tower::ServiceExt;
use crate::db::RepoRecord;
use crate::test_support::test_state;
/// Regression: anonymous callers must not see the pin/anchor index (#121, #134).
#[sqlx::test]
async fn unsigned_get_pins_and_anchors_is_401_through_build_router(pool: PgPool) {
let state = test_state(pool).await;
// Seed identifiable rows so the denial oracle proves the 401 body omits
// them: a status-only check would stay green if a later change served
// the listing before the auth layer rejected.
state
.db
.record_pinned_cid("seeded-pin-oid", "bafyPIN-SECRET-DO-NOT-LEAK", None)
.await
.expect("seed pin");
let owner_did = "did:key:zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let repo = seed_repo_inline(owner_did, "seeded-repo");
state.db.create_repo(&repo).await.expect("seed repo");
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: "zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/seeded-repo",
owner_did,
ref_name: "refs/heads/secret",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
arweave_url: "https://arweave.net/secret-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let router = build_router(state);
let pins = Request::builder()
.method("GET")
.uri("/api/v1/ipfs/pins?limit=50")
.body(Body::empty())
.unwrap();
let pins_resp = router.clone().oneshot(pins).await.unwrap();
assert_eq!(
pins_resp.status(),
StatusCode::UNAUTHORIZED,
"anonymous pin listing must be rejected"
);
let bytes = axum::body::to_bytes(pins_resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("bafyPIN-SECRET-DO-NOT-LEAK"),
"the 401 body must not leak the seeded pin cid; body was: {body}"
);
let anchors = Request::builder()
.method("GET")
.uri("/api/v1/arweave/anchors?limit=50")
.body(Body::empty())
.unwrap();
let anchors_resp = router.oneshot(anchors).await.unwrap();
assert_eq!(
anchors_resp.status(),
StatusCode::UNAUTHORIZED,
"anonymous anchors listing must be rejected"
);
let bytes = axum::body::to_bytes(anchors_resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
);
}
/// Regression: a real RFC-9421 signature produced exactly as `gl` does — built
/// with `gitlawb_core::http_sig::sign_request` over a GET, headers attached,
/// and sent through the actual `build_router` — is verified by the
/// `optional_signature` layer that wraps the pin/anchor routes, and the
/// handler returns 200. Pairs with the anonymous-denial test above; one
/// proves headers are required, the other proves a valid header is honored.
/// Without this test the unsigned-denial test would stay green even if the
/// `optional_signature` layer were never wired onto these routes, because
/// signed and unsigned requests would fail identically (#134 review).
#[sqlx::test]
async fn signed_get_pins_and_anchors_succeeds_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let state = test_state(pool).await;
let router = build_router(state);
let kp = Keypair::generate();
let pins_path = "/api/v1/ipfs/pins";
let signed = sign_request(&kp, "GET", pins_path, b"");
let pins = Request::builder()
.method("GET")
.uri(pins_path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let pins_resp = router.clone().oneshot(pins).await.unwrap();
assert_eq!(
pins_resp.status(),
StatusCode::OK,
"a valid signature on /api/v1/ipfs/pins must be honored through build_router"
);
let anchors_path = "/api/v1/arweave/anchors";
let signed = sign_request(&kp, "GET", anchors_path, b"");
let anchors = Request::builder()
.method("GET")
.uri(anchors_path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let anchors_resp = router.oneshot(anchors).await.unwrap();
assert_eq!(
anchors_resp.status(),
StatusCode::OK,
"a valid signature on /api/v1/arweave/anchors must be honored through build_router"
);
}
/// Companion to the two regressions above: a request that *carries* signature
/// headers but whose signature does not verify (here, garbled) must be denied
/// with 401, not silently treated as anonymous and re-checked by the handler.
/// This pins the failure mode of the `optional_signature` layer: when the
/// caller claims to be signed, the layer must commit to verifying — there is
/// no fall-through path that lets a bad signature bypass auth.
#[sqlx::test]
async fn malformed_signature_on_pins_is_401_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let state = test_state(pool).await;
let router = build_router(state);
let kp = Keypair::generate();
let path = "/api/v1/ipfs/pins";
let mut signed = sign_request(&kp, "GET", path, b"");
// Flip a character well inside the signature value (base64) so the header
// still parses but does not verify against the key.
let mut tampered = signed.signature.clone();
let mid = tampered.len() / 2;
let flipped = if tampered.as_bytes()[mid] == b'A' {
'B'
} else {
'A'
};
tampered.replace_range(mid..mid + 1, &flipped.to_string());
signed.signature = tampered;
let req = Request::builder()
.method("GET")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"a malformed signature must be rejected by the auth layer, not silently accepted"
);
}
/// The same malformed-signature rejection must hold on the anchors route,
/// which carries its own `optional_signature` layer beside pins: a later
/// edit that dropped the layer here (but not on pins) would otherwise stay
/// green.
#[sqlx::test]
async fn malformed_signature_on_anchors_is_401_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let state = test_state(pool).await;
let router = build_router(state);
let kp = Keypair::generate();
let path = "/api/v1/arweave/anchors";
let mut signed = sign_request(&kp, "GET", path, b"");
let mut tampered = signed.signature.clone();
let mid = tampered.len() / 2;
let flipped = if tampered.as_bytes()[mid] == b'A' {
'B'
} else {
'A'
};
tampered.replace_range(mid..mid + 1, &flipped.to_string());
signed.signature = tampered;
let req = Request::builder()
.method("GET")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"a malformed signature must be rejected by the auth layer, not silently accepted"
);
}
// ── Scoped anchor contract (P1 follow-up: ?repo= must authorize_repo_read) ──
//
// The follow-up review (after the auth-layer wiring landed) found that a
// signed but unauthorized caller could still obtain scoped anchor metadata
// because the `?repo=` branch handed the user-supplied string straight to
// the SQL filter. These tests exercise the full production contract end to
// end: real RFC-9421 signature → `optional_signature` layer →
// `authorize_repo_read` → SQL. They fail closed if the authz call is moved
// after the query or removed entirely.
/// Inline repo seed for the scoped-anchor tests. Mirrors the shape in
/// `test_support::tests::seed_repo` without taking a cross-module private
/// helper as a dependency.
fn seed_repo_inline(owner_did: &str, name: &str) -> RepoRecord {
let now = chrono::Utc::now();
RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// Unsigned scoped anchor request → 401, identical to the global anchor
/// 401 test. Proves the auth layer fires before any scope decision (no
/// existence oracle via the `?repo=` path either).
#[sqlx::test]
async fn unsigned_scoped_anchors_is_401_through_build_router(pool: PgPool) {
let state = test_state(pool).await;
// Seed an anchor under a repo the request claims to scope to, so the
// denial oracle proves the 401 body omits it rather than merely
// returning the right status over an empty table.
let owner_did = "did:key:zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let repo = seed_repo_inline(owner_did, "priv");
state.db.create_repo(&repo).await.expect("seed repo");
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: "zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/priv",
owner_did,
ref_name: "refs/heads/secret",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-scoped-secret-DO-NOT-LEAK",
arweave_url: "https://arweave.net/scoped-secret",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let router = build_router(state);
let resp = router
.oneshot(
Request::builder()
.method("GET")
.uri("/api/v1/arweave/anchors?repo=did:key:zSCOPED%2Fpriv")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"unsigned scoped anchor request must be 401, before any scope lookup"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("irys-scoped-secret-DO-NOT-LEAK"),
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
);
}
/// Signed owner on a private repo → 200, anchor metadata returned. Mirrors
/// the existing `list_webhooks_accepts_a_real_gl_signature_e2e` shape: real
/// signature, real middleware, real `authorize_repo_read`.
#[sqlx::test]
async fn signed_scoped_anchors_owner_succeeds_through_build_router(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 state = test_state(pool).await;
let mut repo = seed_repo_inline(&owner_did, "scoped-priv");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
let short = owner_did.split(':').next_back().unwrap().to_string();
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!("{short}/scoped-priv"),
owner_did: &owner_did,
ref_name: "refs/heads/main",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-owner-tx",
arweave_url: "https://arweave.net/owner-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let path = format!("/api/v1/arweave/anchors?repo={short}/scoped-priv");
let signed = sign_request(&kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the owner of a private repo must see their scoped anchors"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("irys-owner-tx"),
"owner must see the anchor's irys tx id; body was: {body}"
);
// Owner must see the ref name + new SHA so this is not just a count oracle.
assert!(
body.contains("refs/heads/main"),
"owner must see the ref name; body was: {body}"
);
// Sanity: a second test would need a different repo name to avoid
// colliding on `did:key:zSCOPED/scoped-priv` in the anchors table.
// Distinct repo names per test keep the rows queryable in isolation.
let _ = repo;
}
/// #121 R3: the SQL filter must use the canonical stored slug, not the
/// user-supplied `?repo=` string. Anchor rows are stored as
/// `{normalize_owner_key(owner_did)}/{name}` (the short form). Authz
/// passes for the full-DID form, but a literal `WHERE repo=$1` against
/// the full DID would match zero rows and return a false empty page,
/// breaking callers that legitimately use the full `did:key:…/name`
/// form in `?repo=`. RED before the fix: handler built the filter from
/// the raw `?repo=` string, so this query returned `anchors: []`.
#[sqlx::test]
async fn signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router(
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 state = test_state(pool).await;
let mut repo = seed_repo_inline(&owner_did, "scoped-did-form");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
// Use the same slug-construction the writer uses
// (`db::normalize_owner_key`) so the test reader and the production
// writer can't drift if a future change moves the slug off the
// short-form. P3 (reviewer-3): the previous `split(':').next_back()`
// hand-rolled a third convention that agreed with the writer for
// `did:key` owners and diverged for every other method.
let short = crate::db::normalize_owner_key(&owner_did).to_string();
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!("{short}/scoped-did-form"),
owner_did: &owner_did,
ref_name: "refs/heads/main",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-did-form-tx",
arweave_url: "https://arweave.net/did-form-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
// P2 (reviewer-3): the previous suite only proved the authorized
// row is present. A correct filter must ALSO prove that anchors
// for OTHER repos do not leak into the response — the failure
// mode of the original bug was a cross-repo leak, not a
// fail-safe empty page. Seed a second repo (also under the
// same owner, so the authz test stays scoped) with its own
// irys_tx_id and assert it is absent from the body.
let other_owner = {
let kp2 = Keypair::generate();
kp2.did().to_string()
};
let mut other_repo = seed_repo_inline(&other_owner, "other-repo");
other_repo.is_public = true;
state
.db
.create_repo(&other_repo)
.await
.expect("seed other repo");
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!(
"{}/other-repo",
crate::db::normalize_owner_key(&other_owner)
),
owner_did: &other_owner,
ref_name: "refs/heads/main",
old_sha: "0".repeat(64).as_str(),
new_sha: "2".repeat(64).as_str(),
cid: Some("bafyother"),
irys_tx_id: "irys-other-repo-tx",
arweave_url: "https://arweave.net/other-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed other anchor");
// The caller passes the FULL DID form in ?repo=. validate_repo_slug
// accepts it, authorize_repo_read resolves it, and the handler MUST
// then translate to the canonical stored slug (short form) before
// running the SQL — otherwise this returns 200 with anchors: [].
let path = format!("/api/v1/arweave/anchors?repo={owner_did}/scoped-did-form");
let signed = sign_request(&kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"authz passed via the full DID form, so the response must be 200"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("irys-did-form-tx"),
"the SQL filter must use the stored short slug, not the full DID — \
a false-empty page would break callers that pass ?repo=did:key:…/name. \
body was: {body}"
);
assert!(
!body.contains("irys-other-repo-tx"),
"the scoped anchor filter must exclude other repos' anchors — \
a cross-repo leak would expose metadata of repos the caller \
did not authorize. body was: {body}"
);
let _ = repo;
}
/// Signed non-reader on a private repo → 404, anchor metadata MUST NOT
/// leak. This is the test that catches the "auth-but-no-authz" bug if
/// the gate is moved after the SQL query or removed entirely. The 404
/// is the standard `repo_not_found` shape — indistinguishable from the
/// missing-repo case below.
#[sqlx::test]
async fn signed_scoped_anchors_non_reader_is_404_no_leak_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let owner_kp = Keypair::generate();
let stranger_kp = Keypair::generate();
let owner_did = owner_kp.did().to_string();
let stranger_did = stranger_kp.did().to_string();
let state = test_state(pool).await;
let mut repo = seed_repo_inline(&owner_did, "scoped-priv-nr");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
let short_owner = owner_did.split(':').next_back().unwrap().to_string();
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!("{short_owner}/scoped-priv-nr"),
owner_did: &owner_did,
ref_name: "refs/heads/secret",
old_sha: "2".repeat(64).as_str(),
new_sha: "3".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
arweave_url: "https://arweave.net/secret-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let _ = stranger_did; // signature carries the DID; this is just documentation
let path = format!("/api/v1/arweave/anchors?repo={short_owner}/scoped-priv-nr");
let signed = sign_request(&stranger_kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a signed non-reader must be 404 on a private repo's anchors"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
"the 404 body must not leak the anchor's irys tx id; body was: {body}"
);
assert!(
!body.contains("refs/heads/secret"),
"the 404 body must not leak the ref name; body was: {body}"
);
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v["error"], "repo_not_found",
"non-reader denial must carry the same error code as a missing repo"
);
}
/// Signed caller on a missing repo → 404, same shape as the non-reader
/// case. This is the indistinguishability half: a probe cannot tell
/// "private" from "absent" from the response.
#[sqlx::test]
async fn signed_scoped_anchors_missing_repo_is_404_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let state = test_state(pool).await;
let short = kp
.did()
.to_string()
.split(':')
.next_back()
.unwrap()
.to_string();
let path = format!("/api/v1/arweave/anchors?repo={short}/does-not-exist");
let signed = sign_request(&kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a missing repo must 404 indistinguishably from a non-readable private repo"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v["error"], "repo_not_found",
"the 404 body must carry the standard repo_not_found error code"
);
}
/// `?limit=-1` must not crash as `LIMIT -1` (Postgres 500). It clamps to
/// zero and returns 200 with an empty list — the same shape as a valid
/// listing that happens to have no rows in the configured range.
#[sqlx::test]
async fn signed_anchors_negative_limit_clamps_to_zero_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let state = test_state(pool).await;
let path = "/api/v1/arweave/anchors?limit=-1";
let signed = sign_request(&kp, "GET", path, b"");
let req = Request::builder()
.method("GET")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"?limit=-1 must clamp, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(v["count"], 0, "clamped limit yields an empty list");
assert_eq!(v["anchors"].as_array().map(|a| a.len()), Some(0));
}
}
-175
View File
@@ -16560,181 +16560,6 @@ mod tests {
);
}
/// #134 R2 (split buckets): signed `/api/v1/ipfs/pins` polling must NOT debit
/// the CID resolver's per-IP flood brake. The pre-fix composition merged
/// pins under the same router as `/ipfs/{cid}`, so every signed pin
/// request consumed a token from `ipfs_rate_limiter` and a flood of pinned
/// traffic could 429 legitimate CID reads. RED before the split (pins
/// debited the bucket): a flood of pins followed by a CID request → 429
/// on the CID. GREEN after the split: the CID bucket is untouched.
#[sqlx::test]
async fn ipfs_pins_do_not_debit_the_cid_bucket(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 slug = owner_did.replace([':', '/'], "_");
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
// Bucket size 2: any third /ipfs/{cid} request from the same IP would
// 429 if the bucket were touched by the preceding pin requests.
state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(2, Duration::from_secs(3600));
state.ipfs_work_rate_limiter =
crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor;
// Seed a public, walk-free CID so /ipfs/{cid} serves cheaply. This
// matters because the proof half of the test fires a real CID request
// and needs the route bucket to be the only thing that can 429 it.
let fx = seed_cid_repos(&slug, &short, &["pinsdebit"]);
let bare = std::path::PathBuf::from("/tmp")
.join(&slug)
.join("pinsdebit.git");
let repo = seed_repo(&owner_did, "pinsdebit");
state.db.create_repo(&repo).await.expect("seed repo");
let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
let router = crate::server::build_router(state);
let peer_ip = "203.0.113.50";
// Five signed pin requests — more than the bucket — all must 200 (the
// pins route has no rate limit; the bucket only charges /ipfs/{cid}).
let pins_path = "/api/v1/ipfs/pins";
let pins_signed = sign_request(&kp, "GET", pins_path, b"");
for i in 0..5 {
let req = Request::builder()
.method(Method::GET)
.uri(pins_path)
.header("content-digest", &pins_signed.content_digest)
.header("signature-input", &pins_signed.signature_input)
.header("signature", &pins_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"pin request {i} must not be rate-limited even though the CID bucket is size 2"
);
}
// A subsequent /ipfs/{cid} request from the same IP must still serve
// 200 — the bucket was untouched by the pin traffic. (If pins had
// debited it, the bucket would be empty and the CID request would 429.)
let cid_path = format!("/ipfs/{cid}");
let cid_signed = sign_request(&kp, "GET", &cid_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(&cid_path)
.header("content-digest", cid_signed.content_digest)
.header("signature-input", cid_signed.signature_input)
.header("signature", cid_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the CID bucket must still have tokens — pin traffic did not debit it"
);
}
/// #134 R2 (split buckets, second half): an EXHAUSTED CID bucket must
/// NOT block a valid signed `/api/v1/ipfs/pins` listing. The pre-fix
/// composition routed pins through the same middleware stack, so a CID
/// flood that exhausted the bucket would 429 the pins endpoint too. RED
/// before the split: a CID request that 429s the bucket makes the next
/// pin request 429 too. GREEN after the split: pins never see this
/// bucket and remain servable.
#[sqlx::test]
async fn ipfs_pins_survive_an_exhausted_cid_bucket(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 slug = owner_did.replace([':', '/'], "_");
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
// Bucket size 1: the first /ipfs/{cid} request from the IP spends the
// only token; the second is 429 (proves exhaustion).
state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(3600));
state.ipfs_work_rate_limiter =
crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor;
let fx = seed_cid_repos(&slug, &short, &["pinsexh"]);
let bare = std::path::PathBuf::from("/tmp")
.join(&slug)
.join("pinsexh.git");
let repo = seed_repo(&owner_did, "pinsexh");
state.db.create_repo(&repo).await.expect("seed repo");
let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
let router = crate::server::build_router(state);
let peer_ip = "203.0.113.60";
// First /ipfs/{cid} request — debits the single CID token, serves 200.
let cid_path = format!("/ipfs/{cid}");
let cid_signed = sign_request(&kp, "GET", &cid_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(&cid_path)
.header("content-digest", cid_signed.content_digest)
.header("signature-input", cid_signed.signature_input)
.header("signature", cid_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the first CID request spends the token and serves"
);
// Second /ipfs/{cid} request — bucket empty → 429 (proves the
// exhaustion is real and observable on this router). Re-sign because
// `SignedHeaders` holds owned `String`s.
let cid_signed_2 = sign_request(&kp, "GET", &cid_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(&cid_path)
.header("content-digest", cid_signed_2.content_digest)
.header("signature-input", cid_signed_2.signature_input)
.header("signature", cid_signed_2.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"the second CID request from the same IP is 429 — bucket exhausted"
);
// A signed /api/v1/ipfs/pins request from the SAME IP must still
// serve 200 — pins are auth-only and never see this bucket.
let pins_path = "/api/v1/ipfs/pins";
let pins_signed = sign_request(&kp, "GET", pins_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(pins_path)
.header("content-digest", pins_signed.content_digest)
.header("signature-input", pins_signed.signature_input)
.header("signature", pins_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"an exhausted CID bucket must not block a valid signed pin listing"
);
}
/// U5 (R6): the two buckets are independent — the WORK budget can be exhausted
/// (429) WITHOUT draining the ROUTE bucket. Through the production router, route
/// generous (5) but work tight (1): one request drives two legacy probes, so the