diff --git a/.env.example b/.env.example index b70d1117..5ecf1ad9 100644 --- a/.env.example +++ b/.env.example @@ -102,6 +102,33 @@ GITLAWB_ENFORCE_OWNER_PUSH=false # Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW... GITLAWB_P2P_BOOTSTRAP= +# ── IPFS pin listing (visibility walk + rate limiting) ──────────────────── +# Maximum concurrent visibility walks (git rev-list / ls-tree) across all +# IPFS pin listing requests. Prevents a flood of signed requests from +# exhausting the blocking-pool worker or leaving git children running past +# their timeout. Default 4. +GITLAWB_WALK_CONCURRENCY_LIMIT=4 + +# Per-DID rate limit — requests per hour per signed DID. The listing performs +# Per-DID rate limit — requests per hour per signed DID. The listing performs +# expensive git walks and cat-file probes, so a throwaway DID with a valid +# signature can otherwise exhaust resources. Default 60. +GITLAWB_IPFS_LIST_RATE_LIMIT=60 + +# Global (non-sybil) rate limit — total requests per hour regardless of signed +# DID. Prevents DID-rotation attacks from bypassing the per-DID limiter. +# Charged only after the per-DID check passes so a single DID cannot drain the +# shared bucket with rejected requests. Default 1200. +GITLAWB_IPFS_LIST_GLOBAL_RATE_LIMIT=1200 + +# Arweave anchor listing uses its OWN rate-limit buckets so anchor enumeration +# cannot drain the IPFS pin-listing budget (same shapes, separate state). +# Per-DID requests per hour. Default 60. +GITLAWB_ARWEAVE_LIST_RATE_LIMIT=60 + +# Global (non-sybil) Arweave anchor listing requests per hour. Default 1200. +GITLAWB_ARWEAVE_LIST_GLOBAL_RATE_LIMIT=1200 + # ── Access control ──────────────────────────────────────────────────────── # Reserved for private-read mode. Public/private repo read enforcement is not # wired in the current live release; do not rely on this for private repositories. diff --git a/Cargo.lock b/Cargo.lock index b7050bc6..789af8b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3445,6 +3445,7 @@ dependencies = [ "axum", "base64", "bytes", + "chacha20poly1305", "chrono", "cid", "clap", @@ -3453,6 +3454,7 @@ dependencies = [ "futures", "gitlawb-core", "hex", + "hkdf", "hmac", "http-body-util", "libc", @@ -3467,6 +3469,7 @@ dependencies = [ "mockito", "multiaddr", "prometheus", + "rand 0.8.6", "reqwest", "serde", "serde_json", diff --git a/crates/gitlawb-node/Cargo.toml b/crates/gitlawb-node/Cargo.toml index c583569c..4675cd5a 100644 --- a/crates/gitlawb-node/Cargo.toml +++ b/crates/gitlawb-node/Cargo.toml @@ -73,6 +73,9 @@ alloy = { version = "1", default-features = false, features = [ "rpc-types-eth", ] } libp2p-dns = { version = "0.44.0", features = ["tokio"] } +rand = { workspace = true } +hkdf = "0.12" +chacha20poly1305 = "0.10" [dev-dependencies] mockito = "1" diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index ad8f45a7..e3cf39ff 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -1,12 +1,19 @@ //! GET /api/v1/arweave/anchors — list Arweave ref-update anchors. +//! +//! Requires authentication (RFC 9421 HTTP Signature); anonymous callers are +//! rejected with 401 before any branch. When `?repo=...` is +//! specified the caller must also be authorized to read that repo, and the +//! query uses the normalized slug. Without `?repo=` the endpoint returns +//! anchors scoped to repos the caller can read. use axum::{ extract::{Query, State}, - Json, + Extension, Json, }; use serde::Deserialize; -use crate::error::Result; +use crate::auth::AuthenticatedDid; +use crate::error::{AppError, Result}; use crate::state::AppState; #[derive(Debug, Deserialize)] @@ -20,17 +27,99 @@ fn default_limit() -> i64 { 50 } +/// Compute the set of (repo_slug, owner_did) pairs the caller can read. +/// Used when `?repo=` is absent and the caller is authenticated (P1). +fn readable_repo_pairs( + repos: &[crate::db::RepoRecord], + rules_by_repo: &std::collections::HashMap>, + caller: &str, +) -> (Vec, Vec) { + let mut slugs = Vec::new(); + let mut dids = Vec::new(); + for r in repos { + let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]); + if crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, Some(caller)) { + let owner_short = crate::db::normalize_owner_key(&r.owner_did); + slugs.push(format!("{owner_short}/{}", r.name)); + dids.push(r.owner_did.clone()); + } + } + (slugs, dids) +} + /// GET /api/v1/arweave/anchors pub async fn list_anchors( State(state): State, Query(q): Query, + auth: Option>, ) -> Result> { - 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 caller = auth.as_ref().map(|e| e.0 .0.as_str()); + let limit = q.limit.clamp(0, 200); + + // Reject missing authentication before any branch — the ?repo= path also + // needs a caller so that public repos are gated by the same auth contract (P1). + if caller.is_none() { + return Err(AppError::Unauthorized( + "authentication required for anchor listing".into(), + )); + } + let caller_str = caller.unwrap(); + + // Short-circuit for zero limit before any work or admission check (P2). + if limit == 0 { + return Ok(Json(serde_json::json!({ + "anchors": [], + "count": 0, + }))); + } + + // Shared listing admission (P2): the GLOBAL budget is probed first + // (non-consuming) so a request a full global window is about to reject is + // shed before any per-DID key is allocated — once the global bucket is + // exhausted, a DID flood cannot grow the per-DID map and shed every + // legitimate new caller for the rest of the window. The per-DID bucket is + // then checked before the global slot is committed, so a caller over its + // own per-DID limit sheds without spending shared global capacity. Same + // helper as the pin listing. Checked above the ?repo= branch so the scoped + // path shares the same listing budget as the global path. + crate::rate_limit::check_listing_admission( + &state.arweave_list_global_limiter, + &state.arweave_list_rate_limiter, + caller_str, + "anchor listing", + ) + .await?; + + if let Some(slug) = &q.repo { + let Some((owner_key, name)) = slug.split_once('/') else { + return Err(AppError::BadRequest(format!("invalid repo slug: {slug}"))); + }; + let (record, _rules) = + crate::api::authorize_repo_read(&state, owner_key, name, Some(caller_str), "/").await?; + + // Use the normalized slug so full-DID queries match persisted values (P2) + let owner_short = crate::db::normalize_owner_key(&record.owner_did); + let normalized_slug = format!("{owner_short}/{}", record.name); + let anchors = state + .db + .list_arweave_anchors(Some(&normalized_slug), limit) + .await?; + + return Ok(Json(serde_json::json!({ + "anchors": anchors, + "count": anchors.len(), + }))); + } + + // Authenticated caller without ?repo=: scope to readable repos (P1). + // Use the deduped, quarantine-filtered view (same as the pin listing). + let repos = state.db.list_all_repos_deduped().await?; + let ids: Vec = repos.iter().map(|r| r.id.clone()).collect(); + let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?; + let (repos, owner_dids) = readable_repo_pairs(&repos, &rules_by_repo, caller_str); let anchors = state .db - .list_arweave_anchors(q.repo.as_deref(), limit) + .list_arweave_anchors_for_repos(&repos, &owner_dids, limit) .await?; Ok(Json(serde_json::json!({ @@ -39,6 +128,464 @@ pub async fn list_anchors( }))) } +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::test_state; + use axum::extract::{Extension, Query, State}; + use sqlx::PgPool; + + use crate::api::ipfs::list_pins; + + fn alice_did() -> String { + "did:key:z6MkwAlice".into() + } + + fn bob_did() -> String { + "did:key:z6MkwBob".into() + } + + fn auth_ext(did: &str) -> Option> { + Some(Extension(AuthenticatedDid(did.to_string()))) + } + + #[sqlx::test] + async fn anonymous_is_401_before_any_db_work(pool: PgPool) { + let state = test_state(pool).await; + let q = Query(ListAnchorsQuery { + repo: None, + limit: 50, + }); + let result = list_anchors(State(state), q, None).await; + assert!( + matches!(result, Err(AppError::Unauthorized(_))), + "expected 401 for anonymous, got {result:?}" + ); + } + + #[sqlx::test] + async fn anonymous_with_repo_is_401(pool: PgPool) { + let state = test_state(pool).await; + let q = Query(ListAnchorsQuery { + repo: Some("z6MkwAlice/public-repo".into()), + limit: 50, + }); + let result = list_anchors(State(state), q, None).await; + assert!( + matches!(result, Err(AppError::Unauthorized(_))), + "expected 401 for anonymous with ?repo=, got {result:?}" + ); + } + + #[sqlx::test] + async fn stranger_repo_on_private_is_denied(pool: PgPool) { + let state = test_state(pool).await; + + // Seed a private repo. + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("arw-test-private") + .bind("priv-repo") + .bind(alice_did()) + .bind("desc") + .bind(false) + .bind("main") + .bind("2026-07-19T00:00:00Z") + .bind("2026-07-19T00:00:00Z") + .bind("/srv/priv-repo") + .execute(state.db.pool()) + .await + .unwrap(); + + // Seed an anchor for the private repo. + sqlx::query( + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + ) + .bind("anchor-1") + .bind("z6MkwAlice/priv-repo") + .bind(alice_did()) + .bind("refs/heads/main") + .bind("0000") + .bind("aaaa") + .bind("QmAnchor") + .bind("irys-tx-1") + .bind("https://arweave.net/tx1") + .bind("did:key:z6MkwNode") + .bind("2026-07-19T00:00:00Z") + .execute(state.db.pool()) + .await + .unwrap(); + + // Bob (stranger) tries ?repo= on private repo — denied. + let q = Query(ListAnchorsQuery { + repo: Some("z6MkwAlice/priv-repo".into()), + limit: 50, + }); + let result = list_anchors(State(state), q, auth_ext(&bob_did())).await; + assert!( + matches!(result, Err(AppError::RepoNotFound(_))), + "stranger should get RepoNotFound for private repo, got {result:?}" + ); + } + + /// The Arweave listing uses its OWN rate-limit buckets: exhausting the + /// per-DID arweave bucket must NOT touch the per-DID ipfs listing bucket, + /// and an exhausted arweave bucket must refuse anchor listing with 429. + /// MUTATION (RED): point `list_anchors` back at + /// `state.ipfs_list_rate_limiter` (the pre-fix shared bucket) and the + /// "arweave bucket still admits" assertion fails. + #[sqlx::test] + async fn arweave_uses_own_rate_limit_buckets(pool: PgPool) { + let mut state = test_state(pool).await; + // Exhaust the ARWEAVE per-DID bucket (budget 1), leave the IPFS + // listing bucket generous. + state.arweave_list_rate_limiter = crate::rate_limit::RateLimiter::new_bounded( + 1, + std::time::Duration::from_secs(3600), + 200_000, + ); + + let did = alice_did(); + + // First request admits (charges the arweave bucket). + let r1 = list_anchors( + State(state.clone()), + Query(ListAnchorsQuery { + repo: None, + limit: 50, + }), + auth_ext(&did), + ) + .await; + assert!( + r1.is_ok(), + "first request should pass the arweave per-DID bucket, got {r1:?}" + ); + + // Second request — the arweave bucket is exhausted. + let r2 = list_anchors( + State(state.clone()), + Query(ListAnchorsQuery { + repo: None, + limit: 50, + }), + auth_ext(&did), + ) + .await; + assert!( + matches!(r2, Err(AppError::TooManyRequests(_))), + "second request must be shed by the exhausted arweave bucket, got {r2:?}" + ); + + // The IPFS listing bucket is a SEPARATE limiter: same DID can still + // hit the pin listing without tripping the arweave shed. + let pins = list_pins( + State(state), + axum::extract::Query(crate::api::ipfs::ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + auth_ext(&did), + ) + .await; + assert!( + pins.is_ok() || matches!(&pins, Err(AppError::Unauthorized(_))), + "ipfs listing must use its own bucket, got {pins:?}" + ); + } + + #[sqlx::test] + async fn global_path_scopes_to_readable_repos(pool: PgPool) { + let state = test_state(pool).await; + + // Seed two repos: one public (readable by all) and one private (readable + // only by Alice). Bob should only see the public repo's anchors. + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("arw-pub") + .bind("pub-repo") + .bind(alice_did()) + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-19T00:00:00Z") + .bind("2026-07-19T00:00:00Z") + .bind("/srv/pub") + .execute(state.db.pool()) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("arw-priv") + .bind("priv-repo") + .bind(alice_did()) + .bind("desc") + .bind(false) + .bind("main") + .bind("2026-07-19T00:00:00Z") + .bind("2026-07-19T00:00:00Z") + .bind("/srv/priv") + .execute(state.db.pool()) + .await + .unwrap(); + + // Seed anchors for both repos. + sqlx::query( + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + ) + .bind("anchor-pub") + .bind("z6MkwAlice/pub-repo") + .bind(alice_did()) + .bind("refs/heads/main") + .bind("0000") + .bind("aaaa") + .bind("QmPub") + .bind("irys-tx-pub") + .bind("https://arweave.net/pub") + .bind("did:key:z6MkwNode") + .bind("2026-07-19T00:00:00Z") + .execute(state.db.pool()) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + ) + .bind("anchor-priv") + .bind("z6MkwAlice/priv-repo") + .bind(alice_did()) + .bind("refs/heads/main") + .bind("0000") + .bind("bbbb") + .bind("QmPriv") + .bind("irys-tx-priv") + .bind("https://arweave.net/priv") + .bind("did:key:z6MkwNode") + .bind("2026-07-19T00:00:00Z") + .execute(state.db.pool()) + .await + .unwrap(); + + // Bob (stranger) without ?repo=: only public repo anchors returned. + let q = Query(ListAnchorsQuery { + repo: None, + limit: 200, + }); + let Json(body) = list_anchors(State(state), q, auth_ext(&bob_did())) + .await + .unwrap(); + let anchors = body["anchors"].as_array().unwrap(); + let count = body["count"].as_u64().unwrap(); + assert_eq!(count, 1, "bob should see only 1 anchor (public repo)"); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0]["cid"].as_str(), Some("QmPub")); + } + + /// Mirror/canonical owner-spelling regression (P2, parallel to the pin + /// listing's twin-slug test). An anchor recorded through a BARE-key mirror + /// row persists owner_did as the bare key, while the deduped catalog + /// surfaces the canonical `did:key:` twin; `list_arweave_anchors_for_repos` + /// must match under either spelling. Both the global listing (which passes + /// the deduped canonical pairs) and the scoped `?repo=` listing (which + /// filters by normalized slug) must return the anchor. + #[sqlx::test] + async fn mirror_bare_key_anchor_visible_under_canonical_twin(pool: PgPool) { + let state = test_state(pool).await; + + // Bare-key MIRROR row (slash-form id, bare owner key). + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("z6MkwAlice/mirror-repo") + .bind("mirror-repo") + .bind("z6MkwAlice") // bare key + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-19T00:00:00Z") + .bind("2026-07-19T00:00:00Z") + .bind("/srv/mirror") + .execute(state.db.pool()) + .await + .unwrap(); + + // Canonical twin (UUID id, full did:key). + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("mirror-canon-id") + .bind("mirror-repo") + .bind(alice_did()) // did:key:z6MkwAlice + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-19T00:00:00Z") + .bind("2026-07-19T00:00:00Z") + .bind("/srv/canon") + .execute(state.db.pool()) + .await + .unwrap(); + + // Anchor recorded through the MIRROR: repo slug uses the bare key and + // owner_did persists as the bare key — exactly what the mirror's writer + // stores. + sqlx::query( + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + ) + .bind("anchor-mirror") + .bind("z6MkwAlice/mirror-repo") + .bind("z6MkwAlice") // bare key spelling persisted by the mirror + .bind("refs/heads/main") + .bind("0000") + .bind("aaaa") + .bind("QmMirror") + .bind("irys-tx-mirror") + .bind("https://arweave.net/mirror") + .bind("did:key:z6MkwNode") + .bind("2026-07-19T00:00:00Z") + .execute(state.db.pool()) + .await + .unwrap(); + + // Global path: the deduped catalog surfaces the canonical twin + // (did:key:z6MkwAlice), so the owner comparison must fold the bare-key + // anchor to match. Alice is the owner of both rows. + let q = Query(ListAnchorsQuery { + repo: None, + limit: 200, + }); + let Json(body) = list_anchors(State(state.clone()), q, auth_ext(&alice_did())) + .await + .unwrap(); + let anchors = body["anchors"].as_array().unwrap(); + let count = body["count"].as_u64().unwrap(); + assert_eq!( + count, 1, + "global listing must return the bare-key mirror anchor under the \ + canonical twin, got {count}: {anchors:?}" + ); + assert_eq!(anchors[0]["cid"].as_str(), Some("QmMirror")); + + // Scoped path: `?repo=` authorizes via the canonical row and filters by + // the normalized slug, so the same anchor must be returned. + let q = Query(ListAnchorsQuery { + repo: Some("z6MkwAlice/mirror-repo".into()), + limit: 200, + }); + let Json(body) = list_anchors(State(state), q, auth_ext(&alice_did())) + .await + .unwrap(); + let anchors = body["anchors"].as_array().unwrap(); + let count = body["count"].as_u64().unwrap(); + assert_eq!( + count, 1, + "scoped listing must return the bare-key mirror anchor, got {count}: {anchors:?}" + ); + assert_eq!(anchors[0]["cid"].as_str(), Some("QmMirror")); + } + + /// Regression (P2, mirror of the pin-listing test): a request the exhausted + /// GLOBAL bucket rejects must not allocate per-DID limiter state at the + /// anchor handler either. The fix probes the fixed global budget first + /// (non-consuming), so a fresh-DID flood once the global window is full + /// allocates no per-DID keys, and a new legit DID is still admitted once the + /// global window resets. + #[sqlx::test] + async fn anchor_global_exhaustion_does_not_populate_per_did_state(pool: PgPool) { + let mut state = test_state(pool).await; + let window = std::time::Duration::from_millis(150); + state.arweave_list_rate_limiter = + crate::rate_limit::RateLimiter::new_bounded(2, window, 200_000); + state.arweave_list_global_limiter = + crate::rate_limit::RateLimiter::new_bounded(1, window, 1); + + let call = |did: &str| { + list_anchors( + State(state.clone()), + Query(ListAnchorsQuery { + repo: None, + limit: 50, + }), + auth_ext(did), + ) + }; + + // First legit caller passes (per-DID key recorded, global slot committed). + let r1 = call("did:key:z6MkwLegit1").await; + assert!( + r1.is_ok(), + "first legit caller should pass the global budget, got {r1:?}" + ); + assert_eq!( + state.arweave_list_rate_limiter.tracked_keys().await, + 1, + "one per-DID key tracked after the legit call" + ); + + // Global window is now full — flood fresh DIDs. Every one is shed 429 + // and the flood must NOT grow the per-DID key map. + for i in 0..20 { + let r = call(&format!("did:key:z6MkwFlood{i}")).await; + assert!( + matches!(r, Err(AppError::TooManyRequests(_))), + "flood caller {i} must be shed by the full global bucket, got {r:?}" + ); + } + assert_eq!( + state.arweave_list_rate_limiter.tracked_keys().await, + 1, + "the DID flood must not have allocated per-DID keys while the global bucket was full" + ); + + // When the global window resets, a fresh legitimate DID is still admitted. + tokio::time::sleep(window + std::time::Duration::from_millis(30)).await; + let r2 = call("did:key:z6MkwLegit2").await; + assert!( + r2.is_ok(), + "fresh legit DID should be admitted once the global window resets, got {r2:?}" + ); + } + + /// A negative `limit` must clamp to 0 (empty result, 200), never reaching + /// Postgres as a negative LIMIT (which the driver rejects with a DB error → + /// 500). Regression locking in the `q.limit.clamp(0, 200)` floor (P2). + #[sqlx::test] + async fn negative_limit_clamps_to_zero(pool: PgPool) { + let state = test_state(pool).await; + let q = Query(ListAnchorsQuery { + repo: None, + limit: -1, + }); + let Json(body) = list_anchors(State(state), q, auth_ext(&alice_did())) + .await + .expect("negative limit must not produce a DB error"); + assert_eq!( + body["count"].as_u64(), + Some(0), + "negative limit must clamp to 0, not reach Postgres as negative LIMIT" + ); + assert_eq!( + body["anchors"].as_array().unwrap().len(), + 0, + "negative limit must not return rows" + ); + } +} + #[cfg(test)] mod closed_pool_tests { use super::*; @@ -60,6 +607,10 @@ mod closed_pool_tests { .oneshot( Request::builder() .uri("/api/v1/arweave/anchors") + // The listing gate requires authentication before the DB + // call (PR #121); the closed-pool 503 must still fire for + // an authenticated caller. + .extension(crate::auth::AuthenticatedDid("did:key:z6MkwAlice".into())) .body(axum::body::Body::empty()) .unwrap(), ) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index df7a42db..b54aaac3 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -15,14 +15,30 @@ //! see `get_by_cid`). use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Extension, Json, }; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit}, + XChaCha20Poly1305, XNonce, +}; use cid::CidGeneric; +use hkdf::Hkdf; +use rand::rngs::OsRng; +use serde::Deserialize; +use sha2::Sha256; use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; @@ -678,20 +694,878 @@ pub async fn get_by_cid( ))) } +/// Query parameters for `GET /api/v1/ipfs/pins`. +#[derive(Debug, Deserialize, Clone)] +pub struct ListPinsQuery { + #[serde(default = "default_limit")] + pub limit: i64, + pub cursor: Option, + pub truncated_cursor: Option, +} + +fn default_limit() -> i64 { + 50 +} + +/// Derive a dedicated 32-byte cursor cipher key from the node's Ed25519 seed +/// using HKDF with a domain-separated info string. This decouples cursor +/// confidentiality from the write-signing identity and avoids feeding the raw +/// seed into an unrelated primitive. +fn derive_cursor_key(seed: &[u8; 32]) -> [u8; 32] { + let hk = Hkdf::::new(None, seed.as_slice()); + let mut okm = [0u8; 32]; + hk.expand(b"gitlawb-ipfs-cursor-v1", &mut okm) + .expect("32 bytes is a valid HKDF output length"); + okm +} + +/// Create an opaque, self-contained truncated cursor token using +/// XChaCha20Poly1305 AEAD. +/// +/// Format: `base64_url_no_pad(nonce_24 || ciphertext)` where `ciphertext` = +/// XChaCha20Poly1305-encrypt(expiry_be_8 || cursor_string) with the 16-byte +/// AEAD tag appended by the encryptor. The caller cannot decode hidden-row +/// metadata without the server's Ed25519 seed. Tokens are durable (survive +/// restart, cross-node routing, retries) and expire after 600 seconds. +fn create_opaque_cursor(seed: &[u8; 32], cursor: &str) -> String { + let cursor_key = derive_cursor_key(seed); + let cipher = XChaCha20Poly1305::new_from_slice(&cursor_key) + .expect("32-byte key is valid for XChaCha20Poly1305"); + + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + + let expiry = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + 600) + .to_be_bytes(); + + let mut plaintext = Vec::with_capacity(8 + cursor.len()); + plaintext.extend_from_slice(&expiry); + plaintext.extend_from_slice(cursor.as_bytes()); + + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .expect("AEAD encrypt should never fail"); + + let mut token = Vec::with_capacity(24 + ciphertext.len()); + token.extend_from_slice(nonce.as_ref()); + token.extend_from_slice(&ciphertext); + + URL_SAFE_NO_PAD.encode(&token) +} + +/// Decode and verify an opaque truncated cursor token. +/// Returns the original cursor string if valid and not expired. +fn decode_opaque_cursor(seed: &[u8; 32], token: &str) -> Option<(String, String, String)> { + let cursor_key = derive_cursor_key(seed); + let cipher = XChaCha20Poly1305::new_from_slice(&cursor_key) + .expect("32-byte key is valid for XChaCha20Poly1305"); + + let data = URL_SAFE_NO_PAD.decode(token.as_bytes()).ok()?; + if data.len() < 24 + 1 { + return None; + } + + let (nonce_bytes, ciphertext) = data.split_at(24); + let nonce = XNonce::from_slice(nonce_bytes); + + let plaintext = cipher.decrypt(nonce, ciphertext).ok()?; + if plaintext.len() < 8 { + return None; + } + + let expiry = u64::from_be_bytes(plaintext[..8].try_into().ok()?); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if now >= expiry { + return None; + } + + let cursor = std::str::from_utf8(&plaintext[8..]).ok()?; + + let parts: Vec<&str> = cursor.splitn(3, '|').collect(); + if parts.len() >= 2 { + let repo = parts.get(2).map(|s| s.to_string()).unwrap_or_default(); + Some((parts[0].to_string(), parts[1].to_string(), repo)) + } else { + None + } +} + +/// Batch-check git object types for many SHAs in a single repo, using one +/// `git cat-file --batch-check` subprocess instead of N individual `cat-file -t` +/// calls. Returns a map from SHA → `Some("blob"|"commit"|"tree"|"tag")` or +/// `None` (missing/dangling). +/// +/// Must be called from a blocking context (e.g. `tokio::task::spawn_blocking`) +/// since it spawns a child process and reads its output synchronously. +fn batch_object_types( + repo_path: &std::path::Path, + shas: &[String], + cancelled: &AtomicBool, +) -> Result>> { + use anyhow::Context; + + let mut child = Command::new("git") + .args(["cat-file", "--batch-check"]) + .current_dir(repo_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("failed to spawn git cat-file --batch-check")?; + + { + let stdin = child.stdin.as_mut().context("stdin not captured")?; + for sha in shas { + writeln!(stdin, "{sha}").context("failed to write sha to cat-file stdin")?; + } + } + + // Drop stdin so the child sees EOF on its input pipe. + drop(child.stdin.take()); + + // Drain stdout on a background thread so the pipe can never fill up and + // deadlock the child while we poll for completion (P2). Otherwise a + // batch larger than the OS pipe buffer would block the child on write + // and try_wait would never observe an exit. + let mut stdout_reader = child.stdout.take().context("stdout not captured")?; + let stdout_thread = std::thread::spawn(move || { + use std::io::Read; + let mut buf = Vec::new(); + let _ = stdout_reader.read_to_end(&mut buf); + buf + }); + + // Poll for completion, checking the cancellation flag between iterations. + let status = loop { + if cancelled.load(Ordering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Ok(HashMap::new()); + } + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + std::thread::sleep(std::time::Duration::from_millis(50)); + continue; + } + Err(e) => { + return Err(AppError::Git(format!( + "git cat-file --batch-check wait failed: {e}", + ))); + } + } + }; + + // The child has exited, so the pipe write end is closed; join the reader + // thread which sees EOF once it drains the remaining buffered output. + let stdout = stdout_thread + .join() + .map_err(|_| AppError::Git("git cat-file --batch-check stdout reader panicked".into()))?; + + // A non-zero exit means the check itself failed (e.g. repo gone) — do not + // interpret partial output as authoritative (P2). + if !status.success() { + return Err(AppError::Git( + "git cat-file --batch-check exited unsuccessfully".into(), + )); + } + + let stdout = String::from_utf8_lossy(&stdout); + + let mut results = HashMap::with_capacity(shas.len()); + for line in stdout.lines() { + let parts: Vec<&str> = line.splitn(3, ' ').collect(); + if parts.len() < 2 { + continue; + } + let sha = parts[0].to_string(); + match parts[1] { + "missing" => { + results.insert(sha, None); + } + obj_type => { + results.insert(sha, Some(obj_type.to_string())); + } + } + } + Ok(results) +} + /// GET /api/v1/ipfs/pins /// /// 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) -> Result> { - // 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?; +/// +/// Requires authentication: the global pin index would otherwise disclose +/// metadata for every object ever pushed here (#121). +/// +/// The global listing filters each pinned object on current repo visibility +/// to prevent metadata disclosure when repos are made private after push (#136). +/// Only pins from repos the caller can currently read are returned. +/// +/// Response fields: `pins` is this page's visible pins (0..=limit), `count` +/// is the number of pins in THIS page only (not a node-wide total), and +/// `truncated` is set when the scan was cut short by the walk/probe/deadline +/// budgets — paginate with `next_cursor`/`truncated_cursor` to continue (P2). +pub async fn list_pins( + State(state): State, + Query(query): Query, + auth: Option>, +) -> Result> { + let caller = auth.as_ref().map(|e| e.0 .0.as_str()); + + // Reject anonymous callers: the pin index spans the entire node and would + // expose metadata for every object ever pushed here (#121). + if caller.is_none() { + return Err(AppError::Unauthorized( + "authentication required for pin listing".into(), + )); + } + let caller_str = caller.unwrap(); + let caller_owned = Some(caller_str.to_string()); + + // Clamp/handle zero limit before any quota or expensive work so short-lived + // requests do not drain rate-limit buckets or enumerate the node (P2). + let max_visible = query.limit.clamp(0, 200); + + if max_visible == 0 { + return Ok(Json(serde_json::json!({ + "pins": [], + "count": 0, + }))); + } + + // Decode cursors BEFORE charging any rate limit: malformed or expired + // tokens return 400 without consuming per-DID/global quota or triggering + // the full-repo catalog load (P2). + let decode_cursor = |s: &str| -> Option<(String, String)> { + let bytes = URL_SAFE_NO_PAD.decode(s.as_bytes()).ok()?; + let decoded = String::from_utf8(bytes).ok()?; + let parts: Vec<&str> = decoded.splitn(2, '|').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + None + } + }; + let encode_cursor = + |pa: &str, sha: &str| -> String { URL_SAFE_NO_PAD.encode(format!("{pa}|{sha}")) }; + + let initial_cursor: Option<(String, String, String)> = match query.cursor.as_ref() { + Some(c) => match decode_cursor(c) { + Some((pa, sha)) => Some((pa, sha, String::new())), + None => { + return Err(AppError::BadRequest( + "invalid cursor: expected base64-encoded pinned_at|sha256_hex".into(), + )) + } + }, + None => None, + }; + + // Truncated resume cursor: XChaCha20Poly1305 AEAD token. Decrypts to the + // same (pinned_at, sha256_hex, repo) cursor on the server side but the + // caller cannot decode hidden-row metadata from the wire format. If the + // token is present but undecodable we return an explicit error so the + // client does not silently restart at page 1. + let truncated_resume: Option<(String, String, String)> = match query.truncated_cursor.as_ref() { + Some(t) => { + let seed = state.cursor_seed(); + match decode_opaque_cursor(&seed, t) { + Some((pa, sha, repo)) => Some((pa, sha, repo)), + None => { + return Err(AppError::BadRequest( + "invalid or expired truncated_cursor".into(), + )) + } + } + } + None => None, + }; + + // Shared listing admission (P2): the GLOBAL budget is probed first + // (non-consuming) so a request a full global window is about to reject is + // shed before any per-DID key is allocated — once the global bucket is + // exhausted, a DID flood cannot grow the per-DID map and shed every + // legitimate new caller for the rest of the window. The per-DID bucket is + // then checked before the global slot is committed, so a caller over its + // own per-DID limit sheds without spending shared global capacity. Same + // helper as the anchor listing. + crate::rate_limit::check_listing_admission( + &state.ipfs_list_global_limiter, + &state.ipfs_list_rate_limiter, + caller_str, + "IPFS pin listing", + ) + .await?; + + // Build the set of readable repo slugs and owner DIDs from the deduped repo view + // (mirror rows already collapsed, quarantined excluded), then query + // pins bounded in SQL. + let repos = state.db.list_all_repos_deduped().await?; + let repo_ids: Vec = repos.iter().map(|r| r.id.clone()).collect(); + let rules_by_repo = state.db.list_visibility_rules_for_repos(&repo_ids).await?; + + // Build parallel vectors of readable (slug, owner_did) pairs to query in SQL. + let mut query_repos = Vec::new(); + let mut query_owner_dids = Vec::new(); + + for r in &repos { + let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]); + if visibility_check(rules, r.is_public, &r.owner_did, caller, "/") == Decision::Deny { + continue; + } + let short = crate::db::normalize_owner_key(&r.owner_did); + let slug = format!("{}/{}", short, r.name); + query_repos.push(slug); + query_owner_dids.push(r.owner_did.clone()); + } + + // Build a lookup of slug -> (repo, rules) once. + let mut repos_by_slug = HashMap::new(); + for r in repos { + let short = crate::db::normalize_owner_key(&r.owner_did); + let slug = format!("{}/{}", short, r.name); + let rules = rules_by_repo.get(&r.id).cloned().unwrap_or_default(); + repos_by_slug.insert(slug, (r, rules)); + } + + // Use keyset pagination to fetch batches and post-filter path-scoped + // hidden pins so the caller still receives up to `max_visible` visible + // entries even when newer pins are hidden under /secret/** rules. + // Keyset cursor avoids duplicate/skip rows when new pins land between + // batches (unlike LIMIT/OFFSET) and removes the cost of deep OFFSET + // re-scanning. + // + // The loop is bounded by MAX_BATCHES to prevent a single request from + // scanning an unbounded number of hidden rows. Path-scoped git walks + // are independently bounded by MAX_WALKS as a secondary safeguard. + // + // next_cursor is derived from the last *accepted* (visible) pin, never + // from the last scanned row, to avoid leaking withheld-blob metadata + // or skipping rows the caller was never shown. + const BATCH_SIZE: i64 = 200; // max unique SHAs per batch + const MAX_ASSOC: i64 = 2000; // max association rows per batch (SHA limit × 10) + const MAX_BATCHES: usize = 10; + const MAX_WALKS: usize = 50; + const MAX_PROBES: usize = 200; + // P1: hard deadline for cumulative visibility-walk work so a single + // request with many path-scoped repos cannot hold the global permits + // for minutes on end. + const LISTING_DEADLINE_SECS: u64 = 120; + let listing_deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(LISTING_DEADLINE_SECS); + let mut batch_count = 0usize; + let mut batch_hit_limit = false; + let mut pins = Vec::new(); + // Dedup by sha256_hex: the SQL query returns all associations per SHA + // (one per readable repo), so the same object can appear via multiple + // repo associations. Track seen SHAs so each object is emitted at most + // once, after evaluating per-association visibility (P2). + let mut seen_shas: HashSet = HashSet::new(); + let mut db_cursor: Option<(String, String, String)> = truncated_resume.or(initial_cursor); + let mut response_cursor: Option<(String, String)> = None; + let mut allowed_blobs_by_repo: HashMap, PathBuf)> = HashMap::new(); + let mut page_truncated = false; + // Set when a visibility walk fails so the fetch loop stops instead of + // burning MAX_BATCHES iterations re-fetching a tail it cannot make + // progress on (P2). + let mut walk_failed = false; + // Per-repo cache of sha256_hex → is_structural (true for commit/tree/tag). + let mut structural_cache: HashMap> = HashMap::new(); + let mut probe_count = 0usize; + let mut probe_limit = usize::MAX; + + 'fetch: loop { + if batch_count >= MAX_BATCHES { + batch_hit_limit = true; + break; + } + batch_count += 1; + + let batch = if query_repos.is_empty() { + Vec::new() + } else { + state + .db + .list_pinned_cids_for_repos( + &query_repos, + &query_owner_dids, + BATCH_SIZE, + MAX_ASSOC, + db_cursor + .as_ref() + .map(|(pa, sha, repo)| (pa.as_str(), sha.as_str(), repo.as_str())), + ) + .await? + }; + + if batch.is_empty() { + break; + } + + // Snapshot the cursor used to fetch THIS batch so Phase 3 can retry + // the first row inclusively when it is deferred (P2). + let batch_cursor = db_cursor.clone(); + + // ── Phase 1 — collect structural candidates per repo ────────────── + // Track per-pin outcome: None = structural candidate (needs type check + // before final decision), Some(false) = hidden, Some(true) = visible. + let mut pin_outcome: Vec> = Vec::with_capacity(batch.len()); + let mut structural_candidates: HashMap> = HashMap::new(); + let mut walk_limit_idx = batch.len(); + + for (i, pin) in batch.iter().enumerate() { + if pin.repo.is_empty() { + db_cursor = Some((pin.pinned_at.clone(), pin.sha256_hex.clone(), String::new())); + pin_outcome.push(None); + continue; + } + let Some((repo, rules)) = repos_by_slug.get(&pin.repo) else { + // Unknown slug — advance cursor past it, no visibility check. + db_cursor = Some(( + pin.pinned_at.clone(), + pin.sha256_hex.clone(), + pin.repo.clone(), + )); + pin_outcome.push(None); + continue; + }; + + if !has_path_scoped_rule(rules) { + // No path-scoped rules — every pin from this repo is visible. + pin_outcome.push(Some(true)); + continue; + } + + // Path-scoped repo — ensure walk result is cached. + if !allowed_blobs_by_repo.contains_key(&repo.id) { + if allowed_blobs_by_repo.len() >= MAX_WALKS { + // Walk budget exhausted. Stop before this pin and leave + // db_cursor at the last processed pin so the next request + // picks up here and retries the walk. + page_truncated = true; + walk_limit_idx = i; + break; + } + // Respect the total visibility-walk deadline so a single + // request cannot hold the global permits for minutes (P1). + if tokio::time::Instant::now() >= listing_deadline { + page_truncated = true; + if i < walk_limit_idx { + walk_limit_idx = i; + } + // Defer like the walk-failure arm: nothing processed past + // this row is decided this request, so stop classifying to + // avoid deciding pins Phase 3 would then drop at the wall. + break; + } + + // Acquire a concurrency permit so a flood of requests cannot + // exhaust the blocking-pool worker or leave unbounded git + // children running (P1). + let permit = match state.walk_semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + // All walk slots are occupied — defer this repo's pins + // to the next request. + page_truncated = true; + if i < walk_limit_idx { + walk_limit_idx = i; + } + // Same deferral shape as the deadline arm above: stop + // classifying at the first deferred row so Phase 3 does + // not drop already-decided pins that fall past the wall. + break; + } + }; + + // Wrap acquire() in a timeout so a slow Tigris fetch does not + // hold the walk permit unboundedly (P2). + let acquire_fut = state.repo_store.acquire(&repo.owner_did, &repo.name); + match tokio::time::timeout(std::time::Duration::from_secs(30), acquire_fut).await { + Ok(Ok(rp)) => { + let rp_clone = rp.clone(); + let r_clone = rules.clone(); + let is_public = repo.is_public; + let owner = repo.owner_did.clone(); + let caller_for_walk = caller_owned.clone(); + let git_bin = state.git_bin.clone(); + let git_service_timeout = + std::time::Duration::from_secs(state.config.git_service_timeout_secs); + let walk_deadline = listing_deadline; + + // Move the semaphore permit into the blocking task so it + // is released only when the walk truly completes — even + // on timeout the permit stays alive until the git child + // is killed and the worker returns (P1). The walk itself + // is deadline-bounded (`allowed_blob_set_for_caller_bounded`), + // which reaps its git children past the budget (#174). + // The walk's own budget is derived from the listing + // deadline HERE, inside the closure — not on the async + // side before the task is queued — so queue delay is + // charged against the listing budget exactly as + // `get_by_cid` clamps its walk. Without this clamp a + // slow walk holds its permit for the full + // `git_service_timeout_secs` even after the handler has + // moved on (the outer 60 s timeout does not cancel + // `spawn_blocking`) (P2). + let walk_fut = tokio::task::spawn_blocking(move || { + let _hold = permit; + let walk_timeout = std::cmp::min( + git_service_timeout, + walk_deadline + .saturating_duration_since(tokio::time::Instant::now()), + ); + allowed_blob_set_for_caller_bounded( + &rp_clone, + &git_bin, + walk_timeout, + &r_clone, + is_public, + &owner, + caller_for_walk.as_deref(), + ) + }); + match tokio::time::timeout(std::time::Duration::from_secs(60), walk_fut) + .await + { + Ok(Ok(Ok(allowed))) => { + allowed_blobs_by_repo.insert(repo.id.clone(), (allowed, rp)); + } + _ => { + // Walk failed (timeout / error / panic). Do + // NOT cache an empty result here: that would + // classify this repo's pins as hidden and + // permanently skip rows the caller should see + // (P2). Defer them by stopping before this + // index and ending the request; the next + // request re-attempts the walk. + page_truncated = true; + walk_failed = true; + if i < walk_limit_idx { + walk_limit_idx = i; + } + break; + } + } + } + Ok(Err(_)) | Err(_) => { + // Repo-store acquisition failed or timed out — same + // strategy (P2). The walk permit is dropped here. + // Never cache an empty result (would hide this repo's + // pins); defer to the next request instead. + page_truncated = true; + walk_failed = true; + if i < walk_limit_idx { + walk_limit_idx = i; + } + break; + } + }; + } + + let (allowed, repo_path) = allowed_blobs_by_repo.get(&repo.id).unwrap(); + if allowed.contains(&pin.sha256_hex) { + pin_outcome.push(Some(true)); + } else if !repo_path.as_os_str().is_empty() { + // Not in the allowed set — could be a withheld blob or a + // structural object (commit/tree/tag). Mark as structural + // candidate; Phase 2 will probe the type. + pin_outcome.push(None); // deferred — decided after phase 2 + structural_candidates + .entry(repo.id.clone()) + .or_default() + .push((i, pin.sha256_hex.clone())); + } else { + pin_outcome.push(Some(false)); + } + } + + // When Phase 1 never advanced db_cursor (all pins are path-scoped and + // no walk permit was available), keep the batch-fetch cursor so the + // next request retries the same batch. Only advance past the batch + // when walk permits were available but the pins had no repo match, + // because those rows are permanently unprocessable (P2). + let all_deferred = walk_limit_idx == 0 && db_cursor.as_ref() == batch_cursor.as_ref(); + if all_deferred { + // db_cursor already equals batch_cursor — the next fetch uses the + // same position and retries the deferred path-scoped pins. + } else if db_cursor.as_ref() == batch_cursor.as_ref() { + // All pins had empty/unmatched repos — advance past the batch so + // we don't loop forever on the same unprocessable rows (P1). + if let Some(last) = batch.last() { + db_cursor = Some(( + last.pinned_at.clone(), + last.sha256_hex.clone(), + last.repo.clone(), + )); + } + } + + // ── Phase 2 — batch-check structural candidates per repo ────────── + 'phase2: for (repo_id, candidates) in &structural_candidates { + // Honor the same listing_deadline and walk_semaphore that Phase 1 + // respects, so probe subprocesses don't run past the total request + // budget or outside the concurrency cap (P2). + if tokio::time::Instant::now() >= listing_deadline { + for &(idx, _) in candidates { + if idx < probe_limit { + probe_limit = idx; + } + } + continue 'phase2; + } + let probe_permit = match state.walk_semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + for &(idx, _) in candidates { + if idx < probe_limit { + probe_limit = idx; + } + } + continue 'phase2; + } + }; + if probe_count >= MAX_PROBES { + // Probe budget exhausted. Fold EVERY remaining structural + // candidate index (not just the current repo's) into + // probe_limit so none are silently dropped as hidden. + for &(idx, _) in candidates { + if idx < probe_limit { + probe_limit = idx; + } + } + continue 'phase2; + } + let rp = allowed_blobs_by_repo + .get(repo_id) + .map(|(_, p)| p.clone()) + .unwrap_or_default(); + if rp.as_os_str().is_empty() { + continue; + } + // Filter to SHAs not already cached. + let repo_cache = structural_cache.entry(repo_id.clone()).or_default(); + let to_check: Vec = candidates + .iter() + .filter(|(_, sha)| !repo_cache.contains_key(sha)) + .map(|(_, sha)| sha.clone()) + .collect(); + if to_check.is_empty() { + continue; + } + let remaining = MAX_PROBES.saturating_sub(probe_count); + let to_check: Vec = to_check.into_iter().take(remaining).collect(); + probe_count += to_check.len(); + + // Fold into probe_limit every candidate from THIS repo that will NOT + // be probed this request, so Phase 3 stops before it (P2). The + // unprobed set is "uncached AND dropped by the probe-budget take", + // not `skip(to_check.len())`: cache hits are already decided and must + // NOT fold (they stop Phase 3 needlessly), so an iterate-and-filter + // against the final probe list keeps the fold honest when earlier + // candidates were cache hits (reviewer finding). + let probed: std::collections::HashSet<&str> = + to_check.iter().map(String::as_str).collect(); + for (idx, sha) in candidates { + if !repo_cache.contains_key(sha) + && !probed.contains(sha.as_str()) + && *idx < probe_limit + { + probe_limit = *idx; + } + } + + let rp_for_block = rp.clone(); + let cancelled_probe = Arc::new(AtomicBool::new(false)); + let cancelled_probe_clone = Arc::clone(&cancelled_probe); + let probe_fut = tokio::task::spawn_blocking(move || { + let _probe_hold = probe_permit; + batch_object_types(&rp_for_block, &to_check, &cancelled_probe_clone) + }); + let results = + match tokio::time::timeout(std::time::Duration::from_secs(30), probe_fut).await { + Ok(Ok(Ok(map))) => map, + _ => { + // Probe timeout/error — don't cache empty results + // (they'd be classified as hidden). Fold the current + // repo's candidates into probe_limit so Phase 3 + // defers them to the next request (P2). + cancelled_probe.store(true, Ordering::Relaxed); + for &(idx, _) in candidates { + if idx < probe_limit { + probe_limit = idx; + } + } + HashMap::new() + } + }; + for (sha, obj_type) in results { + repo_cache.insert(sha, obj_type.is_some_and(|t| t != "blob")); + } + } + + // ── Phase 3 — emit visible pins ─────────────────────────────────── + let mut phase3_wall_hit = false; + for i in 0..batch.len() { + if i >= walk_limit_idx.min(probe_limit) { + // Past the MAX_WALKS wall or an unprobed structural candidate. + // Remaining pins are handled by the next request; db_cursor + // stays at the last processed pin so no row is skipped. + // page_truncated must be set for BOTH walls: at a probe wall + // (probe_limit < walk_limit_idx) the page can be unfilled, and + // without the flag the response carries no cursor and the + // client stops instead of resuming past the wall. + page_truncated = true; + phase3_wall_hit = true; + // Save cursor so the keyset predicate < resumes at the + // first unprocessed row. When i == 0 there is no processed + // pin — use the cursor that fetched this batch so the SQL + // predicate < re-evaluates the deferred row (P2). When + // batch_cursor is None (page 1), keep the Phase 1 fallback + // value so the response can produce a truncated_cursor (P1). + if i == 0 { + if batch_cursor.is_some() { + db_cursor = batch_cursor; + } + } else if let Some(prev) = i.checked_sub(1).and_then(|prev| batch.get(prev)) { + db_cursor = Some(( + prev.pinned_at.clone(), + prev.sha256_hex.clone(), + prev.repo.clone(), + )); + } + break; + } + + let pin = batch[i].clone(); + let Some((repo, rules)) = repos_by_slug.get(&pin.repo) else { + // Already advanced past in phase 1 — just maintain cursor. + db_cursor = Some(( + pin.pinned_at.clone(), + pin.sha256_hex.clone(), + pin.repo.clone(), + )); + continue; + }; + + if !has_path_scoped_rule(rules) { + let pa = pin.pinned_at.clone(); + let sha = pin.sha256_hex.clone(); + let repo_slug = pin.repo.clone(); + + // Dedup by sha256_hex (P2): only skip if already *emitted* — + // do not suppress a visible association because a hidden one + // appeared first in the batch. + if !seen_shas.insert(sha.clone()) { + db_cursor = Some((pa, sha, repo_slug)); + continue; + } + + response_cursor = Some((pa.clone(), sha.clone())); + pins.push(pin); + db_cursor = Some((pa, sha, repo_slug)); + } else { + let pa = pin.pinned_at.clone(); + let sha = pin.sha256_hex.clone(); + let repo_slug = pin.repo.clone(); + + let visible = match pin_outcome[i] { + Some(v) => v, + None => { + // Structural candidate — consult cache. If the + // candidate was never probed (MAX_PROBES exhausted) + // this shouldn't be reached (probe_limit stops Phase 3 + // before unprobed rows), but handle it defensively. + structural_cache + .get(&repo.id) + .and_then(|c| c.get(&pin.sha256_hex)) + .copied() + .unwrap_or(false) + } + }; + if visible { + if !seen_shas.insert(sha.clone()) { + db_cursor = Some((pa, sha, repo_slug)); + continue; + } + response_cursor = Some((pa.clone(), sha.clone())); + pins.push(pin); + } + db_cursor = Some((pa, sha, repo_slug)); + } - Ok(Json(serde_json::json!({ + if pins.len() >= max_visible as usize { + break 'fetch; + } + } + + // A walk failed this batch, or Phase 3 hit a walk/probe wall that left + // the page unfilled — do not loop back and re-fetch the same deferred + // tail (it cannot make progress this request: the walk budget is + // spent, the probe budget is spent, or the per-request deadline is + // reached). Pins already emitted before the wall stay in the response + // and page_truncated tells the caller to continue on a fresh request + // (P2). Without this break the fetch loop burns MAX_BATCHES round + // trips re-fetching an unfillable tail before batch_hit_limit promotes + // into truncated. + if walk_failed || phase3_wall_hit { + break; + } + } + let page_filled = pins.len() >= max_visible as usize; + if !page_truncated && batch_hit_limit { + page_truncated = true; + } + pins.truncate(max_visible as usize); + + // When page 1 is all-deferred (no walk permit available) neither + // response_cursor nor db_cursor was set. Emit a sentinel opaque cursor + // so the client can retry; on the retry the sentinel decodes to + // ("\x7f", "\x7f", "\x7f") which the keyset WHERE < predicate treats + // as "include every row" — effectively restarting from the beginning (P2). + if page_truncated && response_cursor.is_none() && db_cursor.is_none() { + db_cursor = Some(("\x7f".to_string(), "\x7f".to_string(), "\x7f".to_string())); + } + + let mut body = serde_json::json!({ "pins": pins, + // Per-page count, NOT a node-wide total — clients must paginate to + // count the full listing (P2). "count": pins.len(), - }))) + }); + + if page_truncated { + body["truncated"] = serde_json::json!(true); + } + if page_filled { + // Page is full — provide a cursor from the last visible row so the + // caller can paginate. + if let Some((ref pa, ref sha)) = response_cursor { + body["next_cursor"] = serde_json::json!(encode_cursor(pa, sha)); + } + } else if page_truncated { + // Scan bound hit before filling the page — opaque cursor fallback + // when there are no visible rows to derive a keyset cursor from. + if let Some((ref pa, ref sha)) = response_cursor { + body["next_cursor"] = serde_json::json!(encode_cursor(pa, sha)); + } else if let Some((ref pa, ref sha, ref repo)) = db_cursor { + let cursor_str = format!("{pa}|{sha}|{repo}"); + let seed = state.cursor_seed(); + let token = create_opaque_cursor(&seed, &cursor_str); + body["truncated_cursor"] = serde_json::json!(token); + } + } + + Ok(Json(body)) } #[cfg(test)] @@ -716,6 +1590,10 @@ mod closed_pool_tests { .oneshot( Request::builder() .uri("/api/v1/ipfs/pins") + // The listing gate requires authentication before the DB + // call (PR #121); the closed-pool 503 must still fire for + // an authenticated caller. + .extension(crate::auth::AuthenticatedDid("did:key:z6MkwAlice".into())) .body(axum::body::Body::empty()) .unwrap(), ) @@ -783,6 +1661,1434 @@ mod closed_pool_tests { #[cfg(test)] mod tests { + use super::*; + use crate::auth::AuthenticatedDid; + use crate::test_support::test_state; + use axum::extract::{Extension, Query, State}; + use sqlx::PgPool; + + #[sqlx::test] + async fn anonymous_pins_is_401_before_any_db_work(pool: PgPool) { + let app_state = test_state(pool).await; + let q = Query(ListPinsQuery { + limit: 50, + cursor: None, + truncated_cursor: None, + }); + let result = list_pins(State(app_state), q, None).await; + assert!( + matches!(result, Err(AppError::Unauthorized(_))), + "expected 401 for anonymous, got {result:?}" + ); + } + + #[sqlx::test] + async fn test_ipfs_cursor_guard(pool: PgPool) { + let app_state = test_state(pool.clone()).await; + + // Create a real bare repo on disk for the path-scoped repo so its + // visibility walk SUCCEEDS. A failed walk is now DEFERRED (P2) rather + // than treated as empty, so without a real repo the listing could never + // advance past the hidden window. The fabricated hidden SHAs below are + // absent from the repo, so they probe as missing -> classified hidden. + let hidden_repo_path = std::path::PathBuf::from("/tmp") + .join("did_key_z6Mkwowner") + .join("ipfstest.git"); + let _ = std::fs::remove_dir_all(&hidden_repo_path); + crate::git::store::init_bare(&hidden_repo_path).unwrap(); + + // Seed a path-scoped repo + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind("repo-ipfs-test") + .bind("ipfstest") + .bind("did:key:z6Mkwowner") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/ipfstest") + .execute(app_state.db.pool()) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO visibility_rules (id, repo_id, path_glob, mode, reader_dids, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind("rule-1") + .bind("repo-ipfs-test") + .bind("/secret/**") + .bind("deny") + .bind("") + .bind("did:key:z6Mkwowner") + .bind("2026-07-03T00:00:00Z") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Seed another repo with NO path-scoped rules for visible pagination. + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("repo-ipfs-vis") + .bind("ipfsvis") + .bind("did:key:z6Mkwowner") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/ipfsvis") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Insert 1 visible pin, then a 250-pin hidden stretch, then 1 visible pin. + // The hidden pins go in `ipfstest` (path-scoped deny rule; the walk + // succeeds against the empty bare repo above, so these fabricated SHAs + // probe as "missing" and are classified hidden — under the P2 fix a + // FAILED walk is deferred, not treated as empty, so a real repo is + // required for the hidden window to be classifiable). + // 250 > one 200-SHA batch but < two batches, so the FIRST request + // probes batch 1 and returns a truncated_cursor at batch 2, and a + // single resume classifies batch 2 and surfaces vis-2-sha. + // The visible pins go in `ipfsvis` (which has no rules, so they are always visible). + // Note: three separate execute calls — sqlx prepared statements do not + // support multiple semicolon-delimited statements in a single query(). + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ('vis-1-sha', 'vis-1-cid', '2026-07-03T10:00:00Z', 'z6Mkwowner/ipfsvis', 'did:key:z6Mkwowner')", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + SELECT 'hid-sha-' || i, 'hid-cid-' || i, '2026-07-03T09:00:00Z', 'z6Mkwowner/ipfstest', 'did:key:z6Mkwowner' + FROM generate_series(1, 250) as i", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ('vis-2-sha', 'vis-2-cid', '2026-07-03T08:00:00Z', 'z6Mkwowner/ipfsvis', 'did:key:z6Mkwowner')", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Visible pagination case asserting the cursor equals the last returned row + let auth = Extension(AuthenticatedDid("did:key:z6Mkcaller".to_string())); + let mut q = ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }; + + let res1 = list_pins( + State(app_state.clone()), + Query(q.clone()), + Some(auth.clone()), + ) + .await + .unwrap() + .0; + let pins1 = res1["pins"].as_array().unwrap(); + assert_eq!(pins1.len(), 1); + assert_eq!(pins1[0]["sha256_hex"], "vis-1-sha"); + + let cursor1 = res1["next_cursor"].as_str().unwrap().to_string(); + // Decode to ensure it equals the last returned row + let bytes = URL_SAFE_NO_PAD.decode(cursor1.as_bytes()).unwrap(); + let decoded = String::from_utf8(bytes).unwrap(); + assert!(decoded.contains("vis-1-sha")); + + // Case 2: Follow the cursor. The next 250 rows are hidden. + // The first 200-SHA batch consumes the 200-probe budget, so the second + // batch defers and the response returns a truncated_cursor whose + // XChaCha20Poly1305-encrypted payload conceals the hidden SHA. + q.cursor = Some(cursor1); + let res2 = list_pins( + State(app_state.clone()), + Query(q.clone()), + Some(auth.clone()), + ) + .await + .unwrap() + .0; + assert!(res2.get("pins").unwrap().as_array().unwrap().is_empty()); + assert_eq!(res2.get("truncated").unwrap().as_bool(), Some(true)); + assert!(res2.get("next_cursor").is_none()); + let truncated_cursor = res2["truncated_cursor"] + .as_str() + .expect("truncated_cursor should be present") + .to_string(); + + // Case 3: Resume with truncated_cursor. It should skip past the hidden + // batch and reach the older visible pin (vis-2-sha at 08:00:00Z). + q.cursor = None; + q.truncated_cursor = Some(truncated_cursor); + let res3 = list_pins( + State(app_state.clone()), + Query(q.clone()), + Some(auth.clone()), + ) + .await + .unwrap() + .0; + let pins3 = res3["pins"].as_array().unwrap(); + assert!( + !pins3.is_empty(), + "must surface vis-2-sha behind hidden window" + ); + assert_eq!(pins3[0]["sha256_hex"], "vis-2-sha"); + } + + #[test] + fn test_truncated_cursor_does_not_leak_hidden_sha() { + // The token is AEAD-encrypted with XChaCha20Poly1305: the hidden + // sha256_hex must NOT be recoverable by a caller who knows the + // pinned_at and repo prefix. Unlike a stream-cipher XOR construction + // (where known plaintext at offset i reveals keystream[i] via + // keystream[i] = ciphertext[i] XOR plaintext[i]), the AEAD ciphertext + // is ChaCha20 encryption with a per-nonce block counter applied to + // 16-byte blocks, then authenticated by Poly1305 — so XOR at a single + // offset does not yield a reusable keystream byte and the tag prevents + // any chosen-ciphertext oracle. + // + // This test demonstrates the unrecoverability property by attempting a + // known-plaintext attack against the ciphertext suffix. + let seed = [0xab; 32]; // arbitrary test seed + let pinned_at = "2026-07-03T09:00:00Z"; + let hidden_sha = "ab".repeat(32); // 64-char hex — well-known hidden SHA + + let cursor = format!("{pinned_at}|{hidden_sha}"); + let token = create_opaque_cursor(&seed, &cursor); + + // Decode the raw token bytes — these are (nonce_24 || ciphertext). + let raw = URL_SAFE_NO_PAD.decode(token.as_bytes()).unwrap(); + let (_nonce, ciphertext) = raw.split_at(24); + + // Known plaintext: the first 19 chars of pinned_at "2026-07-03T09:00:00Z" + // plus "|" = 20 bytes we know at the start. + // In the XOR-from-stream-cipher world, XOR of known plaintext with the + // ciphertext yields the keystream for those positions. If the keystream + // were reused at the sha suffix (modulo 32), XOR of known suffix with + // the recovered keystream would yield the hidden sha. + let known_prefix = format!("{pinned_at}|"); + let known_bytes = known_prefix.as_bytes(); + + let attempted_keystream: Vec = known_bytes + .iter() + .zip(ciphertext.iter()) + .map(|(p, c)| p ^ c) + .collect(); + + // Use the "recovered keystream" at the same positions in the suffix + // (which would be valid only with a repeating XOR keystream). The + // suffix is the last 64 bytes of the ciphertext (hidden_sha length). + if ciphertext.len() >= known_bytes.len() + 64 { + let suffix_start = ciphertext.len() - 64; + let attempted_sha: String = ciphertext[suffix_start..] + .iter() + .zip(attempted_keystream.iter().cycle()) + .map(|(c, k)| (c ^ k) as char) + .collect(); + + // With a real AEAD the "recovered" suffix is garbage, not the sha. + assert_ne!( + attempted_sha, hidden_sha, + "XOR-based known-plaintext attack on AEAD must NOT recover the hidden sha" + ); + } + + // Substring check: the token bytes must not contain the sha256_hex in + // the clear. + let raw_str = std::str::from_utf8(&raw).unwrap_or(""); + assert!( + !raw_str.contains(&hidden_sha), + "truncated_cursor token MUST NOT contain hidden sha256_hex in the clear" + ); + + // Positive round-trip: correct seed decodes the full cursor. + let decoded = decode_opaque_cursor(&seed, &token).unwrap(); + assert_eq!(decoded.0, pinned_at); + assert_eq!(decoded.1, hidden_sha); + + // Wrong key must not decode. + let wrong_seed = [0xcd; 32]; + assert!(decode_opaque_cursor(&wrong_seed, &token).is_none()); + } + + #[sqlx::test] + async fn test_max_walks_plaintext_not_in_response_cursor(pool: PgPool) { + let app_state = test_state(pool.clone()).await; + + // ── Create one visible repo (no path-scoped rules) ──────────────── + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("repo-walks-vis") + .bind("walksvis") + .bind("did:key:z6Mkwowner") + .bind("visible") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/walksvis") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // ── Seed > MAX_WALKS (50) path-scoped repos with hidden pins ───── + let num_wall_repos = 55usize; + for i in 0..num_wall_repos { + let repo_id = format!("repo-wall-{i}"); + let repo_name = format!("wall{i}"); + let disk_path = format!("/srv/{repo_name}"); + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(&repo_id) + .bind(&repo_name) + .bind("did:key:z6Mkwowner") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind(&disk_path) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Add a /secret/** deny rule so the repo is path-scoped. + sqlx::query( + "INSERT INTO visibility_rules (id, repo_id, path_glob, mode, reader_dids, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(format!("rule-wall-{i}")) + .bind(&repo_id) + .bind("/secret/**") + .bind("deny") + .bind("") + .bind("did:key:z6Mkwowner") + .bind("2026-07-03T00:00:00Z") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // One hidden pin per wall repo. + let sha = format!("wallsha{i:04}"); + let slug = format!("z6Mkwowner/{repo_name}"); + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&sha) + .bind(format!("cid-wall-{i}")) + .bind("2026-07-03T09:00:00Z") + .bind(&slug) + .bind("did:key:z6Mkwowner") + .execute(app_state.db.pool()) + .await + .unwrap(); + } + + // ── One visible pin (newest timestamp so it appears first) ──────── + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ('vis-walks-sha', 'vis-walks-cid', '2026-07-03T10:00:00Z', 'z6Mkwowner/walksvis', 'did:key:z6Mkwowner')", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + + let auth = Extension(AuthenticatedDid("did:key:z6Mkstranger".to_string())); + let res = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: None, + truncated_cursor: None, + }), + Some(auth), + ) + .await + .unwrap() + .0; + + // The visible pin (newest) must be returned. + let pins = res["pins"].as_array().unwrap(); + assert_eq!(pins.len(), 1, "must return the visible pin"); + assert_eq!(pins[0]["sha256_hex"], "vis-walks-sha"); + + // The page is truncated (not filled) because MAX_WALKS was hit. + assert_eq!(res.get("truncated").and_then(|v| v.as_bool()), Some(true)); + // next_cursor IS present — it points to the VISIBLE pin shown to the + // caller (no leak). When response_cursor holds a visible pin the + // plaintext cursor is safe; the P1 leak only happened when skip_pos + // (an un-walked hidden pin) was put in response_cursor. + let nc = res["next_cursor"] + .as_str() + .expect("next_cursor must be present for visible pin pagination"); + let bytes = URL_SAFE_NO_PAD.decode(nc.as_bytes()).unwrap(); + let decoded = String::from_utf8(bytes).unwrap(); + assert!( + decoded.contains("vis-walks-sha"), + "next_cursor must reference the visible pin, not a hidden SHA: {decoded}" + ); + // No truncated_cursor — next_cursor handles pagination. + assert!( + res.get("truncated_cursor").is_none(), + "truncated_cursor must NOT be present when next_cursor suffices" + ); + + // ── Second request: skip past the visible pin into the hidden wall ── + // The response must use the AEAD token (no plaintext next_cursor) + // because no visible pin is in the returned batch. + let auth = Extension(AuthenticatedDid("did:key:z6Mkstranger".to_string())); + let res2 = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: Some(nc.to_string()), + truncated_cursor: None, + }), + Some(auth), + ) + .await + .unwrap() + .0; + + let pins2 = res2["pins"].as_array().unwrap(); + assert!(pins2.is_empty(), "second page has no visible pins"); + assert_eq!(res2.get("truncated").and_then(|v| v.as_bool()), Some(true)); + // next_cursor must NOT be present — no visible pin in this batch. + assert!( + res2.get("next_cursor").is_none(), + "next_cursor must not be present when no visible pin is returned" + ); + // truncated_cursor MUST be present and AEAD-encrypted. + let token = res2["truncated_cursor"] + .as_str() + .expect("truncated_cursor must be present for hidden-only page"); + for i in 0..num_wall_repos { + let sha = format!("wallsha{i:04}"); + assert!( + !token.contains(&sha), + "truncated_cursor must not contain hidden sha256_hex in the clear: {sha}" + ); + } + } + + /// A semaphore defer must NOT swallow an already-walked visible pin that + /// sits later in the same batch: the first request tiles the page before + /// the deferred row, truncates with a resume cursor, and the RESUME + /// surfaces the deferred path-scoped pin AND the later walk-free visible + /// pin that the defer skipped. This locks the reviewer-required contract + /// (defer + later already-walked visible in the same batch): the deferred + /// row and everything after it is reached again through the cursor — + /// never dropped, never surfaced before the defers are resolved. + /// MUTATION (RED): (1) delete the semaphore defer (always walk) — the + /// first page then emits all three pins with no truncation, failing the + /// page-composition assertions; (2) place the Phase 3 wall cursor PAST the + /// deferred row — the resume then skips it and the later visible pin, + /// failing the resume assertions. + #[sqlx::test] + async fn test_semaphore_defer_with_later_walked_visible_resumes(pool: PgPool) { + let mut app_state = test_state(pool.clone()).await; + + // Path-scoped repo backed by a REAL bare repo on disk so the visibility + // walk SUCCEEDS on the resume (a failed walk defers instead of + // classifying and would never reach the later visible pin). + let owner_slug = "did_key_z6Mkwowner"; + let rg_path = std::path::PathBuf::from("/tmp") + .join(owner_slug) + .join("deferwalkvis.git"); + let _ = std::fs::remove_dir_all(&rg_path); + crate::git::store::init_bare(&rg_path).unwrap(); + + // Commit a real blob so the walk finds a reachable object (HEAD exists). + let blob_sha = { + let mut child = std::process::Command::new("git") + .args([ + "-C", + rg_path.to_str().unwrap(), + "hash-object", + "-w", + "--stdin", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(b"defer-walk visible blob") + .unwrap(); + drop(child.stdin.take()); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let tree_sha = { + let mut child = std::process::Command::new("git") + .args(["-C", rg_path.to_str().unwrap(), "mktree"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(format!("100644 blob {blob_sha}\tvisible.txt").as_bytes()) + .unwrap(); + drop(child.stdin.take()); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let commit_output = std::process::Command::new("git") + .args([ + "-C", + rg_path.to_str().unwrap(), + "commit-tree", + &tree_sha, + "-m", + "initial", + ]) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!(commit_output.status.success()); + let commit_sha = String::from_utf8_lossy(&commit_output.stdout) + .trim() + .to_string(); + let upd = std::process::Command::new("git") + .args([ + "-C", + rg_path.to_str().unwrap(), + "update-ref", + "refs/heads/main", + &commit_sha, + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap(); + assert!(upd.status.success()); + + // Real blob pinned (visible on walk) in the path-scoped repo. + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("repo-defer-walkvis") + .bind("deferwalkvis") + .bind("did:key:z6Mkwowner") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind(rg_path.to_str().unwrap()) + .execute(app_state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO visibility_rules (id, repo_id, path_glob, mode, reader_dids, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind("rule-defer-walkvis") + .bind("repo-defer-walkvis") + .bind("/secret/**") + .bind("deny") + .bind("") + .bind("did:key:z6Mkwowner") + .bind("2026-07-03T00:00:00Z") + .execute(app_state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&blob_sha) + .bind("cid-defer-walkvis") + .bind("2026-07-03T09:00:00Z") + .bind("z6Mkwowner/deferwalkvis") + .bind("did:key:z6Mkwowner") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // ── Walk-free repo (no path-scoped rules) with TWO pins: one NEWER and + // one OLDER than the path-scoped pin. Batch order is pinned_at DESC, so + // the newer walk-free pin sorts BEFORE the path-scoped pin (emitted this + // request) and the older walk-free pin sorts AFTER it (the "later + // already-walked visible" that the defer must not lose). + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("repo-defer-walkfree") + .bind("deferwalkfree") + .bind("did:key:z6Mkwowner") + .bind("walkfree") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/deferwalkfree") + .execute(app_state.db.pool()) + .await + .unwrap(); + for (sha, cid, pinned_at) in [ + ("defer-vis-new", "cid-defer-new", "2026-07-03T12:00:00Z"), + ("defer-vis-old", "cid-defer-old", "2026-07-03T08:00:00Z"), + ] { + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(sha) + .bind(cid) + .bind(pinned_at) + .bind("z6Mkwowner/deferwalkfree") + .bind("did:key:z6Mkwowner") + .execute(app_state.db.pool()) + .await + .unwrap(); + } + + let auth = Extension(AuthenticatedDid("did:key:z6Mkstranger".to_string())); + + // First request: exhaust the walk pool so the path-scoped repo DEFERS at + // its batch position. The newer walk-free pin (sorted before it) is + // still emitted; the older walk-free pin (sorted after it) must NOT be + // emitted this request (the defer is evaluated in batch order), and the + // page must carry a resume cursor so the caller can reach it. + app_state.walk_semaphore = Arc::new(tokio::sync::Semaphore::new(0)); + let res1 = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: None, + truncated_cursor: None, + }), + Some(auth.clone()), + ) + .await + .unwrap() + .0; + let pins1 = res1["pins"].as_array().unwrap(); + assert_eq!( + pins1.len(), + 1, + "the walk-free pin newest-pinned_at-first must be emitted before the deferred row: {res1}" + ); + assert_eq!(pins1[0]["sha256_hex"], "defer-vis-new"); + assert_eq!( + res1.get("truncated").and_then(|v| v.as_bool()), + Some(true), + "the semaphore defer must truncate, not silently drop the later pin" + ); + let nc1 = res1["next_cursor"] + .as_str() + .expect("the LAST emitted visible pin yields a keyset cursor") + .to_string(); + + // Second request: restore the pool, resume. The deferred path-scoped + // pin is walked and the LATER walk-free visible pin — skipped by the + // defer — must surface here. + app_state.walk_semaphore = Arc::new(tokio::sync::Semaphore::new(4)); + let res2 = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: Some(nc1), + truncated_cursor: None, + }), + Some(auth), + ) + .await + .unwrap() + .0; + let shas2: Vec<&str> = res2["pins"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["sha256_hex"].as_str()) + .collect(); + assert!( + shas2.contains(&blob_sha.as_str()), + "the deferred path-scoped pin must be walked and emitted on resume: {shas2:?}" + ); + assert!( + shas2.contains(&"defer-vis-old"), + "the later already-walked visible pin must surface on resume (not be dropped by the defer): {shas2:?}" + ); + assert!( + res2.get("truncated").is_none(), + "resume past the defer must complete without another truncation" + ); + + // Clean up the on-disk repo. + let _ = std::fs::remove_dir_all(&rg_path); + } + + #[sqlx::test] + async fn test_probe_wall_sets_truncated_and_resumes(pool: PgPool) { + let app_state = test_state(pool.clone()).await; + + // Two path-scoped repos, each backed by a real bare repo on disk so the + // visibility walk SUCCEEDS (a failed walk defers instead of classifying). + let owner_slug = "did_key_z6Mkwowner"; + let repo_a_path = std::path::PathBuf::from("/tmp") + .join(owner_slug) + .join("probrepa.git"); + let repo_b_path = std::path::PathBuf::from("/tmp") + .join(owner_slug) + .join("probrepb.git"); + let _ = std::fs::remove_dir_all(&repo_a_path); + let _ = std::fs::remove_dir_all(&repo_b_path); + crate::git::store::init_bare(&repo_a_path).unwrap(); + crate::git::store::init_bare(&repo_b_path).unwrap(); + + for (id, name, path) in [ + ("repo-probrepa", "probrepa", repo_a_path.clone()), + ("repo-probrepb", "probrepb", repo_b_path.clone()), + ] { + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(id) + .bind(name) + .bind("did:key:z6Mkwowner") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind(path.to_str().unwrap()) + .execute(app_state.db.pool()) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO visibility_rules (id, repo_id, path_glob, mode, reader_dids, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(format!("rule-{id}")) + .bind(id) + .bind("/secret/**") + .bind("deny") + .bind("") + .bind("did:key:z6Mkwowner") + .bind("2026-07-03T00:00:00Z") + .execute(app_state.db.pool()) + .await + .unwrap(); + } + + // 150 SHAs, each pinned in repo A and ALSO associated with repo B via the + // junction table. Junction rows are needed for BOTH repos: the listing + // query LEFT JOINs pinned_cid_repos on sha, so a lone repo-B junction + // row would make COALESCE(pr.repo, p.repo) collapse every association to + // repo B. With both junction rows each SHA yields 2 association rows in + // one batch (<=200 unique SHAs) = 300 structural candidates — more than + // MAX_PROBES (200), so Phase 2 folds the tail into probe_limit mid-batch. + const SHARED: i64 = 150; + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + SELECT 'probesha-' || i, 'probecid-' || i, '2026-07-03T09:00:00Z', + 'z6Mkwowner/probrepa', 'did:key:z6Mkwowner' + FROM generate_series(1, $1) as i", + ) + .bind(SHARED) + .execute(app_state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + SELECT 'probesha-' || i, 'z6Mkwowner/probrepa', 'did:key:z6Mkwowner', '2026-07-03T09:00:00Z' + FROM generate_series(1, $1) as i", + ) + .bind(SHARED) + .execute(app_state.db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + SELECT 'probesha-' || i, 'z6Mkwowner/probrepb', 'did:key:z6Mkwowner', '2026-07-03T09:00:00Z' + FROM generate_series(1, $1) as i", + ) + .bind(SHARED) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // First request hits the probe wall mid-batch. The page has no visible + // pins, so without page_truncated it would emit NO cursor and the client + // would stop; the fix must set truncated and return a truncated_cursor. + let auth = Extension(AuthenticatedDid("did:key:z6Mkstranger".to_string())); + let res1 = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 200, + cursor: None, + truncated_cursor: None, + }), + Some(auth.clone()), + ) + .await + .unwrap() + .0; + + assert!(res1["pins"].as_array().unwrap().is_empty()); + assert_eq!( + res1.get("truncated").and_then(|v| v.as_bool()), + Some(true), + "a probe wall mid-batch must set truncated even when the page is empty" + ); + let truncated_cursor = res1["truncated_cursor"] + .as_str() + .expect("probe wall must emit a truncated_cursor for resume") + .to_string(); + + // Resume: the folded tail is re-fetched past the wall, classified, and + // the listing completes without further truncation. + let res2 = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 200, + cursor: None, + truncated_cursor: Some(truncated_cursor), + }), + Some(auth), + ) + .await + .unwrap() + .0; + assert!(res2["pins"].as_array().unwrap().is_empty()); + assert!( + res2.get("truncated").is_none(), + "resume past the probe wall must complete without another wall" + ); + + // Clean up the on-disk repos. + let _ = std::fs::remove_dir_all(&repo_a_path); + let _ = std::fs::remove_dir_all(&repo_b_path); + } + + #[sqlx::test] + async fn test_structural_pin_included_withheld_blob_excluded(pool: PgPool) { + let app_state = test_state(pool.clone()).await; + + // ── Create a real on-disk bare repo with objects ────────────────── + let owner_did = "did:key:z6Mkwowner"; + let repo_name = "structest"; + let owner_slug = owner_did.replace([':', '/'], "_"); + let repo_path = std::path::PathBuf::from("/tmp") + .join(&owner_slug) + .join(format!("{repo_name}.git")); + + // Remove leftovers from a prior failed run, then init a bare repo. + let _ = std::fs::remove_dir_all(&repo_path); + crate::git::store::init_bare(&repo_path).unwrap(); + + // Create a blob: echo -n "secret content" | git hash-object -w --stdin + let mut blob_child = Command::new("git") + .args([ + "-C", + repo_path.to_str().unwrap(), + "hash-object", + "-w", + "--stdin", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + blob_child + .stdin + .as_mut() + .unwrap() + .write_all(b"secret content") + .unwrap(); + // Drop stdin to close it so hash-object can finish. + drop(blob_child.stdin.take()); + let blob_output = blob_child.wait_with_output().unwrap(); + assert!( + blob_output.status.success(), + "git hash-object failed: {}", + String::from_utf8_lossy(&blob_output.stderr) + ); + let blob_sha = String::from_utf8_lossy(&blob_output.stdout) + .trim() + .to_string(); + assert!(!blob_sha.is_empty(), "blob sha must not be empty"); + + // Create a sub-tree for "secret/" containing the blob at "file.txt" + let sub_tree_input = format!("100644 blob {blob_sha}\tfile.txt"); + let mut sub_tree_child = Command::new("git") + .args(["-C", repo_path.to_str().unwrap(), "mktree"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + sub_tree_child + .stdin + .as_mut() + .unwrap() + .write_all(sub_tree_input.as_bytes()) + .unwrap(); + drop(sub_tree_child.stdin.take()); + let sub_tree_output = sub_tree_child.wait_with_output().unwrap(); + assert!( + sub_tree_output.status.success(), + "git mktree for secret/ failed: {}", + String::from_utf8_lossy(&sub_tree_output.stderr) + ); + let sub_tree_sha = String::from_utf8_lossy(&sub_tree_output.stdout) + .trim() + .to_string(); + assert!(!sub_tree_sha.is_empty(), "sub-tree sha must not be empty"); + + // Create the root tree containing the secret/ sub-tree at path "secret" + let root_tree_input = format!("040000 tree {sub_tree_sha}\tsecret"); + let mut root_tree_child = Command::new("git") + .args(["-C", repo_path.to_str().unwrap(), "mktree"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + root_tree_child + .stdin + .as_mut() + .unwrap() + .write_all(root_tree_input.as_bytes()) + .unwrap(); + drop(root_tree_child.stdin.take()); + let root_tree_output = root_tree_child.wait_with_output().unwrap(); + assert!( + root_tree_output.status.success(), + "git mktree for root tree failed: {}", + String::from_utf8_lossy(&root_tree_output.stderr) + ); + let tree_sha = String::from_utf8_lossy(&root_tree_output.stdout) + .trim() + .to_string(); + assert!(!tree_sha.is_empty(), "root tree sha must not be empty"); + + // Create a commit pointing to the tree + let commit_output = Command::new("git") + .args([ + "-C", + repo_path.to_str().unwrap(), + "commit-tree", + &tree_sha, + "-m", + "initial", + ]) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!( + commit_output.status.success(), + "git commit-tree failed: {}", + String::from_utf8_lossy(&commit_output.stderr) + ); + let commit_sha = String::from_utf8_lossy(&commit_output.stdout) + .trim() + .to_string(); + assert!(!commit_sha.is_empty(), "commit sha must not be empty"); + + // Update HEAD so the blob walk can reach the blob. + // In a bare repo HEAD is a symref to refs/heads/main, so we update the ref. + let update_output = Command::new("git") + .args([ + "-C", + repo_path.to_str().unwrap(), + "update-ref", + "refs/heads/main", + &commit_sha, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!( + update_output.status.success(), + "git update-ref failed: {}", + String::from_utf8_lossy(&update_output.stderr) + ); + + // ── Seed the DB ─────────────────────────────────────────────────── + // Slug must match what list_pins computes from normalize_owner_key: + // normalize_owner_key("did:key:z6Mkwowner") = "z6Mkwowner" + // slug = "z6Mkwowner/structest" + let repo_slug = format!("z6Mkwowner/{repo_name}"); + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind("repo-structest") + .bind(repo_name) + .bind(owner_did) + .bind("structural test repo") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind(repo_path.to_str().unwrap()) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Add a /secret/** deny rule so the blob is withheld from strangers. + sqlx::query( + "INSERT INTO visibility_rules (id, repo_id, path_glob, mode, reader_dids, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)" + ) + .bind("rule-structest") + .bind("repo-structest") + .bind("/secret/**") + .bind("deny") + .bind("") + .bind("did:key:z6Mkwowner") + .bind("2026-07-03T00:00:00Z") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Pin the blob (must be withheld under /secret/**). + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&blob_sha) + .bind("blob-cid") + .bind("2026-07-03T12:00:00Z") + .bind(&repo_slug) + .bind(owner_did) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Pin the tree (structural — must be visible). + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&tree_sha) + .bind("tree-cid") + .bind("2026-07-03T11:00:00Z") + .bind(&repo_slug) + .bind(owner_did) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Pin the commit (structural — must be visible). + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&commit_sha) + .bind("commit-cid") + .bind("2026-07-03T10:00:00Z") + .bind(&repo_slug) + .bind(owner_did) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // ── Call list_pins as a stranger ────────────────────────────────── + let stranger = Extension(AuthenticatedDid("did:key:z6Mkstranger".to_string())); + let res = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: None, + truncated_cursor: None, + }), + Some(stranger), + ) + .await + .unwrap() + .0; + + let pins = res["pins"].as_array().unwrap(); + let sha_hexes: Vec<&str> = pins + .iter() + .filter_map(|p| p["sha256_hex"].as_str()) + .collect(); + + // The withheld blob at /secret/** must NOT appear. + assert!( + !sha_hexes.contains(&blob_sha.as_str()), + "withheld blob pin under /secret/** must NOT appear for stranger" + ); + // The tree and commit are structural objects not in the blob set — + // they MUST appear (KTD3). + assert!( + sha_hexes.contains(&tree_sha.as_str()), + "structural tree pin must appear for stranger" + ); + assert!( + sha_hexes.contains(&commit_sha.as_str()), + "structural commit pin must appear for stranger" + ); + + // Clean up the on-disk repo. + let _ = std::fs::remove_dir_all(&repo_path); + } + + #[sqlx::test] + async fn test_stranger_denied_private_repo_pins(pool: PgPool) { + let app_state = test_state(pool.clone()).await; + + // Seed a fully private repo (is_public = false). + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind("repo-private") + .bind("privaterepo") + .bind("did:key:z6Mkwowner") + .bind("private repo") + .bind(false) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/privaterepo") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Insert a pin owned by the owner. + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ('priv-sha-1', 'priv-cid-1', '2026-07-03T12:00:00Z', 'z6Mkwowner/privaterepo', 'did:key:z6Mkwowner')", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // A stranger (not the owner, not a listed reader) must see no pins. + let stranger_auth = Extension(AuthenticatedDid("did:key:z6Mkstranger".to_string())); + let res = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: None, + truncated_cursor: None, + }), + Some(stranger_auth), + ) + .await + .unwrap() + .0; + assert_eq!( + res["pins"].as_array().unwrap().len(), + 0, + "stranger must not see pins from a private repo" + ); + assert_eq!(res["count"].as_u64().unwrap(), 0); + } + + #[sqlx::test] + async fn test_orphan_empty_repo_pins_excluded(pool: PgPool) { + let app_state = test_state(pool.clone()).await; + + // Seed a public repo (so the caller has some readable repo context). + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind("repo-public") + .bind("pubrepo") + .bind("did:key:z6Mkwowner") + .bind("public repo") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/pubrepo") + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Insert a legit pin for the public repo. + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ('legit-sha', 'legit-cid', '2026-07-03T12:00:00Z', 'z6Mkwowner/pubrepo', 'did:key:z6Mkwowner')", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Insert a legacy orphan pin with repo = '' (empty string) and owner_did = ''. + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ('orphan-sha', 'orphan-cid', '2026-07-03T11:00:00Z', '', '')", + ) + .execute(app_state.db.pool()) + .await + .unwrap(); + + // Signed caller must see the legit pin but NOT the orphan. + let auth = Extension(AuthenticatedDid("did:key:z6Mkcaller".to_string())); + let res = list_pins( + State(app_state.clone()), + Query(ListPinsQuery { + limit: 50, + cursor: None, + truncated_cursor: None, + }), + Some(auth), + ) + .await + .unwrap() + .0; + let pins = res["pins"].as_array().unwrap(); + let sha_hexes: Vec<&str> = pins + .iter() + .filter_map(|p| p["sha256_hex"].as_str()) + .collect(); + assert!(sha_hexes.contains(&"legit-sha"), "legit pin must appear"); + assert!( + !sha_hexes.contains(&"orphan-sha"), + "orphan pin with repo='' must NOT appear" + ); + } + + /// Verifies the non-sybil (global) rate limiter sheds requests after + /// its cap is reached, even across distinct DIDs (P3). + #[sqlx::test] + async fn global_rate_limiter_sheds_after_budget_exhausted(pool: PgPool) { + let mut state = test_state(pool).await; + // Tighten the global limiter to max 2 with a singleton map so + // rotating DIDs cannot bypass the cap. + state.ipfs_list_global_limiter = + crate::rate_limit::RateLimiter::new_bounded(2, std::time::Duration::from_secs(3600), 1); + + // First two requests with distinct DIDs are within budget. + let r1 = list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid("did:key:z6MkwA".into()))), + ) + .await; + assert!( + r1.is_ok() || matches!(r1, Err(AppError::Unauthorized(_))), + "first caller should not be refused by global limiter, got {r1:?}", + ); + + let r2 = list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid("did:key:z6MkwB".into()))), + ) + .await; + assert!( + r2.is_ok() || matches!(r2, Err(AppError::Unauthorized(_))), + "second caller should not be refused by global limiter, got {r2:?}", + ); + + // Third request with a fresh DID — global bucket is empty. + let r3 = list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid("did:key:z6MkwC".into()))), + ) + .await; + assert!( + matches!(r3, Err(AppError::TooManyRequests(_))), + "third caller should be refused by global limiter, got {r3:?}", + ); + } + + /// Single DID that exhausts its per-DID budget does NOT drain the + /// shared global bucket — the global check is charged only after the + /// per-DID check passes (P2, P3). + #[sqlx::test] + async fn single_did_over_budget_does_not_drain_global(pool: PgPool) { + let mut state = test_state(pool).await; + // Per-DID limit of 1 so the second request from the same DID is + // refused before the global limiter is charged. + state.ipfs_list_rate_limiter = crate::rate_limit::RateLimiter::new_bounded( + 1, + std::time::Duration::from_secs(3600), + 200_000, + ); + // Global limit of 3 — generous enough that two distinct DIDs can + // both pass even if the over-budget DID had drained the bucket. + state.ipfs_list_global_limiter = + crate::rate_limit::RateLimiter::new_bounded(3, std::time::Duration::from_secs(3600), 1); + + // DID A — first request passes per-DID and charges global. + let r1 = list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid("did:key:z6MkwX".into()))), + ) + .await; + assert!( + r1.is_ok() || matches!(&r1, Err(AppError::Unauthorized(_))), + "DID A first request should not be refused, got {r1:?}", + ); + + // DID A — second request is refused by per-DID limiter (budget + // exhausted), BEFORE the global bucket would be charged. + let r2 = list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid("did:key:z6MkwX".into()))), + ) + .await; + assert!( + matches!(r2, Err(AppError::TooManyRequests(_))), + "DID A second request should get per-DID 429, got {r2:?}", + ); + + // DID B — should still pass because the global bucket was charged + // only once (by DID A's first request, which passed per-DID). + let r3 = list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid("did:key:z6MkwY".into()))), + ) + .await; + assert!( + r3.is_ok() || matches!(&r3, Err(AppError::Unauthorized(_))), + "DID B should not be refused (global bucket has 2 of 3 remaining), got {r3:?}", + ); + } + + /// Regression (P2): a request the exhausted GLOBAL bucket rejects must not + /// allocate per-DID limiter state at the handler. Before the fix `list_pins` + /// checked the per-DID limiter first, so once the global window was full a + /// fresh-DID flood grew the per-DID key map (a 200 000-key ceiling) and shed + /// every legitimate new caller for the whole hour. The fix probes the fixed + /// global budget first (non-consuming), so the flood allocates no per-DID + /// keys, and a new legit DID is still admitted once the global window resets. + #[sqlx::test] + async fn global_exhaustion_does_not_populate_per_did_state(pool: PgPool) { + let mut state = test_state(pool).await; + let window = std::time::Duration::from_millis(150); + // Tiny window so the "resets" leg of the test runs fast; tiny global + // budget (one slot per window, fixed key) so one caller exhausts it. + state.ipfs_list_rate_limiter = + crate::rate_limit::RateLimiter::new_bounded(2, window, 200_000); + state.ipfs_list_global_limiter = crate::rate_limit::RateLimiter::new_bounded(1, window, 1); + + let call = |did: &str| { + list_pins( + State(state.clone()), + Query(ListPinsQuery { + limit: 1, + cursor: None, + truncated_cursor: None, + }), + Some(Extension(AuthenticatedDid(did.to_string()))), + ) + }; + + // First legit caller passes (per-DID key recorded, global slot committed). + let r1 = call("did:key:z6MkwLegit1").await; + assert!( + r1.is_ok(), + "first legit caller should pass the global budget, got {r1:?}" + ); + assert_eq!( + state.ipfs_list_rate_limiter.tracked_keys().await, + 1, + "one per-DID key tracked after the legit call" + ); + + // Global window is now full — flood fresh DIDs. Every one is shed 429 + // and the flood must NOT grow the per-DID key map. + for i in 0..20 { + let r = call(&format!("did:key:z6MkwFlood{i}")).await; + assert!( + matches!(r, Err(AppError::TooManyRequests(_))), + "flood caller {i} must be shed by the full global bucket, got {r:?}", + ); + } + assert_eq!( + state.ipfs_list_rate_limiter.tracked_keys().await, + 1, + "the DID flood must not have allocated per-DID keys while the global bucket was full" + ); + + // When the global window resets, a fresh legitimate DID can still obtain + // per-DID admission — its map slot was never consumed by the flood. + tokio::time::sleep(window + std::time::Duration::from_millis(30)).await; + let r2 = call("did:key:z6MkwLegit2").await; + assert!( + r2.is_ok(), + "fresh legit DID should be admitted once the global window resets, got {r2:?}" + ); + } +} + +#[cfg(test)] +mod walk_tests { //! #174 P1-3 (U3): the public `GET /ipfs/{cid}` walk carries bounded CONCURRENCY //! admission (a global pool + per-source sub-cap) held through the `spawn_blocking` //! walk, plus a per-IP route rate limit. These are handler-layer proofs: mount the diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 2ba4591f..347c1858 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1147,6 +1147,8 @@ async fn pin_new_objects_gated( repo_path: &std::path::Path, object_list: Vec, db: &Arc, + repo_slug: &str, + owner_did: &str, ) -> Vec<(String, String)> { // Nothing to pin: answer before taking a permit (#174 F2b). The permit bounds how // many pin loops run concurrently, and an empty list does no pinning, so parking @@ -1169,6 +1171,8 @@ async fn pin_new_objects_gated( "git", object_list, db, + repo_slug, + owner_did, crate::ipfs_pin::PIN_BATCH_BUDGET, ) .await @@ -1185,12 +1189,16 @@ async fn pin_and_encrypt_objects( rules: Option>, is_public: bool, ) { + let owner_short = crate::db::normalize_owner_key(&ctx.owner_did); + let repo_slug = format!("{owner_short}/{}", ctx.repo_name); let pinned = pin_new_objects_gated( &ctx.pin_sem, &ctx.ipfs_api, &ctx.repo_path, object_list, &ctx.db, + &repo_slug, + &ctx.owner_did, ) .await; if !pinned.is_empty() { @@ -2350,6 +2358,7 @@ async fn post_receive_replication_tail( crate::db::normalize_owner_key(&record.owner_did), record.name ); + let owner_did_for_pinata = record.owner_did.clone(); let ref_updates_clone = ref_updates .iter() .map(|u| (u.ref_name.clone(), u.old_sha.clone(), u.new_sha.clone())) @@ -2433,6 +2442,8 @@ async fn post_receive_replication_tail( "git", object_list, &db_clone, + &repo_slug, + &owner_did_for_pinata, crate::ipfs_pin::PIN_BATCH_BUDGET, ) .await, @@ -6186,7 +6197,15 @@ mod tests { let objects = vec!["0123456789abcdef0123456789abcdef01234567".to_string()]; let blocked = tokio::time::timeout( std::time::Duration::from_millis(500), - pin_new_objects_gated(&pin_sem, "", tmp.path(), objects.clone(), &db), + pin_new_objects_gated( + &pin_sem, + "", + tmp.path(), + objects.clone(), + &db, + "repo", + "did:key:owner", + ), ) .await; assert!( @@ -6198,7 +6217,15 @@ mod tests { drop(held); let out = tokio::time::timeout( std::time::Duration::from_secs(5), - pin_new_objects_gated(&pin_sem, "", tmp.path(), objects, &db), + pin_new_objects_gated( + &pin_sem, + "", + tmp.path(), + objects, + &db, + "repo", + "did:key:owner", + ), ) .await .expect("the pin loop completes once admission frees"); @@ -6227,7 +6254,15 @@ mod tests { let out = tokio::time::timeout( std::time::Duration::from_millis(500), - pin_new_objects_gated(&pin_sem, "", tmp.path(), vec![], &db), + pin_new_objects_gated( + &pin_sem, + "", + tmp.path(), + vec![], + &db, + "repo", + "did:key:owner", + ), ) .await .expect("an empty object list must not wait on pin admission (#174 F2b)"); diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index eee8fd8b..7f0d441d 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -519,6 +519,15 @@ mod tests { push_limiter_trust: crate::rate_limit::TrustedProxy::None, sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)), peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), + walk_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), + ipfs_list_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)), + ipfs_list_global_limiter: RateLimiter::new_bounded(1200, Duration::from_secs(3600), 1), + arweave_list_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)), + arweave_list_global_limiter: RateLimiter::new_bounded( + 1200, + Duration::from_secs(3600), + 1, + ), shutdown_tx: tokio::sync::watch::channel(false).0, git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index fefa063e..3531e823 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -290,6 +290,58 @@ pub struct Config { )] pub db_retry_max_secs: u64, + /// Maximum number of concurrent visibility walks (git rev-list / ls-tree) + /// across all IPFS pin listing requests. Prevents a flood of signed + /// requests from exhausting the blocking-pool worker or leaving git + /// children running past their timeout (P1). + #[arg(long, env = "GITLAWB_WALK_CONCURRENCY_LIMIT", default_value_t = 4, value_parser = clap::value_parser!(u32).range(1..))] + pub walk_concurrency_limit: u32, + + /// Per-DID rate limit for IPFS pin listing — requests per hour per DID. + /// The listing performs expensive git walks and cat-file probes, so a + /// throwaway DID with a valid signature can otherwise exhaust resources. + #[arg(long, env = "GITLAWB_IPFS_LIST_RATE_LIMIT", default_value_t = 60)] + pub ipfs_list_rate_limit: usize, + + /// Global (non-sybil) rate limit for IPFS pin listing — total requests + /// per hour regardless of signed DID. Prevents DID-rotation attacks + /// from bypassing the per-DID limiter (P1). + #[arg( + long, + env = "GITLAWB_IPFS_LIST_GLOBAL_RATE_LIMIT", + default_value_t = 1200 + )] + pub ipfs_list_global_rate_limit: usize, + + /// Per-DID rate limit for the Arweave anchor listing endpoint — requests + /// per hour per DID. Arweave anchors are cheap DB reads (no git walks), + /// but the endpoint runs the readable-repo catalog load like the pin + /// listing, so it keeps its OWN per-DID bucket rather than sharing (and + /// draining) the IPFS listing one (reviewer finding). + #[arg(long, env = "GITLAWB_ARWEAVE_LIST_RATE_LIMIT", default_value_t = 60)] + pub arweave_list_rate_limit: usize, + + /// Global (non-sybil) rate limit for the Arweave anchor listing — total + /// requests per hour regardless of signed DID. Separate from the IPFS + /// listing global bucket so anchor enumeration cannot consume the + /// slash-heavy pin-listing budget (reviewer finding). + #[arg( + long, + env = "GITLAWB_ARWEAVE_LIST_GLOBAL_RATE_LIMIT", + default_value_t = 1200 + )] + pub arweave_list_global_rate_limit: usize, + + /// Optional cluster-shared secret used to key the AEAD-sealed opaque + /// truncated_cursor tokens. When unset, tokens are keyed on this node's + /// Ed25519 seed, so a token minted on node A cannot be resumed on node B + /// (the CLI treats that as expired and restarts with the last keyset + /// cursor, which re-scans without advancing through hidden windows). + /// Set the same GITLAWB_CURSOR_SECRET on every node behind a load + /// balancer so truncated cursors resume correctly across instances. + #[arg(long, env = "GITLAWB_CURSOR_SECRET")] + pub cursor_secret: Option, + /// Maximum number of served git operations (upload-pack / receive-pack / /// info-refs) allowed to run concurrently. Beyond this the node sheds the /// request with a clean 503 + Retry-After instead of spawning another git diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 99c5d8c6..5ae508c8 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{postgres::PgPoolOptions, PgPool, Row}; @@ -160,6 +160,8 @@ pub struct PinnedCidRecord { pub cid: String, pub pinned_at: String, pub pinata_cid: Option, + pub repo: String, + pub owner_did: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -365,15 +367,33 @@ impl Db { /// Must be called while holding the migration advisory lock. async fn run_pending_migrations(&self) -> Result<()> { for m in MIGRATIONS { - let already: bool = sqlx::query( - "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1) AS applied", - ) - .bind(m.version) - .fetch_one(&self.pool) - .await? - .get::("applied"); - - if already { + let row: Option<(String,)> = + sqlx::query_as("SELECT name FROM schema_migrations WHERE version = $1") + .bind(m.version) + .fetch_optional(&self.pool) + .await?; + + if let Some((applied_name,)) = row { + // Name-collision guard: the runner keys the applied set on the + // version integer alone, so a version claimed by two in-flight + // branches is silently skipped in full on whichever side merges + // second. Catch that here: if this binary's migration name for + // an already-applied version differs from what was recorded, + // another branch claimed the number first and this migration + // will never run. Fail loudly instead of shipping a node that + // believes it migrated. + if applied_name != m.name { + bail!( + "migration v{} is already recorded under name {:?}, \ + but this binary applies {:?} for that version; \ + another branch claimed version {} first. Renumber this \ + migration above the current high-water mark.", + m.version, + applied_name, + m.name, + m.version + ); + } continue; } @@ -856,6 +876,7 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE repos ADD COLUMN IF NOT EXISTS quarantined BOOLEAN NOT NULL DEFAULT FALSE", ], }, + Migration { version: 10, name: "ref_cert_unique_per_ref", @@ -883,14 +904,6 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", ], }, - // Reservation: v17, deliberately not main's current_max + 1 (which is 12). - // The runner keys the applied set on the integer alone, so a version another - // in-flight branch also claims is skipped in full on whichever side merges - // second — no error, no warning, and schema_migrations still reads healthy - // while the column is simply absent. Two open branches already claim into - // this range: #135/#173 holds through 14 (15 once it rebases past v11), and - // #253 took 16. 17 clears both. Gaps are harmless: the runner iterates the - // array and never requires contiguity. Migration { version: 17, name: "sync_queue_attempted_at", @@ -901,6 +914,124 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE sync_queue ADD COLUMN IF NOT EXISTS attempted_at TEXT", ], }, + // Reservation: these four migrations claim versions 26-29, deliberately + // above every live claim. The still-open #173 stack claims 18-25 on its + // head (pinned_cids_cid_index through pin_source_failures, plus + // repos_created_at_id_index); v17 is taken by sync_queue_attempted_at. + // The runner keys the applied set on the integer alone, so a version + // another in-flight branch also claims is skipped in full on whichever + // side merges second — no error, no warning, and schema_migrations still + // reads healthy while the objects are simply absent. If #173 merges with + // more migrations than its current 18-25 head, renumber these above the + // new high-water mark before merging. The name-collision guard in + // run_pending_migrations fails loudly on a version that was applied under a + // different name, which is the closest the runner can get to catching a + // silent skip. + Migration { + version: 26, + name: "pinned_cids_repo_owner", + stmts: &[ + "ALTER TABLE pinned_cids ADD COLUMN IF NOT EXISTS repo TEXT", + "ALTER TABLE pinned_cids ADD COLUMN IF NOT EXISTS owner_did TEXT", + // Backfill repo/owner only when both the CID AND the Git SHA match + // between pinned_cids and branch_cids. The CID alone does not encode + // the Git object type, so a private blob and a public commit with the + // same raw bytes would share a CID, and the plain CID-join would + // wrongly assign the private pin to the public repo (P1). Requiring + // p.sha256_hex = bc.sha ensures only the ref-target objects + // (commits) are matched — blobs/trees fall through to the empty- + // string fallback below, which is safer than a wrong assignment. + r#"UPDATE pinned_cids p + SET repo = m.repo, + owner_did = m.owner_did + FROM ( + SELECT DISTINCT + bc.cid, + bc.sha, + bc.repo, + r.owner_did + FROM branch_cids bc + JOIN repos r + ON r.name = split_part(bc.repo, '/', 2) + AND (CASE WHEN r.owner_did LIKE 'did:key:%' AND position(':' in substr(r.owner_did, 9)) = 0 THEN substr(r.owner_did, 9) ELSE r.owner_did END) + = split_part(bc.repo, '/', 1) + ) m + WHERE p.cid = m.cid + AND p.sha256_hex = m.sha"#, + // Fallback for remaining rows + "UPDATE pinned_cids SET repo = '' WHERE repo IS NULL", + "UPDATE pinned_cids SET owner_did = '' WHERE owner_did IS NULL", + // Default the new columns so a pre-v11 binary still running during + // a rolling deploy can INSERT (sha256_hex, cid, pinned_at) without + // hitting a NOT NULL violation (P2). + "ALTER TABLE pinned_cids ALTER COLUMN repo SET DEFAULT ''", + "ALTER TABLE pinned_cids ALTER COLUMN owner_did SET DEFAULT ''", + "ALTER TABLE pinned_cids ALTER COLUMN repo SET NOT NULL", + "ALTER TABLE pinned_cids ALTER COLUMN owner_did SET NOT NULL", + // New unique constraint for post-v11 ON CONFLICT(repo, sha256_hex) + "CREATE UNIQUE INDEX IF NOT EXISTS pinned_cids_repo_sha_hex_key ON pinned_cids (repo, sha256_hex)", + // Old PK on sha256_hex kept intact for pre-v11 ON CONFLICT(sha256_hex) + "CREATE INDEX IF NOT EXISTS idx_pinned_cids_repo_owner ON pinned_cids (repo, owner_did)", + ], + }, + Migration { + version: 27, + name: "arweave_anchors_repo_owner_index", + stmts: &[ + "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_repo_owner_anchored ON arweave_anchors (repo, owner_did, anchored_at DESC)", + ], + }, + Migration { + version: 28, + name: "pinned_cid_repos_junction", + stmts: &[ + r#"CREATE TABLE IF NOT EXISTS pinned_cid_repos ( + sha256_hex TEXT NOT NULL, + repo TEXT NOT NULL, + owner_did TEXT NOT NULL, + pinned_at TEXT NOT NULL, + PRIMARY KEY (sha256_hex, repo) + )"#, + "CREATE INDEX IF NOT EXISTS idx_pinned_cid_repos_repo ON pinned_cid_repos(repo, owner_did)", + // Backfill from existing pinned_cids rows that have non-empty repo/owner + r#"INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + SELECT sha256_hex, repo, owner_did, pinned_at + FROM pinned_cids + WHERE repo IS NOT NULL AND repo != '' + AND owner_did IS NOT NULL AND owner_did != '' + ON CONFLICT (sha256_hex, repo) DO NOTHING"#, + // Note: legacy (unassociated) pinned_cids rows with empty repo + // or owner_did are intentionally not migrated to the junction + // table: the scoped listing requires a known (repo, owner_did) + // pair, and silently showing orphaned objects to every caller + // would leak SHA/CID pairs from before the migration., + ], + }, + Migration { + version: 29, + name: "pinned_cid_repos_backfill_all_associations", + stmts: &[ + // Migration 18's UPDATE … FROM branch_cids selects one arbitrary + // row per SHA because UPDATE … FROM picks one matching source row + // when multiple match. Migration 20 then backfills pinned_cid_repos + // from the scalar pinned_cids.repo, losing every other association. + // This migration backfills the junction table directly from ALL + // matching branch_cids rows so every repo that pinned an object + // can discover it through the scoped listing (P2). + r#"INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + SELECT DISTINCT p.sha256_hex, m.repo, m.owner_did, p.pinned_at + FROM pinned_cids p + INNER JOIN ( + SELECT DISTINCT bc.cid, bc.sha, bc.repo, r.owner_did + FROM branch_cids bc + JOIN repos r + ON r.name = split_part(bc.repo, '/', 2) + AND (CASE WHEN r.owner_did LIKE 'did:key:%' AND position(':' in substr(r.owner_did, 9)) = 0 THEN substr(r.owner_did, 9) ELSE r.owner_did END) + = split_part(bc.repo, '/', 1) + ) m ON p.cid = m.cid AND p.sha256_hex = m.sha + ON CONFLICT (sha256_hex, repo) DO NOTHING"#, + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -921,6 +1052,18 @@ const OWNER_KEY_CASE_SQL: &str = "CASE WHEN owner_did LIKE 'did:key:%' AND posit /// named `did` (like in agent_profiles) instead of `owner_did`. const PROFILE_DID_CASE_SQL: &str = "CASE WHEN did LIKE 'did:key:%' AND position(':' in substr(did, 9)) = 0 THEN substr(did, 9) ELSE did END"; +/// `OWNER_KEY_CASE_SQL` parameterized over the expression it normalizes, for +/// joins where the owner key is `COALESCE(source_a, source_b)` instead of a +/// bare `owner_did` column. A mirror writes a junction row under the bare key +/// while its canonical twin uses `did:key:`; both must key the join on the +/// same normalized form or one spelling's rows become invisible (#134 P2). +fn owner_key_case_sql(expr: &str) -> String { + format!( + "CASE WHEN {expr} LIKE 'did:key:%' AND position(':' in substr({expr}, 9)) = 0 \ + THEN substr({expr}, 9) ELSE {expr} END" + ) +} + #[cfg(test)] mod normalize_owner_key_tests { use super::normalize_owner_key; @@ -1659,31 +1802,6 @@ impl Db { Ok(()) } - /// Take up to `limit` pending syncs — the least recently attempted ones — - /// and stamp each with the time it was handed out. - /// - /// Selecting and stamping in one statement is deliberate. A row the worker - /// cannot make progress on stays `pending` so it is retried, and if its - /// ordering key never moved it would remain among the oldest rows forever, - /// holding a fixed-size window against every healthy repo behind it. - /// Stamping on the way out makes the key "least recently handed out", so a - /// stuck row rotates to the back instead. Doing it here rather than at each - /// deferral branch in the worker is what makes that hold by construction: - /// no call site can forget it, and a batch that dies mid-loop still leaves - /// its rows stamped. `enqueued_at` is left alone so backlog age stays - /// measurable. - /// - /// Two things this deliberately does not promise. The returned rows are the - /// right *set*, in no particular order — `RETURNING` does not sort, and - /// nothing in `process_batch` depends on the order within a batch. And this - /// is not a claim: the rows stay `pending` with no row lock held past the - /// statement, so two workers against one database can still be handed the - /// same batch. Single-worker deployment is the existing assumption; - /// `FOR UPDATE SKIP LOCKED` is what would change that, and it is not here. - /// - /// Errors surface to the caller, which logs and skips the poll. That is - /// worth knowing now that this writes: it can fail for reasons a plain - /// SELECT could not, such as a read-only transaction or a lock timeout. pub async fn dequeue_pending_syncs(&self, limit: i64) -> Result> { let rows = sqlx::query( // The outer `status = 'pending'` is not redundant with the @@ -2467,15 +2585,93 @@ impl Db { Ok(row.get::("cnt") > 0) } - pub async fn record_pinned_cid(&self, sha256_hex: &str, cid: &str) -> Result<()> { + #[allow(dead_code)] + pub async fn get_pinned_cid(&self, sha256_hex: &str) -> Result> { + let row = sqlx::query("SELECT cid FROM pinned_cids WHERE sha256_hex = $1 LIMIT 1") + .bind(sha256_hex) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| r.get("cid"))) + } + + /// Record a pinned CID with explicit repo/owner_did association. + /// Phase 1 (expand): targets the kept sha256_hex PK so pre-v10 and + /// post-v10 writers share the same conflict target. Phase 2 (contract) + /// will switch to ON CONFLICT(repo, sha256_hex) after the old PK is + /// dropped and (repo, sha256_hex) becomes the new primary key. + pub async fn record_pinned_cid_full( + &self, + sha256_hex: &str, + cid: &str, + repo: &str, + owner_did: &str, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + // The junction stores the owner key in the SAME normalized spelling the + // reader joins on, so a bare-key/mirror and a did:key/canonical spelling + // of one owner cannot split the (sha256_hex, repo) conflict target and + // leave the pin invisible to the canonical pair (P2 mirror/canonical). + let owner_key = normalize_owner_key(owner_did); + // Both writes run in one transaction so a failure cannot leave a + // pinned_cids row without its pinned_cid_repos association (P2). + let mut tx = self.pool.begin().await?; sqlx::query( - "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) - VALUES ($1, $2, $3) - ON CONFLICT(sha256_hex) DO NOTHING", + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT(sha256_hex) DO UPDATE SET + repo = COALESCE(NULLIF(EXCLUDED.repo, ''), pinned_cids.repo), + owner_did = COALESCE(NULLIF(EXCLUDED.owner_did, ''), pinned_cids.owner_did)", ) .bind(sha256_hex) .bind(cid) - .bind(Utc::now().to_rfc3339()) + .bind(&now) + .bind(repo) + .bind(owner_did) + .execute(&mut *tx) + .await?; + + // Also record the (repo, owner_did) association in the junction table + // so shared Git objects are visible to every repo's readers (P2). + if !repo.is_empty() && !owner_did.is_empty() { + sqlx::query( + "INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (sha256_hex, repo) DO NOTHING", + ) + .bind(sha256_hex) + .bind(repo) + .bind(owner_key) + .bind(&now) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + pub async fn update_pinned_cid_repo( + &self, + sha256_hex: &str, + repo: &str, + owner_did: &str, + ) -> Result<()> { + if repo.is_empty() || owner_did.is_empty() { + return Ok(()); + } + let now = Utc::now().to_rfc3339(); + // Same normalized owner-key spelling as record_pinned_cid_full (P2 + // mirror/canonical): the junction must not split a single owner across + // bare-key and did:key spellings or the scoped reader misses its rows. + let owner_key = normalize_owner_key(owner_did); + sqlx::query( + "INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (sha256_hex, repo) DO NOTHING", + ) + .bind(sha256_hex) + .bind(repo) + .bind(owner_key) + .bind(&now) .execute(&self.pool) .await?; Ok(()) @@ -2551,9 +2747,10 @@ impl Db { Ok(row.map(|r| r.get("recipients_tag"))) } + #[allow(dead_code)] pub async fn list_pinned_cids(&self) -> Result> { let rows = sqlx::query( - "SELECT sha256_hex, cid, pinned_at, pinata_cid FROM pinned_cids ORDER BY pinned_at DESC", + "SELECT sha256_hex, cid, pinned_at, pinata_cid, repo, owner_did FROM pinned_cids ORDER BY pinned_at DESC", ) .fetch_all(&self.pool) .await?; @@ -2564,6 +2761,146 @@ impl Db { cid: r.get("cid"), pinned_at: r.get("pinned_at"), pinata_cid: r.get("pinata_cid"), + repo: r.get("repo"), + owner_did: r.get("owner_did"), + }) + .collect()) + } + + #[allow(dead_code)] + pub async fn get_pinata_cid(&self, sha256_hex: &str) -> Result> { + let row = sqlx::query("SELECT pinata_cid FROM pinned_cids WHERE sha256_hex = $1 AND pinata_cid IS NOT NULL LIMIT 1") + .bind(sha256_hex) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| r.get("pinata_cid"))) + } + + /// Bounded global pin query: returns pins for any of the given (repo, owner_did) + /// pairs, ordered by (pinned_at DESC, sha256_hex DESC, repo DESC) and capped + /// by both distinct-object count and total-association row count. + /// The inner DISTINCT ON picks up to `sha_limit` unique SHAs; the outer query + /// then returns ALL repo associations for those SHAs, bounded by `assoc_limit` + /// rows. When `cursor` is `Some((pinned_at, sha256_hex, repo))`, the inner + /// query skips rows whose keyset position is not strictly before the cursor, + /// so a partially-returned SHA (outer result hit `assoc_limit`) resumes + /// correctly on the next fetch. + pub async fn list_pinned_cids_for_repos( + &self, + repos: &[String], + owner_dids: &[String], + sha_limit: i64, + assoc_limit: i64, + cursor: Option<(&str, &str, &str)>, + ) -> Result> { + // Normalize the owner KEY on both sides of the association join so a + // mirror's bare-key junction row and the canonical did:key spelling + // match the same query pair (P2). The junction writer stores the bare + // key, but the reader must also fold a full `did:key:` in pinned_cids' + // COALESCE fallback, so normalize the COALESCE expression and the + // bound pair in SQL via the byte-identical OWNER_KEY_CASE expression. + let assoc_owner = owner_key_case_sql("COALESCE(pr.owner_did, p.owner_did)"); + let pair_owner = owner_key_case_sql("pairs.owner_did"); + let rows = if let Some((pa, sha, repo)) = cursor { + sqlx::query(&format!( + r#"WITH batch_shas AS ( + SELECT sha256_hex + FROM ( + SELECT DISTINCT ON (p.sha256_hex) p.sha256_hex, p.pinned_at + FROM pinned_cids p + LEFT JOIN pinned_cid_repos pr ON pr.sha256_hex = p.sha256_hex + JOIN UNNEST($1::text[], $2::text[]) + AS pairs(repo, owner_did) + ON ( + (COALESCE(pr.repo, p.repo), + {assoc_owner}) + = (pairs.repo, {pair_owner})) + WHERE (p.pinned_at, p.sha256_hex, COALESCE(pr.repo, p.repo)) + < ($3::text, $4::text, $5::text) + ORDER BY p.sha256_hex, p.pinned_at DESC + ) deduped + ORDER BY pinned_at DESC, sha256_hex DESC + LIMIT $6 + ) + SELECT p.sha256_hex, p.cid, p.pinned_at, p.pinata_cid, + COALESCE(pr.repo, p.repo) AS repo, + COALESCE(pr.owner_did, p.owner_did) AS owner_did + FROM pinned_cids p + LEFT JOIN pinned_cid_repos pr ON pr.sha256_hex = p.sha256_hex + JOIN batch_shas bs ON bs.sha256_hex = p.sha256_hex + JOIN UNNEST($1::text[], $2::text[]) + AS pairs(repo, owner_did) + ON ( + (COALESCE(pr.repo, p.repo), + {assoc_owner}) + = (pairs.repo, {pair_owner})) + WHERE (p.pinned_at, p.sha256_hex, COALESCE(pr.repo, p.repo)) + < ($3::text, $4::text, $5::text) + ORDER BY p.pinned_at DESC, p.sha256_hex DESC, + COALESCE(pr.repo, p.repo) DESC + LIMIT $7"#, + )) + .bind(repos) + .bind(owner_dids) + .bind(pa) + .bind(sha) + .bind(repo) + .bind(sha_limit) + .bind(assoc_limit) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query(&format!( + r#"WITH batch_shas AS ( + SELECT sha256_hex + FROM ( + SELECT DISTINCT ON (p.sha256_hex) p.sha256_hex, p.pinned_at + FROM pinned_cids p + LEFT JOIN pinned_cid_repos pr ON pr.sha256_hex = p.sha256_hex + JOIN UNNEST($1::text[], $2::text[]) + AS pairs(repo, owner_did) + ON ( + (COALESCE(pr.repo, p.repo), + {assoc_owner}) + = (pairs.repo, {pair_owner})) + ORDER BY p.sha256_hex, p.pinned_at DESC + ) deduped + ORDER BY pinned_at DESC, sha256_hex DESC + LIMIT $3 + ) + SELECT p.sha256_hex, p.cid, p.pinned_at, p.pinata_cid, + COALESCE(pr.repo, p.repo) AS repo, + COALESCE(pr.owner_did, p.owner_did) AS owner_did + FROM pinned_cids p + LEFT JOIN pinned_cid_repos pr ON pr.sha256_hex = p.sha256_hex + JOIN batch_shas bs ON bs.sha256_hex = p.sha256_hex + JOIN UNNEST($1::text[], $2::text[]) + AS pairs(repo, owner_did) + ON ( + (COALESCE(pr.repo, p.repo), + {assoc_owner}) + = (pairs.repo, {pair_owner})) + ORDER BY p.pinned_at DESC, p.sha256_hex DESC, + COALESCE(pr.repo, p.repo) DESC + LIMIT $4"#, + )) + .bind(repos) + .bind(owner_dids) + .bind(sha_limit) + .bind(assoc_limit) + .fetch_all(&self.pool) + .await? + }; + + Ok(rows + .into_iter() + .map(|r| PinnedCidRecord { + sha256_hex: r.get("sha256_hex"), + cid: r.get("cid"), + pinned_at: r.get("pinned_at"), + pinata_cid: r.get("pinata_cid"), + repo: r.get("repo"), + owner_did: r.get("owner_did"), }) .collect()) } @@ -2579,21 +2916,53 @@ impl Db { Ok(row.get::("cnt") > 0) } - /// Record the Pinata CID for a git object. - /// Inserts the row if it doesn't exist (objects pinned directly to Pinata - /// without a prior local IPFS pin get cid = pinata_cid). - pub async fn record_pinata_cid(&self, sha256_hex: &str, pinata_cid: &str) -> Result<()> { + /// Record the Pinata CID with explicit repo/owner_did association. + /// `cid` is the local content CID computed from the git object bytes and + /// `pinata_cid` is the CID assigned by the pinning provider; they can + /// differ (providers may re-block or re-name), so each is stored in its + /// own column. Both writes run in one transaction so a failure cannot + /// leave a half-recorded pin (P2). + pub async fn record_pinata_cid_full( + &self, + sha256_hex: &str, + cid: &str, + pinata_cid: &str, + repo: &str, + owner_did: &str, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let owner_key = normalize_owner_key(owner_did); + let mut tx = self.pool.begin().await?; sqlx::query( - "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid) - VALUES ($1, $2, $3, $4) - ON CONFLICT(sha256_hex) DO UPDATE SET pinata_cid = EXCLUDED.pinata_cid", + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid, repo, owner_did) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT(sha256_hex) DO UPDATE SET pinata_cid = EXCLUDED.pinata_cid, + repo = COALESCE(NULLIF(EXCLUDED.repo, ''), pinned_cids.repo), + owner_did = COALESCE(NULLIF(EXCLUDED.owner_did, ''), pinned_cids.owner_did)", ) .bind(sha256_hex) - .bind(pinata_cid) // fallback local cid if row is new - .bind(Utc::now().to_rfc3339()) + .bind(cid) + .bind(&now) .bind(pinata_cid) - .execute(&self.pool) + .bind(repo) + .bind(owner_did) + .execute(&mut *tx) .await?; + + if !repo.is_empty() && !owner_did.is_empty() { + sqlx::query( + "INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (sha256_hex, repo) DO NOTHING", + ) + .bind(sha256_hex) + .bind(repo) + .bind(owner_key) + .bind(&now) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; Ok(()) } } @@ -3027,6 +3396,59 @@ impl Db { }) .collect()) } + + /// List arweave anchors scoped to repos the caller can read. + /// Filtered by (repo, owner_did) pairs from the caller's readable set. + /// The owner comparison is normalized on both sides (mirror/canonical + /// P2): the caller's pairs carry the deduped catalog's did:key spelling + /// while an anchor recorded through a bare-key mirror row persisted the + /// bare key, and one logical repo must match under either spelling. + pub async fn list_arweave_anchors_for_repos( + &self, + repos: &[String], + owner_dids: &[String], + limit: i64, + ) -> Result> { + if repos.is_empty() { + return Ok(Vec::new()); + } + let anchor_owner = owner_key_case_sql("owner_did"); + let pair_owner = owner_key_case_sql("pairs.owner_did"); + let rows = sqlx::query( + &format!( + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at + FROM arweave_anchors + WHERE (repo, {anchor_owner}) IN ( + SELECT repo, {pair_owner} + FROM UNNEST($1::text[], $2::text[]) AS pairs(repo, owner_did) + ) + ORDER BY anchored_at DESC + LIMIT $3", + ), + ) + .bind(repos) + .bind(owner_dids) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| ArweaveAnchor { + id: r.get("id"), + repo: r.get("repo"), + owner_did: r.get("owner_did"), + ref_name: r.get("ref_name"), + old_sha: r.get("old_sha"), + new_sha: r.get("new_sha"), + cid: r.get("cid"), + irys_tx_id: r.get("irys_tx_id"), + arweave_url: r.get("arweave_url"), + node_did: r.get("node_did"), + anchored_at: r.get("anchored_at"), + }) + .collect()) + } } // ── Row helpers ─────────────────────────────────────────────────────────────── @@ -3773,7 +4195,8 @@ impl Db { #[cfg(test)] mod migration_tests { - use super::{MIGRATIONS, MIGRATION_V1_NAME}; + use super::{Db, MIGRATIONS, MIGRATION_V1_NAME}; + use sqlx::{PgPool, Row}; #[test] fn migrations_are_non_empty() { @@ -3961,85 +4384,361 @@ mod migration_tests { db.migrate().await.unwrap(); } - // ── sync_queue scheduling (attempted_at, v17) ──────────────────────────── - - async fn enqueue_one(db: &super::Db, repo: &str) { - db.enqueue_sync( - repo, - "did:key:zPEER", - "refs/heads/main", - &"0".repeat(40), - None, - ) - .await - .unwrap(); - } - - async fn attempted_at_of(db: &super::Db, repo: &str) -> Option { - sqlx::query_scalar("SELECT attempted_at FROM sync_queue WHERE repo = $1") - .bind(repo) - .fetch_one(&db.pool) - .await - .unwrap() - } - - /// Upgrade-path test: simulate a node already at v11 and let the real - /// migration entry point apply v17, rather than hand-copying its SQL. - /// - /// This is the test that catches the column being added to the v1 - /// statement array instead of a new migration. v1 never re-runs on an - /// existing install, so that mistake breaks every deployed node's dequeue - /// while staying invisible to every other test here, since `#[sqlx::test]` - /// hands out a fresh database that runs the whole chain. #[sqlx::test] - async fn migration_v17_adds_sync_queue_attempted_at(pool: sqlx::PgPool) { - let db = super::Db::for_testing(pool); - db.migrate().await.unwrap(); + async fn test_migration_v11_upgrade_path(pool: PgPool) { + let db = Db::for_testing(pool); - // Roll back to v11: drop the column and forget the version. - sqlx::query("ALTER TABLE sync_queue DROP COLUMN attempted_at") - .execute(&db.pool) - .await - .unwrap(); - sqlx::query("DELETE FROM schema_migrations WHERE version = 17") + // Run migrations up to version 9 + async fn run_migrations_up_to(db: &Db, version: i64) { + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )"#, + ) .execute(&db.pool) .await .unwrap(); - // A row written by the old node, before the column existed. - enqueue_one(&db, "z6Mkfoo/legacy").await; - - db.migrate().await.unwrap(); + for m in super::MIGRATIONS { + if m.version > version { + break; + } + let already: bool = sqlx::query( + "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1) AS applied", + ) + .bind(m.version) + .fetch_one(&db.pool) + .await + .unwrap() + .get::("applied"); - let col: (String, String) = sqlx::query_as( - "SELECT data_type, is_nullable - FROM information_schema.columns - WHERE table_name = 'sync_queue' AND column_name = 'attempted_at'", - ) - .fetch_one(&db.pool) - .await - .unwrap(); - assert_eq!(col.0, "text"); - assert_eq!(col.1, "YES", "attempted_at must be nullable"); + if already { + continue; + } - let recorded: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 17") - .fetch_one(&db.pool) + let mut tx = db.pool.begin().await.unwrap(); + for stmt in m.stmts { + sqlx::query(stmt).execute(&mut *tx).await.unwrap(); + } + sqlx::query( + "INSERT INTO schema_migrations (version, name, applied_at) VALUES ($1, $2, $3)", + ) + .bind(m.version) + .bind(m.name) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(&mut *tx) .await .unwrap(); - assert_eq!(recorded.0, 1, "v17 must be recorded as applied"); + tx.commit().await.unwrap(); + } + } - // The pre-existing row survives with a null key and is still dequeued. - assert_eq!(attempted_at_of(&db, "z6Mkfoo/legacy").await, None); - let items = db.dequeue_pending_syncs(10).await.unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0].repo, "z6Mkfoo/legacy"); + run_migrations_up_to(&db, 9).await; - // Idempotent re-run. - db.migrate().await.unwrap(); - } + // Seed a repo, branch_cids, and pinned_cids under v9 schema + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind("repo-123") + .bind("myrepo") + .bind("did:key:z6Mkwowner") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/repo-123") + .execute(&db.pool) + .await + .unwrap(); - #[sqlx::test] + sqlx::query( + "INSERT INTO branch_cids (repo, ref_name, sha, cid, node_did, updated_at) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind("z6Mkwowner/myrepo") + .bind("refs/heads/main") + .bind("old-sha") + .bind("old-cid") + .bind("node-did") + .bind("2026-07-03T00:00:00Z") + .execute(&db.pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) + VALUES ($1, $2, $3)", + ) + .bind("old-sha") + .bind("old-cid") + .bind("2026-07-03T00:00:00Z") + .execute(&db.pool) + .await + .unwrap(); + + // Run remaining migrations (v10 = ref_cert_dedup, v11 = pinned_cids) + db.run_migrations().await.unwrap(); + + // Verify backfilling of repo and owner_did columns + let row = sqlx::query( + "SELECT sha256_hex, cid, repo, owner_did FROM pinned_cids WHERE sha256_hex = 'old-sha'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + + assert_eq!(row.get::("repo"), "z6Mkwowner/myrepo"); + assert_eq!(row.get::("owner_did"), "did:key:z6Mkwowner"); + + // Phase 1 (expand): the old PK on sha256_hex still rejects duplicate + // SHA across repos — pre-v10 ON CONFLICT(sha256_hex) keeps working. + let res = sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind("old-sha") + .bind("old-cid") + .bind("2026-07-03T00:00:00Z") + .bind("other-repo") + .bind("other-owner") + .execute(&db.pool) + .await; + + assert!( + res.is_err(), + "Phase 1: old PK on sha256_hex must reject duplicate SHA across repos" + ); + + // Phase 2 (contract): drop the old PK and UNIQUE, promote to compound PK. + // Once all pre-v10 writers are drained this step makes the migration + // complete — same SHA can appear in different repos. + sqlx::query("ALTER TABLE pinned_cids DROP CONSTRAINT pinned_cids_pkey") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("DROP INDEX IF EXISTS pinned_cids_repo_sha_hex_key") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("ALTER TABLE pinned_cids ADD PRIMARY KEY (repo, sha256_hex)") + .execute(&db.pool) + .await + .unwrap(); + + // Now the same SHA works in a different repo (compound PK allows it). + let res = sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind("old-sha") + .bind("old-cid") + .bind("2026-07-03T00:00:00Z") + .bind("other-repo") + .bind("other-owner") + .execute(&db.pool) + .await; + + assert!( + res.is_ok(), + "Phase 2: compound PK must allow same SHA in different repos" + ); + } + + /// A pinned CID whose SHA is not a current branch_cids ref tip falls back to + /// repo = '' after migration v11. This tests that the backfill does not + /// silently orphan such pins by leaving repo NULL/unqueryable; the empty + /// string is at least queryable by list_pinned_cids_for_repos callers. + #[sqlx::test] + async fn test_migration_v11_orphan_non_tip_pin(pool: PgPool) { + let db = Db::for_testing(pool); + + // Run migrations up to version 9 + async fn run_migrations_up_to(db: &Db, version: i64) { + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )"#, + ) + .execute(&db.pool) + .await + .unwrap(); + + for m in super::MIGRATIONS { + if m.version > version { + break; + } + let already: bool = sqlx::query( + "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1) AS applied", + ) + .bind(m.version) + .fetch_one(&db.pool) + .await + .unwrap() + .get::("applied"); + + if already { + continue; + } + + let mut tx = db.pool.begin().await.unwrap(); + for stmt in m.stmts { + sqlx::query(stmt).execute(&mut *tx).await.unwrap(); + } + sqlx::query( + "INSERT INTO schema_migrations (version, name, applied_at) VALUES ($1, $2, $3)", + ) + .bind(m.version) + .bind(m.name) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + } + } + + run_migrations_up_to(&db, 9).await; + + // Seed a repo and a pinned_cid, but no matching branch_cids entry. + sqlx::query( + "INSERT INTO repos (id, name, owner_did, description, is_public, default_branch, created_at, updated_at, disk_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind("repo-orphan") + .bind("orphan-repo") + .bind("did:key:z6Mkworphan") + .bind("desc") + .bind(true) + .bind("main") + .bind("2026-07-03T00:00:00Z") + .bind("2026-07-03T00:00:00Z") + .bind("/srv/orphan") + .execute(&db.pool) + .await + .unwrap(); + + // This CID is a pinned object that is NOT a current ref tip — + // no matching row in branch_cids exists. + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) + VALUES ($1, $2, $3)", + ) + .bind("orphan-sha") + .bind("orphan-cid") + .bind("2026-07-03T00:00:00Z") + .execute(&db.pool) + .await + .unwrap(); + + // Run remaining migrations (v10 = ref_cert_dedup, v11 = pinned_cids) + db.run_migrations().await.unwrap(); + + // The orphan pin should have fallen back to repo = '' because + // branch_cids had no matching cid to backfill from. + let row = sqlx::query( + "SELECT sha256_hex, repo, owner_did FROM pinned_cids WHERE sha256_hex = 'orphan-sha'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + + assert_eq!( + row.get::("repo"), + "", + "non-tip pin must fall back to empty repo" + ); + assert_eq!( + row.get::("owner_did"), + "", + "non-tip pin must fall back to empty owner_did" + ); + } + + // ── sync_queue scheduling (attempted_at, v17) ──────────────────────────── + + async fn enqueue_one(db: &super::Db, repo: &str) { + db.enqueue_sync( + repo, + "did:key:zPEER", + "refs/heads/main", + &"0".repeat(40), + None, + ) + .await + .unwrap(); + } + + async fn attempted_at_of(db: &super::Db, repo: &str) -> Option { + sqlx::query_scalar("SELECT attempted_at FROM sync_queue WHERE repo = $1") + .bind(repo) + .fetch_one(&db.pool) + .await + .unwrap() + } + + /// Upgrade-path test: simulate a node already at v11 and let the real + /// migration entry point apply v17, rather than hand-copying its SQL. + /// + /// This is the test that catches the column being added to the v1 + /// statement array instead of a new migration. v1 never re-runs on an + /// existing install, so that mistake breaks every deployed node's dequeue + /// while staying invisible to every other test here, since `#[sqlx::test]` + /// hands out a fresh database that runs the whole chain. + #[sqlx::test] + async fn migration_v17_adds_sync_queue_attempted_at(pool: sqlx::PgPool) { + let db = super::Db::for_testing(pool); + db.migrate().await.unwrap(); + + // Roll back to v11: drop the column and forget the version. + sqlx::query("ALTER TABLE sync_queue DROP COLUMN attempted_at") + .execute(&db.pool) + .await + .unwrap(); + sqlx::query("DELETE FROM schema_migrations WHERE version = 17") + .execute(&db.pool) + .await + .unwrap(); + + // A row written by the old node, before the column existed. + enqueue_one(&db, "z6Mkfoo/legacy").await; + + db.migrate().await.unwrap(); + + let col: (String, String) = sqlx::query_as( + "SELECT data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'sync_queue' AND column_name = 'attempted_at'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(col.0, "text"); + assert_eq!(col.1, "YES", "attempted_at must be nullable"); + + let recorded: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 17") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(recorded.0, 1, "v17 must be recorded as applied"); + + // The pre-existing row survives with a null key and is still dequeued. + assert_eq!(attempted_at_of(&db, "z6Mkfoo/legacy").await, None); + let items = db.dequeue_pending_syncs(10).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].repo, "z6Mkfoo/legacy"); + + // Idempotent re-run. + db.migrate().await.unwrap(); + } + + #[sqlx::test] async fn dequeue_stamps_attempted_at_on_every_row_it_hands_out(pool: sqlx::PgPool) { // The stamp is what stops a deferred row from holding the window, and // it happens here rather than at the deferral branches so no call site @@ -4890,6 +5589,99 @@ mod dedup_db_tests { 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::>() + .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}\"" + ); + } + } + + /// Verify that `PROFILE_DID_CASE_SQL` (which aliases the column `did`) also + /// agrees with Rust `normalize_owner_key` across the full boundary matrix. + #[sqlx::test] + async fn profile_did_case_sql_matches_normalize_owner_key(pool: PgPool) { + 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", + ]; + + let values_sql: String = boundary_values + .iter() + .map(|v| format!("('{}'::text)", v)) + .collect::>() + .join(", "); + let sql = format!( + "WITH data(did) AS (VALUES {values_sql}) + SELECT did, ({key}) AS normalized FROM data ORDER BY did", + key = super::PROFILE_DID_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, + "PROFILE_DID_CASE_SQL(\"{val}\") mismatch: Rust = \"{rust_result}\", SQL CASE = \"{sql_result}\"" + ); + } + } + /// get_profile must not resolve a non-key DID (e.g. did:gitlawb:) when /// queried with the bare short id. The old `LIKE '%:' || $1` clause was too /// broad and could return the wrong profile row. @@ -4986,99 +5778,6 @@ mod dedup_db_tests { let got = db.get_profile(short).await.unwrap().unwrap(); assert_eq!(got.profile_cid.as_deref(), Some("bafytestcid")); } - - /// 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::>() - .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}\"" - ); - } - } - - /// Verify that `PROFILE_DID_CASE_SQL` (which aliases the column `did`) also - /// agrees with Rust `normalize_owner_key` across the full boundary matrix. - #[sqlx::test] - async fn profile_did_case_sql_matches_normalize_owner_key(pool: PgPool) { - 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", - ]; - - let values_sql: String = boundary_values - .iter() - .map(|v| format!("('{}'::text)", v)) - .collect::>() - .join(", "); - let sql = format!( - "WITH data(did) AS (VALUES {values_sql}) - SELECT did, ({key}) AS normalized FROM data ORDER BY did", - key = super::PROFILE_DID_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, - "PROFILE_DID_CASE_SQL(\"{val}\") mismatch: Rust = \"{rust_result}\", SQL CASE = \"{sql_result}\"" - ); - } - } } /// Exercises the iCaptcha single-use proof ledger (`icaptcha_consumed_proofs`), @@ -6512,6 +7211,190 @@ mod peer_reachability_tests { } } +#[cfg(test)] +mod pinned_cid_keyset_tests { + use super::Db; + use sqlx::PgPool; + + async fn db(pool: PgPool) -> Db { + let db = Db::for_testing(pool); + db.run_migrations().await.unwrap(); + db + } + + /// One SHA pinned into five repos, all at the same timestamp. Page 1 with + /// assoc_limit=3 shows r05,r04,r03; resuming at r03 must yield r02,r01. + #[sqlx::test] + async fn outer_keyset_resumes_past_assoc_limit(pool: PgPool) { + let db = db(pool).await; + let owner = "did:key:z6Mkwowner"; + + sqlx::query( + "INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo, owner_did) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind("sha-1") + .bind("cid-1") + .bind("2026-07-03T09:00:00Z") + .bind("r01") + .bind(owner) + .execute(db.pool()) + .await + .unwrap(); + + for repo in ["r01", "r02", "r03", "r04", "r05"] { + sqlx::query( + "INSERT INTO pinned_cid_repos (sha256_hex, repo, owner_did, pinned_at) + VALUES ($1, $2, $3, $4)", + ) + .bind("sha-1") + .bind(repo) + .bind(owner) + .bind("2026-07-03T09:00:00Z") + .execute(db.pool()) + .await + .unwrap(); + } + + let repos: Vec = (1..=5).map(|i| format!("r0{i}")).collect(); + let owners: Vec = (0..5).map(|_| owner.to_string()).collect(); + + // Page 1: repo DESC order → r05, r04, r03. + let page1 = db + .list_pinned_cids_for_repos(&repos, &owners, 200, 3, None) + .await + .unwrap(); + let page1_repos: Vec<&str> = page1.iter().map(|p| p.repo.as_str()).collect(); + assert_eq!( + page1_repos, + vec!["r05", "r04", "r03"], + "page 1 must surface the three newest associations in repo-DESC order" + ); + + // Resume at the last shown row (pinned_at, sha, r03). + let last = page1.last().unwrap(); + let cursor = Some(( + last.pinned_at.as_str(), + last.sha256_hex.as_str(), + last.repo.as_str(), + )); + + let page2 = db + .list_pinned_cids_for_repos(&repos, &owners, 200, 3, cursor) + .await + .unwrap(); + let page2_repos: Vec<&str> = page2.iter().map(|p| p.repo.as_str()).collect(); + assert_eq!( + page2_repos, + vec!["r02", "r01"], + "resuming past assoc_limit must advance to the remaining \ + associations, not repeat r05,r04,r03" + ); + } + + /// The mirror/canonical twin regression (P2). A mirror writes its junction + /// row under the BARE key (`z6Mkwowner`) while the canonical node writes the + /// FULL did:key spelling (`did:key:z6Mkwowner`); both refer to the same + /// identity and the same physical repo row, so the scoped reader must see + /// the pin no matter which spelling the caller binds. Both query forms + /// (first page and cursor resume) are exercised. The junction rows are + /// seeded through the writer, not raw SQL, so a change that reverts the + /// junction to the raw caller spelling (or drops normalization on either + /// join side) fails here. + #[sqlx::test] + async fn twin_slug_mirror_and_canonical_share_visibility(pool: PgPool) { + let db = db(pool).await; + let canonical = "did:key:z6Mkwowner"; + let bare = "z6Mkwowner"; + let sha = "sha-twin"; + + // Junction row written by the MIRROR under the bare key. + db.update_pinned_cid_repo(sha, "mirror-repo", bare) + .await + .unwrap(); + // Junction row written by the CANONICAL node under the full did:key. + db.update_pinned_cid_repo(sha, "canon-repo", canonical) + .await + .unwrap(); + + // The backing pin, seeded through the writer so the COALESCE fallback + // also carries the full spelling the reader must fold. + db.record_pinned_cid_full(sha, "cid-twin", "canon-repo", canonical) + .await + .unwrap(); + + // The junction must physically store the normalized bare key for BOTH + // spellings, or a real deployment that wrote pre-fix raw rows would + // still split the owner across two spellings. Guards the writer-side + // half of the fix directly. + let stored: Vec = sqlx::query_scalar( + "SELECT DISTINCT owner_did FROM pinned_cid_repos WHERE sha256_hex = $1", + ) + .bind(sha) + .fetch_all(db.pool()) + .await + .unwrap(); + assert_eq!( + stored, + vec![bare.to_string()], + "both spellings must collapse to the bare owner key in the junction" + ); + + // Reader bound with the canonical spelling; repo pairs are the same + // slugs the junction rows were stored under. assoc_limit=1 forces the + // cursor resume to be the page that surfaces the second twin row, so + // both query forms prove the pin is visible regardless of spelling. + let repos = vec!["mirror-repo".to_string(), "canon-repo".to_string()]; + let owners = vec![canonical.to_string(); 2]; + + let page1 = db + .list_pinned_cids_for_repos(&repos, &owners, 200, 1, None) + .await + .unwrap(); + let page1_repos: Vec<&str> = page1.iter().map(|p| p.repo.as_str()).collect(); + assert_eq!( + page1_repos, + vec!["mirror-repo"], + "the first page must surface the bare-key mirror junction row to a \ + canonical-spelling reader" + ); + + // Cursor form: resume from the row seen on page 1, which must yield + // the did:key canonical row — NOT drop it because its spelling differs + // from the bound pair. + let last = page1.last().unwrap(); + let cursor = Some(( + last.pinned_at.as_str(), + last.sha256_hex.as_str(), + last.repo.as_str(), + )); + let page2 = db + .list_pinned_cids_for_repos(&repos, &owners, 200, 1, cursor) + .await + .unwrap(); + let page2_repos: Vec<&str> = page2.iter().map(|p| p.repo.as_str()).collect(); + assert_eq!( + page2_repos, + vec!["canon-repo"], + "resuming from the mirror row must still surface the did:key canonical row" + ); + + // Reader bound with the BARE spelling (a mirror querying its own rows): + // both twin rows must be visible under either spelling. + let bare_owners = vec![bare.to_string(); 2]; + let bare_page = db + .list_pinned_cids_for_repos(&repos, &bare_owners, 200, 10, None) + .await + .unwrap(); + let bare_repos: Vec<&str> = bare_page.iter().map(|p| p.repo.as_str()).collect(); + assert_eq!( + bare_repos, + vec!["mirror-repo", "canon-repo"], + "a bare-spelling reader must see the same twin rows" + ); + } +} + #[cfg(test)] mod peer_authority_tests { use super::{Db, PeerWriteAuthority, PeerWriteDenied}; @@ -6921,240 +7804,3 @@ mod peer_authority_tests { } } } - -/// #273 completeness ledger: every writer of the `peers` table. -/// -/// The required set is derived from the authority that DEFINES membership, the -/// write statements themselves, not from the set of `upsert_peer` callers. A -/// caller scan is structurally blind to a future writer that issues its own SQL -/// and bypasses `upsert_peer`, which is precisely the case the type system -/// cannot see. -/// -/// The type system carries the real weight: `upsert_peer`'s authority parameter -/// has no default, so a new caller cannot omit its declaration, compile-enforced -/// and exhaustive by construction. This scan is the backstop for the bypass case -/// alone, which is why it is one equality assertion rather than a framework. -/// -/// The ledger, one row per function that writes the table: -/// -/// | Writer | Disposition | -/// | --- | --- | -/// | `upsert_peer` (db/mod.rs) | guarded by the authority parameter | -/// | `mark_peer_ping` (db/mod.rs) | benign for `http_url`: it writes only `last_seen` and `last_ping_ok`. It IS the table's other production writer, and its unauthenticated reachability is tracked separately as issue #269 | -/// | `prune_self_peers` (db/mod.rs) | a delete keyed on `http_url`; cannot repoint; boot-only caller in main.rs | -/// | `prune_non_public_peers` (db/mod.rs) | a delete keyed on a computed bad-DID array; cannot repoint; boot-only caller in main.rs | -/// | `seed_local_peer` (sync.rs) | excluded by test-module location: a deliberate `upsert_peer` bypass for `file://` fixtures, which the public-URL gate rejects | -/// | `a_legacy_row_can_still_refresh_its_liveness` (db/mod.rs) | test-only. Seeds a PRE-GATE row by raw SQL on purpose: `upsert_peer` cannot create one, since the gate it is testing refuses exactly that DID. The fixture models what a deployed table already holds | -/// | `gossip_ping_round_requires_two_failures_before_persisting_unreachable` (main.rs) | test-only. Seeds a peer row by raw SQL so the gossip ping round can probe readiness hysteresis without going through `upsert_peer` | -/// | `manual_ping_uses_readiness_without_mutating_federation_gate` (api/peers.rs) | test-only. Seeds a peer row by raw SQL so the manual ping route can assert readiness probing without mutating federation gate state | -/// -/// And the `upsert_peer` CALL-SITE authority table, which the ledger above -/// structurally cannot hold, because the bootstrap site issues no SQL of its own -/// and therefore can never appear in a scan of write statements: -/// -/// | Call site | Authority declared | -/// | --- | --- | -/// | api/peers.rs (announce) | proven if and only if the `AuthenticatedDid` extension is present, carrying that extension's DID | -/// | main.rs (bootstrap announce-back) | unproven, unconditionally. Reasoned-not-run: no runtime test reaches that site in this change | -#[cfg(test)] -mod peers_table_writer_guard { - use std::collections::BTreeMap; - use std::path::{Path, PathBuf}; - - /// The per-file scan, split out so the multi-line-SQL property can be - /// asserted against a synthetic source rather than only against the real - /// tree, which happens to contain no multi-line peers write today. A guard - /// whose blind spot is invisible because the tree does not currently - /// exercise it is one that fails the moment somebody writes normal code. - fn scan_source(src: &str) -> BTreeMap { - let needles: Vec = needles().iter().map(|n| n.to_lowercase()).collect(); - let mut found: BTreeMap = BTreeMap::new(); - // Normalize the WHOLE file before matching, not each line on its - // own. Per-line `contains` is whitespace- and case-sensitive, so - // `sqlx::query(r#"UPDATE\n peers SET ..."#)` walked straight past - // it, and that multi-line form is how the longer queries in this - // file are already written. Verified both ways: the single-line - // bypass went RED, the identical statement split across lines - // stayed GREEN. Offsets are mapped back to line numbers so each - // statement is still attributed to the function that issues it. - let mut flat = String::with_capacity(src.len()); - let mut starts: Vec<(usize, usize)> = Vec::new(); // (offset, line index) - for (idx, line) in src.lines().enumerate() { - starts.push((flat.len(), idx)); - flat.push_str(&line.trim().to_lowercase()); - flat.push(' '); - } - let flat = flat.split_whitespace().collect::>().join(" "); - - // Re-derive offsets against the collapsed text by walking it once. - let mut collapsed = String::with_capacity(flat.len()); - let mut owners: Vec = Vec::new(); // line index per byte - for (idx, line) in src.lines().enumerate() { - for tok in line.trim().to_lowercase().split_whitespace() { - if !collapsed.is_empty() { - collapsed.push(' '); - owners.push(idx); - } - for _ in 0..tok.len() { - owners.push(idx); - } - collapsed.push_str(tok); - } - } - - let fn_at: Vec<&str> = { - let mut current = ""; - src.lines() - .map(|line| { - if let Some(name) = declared_fn(line) { - current = name; - } - current - }) - .collect() - }; - - for needle in &needles { - let mut from = 0usize; - while let Some(rel) = collapsed[from..].find(needle.as_str()) { - let at = from + rel; - let line_idx = owners.get(at).copied().unwrap_or(0); - let owner = fn_at.get(line_idx).copied().unwrap_or(""); - *found.entry(owner.to_string()).or_default() += 1; - from = at + needle.len(); - } - } - found - } - - /// Each dispositioned writer and the number of statements it issues against - /// the table. Bidirectional: an undispositioned hit fails, and so does a - /// listed function that no longer has one. - const LEDGER: &[(&str, usize)] = &[ - ("a_legacy_row_can_still_refresh_its_liveness", 1), - ( - "gossip_ping_round_requires_two_failures_before_persisting_unreachable", - 1, - ), - ( - "manual_ping_uses_readiness_without_mutating_federation_gate", - 1, - ), - ("mark_peer_ping", 1), - ("prune_non_public_peers", 1), - ("prune_self_peers", 1), - ("seed_local_peer", 1), - ("upsert_peer", 2), - ]; - - /// Assembled at runtime rather than written as literals, so this module's - /// own source does not match the scan it performs. - fn needles() -> Vec { - ["INSERT INTO ", "UPDATE ", "DELETE FROM "] - .iter() - .map(|verb| format!("{verb}peers")) - .collect() - } - - fn rust_sources(dir: &Path, out: &mut Vec) { - for entry in std::fs::read_dir(dir).expect("the crate source tree must be readable") { - let path = entry.expect("directory entry").path(); - if path.is_dir() { - rust_sources(&path, out); - } else if path.extension().is_some_and(|e| e == "rs") { - out.push(path); - } - } - } - - /// The function a line declares, if it declares one. - fn declared_fn(line: &str) -> Option<&str> { - let trimmed = line.trim_start(); - let rest = [ - "pub(crate) async fn ", - "pub(crate) fn ", - "pub async fn ", - "pub fn ", - "async fn ", - "fn ", - ] - .iter() - .find_map(|p| trimmed.strip_prefix(p))?; - rest.split(|c: char| !(c.is_alphanumeric() || c == '_')) - .next() - .filter(|name| !name.is_empty()) - } - - /// The backstop the authority parameter cannot provide: a new raw write - /// against the table from outside `upsert_peer` is invisible to the type - /// system, so it is caught here or not at all. - /// The blind spot this guard shipped with: matching a verb-plus-table needle - /// per line is whitespace- and case-sensitive, so the identical statement split - /// across lines walked past it. That form is how the longer queries in this - /// file are already written, so it is the shape a future writer most likely - /// takes. Both directions asserted, plus lowercase. - /// - /// The fixtures are DERIVED from `needles()` at runtime for the same reason - /// the needles themselves are: a literal here would be found by the scan of - /// this very file and counted against this test function. Deriving them - /// also means the test follows if the needle set ever changes. - #[test] - fn the_scan_sees_a_write_whose_verb_and_table_are_on_different_lines() { - for needle in needles() { - let (verb, table) = needle.rsplit_once(' ').expect("a needle is ' peers'"); - let cases = [ - ("single-line", format!("{verb} {table} SET x = $1")), - ( - "split across lines", - format!("{verb}\n {table}\n SET x = $1"), - ), - ( - "lowercase, double-spaced", - format!("{} {} set x = $1", verb.to_lowercase(), table), - ), - ]; - for (label, sql) in cases { - let src = format!("fn sneaky() {{\n sqlx::query(\"{sql}\");\n}}\n"); - let found = scan_source(&src); - assert_eq!( - found.get("sneaky").copied(), - Some(1), - "the scan missed a peers write written {label} with needle {needle:?}: {found:?}" - ); - } - } - } - - #[test] - fn every_peers_table_write_is_dispositioned() { - let mut files = Vec::new(); - rust_sources( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"), - &mut files, - ); - // Anti-vacuity: a scrape that walked nothing would report a clean tree. - assert!( - files.len() > 10, - "the scan found only {} source files, so a clean result proves nothing", - files.len() - ); - - let mut found: BTreeMap = BTreeMap::new(); - for file in &files { - let src = std::fs::read_to_string(file).expect("source file must be readable"); - - for (owner, n) in scan_source(&src) { - *found.entry(owner).or_default() += n; - } - } - - let expected: BTreeMap = - LEDGER.iter().map(|(f, n)| ((*f).to_string(), *n)).collect(); - assert_eq!( - found, expected, - "the peers-table writers no longer match the ledger. A new writer must \ - be dispositioned in the table above (and gated), and a removed one \ - dropped from LEDGER" - ); - } -} diff --git a/crates/gitlawb-node/src/ipfs_pin.rs b/crates/gitlawb-node/src/ipfs_pin.rs index 4a632b6d..0c2b0e76 100644 --- a/crates/gitlawb-node/src/ipfs_pin.rs +++ b/crates/gitlawb-node/src/ipfs_pin.rs @@ -247,13 +247,20 @@ pub(crate) fn batch_budget_gate( /// the shared client's own ceiling. Everything else about the shape (the /// skip-if-pinned check, the fault arms, the returned pairs) changes in lockstep. /// +/// `repo_slug` and `owner_did` are recorded alongside each pin so the scoped +/// listing query (`list_pinned_cids_for_repos`) can find them. Pass empty +/// strings if not available (the scoped listing will omit such pins). +/// /// Returns a list of `(sha256_hex, cid)` pairs for objects pinned this call. +#[allow(clippy::too_many_arguments)] pub async fn pin_new_objects( ipfs_api: &str, repo_path: &std::path::Path, git_bin: &str, object_list: Vec, db: &crate::db::Db, + repo_slug: &str, + owner_did: &str, batch_budget: Duration, ) -> Vec<(String, String)> { if ipfs_api.is_empty() { @@ -273,9 +280,18 @@ pub async fn pin_new_objects( if batch_budget_gate("IPFS", deadline, pinned.len(), total - attempted).is_none() { break; } - // Skip if already pinned + // Skip if already pinned. Even when pinned, update the repo/owner_did + // association so the scoped listing query can find this object under the + // current repo (P2). match db.is_pinned(&sha).await { - Ok(true) => continue, + Ok(true) => { + if !repo_slug.is_empty() { + if let Err(e) = db.update_pinned_cid_repo(&sha, repo_slug, owner_did).await { + tracing::warn!(sha = %sha, err = %e, "failed to update pinned_cid_repo"); + } + } + continue; + } Ok(false) => {} Err(e) => { tracing::warn!(sha = %sha, err = %e, "DB error checking pinned status"); @@ -369,7 +385,10 @@ pub async fn pin_new_objects( // Pin to IPFS match pin_git_object(ipfs_api, &sha, &data, Some(add_timeout)).await { Ok(cid) if !cid.is_empty() => { - if let Err(e) = db.record_pinned_cid(&sha, &cid).await { + if let Err(e) = db + .record_pinned_cid_full(&sha, &cid, repo_slug, owner_did) + .await + { tracing::warn!(sha = %sha, err = %e, "failed to record pinned CID in DB"); } pinned.push((sha, cid)); @@ -654,6 +673,8 @@ mod tests { "git", oids, &db, + "repo", + "did:key:owner", Duration::from_millis(5500), ), ) @@ -719,6 +740,8 @@ mod tests { "git", oids, &db, + "repo", + "did:key:owner", Duration::from_secs(90), ), ) @@ -755,6 +778,8 @@ mod tests { "git", oids, &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -850,6 +875,8 @@ mod tests { fake.to_str().unwrap(), oids, &db, + "repo", + "did:key:owner", Duration::from_secs(2), ), ) @@ -935,6 +962,8 @@ mod tests { &git_bin, oids, &db, + "repo", + "did:key:owner", Duration::from_millis(1500), ), ) @@ -1006,6 +1035,8 @@ mod tests { &git_bin, oids.clone(), &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -1078,6 +1109,8 @@ mod tests { &git_bin, oids.clone(), &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -1144,6 +1177,8 @@ mod tests { &git_bin, oids.clone(), &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 10aaca5b..de2e6ef0 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -385,6 +385,29 @@ async fn main() -> Result<()> { push_limiter_trust, sync_trigger_rate_limiter, peer_write_rate_limiter, + walk_semaphore: Arc::new(tokio::sync::Semaphore::new( + config.walk_concurrency_limit as usize, + )), + ipfs_list_rate_limiter: rate_limit::RateLimiter::new_bounded( + config.ipfs_list_rate_limit, + std::time::Duration::from_secs(3600), + 200_000, + ), + ipfs_list_global_limiter: rate_limit::RateLimiter::new_bounded( + config.ipfs_list_global_rate_limit, + std::time::Duration::from_secs(3600), + 1, + ), + arweave_list_rate_limiter: rate_limit::RateLimiter::new_bounded( + config.arweave_list_rate_limit, + std::time::Duration::from_secs(3600), + 200_000, + ), + arweave_list_global_limiter: rate_limit::RateLimiter::new_bounded( + config.arweave_list_global_rate_limit, + std::time::Duration::from_secs(3600), + 1, + ), shutdown_tx: shutdown_tx.clone(), git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_git_ops)), git_write_semaphore: Arc::new(tokio::sync::Semaphore::new( @@ -1120,6 +1143,10 @@ async fn sweep_rate_limiters(state: &AppState) { state.sync_trigger_rate_limiter.cleanup().await; state.peer_write_rate_limiter.cleanup().await; state.ipfs_rate_limiter.cleanup().await; + state.ipfs_list_rate_limiter.cleanup().await; + state.ipfs_list_global_limiter.cleanup().await; + state.arweave_list_rate_limiter.cleanup().await; + state.arweave_list_global_limiter.cleanup().await; } async fn gossip_ping_round( diff --git a/crates/gitlawb-node/src/pinata.rs b/crates/gitlawb-node/src/pinata.rs index a3077191..a8f03212 100644 --- a/crates/gitlawb-node/src/pinata.rs +++ b/crates/gitlawb-node/src/pinata.rs @@ -79,6 +79,9 @@ pub async fn pin_object( /// Objects already recorded with a `pinata_cid` are skipped. Returns /// `(sha_hex, cid)` pairs for each newly pinned object. /// +/// `repo_slug` and `owner_did` are recorded alongside each pin so the scoped +/// listing query can find them. Pass empty strings if not available. +/// /// # What `batch_budget` does and does not bound /// /// The loop runs under a `pin_semaphore` permit and that pool defers rather than @@ -108,10 +111,12 @@ pub async fn pin_object( /// The twin in `ipfs_pin.rs` shares the budget gate and the bounded read with this /// loop, so both are at parity again. Change them in lockstep: the skip-if-pinned /// check, the fault arms, the returned pairs, and the budget handling. -// Eight arguments, one over clippy's threshold: the two the budget adds (`git_bin`, -// `batch_budget`) are what put the read under test injection and under a deadline, and -// grouping them into a struct would only move the same values behind a name the twin in -// `ipfs_pin.rs` does not use. Same allow as the sibling call sites in `api::repos`. +// Ten arguments, one over clippy's threshold: the two the budget adds (`git_bin`, +// `batch_budget`) plus the scoped-listing pair (`repo_slug`, `owner_did`) are what +// put the read under test injection and under a deadline and record the association +// the scoped listing needs. Grouping them into a struct would only move the same +// values behind a name the twin in `ipfs_pin.rs` does not use. Same allow as the +// sibling call sites in `api::repos`. #[allow(clippy::too_many_arguments)] pub async fn pin_new_objects( client: &reqwest::Client, @@ -121,6 +126,8 @@ pub async fn pin_new_objects( git_bin: &str, object_list: Vec, db: &crate::db::Db, + repo_slug: &str, + owner_did: &str, batch_budget: Duration, ) -> Vec<(String, String)> { if jwt.is_empty() { @@ -145,7 +152,14 @@ pub async fn pin_new_objects( } match db.has_pinata_cid(&sha).await { - Ok(true) => continue, + Ok(true) => { + if !repo_slug.is_empty() { + if let Err(e) = db.update_pinned_cid_repo(&sha, repo_slug, owner_did).await { + tracing::warn!(sha = %sha, err = %e, "failed to update pinned_cid_repo"); + } + } + continue; + } Ok(false) => {} Err(e) => { tracing::warn!(sha = %sha, err = %e, "DB error checking pinata_cid"); @@ -224,7 +238,20 @@ pub async fn pin_new_objects( match pin_object(client, upload_url, jwt, &sha, &data).await { Ok(cid) if !cid.is_empty() => { - if let Err(e) = db.record_pinata_cid(&sha, &cid).await { + // Store the provider-assigned CID in pinata_cid and the locally + // computed content CID in cid so the two never get confused + // (P2). + let local_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&data); + if let Err(e) = db + .record_pinata_cid_full( + &sha, + &local_cid.to_string(), + &cid, + repo_slug, + owner_did, + ) + .await + { tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB"); } pinned.push((sha, cid)); @@ -435,6 +462,8 @@ mod tests { "git", oids, &db, + "repo", + "did:key:owner", Duration::from_millis(5500), ), ) @@ -524,6 +553,8 @@ mod tests { fake.to_str().unwrap(), oids, &db, + "repo", + "did:key:owner", Duration::from_secs(2), ), ) @@ -652,6 +683,8 @@ mod tests { &git_bin, oids, &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -724,6 +757,8 @@ mod tests { &git_bin, oids, &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -773,6 +808,8 @@ mod tests { "git", oids.clone(), &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -800,6 +837,8 @@ mod tests { "git", oids, &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) @@ -851,6 +890,8 @@ mod tests { fake.to_str().unwrap(), oids, &db, + "repo", + "did:key:owner", Duration::from_secs(60), ), ) diff --git a/crates/gitlawb-node/src/rate_limit.rs b/crates/gitlawb-node/src/rate_limit.rs index d203f69f..638cd6ed 100644 --- a/crates/gitlawb-node/src/rate_limit.rs +++ b/crates/gitlawb-node/src/rate_limit.rs @@ -93,24 +93,8 @@ impl RateLimiter { // 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; - } + if !self.has_room_for_new_key(&mut state, now).await { + return false; } state.insert( key.to_string(), @@ -121,6 +105,61 @@ impl RateLimiter { true } + /// Non-consuming admission probe: whether a request under `key` would + /// currently be admitted WITHOUT recording it. The paginated catalog + /// handlers probe the fixed GLOBAL budget with this before any caller-keyed + /// state exists (see [`check_listing_admission`]), so a request a full + /// global window is about to reject is shed here, before `check` would + /// allocate a per-DID key on the way to being rejected (P2 — rotating DIDs + /// must not be able to fill the per-DID map once the global bucket is + /// exhausted). Mirrors `check`'s admission rules except the insert. + pub(crate) async fn peek(&self, key: &str) -> bool { + if self.max_requests == 0 { + return true; + } + let now = Instant::now(); + let mut state = self.state.lock().await; + if let Some(window) = state.get(key) { + let live = window + .timestamps + .iter() + .filter(|t| now.duration_since(**t) < self.window) + .count(); + return live < self.max_requests; + } + // New key: admit only if the map has room, same capacity gate as check + // but without inserting. When the map is full this runs the sweep, so + // the probe reflects keys the inline sweep would reclaim. + self.has_room_for_new_key(&mut state, now).await + } + + /// Whether the key map has room for a NEW key, reclaiming expired keys if + /// needed. Shared by `check` (which then inserts) and `peek` (which does + /// not). The eviction scan is O(max_keys), so it runs at most once per + /// [`sweep_interval`](Self::sweep_interval) — the map sits at the cap + /// precisely during a distinct-key flood, so sweeping on every miss would + /// serialize all traffic behind a full scan. + async fn has_room_for_new_key( + &self, + state: &mut HashMap, + now: Instant, + ) -> bool { + if state.len() < self.max_keys { + return true; + } + 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); + state.len() < self.max_keys + } + /// Number of keys currently tracked. Tests use it to observe what a sweep /// reclaimed; there is no production reader. #[cfg(test)] @@ -139,6 +178,44 @@ impl RateLimiter { } } +/// Shared admission protocol for the paginated catalog handlers (the IPFS pin +/// listing and the Arweave anchor listing). The ORDER of the three steps is +/// load-bearing (P2): +/// +/// 1. The fixed GLOBAL budget is probed first, NON-consuming. A request a full +/// global window is about to reject is shed here, before any caller-keyed +/// state exists — so once the global bucket is exhausted, a DID flood cannot +/// grow the per-DID key map and shed every legitimate new caller for the +/// rest of the window. +/// 2. The per-DID bucket is checked (consuming). This runs before the +/// committing global check so a caller already over its own per-DID limit is +/// shed without spending shared global capacity — it only peeked at the +/// global bucket. +/// 3. The global slot is committed. A rejection here (a race where the global +/// window filled between probe and commit) spends one per-DID slot on a +/// rejected request, but only in that narrow race, never under a sustained +/// flood. +/// +/// `surface` names the budget in the 429 body (e.g. "IPFS pin listing"). +pub(crate) async fn check_listing_admission( + global: &RateLimiter, + per_did: &RateLimiter, + caller: &str, + surface: &str, +) -> crate::error::Result<()> { + let msg = || format!("rate limit exceeded for {surface}"); + if !global.peek("global").await { + return Err(crate::error::AppError::TooManyRequests(msg())); + } + if !per_did.check(caller).await { + return Err(crate::error::AppError::TooManyRequests(msg())); + } + if !global.check("global").await { + return Err(crate::error::AppError::TooManyRequests(msg())); + } + Ok(()) +} + /// Per-source concurrency cap derived from the write-pool size: one resolved client /// key (see [`client_key`]) may hold at most an eighth of the pool, so saturating it /// takes ~8 distinct keys. Real for an IPv4 or single-address caller; a caller with a @@ -398,6 +475,7 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response { #[cfg(test)] mod tests { use super::*; + use crate::error::AppError; #[test] fn per_caller_concurrency_caps_one_caller_and_frees_on_drop() { @@ -563,6 +641,89 @@ mod tests { assert!(state.contains_key("resident")); } + // ── listing admission protocol (check_listing_admission, P2) ──────── + + /// Regression (P2): a request the exhausted GLOBAL bucket rejects must not + /// allocate per-DID limiter state. Before the fix the handlers checked the + /// per-DID limiter first, so once the global bucket was full a DID flood + /// grew the per-DID key map and shed every legitimate new caller for the + /// whole window. The fix probes the fixed global budget first, non-consuming + /// (`peek`), so the flood allocates no keys, and a fresh legitimate caller + /// is still admitted once the global window resets. + #[tokio::test] + async fn global_exhaustion_allocates_no_per_did_state_and_window_reset_admits() { + let window = Duration::from_millis(120); + // Global: one slot per window (fixed key). Per-DID: generous request + // budget with a small key cap so map growth is directly observable. + let global = RateLimiter::new_bounded(1, window, 1); + let per_did = RateLimiter::new_bounded(2, window, 4); + + // First legit caller is admitted (per-DID key recorded, global slot committed). + check_listing_admission(&global, &per_did, "did:key:alice", "listing") + .await + .expect("first legit caller admitted"); + assert_eq!( + per_did.tracked_keys().await, + 1, + "one per-DID key after the legit call" + ); + + // Global window is now full — flood fresh DIDs. Every one is shed 429 + // and the flood must NOT grow the per-DID key map. + for i in 0..20 { + let r = + check_listing_admission(&global, &per_did, &format!("did:key:flood{i}"), "listing") + .await; + assert!( + matches!(r, Err(AppError::TooManyRequests(_))), + "flood caller {i} must be shed by the full global bucket, got {r:?}" + ); + } + assert_eq!( + per_did.tracked_keys().await, + 1, + "the DID flood must not have allocated per-DID keys while the global bucket was full" + ); + + // When the global window resets, a fresh legitimate caller can still + // obtain per-DID admission — its map slot was never consumed by the flood. + tokio::time::sleep(window + Duration::from_millis(20)).await; + check_listing_admission(&global, &per_did, "did:key:bob", "listing") + .await + .expect("fresh legit caller admitted after the global window resets"); + } + + /// A caller already over its own per-DID limit is shed BEFORE the committing + /// global check, so it cannot spend shared global capacity (P2). This is the + /// ordering the shared protocol fixes: the per-DID bucket is checked between + /// the non-consuming global probe and the committing global check. + #[tokio::test] + async fn per_did_over_budget_does_not_spend_global_capacity() { + // Global budget of 2 makes Bob's admission load-bearing: it succeeds + // only if Alice's over-budget call did NOT commit a second global slot. + let global = RateLimiter::new_bounded(2, Duration::from_secs(60), 1); + let per_did = RateLimiter::new_bounded(1, Duration::from_secs(60), 4); + + check_listing_admission(&global, &per_did, "did:key:alice", "listing") + .await + .expect("first admits and commits a global slot"); + + // Second call from the same DID: per-DID bucket is full, so it is shed + // before the committing global check — only the non-consuming probe has + // run, so the shared slot is NOT spent. + let r = check_listing_admission(&global, &per_did, "did:key:alice", "listing").await; + assert!( + matches!(r, Err(AppError::TooManyRequests(_))), + "over-budget caller must be shed, got {r:?}" + ); + + // A different caller still obtains a shared global slot, proving the + // over-budget caller did not drain it. + check_listing_admission(&global, &per_did, "did:key:bob", "listing") + .await + .expect("another caller still obtains the global slot"); + } + // ── client_key / trusted-proxy resolution (P1 + P2) ───────────────── fn headers(pairs: &[(&str, &str)]) -> HeaderMap { diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index de61fcbe..42fc0317 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -213,7 +213,9 @@ pub fn build_router(state: AppState) -> Router { // `/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). + // also carries `optional_signature`: its handler rejects anonymous callers + // and applies per-DID/global rate limits (the node-wide pin index would + // otherwise disclose metadata for every object ever pushed here, #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 @@ -228,10 +230,16 @@ pub fn build_router(state: AppState) -> Router { .layer(middleware::from_fn(auth::optional_signature)) .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) .layer(axum::Extension(ipfs_limiter)) - .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); + .merge( + Router::new() + .route("/api/v1/ipfs/pins", get(ipfs::list_pins)) + .layer(middleware::from_fn(auth::optional_signature)), + ); // ── Arweave permanent anchors ────────────────────────────────────────── - let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors)); + let arweave_routes = Router::new() + .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) + .layer(middleware::from_fn(auth::optional_signature)); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( @@ -619,3 +627,46 @@ async fn p2p_info(State(state): State) -> Json { None => Json(json!({ "enabled": false })), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::test_state; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use sqlx::PgPool; + use tower::ServiceExt; + + // Load-bearing auth gate: the pin index spans the entire node, so an + // unsigned GET must be rejected through the real router for BOTH the + // ipfs pins listing and the arweave anchors listing, before any DB work. + #[sqlx::test] + async fn unsigned_get_pins_and_anchors_is_401_through_build_router(pool: PgPool) { + let state = test_state(pool).await; + 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 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" + ); + } +} diff --git a/crates/gitlawb-node/src/state.rs b/crates/gitlawb-node/src/state.rs index 9d23572b..20dd75d2 100644 --- a/crates/gitlawb-node/src/state.rs +++ b/crates/gitlawb-node/src/state.rs @@ -81,6 +81,24 @@ pub struct AppState { /// sink as trigger and accepts unsigned requests from known peers, so it is /// braked too; each peer's distinct IP gets its own bucket. pub peer_write_rate_limiter: RateLimiter, + /// Concurrency limiter for expensive visibility walks (git rev-list / + /// git ls-tree) triggered by the IPFS pin listing endpoint (P1). + /// Prevents a flood of signed requests from exhausting the blocking + /// pool or leaving git children running past their timeout. + pub walk_semaphore: Arc, + /// Per-DID rate limiter for the IPFS pin listing endpoint. + pub ipfs_list_rate_limiter: RateLimiter, + /// Global (non-sybil) rate limiter for the IPFS pin listing endpoint. + /// Keyed on a fixed value so rotating DIDs cannot bypass it (P1). + pub ipfs_list_global_limiter: RateLimiter, + /// Per-DID rate limiter for the Arweave anchor listing endpoint — its OWN + /// bucket, so anchor enumeration cannot drain the pin-listing budget (and + /// vice versa). Same economics: the endpoint loads the readable-repo + /// catalog and visibility rules per request. + pub arweave_list_rate_limiter: RateLimiter, + /// Global (non-sybil) rate limiter for the Arweave anchor listing. Keyed + /// on a fixed value, separate from the IPFS listing global bucket. + pub arweave_list_global_limiter: RateLimiter, /// Process-wide graceful-shutdown signal. Sending `true` causes every /// task that holds a `watch::Receiver` to exit at its next await point. /// Used by: @@ -255,6 +273,29 @@ impl AppState { pub fn is_shutting_down(&self) -> bool { *self.shutdown_tx.borrow() } + + /// Seed for the AEAD key that seals opaque truncated_cursor tokens. + /// + /// Uses the cluster-shared `GITLAWB_CURSOR_SECRET` when configured so a + /// token minted on one node can be resumed on another behind a load + /// balancer; otherwise falls back to this node's Ed25519 seed (single-node + /// deployments only — a load-balanced cluster must set the shared secret, + /// or truncated cursors will not resume across instances). + pub fn cursor_seed(&self) -> [u8; 32] { + use hkdf::Hkdf; + use sha2::Sha256; + + match &self.config.cursor_secret { + Some(secret) if !secret.is_empty() => { + let hk = Hkdf::::new(None, secret.as_bytes()); + let mut okm = [0u8; 32]; + hk.expand(b"gitlawb-ipfs-cursor-v1", &mut okm) + .expect("32 bytes is a valid HKDF output length"); + okm + } + _ => *self.node_keypair.to_seed(), + } + } } /// Bounds the OUTSTANDING post-push encryption-task set by per-repo coalescing diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 2b5aef95..7b82cce1 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -81,6 +81,11 @@ fn build_state(db: Arc, pool: PgPool) -> AppState { push_limiter_trust: crate::rate_limit::TrustedProxy::None, sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)), peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), + walk_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), + ipfs_list_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)), + ipfs_list_global_limiter: RateLimiter::new_bounded(1200, Duration::from_secs(3600), 1), + arweave_list_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)), + arweave_list_global_limiter: RateLimiter::new_bounded(1200, Duration::from_secs(3600), 1), shutdown_tx: tokio::sync::watch::channel(false).0, // Generous — no test drives the handler-level shed (git_permit is unit-tested). git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), diff --git a/crates/gitlawb-node/tests/inv22_gates.rs b/crates/gitlawb-node/tests/inv22_gates.rs index 32e23dd0..3c78cc8d 100644 --- a/crates/gitlawb-node/tests/inv22_gates.rs +++ b/crates/gitlawb-node/tests/inv22_gates.rs @@ -206,11 +206,13 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() { /// metadata queries, and the per-repo loop's first budget gate runs only later. So /// both `list_all_repos` and `list_visibility_rules_for_repos` must be clamped to the /// remaining request budget — otherwise a query blocked in Postgres pins the walk slot -/// for the whole stall, past the budget. This scans the PRODUCTION half of `api/ipfs.rs` -/// (the `mod tests` half names the same calls in its own harness and would make the -/// check vacuous) and asserts each query call is immediately preceded by a -/// `tokio::time::timeout(` wrapper. Removing either wrapper turns this red (proven -/// load-bearing). +/// for the whole stall, past the budget. This scans the `get_by_cid` region of the +/// PRODUCTION half of `api/ipfs.rs` (the `mod tests` half names the same calls in its +/// own harness and would make the check vacuous) and asserts each query call is +/// immediately preceded by a `tokio::time::timeout(` wrapper. Removing either wrapper +/// turns this red (proven load-bearing). The sibling `list_pins` handler issues its +/// metadata queries BEFORE it acquires any walk permit (per-repo, later), so it does +/// not hold a slot across a DB stall. #[test] fn f6_ipfs_metadata_queries_are_deadline_wrapped() { let ipfs = src("api/ipfs.rs"); @@ -218,21 +220,26 @@ fn f6_ipfs_metadata_queries_are_deadline_wrapped() { .split("#[cfg(test)]") .next() .expect("split always yields a first chunk"); + // Scope to get_by_cid: list_pins also names these identifiers in its own body. + let get_by_cid = production + .split("pub async fn list_pins(") + .next() + .expect("api/ipfs.rs must still define a list_pins handler"); for call in [".list_all_repos()", ".list_visibility_rules_for_repos("] { assert_eq!( - production.matches(call).count(), + get_by_cid.matches(call).count(), 1, - "F6 guard stale: `{call}` must appear exactly once in the production half \ + "F6 guard stale: `{call}` must appear exactly once in the get_by_cid region \ of api/ipfs.rs (the deadline-wrapped handler call) — update this guard" ); - let idx = production + let idx = get_by_cid .find(call) .unwrap_or_else(|| panic!("F6 gate: api/ipfs.rs no longer calls `{call}`")); // The wrapper opens a few lines above the call (match tokio::time::timeout( ... // remaining budget ..., )); a 240-char lookback covers it without // reaching the previous statement. - let window = &production[idx.saturating_sub(240)..idx]; + let window = &get_by_cid[idx.saturating_sub(240)..idx]; assert!( window.contains("tokio::time::timeout("), "F6 gate missing: `{call}` must be wrapped in tokio::time::timeout(...) \ @@ -358,59 +365,75 @@ fn f3_second_writer_leased_until_reap() { } /// #174 U1 — every blocking walk in the `/ipfs` scan carries the request's walk -/// admission. +/// admission, held through the blocking work so a disconnect or a panic cannot free +/// the slot while its git child is still running. /// -/// The admission is an `Arc` cloned into each `spawn_blocking` -/// closure, so the global + per-source permits release only when the last holder -/// drops: a client disconnect leaves the abandoned closure holding the slot, and a -/// panicking closure leaves the handler holding it. +/// The merge shaped the file into two handlers, each admitting its own blocking +/// sites: +/// * `get_by_cid` builds one shared `WalkAdmission` per request and clones it into +/// each of its three `spawn_blocking` closures (`let _admission = ...;`); +/// * `list_pins` acquires a `walk_semaphore`/probe permit per repo and MOVES it into +/// its two blocking closures (`let _hold = permit;` / `let _probe_hold = ...;`). /// /// Only the walk site runs under `state.git_bin`, so only it can be pinned by the /// fake-git harness and mutation-verified dynamically (see /// `get_by_cid_walk_permit_held_through_blocking_walk`). The probe and the content /// read deliberately shell to the real `git`, which is why this structural check -/// exists: it binds all three sites, and any blocking site added to this loop +/// exists: it binds all five sites, and any blocking site added to either handler /// later, without reversing that deliberate independence. /// -/// MUTATION (RED): delete any one `Arc::clone(&admission)` binding, or drop the -/// clone from inside its closure, and the count falls below three. +/// MUTATION (RED): delete any one in-closure hold binding, or drop the clone/permits +/// from inside the closure, and a count below the bound on its handler fails. #[test] fn inv22_ipfs_walk_admission_reaches_every_blocking_site() { let ipfs = src("api/ipfs.rs"); + let production = ipfs + .split("#[cfg(test)]") + .next() + .expect("split always yields a first chunk"); + let (get_by_cid, list_pins) = production + .split_once("pub async fn list_pins(") + .expect("api/ipfs.rs must still define a list_pins handler"); - // The shared owner must exist and be built once per request. + // get_by_cid: a shared WalkAdmission exists and each of its three blocking + // sites takes a clone that is bound inside the blocking closure. assert!( - ipfs.contains("struct WalkAdmission") && ipfs.contains("Arc::new(WalkAdmission {"), - "U1 gate missing: the /ipfs walk admission must be a shared WalkAdmission, \ + get_by_cid.contains("struct WalkAdmission") + && get_by_cid.contains("Arc::new(WalkAdmission {"), + "U1 gate missing: get_by_cid's walk admission must be a shared WalkAdmission, \ not a handler-local permit pair" ); - - // Every blocking site in the scan takes its own clone... - let clones = ipfs.matches("Arc::clone(&admission)").count(); + let by_cid_clones = get_by_cid.matches("Arc::clone(&admission)").count(); assert!( - clones >= 3, - "U1 gate bypassed: expected an admission clone for each of the three \ - /ipfs spawn_blocking sites (probe, walk, read); found {clones}. A blocking \ + by_cid_clones >= 3, + "U1 gate bypassed: expected an admission clone for each of get_by_cid's three \ + /ipfs spawn_blocking sites (probe, walk, read); found {by_cid_clones}. A blocking \ walk that does not hold the admission lets a disconnect or a panic free the \ slot while its git child is still running." ); + assert!( + get_by_cid.matches("let _admission = ").count() >= 3, + "U1 gate bypassed: each get_by_cid admission clone must be bound inside its \ + spawn_blocking closure so the blocking work owns it" + ); - // ...and each clone is actually moved INTO the blocking closure, not merely - // created in the async frame (which would hold nothing across the join). - let held = ipfs.matches("let _admission = ").count(); + // list_pins: each blocking closure moves a held permit in (`let _hold = ...`), so + // a permit is not released until the blocking work completes. assert!( - held >= 3, - "U1 gate bypassed: each admission clone must be bound inside its \ - spawn_blocking closure so the blocking work owns it; found {held} of 3." + list_pins.matches("let _hold = permit;").count() >= 1 + && list_pins.matches("let _probe_hold = ").count() >= 1, + "U1 gate bypassed: each list_pins blocking closure must move its acquired \ + walk/probe permit into the closure so the slot lives for the whole walk" ); - // The count of blocking sites is itself the thing being covered: if a fourth - // appears, it needs an admission clone too and this gate must be revisited. - let sites = ipfs.matches("spawn_blocking(move ||").count(); + // The count of blocking sites is itself the thing being covered: if another one + // appears, it needs its own in-closure hold too and this gate must be revisited. + let sites = production.matches("spawn_blocking(move ||").count(); assert_eq!( - sites, 3, - "the /ipfs scan grew or lost a spawn_blocking site ({sites} found, expected 3); \ - give any new blocking walk its own Arc::clone(&admission) and update this gate" + sites, 5, + "the /ipfs scan grew or lost a spawn_blocking site ({sites} found, expected 5: \ + three in get_by_cid, two in list_pins); give any new blocking walk its own \ + admission hold and update this gate" ); } diff --git a/crates/gl/src/http.rs b/crates/gl/src/http.rs index 4a51dc45..01971356 100644 --- a/crates/gl/src/http.rs +++ b/crates/gl/src/http.rs @@ -15,6 +15,14 @@ use icaptcha_client::IcaptchaCfg; /// (absorbs proof expiry / first-seen replay). const MAX_ICAPTCHA_RETRIES: usize = 2; +/// Shared wall-clock budget for one paginated pin-listing traversal +/// (`gl ipfs list` and the `gl node status` pins panel). Each signed request +/// may take up to the client's 30 s timeout, so the per-surface page caps +/// (10 000) alone would let a hostile or overloaded node that returns a fresh +/// cursor on every page hold the CLI for ~83 h. An elapsed-time deadline stops +/// the whole traversal with an explicit incomplete result instead (P2). +pub const PIN_LISTING_TRAVERSAL_BUDGET: std::time::Duration = std::time::Duration::from_secs(600); + pub struct NodeClient { inner: reqwest::Client, pub node_url: String, @@ -196,6 +204,74 @@ impl NodeClient { } } +/// Read a response body with a streaming byte cap so chunked responses don't +/// allocate unbounded memory before the check (P2). +/// +/// Returns an error if the body exceeds `max_bytes` *before* buffering the +/// full payload. Handles both `Content-Length` and chunked transfer-encoding. +pub async fn capped_response(mut resp: reqwest::Response, max_bytes: usize) -> Result> { + let mut body = Vec::new(); + loop { + let chunk = resp.chunk().await?; + match chunk { + Some(bytes) => { + if body.len() + bytes.len() > max_bytes { + anyhow::bail!( + "response body exceeds {max_bytes} byte limit (already read {})", + body.len() + ); + } + body.extend_from_slice(&bytes); + } + None => return Ok(body), + } + } +} + +/// Fetch one paginated-listing page's status + body under a shared traversal +/// deadline (P2). +/// +/// The traversal budget (`PIN_LISTING_TRAVERSAL_BUDGET`) must bound the +/// IN-FLIGHT request too, not just the loop-top check: a node that stalls +/// mid-response — or a dispatch that starts just before the deadline — would +/// otherwise hold the CLI past the budget with no way to stop. The signed +/// dispatch AND the bounded body read are wrapped in a single +/// `tokio::time::timeout` of the remaining budget; on expiry the helper returns +/// `Ok(None)` and the caller marks the traversal incomplete. +/// +/// `max_response_bytes` is the same per-page body cap the callers already +/// enforce (64 MiB). An over-cap body on an ERROR response degrades to an empty +/// body (matching the previous `unwrap_or_default` handling on error pages); an +/// over-cap SUCCESS body still errors so the memory bound is not weakened. +/// Dispatch/network errors propagate as before, so each caller keeps its +/// existing error policy (`PinsPanel::Unavailable` / `anyhow::bail`). +pub(crate) async fn fetch_listing_page( + client: &NodeClient, + path: &str, + max_response_bytes: usize, + deadline: std::time::Instant, +) -> Result)>> { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + let outcome = tokio::time::timeout(remaining, async { + let resp = client.get_signed(path).await?; + let status = resp.status(); + let body = match capped_response(resp, max_response_bytes).await { + Ok(b) => b, + Err(_) if !status.is_success() => Vec::new(), + Err(e) => return Err(e), + }; + Ok((status, body)) + }) + .await; + match outcome { + Ok(res) => Ok(Some(res?)), + Err(_elapsed) => Ok(None), + } +} + /// Run the (blocking) iCaptcha solve loop off the async runtime. async fn obtain_proof(cfg: IcaptchaCfg) -> Result { tokio::task::spawn_blocking(move || icaptcha_client::obtain_proof(&cfg, None)) diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index d4cb64fc..50c81d0a 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -49,24 +49,26 @@ async fn cmd_list(node: String, dir: Option) -> Result<()> { // 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 deadline = std::time::Instant::now() + crate::http::PIN_LISTING_TRAVERSAL_BUDGET; + let (pins, incomplete) = list_pins_paginated(&client, deadline).await?; - let pins = resp["pins"].as_array().cloned().unwrap_or_default(); - let count = resp["count"].as_u64().unwrap_or(pins.len() as u64); + let count = pins.len(); if pins.is_empty() { - println!("No IPFS pins recorded on {node}"); - println!("(Push to a repo with GITLAWB_IPFS_API set to start pinning)"); + if incomplete { + println!("IPFS pins on {node}: listing incomplete — unable to enumerate all pins"); + } else { + println!("No IPFS pins recorded on {node}"); + println!("(Push to a repo with GITLAWB_IPFS_API set to start pinning)"); + } return Ok(()); } - println!("IPFS pins ({count}) on {node}"); + print!("IPFS pins ({count}) on {node}"); + if incomplete { + print!(" (truncated — too many results)"); + } + println!(); println!(); for pin in &pins { let cid = pin["cid"].as_str().unwrap_or("?"); @@ -86,6 +88,214 @@ async fn cmd_list(node: String, dir: Option) -> Result<()> { Ok(()) } +/// Sanitize a node response body for inclusion in CLI error messages. +/// Strips ANSI/OSC control sequences and non-printable bytes (except +/// newline and tab) so a malicious or compromised node cannot inject +/// terminal output through an otherwise bounded error body (P2). +/// Truncates on a char boundary (not a byte boundary) to avoid panicking +/// on multi-byte UTF-8 (P2). +fn sanitize_body(body: &str) -> String { + const MAX_BODY_CHARS: usize = 500; + body.chars() + .take(MAX_BODY_CHARS) + .filter(|&c| c.is_ascii_graphic() || c == ' ' || c == '\n' || c == '\t') + .collect() +} + +/// Paginate through the full pin listing, collecting all pins and handling +/// the expired-truncated_cursor retry (P2). The last_next_cursor restore +/// may cause a duplicate page (self-limiting via the cycle guard). +/// `deadline` is the shared wall-clock budget for the whole traversal +/// (`PIN_LISTING_TRAVERSAL_BUDGET`): once it passes, the listing stops with +/// an explicit incomplete result even if the page/row caps have not tripped +/// (P2). +async fn list_pins_paginated( + client: &NodeClient, + deadline: std::time::Instant, +) -> Result<(Vec, bool)> { + let mut all_pins = Vec::new(); + let mut all_pins_bytes = 0usize; + let mut cursor: Option = None; + let mut truncated_cursor: Option = None; + // Persist the last next_cursor across the truncated leg so that an + // expired truncated_cursor (400) can resume from where we left off + // rather than restarting at page 1 (P2). Note: this may re-fetch + // the page before the truncated one (self-limiting via cycle guard). + let mut last_next_cursor: Option = None; + // Advancement guard: track every cursor value seen to detect cycles. + let mut seen_cursors: std::collections::HashSet = std::collections::HashSet::new(); + let mut incomplete = false; + let mut pages = 0u32; + // Consecutive empty pages without forward progress: a buggy or hostile + // node that returns empty pages with fresh cursors cannot loop + // indefinitely (P2). + let mut consecutive_empty_pages = 0u32; + const MAX_CONSECUTIVE_EMPTY: u32 = 5; + // Bounds: at most 10 000 pages, 1 000 000 rows total, 512 MiB + // aggregate retained JSON, or 64 MiB per response body — limits + // unbounded loops and prevents a single oversized page from + // exhausting memory before the row cap is checked (P2). + const MAX_PAGES: u32 = 10_000; + const MAX_ROWS: usize = 1_000_000; + const MAX_AGGREGATE_BYTES: usize = 512 * 1024 * 1024; + const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; + + loop { + // Wall-clock traversal budget: a hostile node that returns a fresh + // cursor on every page would otherwise keep this loop going for ~83 h + // before MAX_PAGES trips (each request may take up to the 30 s client + // timeout). Stop with an explicit incomplete result (P2). + if std::time::Instant::now() >= deadline { + incomplete = true; + break; + } + + pages += 1; + if pages > MAX_PAGES { + incomplete = true; + break; + } + + // Request the maximum page size to minimise page-turn requests + // against the per-DID quota (P2). + // Server clamps limit to 200 per page (P2). Request the max so we + // minimise page-turn requests against the per-DID quota. + let mut path = "/api/v1/ipfs/pins?limit=200".to_string(); + let mut params = Vec::new(); + let mut had_truncated = false; + if let Some(c) = cursor.take() { + params.push(format!("cursor={}", urlencoding::encode(&c))); + } + if let Some(tc) = truncated_cursor.take() { + had_truncated = true; + params.push(format!("truncated_cursor={}", urlencoding::encode(&tc))); + } + for p in ¶ms { + path.push('&'); + path.push_str(p); + } + + let Some((status, body)) = + crate::http::fetch_listing_page(client, &path, MAX_RESPONSE_BYTES, deadline).await? + else { + // Traversal budget expired mid-request (dispatch or body read): a + // node that stalls a response cannot hold the CLI past the deadline. + // Stop with an explicit incomplete result (P2). + incomplete = true; + break; + }; + + if !status.is_success() { + // P1: rate-limited — surface a partial result instead of failing. + if status == 429 { + incomplete = true; + break; + } + if status == 400 && had_truncated { + let body = String::from_utf8_lossy(&body).to_string(); + // Only treat a 400 as expired-cursor when the server explicitly + // says so. Any other 400 — malformed cursor, protocol change, + // node bug — is surfaced as an error. + if body.contains("invalid or expired truncated_cursor") { + cursor = last_next_cursor.clone(); + continue; + } + anyhow::bail!( + "node returned 400 for pins listing: {}", + sanitize_body(&body) + ); + } + let body = String::from_utf8_lossy(&body).to_string(); + anyhow::bail!( + "node returned {status} for pins listing: {}", + sanitize_body(&body) + ); + } + let resp: Value = serde_json::from_slice(&body).with_context(|| { + format!( + "failed to parse pins response ({len} bytes)", + len = body.len() + ) + })?; + + let pins = resp["pins"].as_array().cloned().unwrap_or_default(); + + if all_pins.len() + pins.len() > MAX_ROWS { + incomplete = true; + break; + } + + // Aggregate memory bound: track the serialised size of each pin + // JSON object to prevent a malicious node from exhausting the + // CLI's memory with many small pages of oversized fields (P2). + for pin in &pins { + all_pins_bytes += serde_json::to_string(pin).map(|s| s.len()).unwrap_or(256); + } + if all_pins_bytes > MAX_AGGREGATE_BYTES { + incomplete = true; + break; + } + + let next = resp["next_cursor"].as_str().map(String::from); + let new_trunc = resp["truncated_cursor"].as_str().map(String::from); + + // Empty pages are only legitimate progress when the server hands us a + // fresh truncated_cursor (an all-deferred page carries no next_cursor). + // An identical token is caught by the cycle guard below, so resetting + // on any fresh token keeps genuine multi-batch truncation from tripping + // the guard while still bounding a hostile node that returns empty + // pages with no way forward (P2). + if pins.is_empty() { + if new_trunc.is_some() { + consecutive_empty_pages = 0; + } else { + consecutive_empty_pages += 1; + if consecutive_empty_pages >= MAX_CONSECUTIVE_EMPTY { + incomplete = true; + break; + } + } + } else { + consecutive_empty_pages = 0; + } + + // Detect cursor cycling: keys on the exact (next_cursor, truncated) + // pair, so a node that returns a fresh pair every page never trips it. + // MAX_PAGES provides the ultimate bound (10 K round-trips per listing). + let cycle_key = + next.as_deref().unwrap_or("").to_string() + "|" + new_trunc.as_deref().unwrap_or(""); + + // Bound cursor / cycle-key retained bytes alongside pin data so a + // node returning fresh near-64 MiB cursors per page cannot exhaust + // memory via the seen_cursors set (P3). Account for the cycle_key + // string that will be stored in seen_cursors plus HashSet entry + // overhead (~32 bytes per entry). + all_pins_bytes += cycle_key.len() + 32; + if all_pins_bytes > MAX_AGGREGATE_BYTES { + incomplete = true; + break; + } + + if !cycle_key.is_empty() && !seen_cursors.insert(cycle_key) { + incomplete = true; + break; + } + + all_pins.extend(pins); + + if next.is_none() && new_trunc.is_none() { + break; + } + if let Some(ref n) = next { + last_next_cursor = Some(n.clone()); + } + cursor = next; + truncated_cursor = new_trunc; + } + + Ok((all_pins, incomplete)) +} + async fn cmd_get(cid: String, node: String) -> Result<()> { let client = NodeClient::new(&node, None); let path = format!("/ipfs/{cid}"); @@ -144,7 +354,7 @@ mod tests { // 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") + .mock("GET", mockito::Matcher::Regex(r"^/api/v1/ipfs/pins".to_string())) .match_header("signature", mockito::Matcher::Any) .match_header("signature-input", mockito::Matcher::Any) .match_header("content-digest", mockito::Matcher::Any) @@ -169,7 +379,10 @@ mod tests { let keystore = seed_keystore(); let m = server - .mock("GET", "/api/v1/ipfs/pins") + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins".to_string()), + ) .match_header("signature", mockito::Matcher::Any) .with_status(200) .with_header("content-type", "application/json") @@ -192,7 +405,10 @@ mod tests { // The endpoint must never be hit when there is no identity. let m = server - .mock("GET", "/api/v1/ipfs/pins") + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins".to_string()), + ) .expect(0) .create_async() .await; @@ -217,7 +433,10 @@ mod tests { // 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") + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins".to_string()), + ) .match_header("signature", mockito::Matcher::Any) .with_status(401) .with_header("content-type", "application/json") @@ -235,4 +454,122 @@ mod tests { m.assert_async().await; } + + /// A hostile or overloaded node returns a fresh next_cursor on every page + /// with a nonempty page, so neither the duplicate-cursor guard nor the + /// consecutive-empty-page guard fires. The only bound that can trip is the + /// wall-clock traversal deadline; the test asserts the listing stops with + /// an explicit incomplete result instead of running to MAX_PAGES (P2). + /// The server never advances: every response carries a unique cursor and + /// a fresh pin, so only the deadline can end the loop. + #[tokio::test] + async fn test_list_pins_paginated_stops_at_traversal_deadline() { + use std::sync::atomic::{AtomicU64, Ordering}; + + let mut server = mockito::Server::new_async().await; + let kp = gitlawb_core::identity::Keypair::generate(); + let client = NodeClient::new(server.url(), Some(kp)); + let counter = std::sync::Arc::new(AtomicU64::new(0)); + + // Every response is a fresh nonempty page with a fresh next_cursor: + // a node that never lets the listing finish. + let counter_for_mock = counter.clone(); + let m = server + .mock("GET", mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string())) + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body_from_request(move |_req| { + let n = counter_for_mock.fetch_add(1, Ordering::Relaxed); + format!( + r#"{{"pins":[{{"cid":"cid-{n}","sha256_hex":"sha-{n}","pinned_at":"2026-07-02T12:00:00Z"}}],"count":1,"next_cursor":"cursor-{n}"}}"# + ) + .into_bytes() + }) + .expect_at_least(1) + .create_async() + .await; + + // Budget nearly expired: the traversal must stop incomplete almost + // immediately, not chase the fresh cursors to MAX_PAGES. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100); + let started = std::time::Instant::now(); + let (pins, incomplete) = list_pins_paginated(&client, deadline).await.unwrap(); + let elapsed = started.elapsed(); + let requests = counter.load(Ordering::Relaxed); + + assert!( + incomplete, + "a node that never advances must yield an explicit incomplete result" + ); + assert!( + !pins.is_empty(), + "the listing must surface whatever pages it collected before the deadline" + ); + // The deadline — not MAX_PAGES — must be what tripped. Delete the + // deadline block and this loop runs to MAX_PAGES (10 000 requests, + // ~26 s), so both the request count and the elapsed time fail here. + assert!( + requests < 100, + "traversal must stop within a small page count, not run to MAX_PAGES \ + (got {requests} requests in {elapsed:?})" + ); + assert!( + elapsed < std::time::Duration::from_secs(5), + "traversal must stop near the 100 ms budget, not run for ~26 s (took {elapsed:?})" + ); + m.assert_async().await; + } + + /// A response that straddles the traversal deadline must still stop the + /// listing by the budget: the page dispatch AND body read are bounded by + /// the same `tokio::time::timeout`, so a node that stalls mid-body cannot + /// hold the CLI past the deadline (P2). The mock writes a first chunk + /// immediately (the request is dispatched and the body begins streaming), + /// then sleeps well past the deadline before completing. + #[tokio::test] + async fn test_list_pins_paginated_stops_inflight_request_at_traversal_deadline() { + let mut server = mockito::Server::new_async().await; + let kp = gitlawb_core::identity::Keypair::generate(); + let client = NodeClient::new(server.url(), Some(kp)); + + let m = server + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_chunked_body(|w| { + w.write_all(b"{\"pins\":[{\"cid\":\"cid-0\"").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(700)); + w.write_all(b"}]}").unwrap(); + Ok(()) + }) + .expect_at_least(1) + .create_async() + .await; + + // Budget far smaller than the response delay: the in-flight read must + // be aborted by the deadline, not allowed to complete 700 ms later. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100); + let started = std::time::Instant::now(); + let (pins, incomplete) = list_pins_paginated(&client, deadline).await.unwrap(); + let elapsed = started.elapsed(); + + assert!( + incomplete, + "a response straddling the deadline must stop the traversal incomplete" + ); + assert!( + elapsed < std::time::Duration::from_millis(500), + "traversal must abort the in-flight request by the deadline, took {elapsed:?}" + ); + assert!( + pins.is_empty(), + "the straddling page must not be collected after the deadline abort" + ); + m.assert_async().await; + } } diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index 367ba576..e7f5fc87 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -189,8 +189,15 @@ async fn try_get_json(client: &NodeClient, path: &str) -> Option { /// 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 and returned pins. + Pins { + count: u64, + /// True when the traversal hit its safety bounds (page cap, row cap, + /// or cursor cycle) before consuming all available data. The count + /// is an undercount; the dashboard signals that the listing was + /// truncated (P2). + incomplete: bool, + }, /// Signed read succeeded but the node has no pins recorded. Empty, /// Signed read was rejected (401/other) or errored. @@ -234,37 +241,189 @@ fn resolve_pins_auth(dir: Option<&std::path::Path>) -> PinsAuth { /// 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 { +/// `deadline` is the shared wall-clock traversal budget +/// (`PIN_LISTING_TRAVERSAL_BUDGET`): when it expires the traversal stops with +/// an explicit incomplete result rather than trusting a hostile node's fresh +/// cursors to keep the dashboard blocked for hours (P2). +async fn fetch_pins(node: &str, auth: PinsAuth, deadline: std::time::Instant) -> 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 mut total: u64 = 0; + let mut cursor: Option = None; + let mut truncated_cursor: Option = None; + // Persist the last next_cursor across the truncated leg so that an + // expired truncated_cursor (400) can resume from where we left off + // rather than restarting at page 1 (P2). + let mut last_next_cursor: Option = None; + let mut seen_cursors: std::collections::HashSet = std::collections::HashSet::new(); + let mut incomplete = false; + let mut pages = 0u32; + // Consecutive empty pages without forward progress: a buggy or hostile + // node that returns empty pages with fresh cursors cannot loop + // indefinitely (P2). + let mut consecutive_empty_pages = 0u32; + const MAX_CONSECUTIVE_EMPTY: u32 = 5; + const MAX_PAGES: u32 = 10_000; + const MAX_ROWS: u64 = 1_000_000; + const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; + // Aggregate memory bound for retained cursor/cycle-key bytes (P3). + // Shared with the pin data budget from ipfs_cmd.rs. + const MAX_AGGREGATE_BYTES: usize = 512 * 1024 * 1024; + let mut all_cursor_bytes: usize = 0; + + loop { + // Wall-clock traversal budget: a hostile node returning a fresh cursor + // on every page would otherwise hold the dashboard for ~83 h (each + // request may take up to the 30 s client timeout). Stop with an + // explicit incomplete result (P2). + if std::time::Instant::now() >= deadline { + incomplete = true; + break; + } + + pages += 1; + if pages > MAX_PAGES { + incomplete = true; + break; + } + + // Request the max page size to minimise page-turn requests + // against the per-DID quota (P2). The server clamps to 200. + let mut path = "/api/v1/ipfs/pins?limit=200".to_string(); + let mut had_truncated = false; + if let Some(c) = cursor.take() { + path.push_str(&format!("&cursor={}", urlencoding::encode(&c))); + } + if let Some(tc) = truncated_cursor.take() { + had_truncated = true; + path.push_str(&format!("&truncated_cursor={}", urlencoding::encode(&tc))); + } + + let (status, body) = + match crate::http::fetch_listing_page(&client, &path, MAX_RESPONSE_BYTES, deadline) + .await + { + Ok(Some(page)) => page, + // Traversal budget expired mid-request (dispatch or body read): a + // node that stalls a response cannot hold the dashboard past the + // deadline. Stop with an explicit incomplete result (P2). + Ok(None) => { + incomplete = true; + break; + } + Err(_) => return PinsPanel::Unavailable, + }; + + if !status.is_success() { + // P2: rate-limited — surface a partial result instead of failing. + if status.as_u16() == 429 { + incomplete = true; + break; + } + if status.as_u16() == 400 && had_truncated { + let body = String::from_utf8_lossy(&body).to_string(); + if body.contains("invalid or expired truncated_cursor") { + cursor = last_next_cursor.clone(); + continue; + } + } + return PinsPanel::Unavailable; + } + let Ok(body) = serde_json::from_slice::(&body) else { + return PinsPanel::Unavailable; + }; + + let page_pins = body["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0); + + if total + page_pins > MAX_ROWS { + incomplete = true; + break; + } + total += page_pins; + + let next = body["next_cursor"].as_str().map(String::from); + let new_trunc = body["truncated_cursor"].as_str().map(String::from); + + // Empty pages are only legitimate progress when the server hands us a + // fresh truncated_cursor (an all-deferred page carries no next_cursor). + // The node can advance across path-scoped or deferred hidden windows + // with empty pages, so mirroring ipfs_cmd's branch keeps genuine + // multi-window truncation from tripping the guard. An identical token + // is caught by the cycle guard below, so resetting on any fresh token + // still bounds a hostile node that returns empty pages with no way + // forward (P2). + if page_pins == 0 { + if new_trunc.is_some() { + consecutive_empty_pages = 0; + } else { + consecutive_empty_pages += 1; + if consecutive_empty_pages >= MAX_CONSECUTIVE_EMPTY { + incomplete = true; + break; + } + } + } else { + consecutive_empty_pages = 0; + } + + // Detect cursor cycling + let cycle_key = + next.as_deref().unwrap_or("").to_string() + "|" + new_trunc.as_deref().unwrap_or(""); + + // Bound retained cursor bytes against the same aggregate budget used + // by ipfs_cmd.rs so a node returning near-64 MiB cursors per page + // cannot exhaust memory (P3). Check before insert so cycle_key is + // not moved. + all_cursor_bytes += cycle_key.len() + 32; // +32 for HashSet entry overhead + if all_cursor_bytes > MAX_AGGREGATE_BYTES { + incomplete = true; + break; + } + + if !cycle_key.is_empty() && !seen_cursors.insert(cycle_key) { + incomplete = true; + break; + } + + if next.is_none() && new_trunc.is_none() { + break; + } + if let Some(ref n) = next { + last_next_cursor = Some(n.clone()); + } + cursor = next; + truncated_cursor = new_trunc; } - let Ok(body) = resp.json::().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)); + let count = total; if count == 0 { - PinsPanel::Empty + if incomplete { + // Buggy or capacity-constrained node: the listing was truncated + // before any row was emitted. Render as unavailable so an + // authoritative "Pinned CIDs: 0" is never shown for a partial + // result (P2). + PinsPanel::Unavailable + } else { + PinsPanel::Empty + } } else { - PinsPanel::Pins(count) + PinsPanel::Pins { count, incomplete } } } /// 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::Pins { count, incomplete } => { + let mut s = format!(" Pinned CIDs: {count}"); + if *incomplete { + s.push_str(" (truncated)"); + } + s + } PinsPanel::Empty => " Pinned CIDs: 0".to_string(), PinsPanel::Unavailable => " IPFS pins: unavailable".to_string(), PinsPanel::NeedsIdentity => { @@ -301,13 +460,15 @@ async fn cmd_status(node: String, dir: Option) -> Result<()> { let pins_auth = resolve_pins_auth(dir.as_deref()); // ── Fetch remaining endpoints in parallel ───────────────────────────── - // Peers/repos/p2p/events stay anonymous; only pins is signed. + // Peers/repos/p2p/events stay anonymous; only pins is signed. The pins + // panel runs under the shared pin-listing traversal budget (P2). + let pins_deadline = std::time::Instant::now() + crate::http::PIN_LISTING_TRAVERSAL_BUDGET; 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"), - fetch_pins(&node, pins_auth), + fetch_pins(&node, pins_auth, pins_deadline), ); // ── Render dashboard ────────────────────────────────────────────────── @@ -515,6 +676,11 @@ mod tests { use super::*; use gitlawb_core::identity::Keypair; + /// Generous deadline so ordinary tests never trip the traversal budget. + fn far_deadline() -> std::time::Instant { + std::time::Instant::now() + std::time::Duration::from_secs(60) + } + #[tokio::test] async fn test_fetch_pins_keyed_happy_signs_and_returns_pins() { let mut server = mockito::Server::new_async().await; @@ -523,7 +689,7 @@ mod tests { // 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") + .mock("GET", mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string())) .match_header("signature", mockito::Matcher::Any) .match_header("signature-input", mockito::Matcher::Any) .match_header("content-digest", mockito::Matcher::Any) @@ -535,9 +701,9 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp), far_deadline()).await; match panel { - PinsPanel::Pins(count) => assert_eq!(count, 1), + PinsPanel::Pins { count, .. } => assert_eq!(count, 1), other => panic!("expected Pins, got {other:?}"), } @@ -550,7 +716,10 @@ mod tests { let kp = Keypair::generate(); let m = server - .mock("GET", "/api/v1/ipfs/pins") + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) .match_header("signature", mockito::Matcher::Any) .with_status(200) .with_header("content-type", "application/json") @@ -558,7 +727,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp), far_deadline()).await; assert!( matches!(panel, PinsPanel::Empty), "expected Empty, got {panel:?}" @@ -567,6 +736,62 @@ mod tests { m.assert_async().await; } + #[tokio::test] + async fn test_fetch_pins_empty_pages_with_fresh_truncated_cursor_is_progress() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + + // The node legitimately advances across path-scoped or deferred hidden + // windows by returning empty pins pages that each carry a fresh + // truncated_cursor (an all-deferred page has no next_cursor). These + // windows can exceed MAX_CONSECUTIVE_EMPTY (5), so an empty page with + // forward progress must reset the guard — otherwise the panel degrades + // to Unavailable in the middle of a valid continuation (P2). + // MUTATION (RED): reverting to the unconditional `+= 1` (counting an + // empty page with a fresh truncated_cursor against the guard) turns the + // first final empty window -> Unavailable and this assertion fails. + for i in 0..6u32 { + let m = server + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"pins":[],"count":0,"truncated_cursor":"dGVzdC1jdXJzb3Itnew{i}"}}"# + )) + .create_async() + .await; + let _ = m; + } + + // The continuation finally lands on a visible pin, so the panel must + // render Pins, not Unavailable. + let m = server + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) + .match_header("signature", 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), far_deadline()).await; + assert!( + matches!(panel, PinsPanel::Pins { count: 1, .. }), + "expected Pins after resuming past 5+ deferred windows, 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; @@ -575,7 +800,10 @@ mod tests { // 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") + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) .match_header("signature", mockito::Matcher::Any) .with_status(401) .with_header("content-type", "application/json") @@ -583,7 +811,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp), far_deadline()).await; assert!( matches!(panel, PinsPanel::Unavailable), "expected Unavailable, got {panel:?}" @@ -603,7 +831,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), PinsAuth::Anonymous).await; + let panel = fetch_pins(&server.url(), PinsAuth::Anonymous, far_deadline()).await; assert!( matches!(panel, PinsPanel::NeedsIdentity), "expected NeedsIdentity, got {panel:?}" @@ -620,7 +848,10 @@ mod tests { // 2xx but the body is not valid JSON: must degrade to Unavailable, // never panic. let m = server - .mock("GET", "/api/v1/ipfs/pins") + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) .match_header("signature", mockito::Matcher::Any) .with_status(200) .with_header("content-type", "application/json") @@ -628,7 +859,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp), far_deadline()).await; assert!( matches!(panel, PinsPanel::Unavailable), "malformed body -> Unavailable, got {panel:?}" @@ -647,16 +878,145 @@ mod tests { }; let kp = Keypair::generate(); - let panel = fetch_pins(&format!("http://127.0.0.1:{port}"), PinsAuth::Keyed(kp)).await; + let panel = fetch_pins( + &format!("http://127.0.0.1:{port}"), + PinsAuth::Keyed(kp), + far_deadline(), + ) + .await; assert!( matches!(panel, PinsPanel::Unavailable), "transport error -> Unavailable, got {panel:?}" ); } + /// A hostile or overloaded node returns a fresh next_cursor with a nonempty + /// page on every request, defeating the duplicate-cursor and + /// consecutive-empty-page guards. Only the shared wall-clock traversal + /// deadline can bound the panel, which must stop with an explicit + /// incomplete result rather than holding the dashboard for ~83 h (P2). + #[tokio::test] + async fn test_fetch_pins_stops_at_traversal_deadline() { + use std::sync::atomic::{AtomicU64, Ordering}; + + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + let counter = std::sync::Arc::new(AtomicU64::new(0)); + + // Every response is a fresh nonempty page with a fresh next_cursor: + // a node that never lets the listing finish. + let counter_for_mock = counter.clone(); + let m = server + .mock("GET", mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string())) + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body_from_request(move |_req| { + let n = counter_for_mock.fetch_add(1, Ordering::Relaxed); + format!( + r#"{{"pins":[{{"cid":"cid-{n}","sha256_hex":"sha-{n}","pinned_at":"2026-07-02T12:00:00Z"}}],"count":1,"next_cursor":"cursor-{n}"}}"# + ) + .into_bytes() + }) + .expect_at_least(1) + .create_async() + .await; + + // Budget nearly expired: the panel must stop incomplete almost + // immediately rather than chase fresh cursors to MAX_PAGES. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100); + let started = std::time::Instant::now(); + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp), deadline).await; + let elapsed = started.elapsed(); + let requests = counter.load(Ordering::Relaxed); + + match panel { + PinsPanel::Pins { count, incomplete } => { + assert!( + incomplete, + "a node that never advances must yield an explicit incomplete result" + ); + assert!( + count > 0, + "the panel must surface whatever pages it collected before the deadline" + ); + } + other => panic!("expected Pins (incomplete), got {other:?}"), + } + // The deadline — not MAX_PAGES — must be what tripped. Delete the + // deadline block and this loop runs to MAX_PAGES (10 000 requests, + // ~26 s), so both the request count and the elapsed time fail here. + assert!( + requests < 100, + "traversal must stop within a small page count, not run to MAX_PAGES \ + (got {requests} requests in {elapsed:?})" + ); + assert!( + elapsed < std::time::Duration::from_secs(5), + "traversal must stop near the 100 ms budget, not run for ~26 s (took {elapsed:?})" + ); + + m.assert_async().await; + } + + /// A response that straddles the traversal deadline must still stop the + /// panel by the budget: the page dispatch AND body read are bounded by the + /// same `tokio::time::timeout`, so a node that stalls mid-body cannot hold + /// the dashboard past the deadline (P2). The mock writes a first chunk + /// immediately (the request is dispatched and the body begins streaming), + /// then sleeps well past the deadline before completing. Zero rows were + /// collected before the abort, so an incomplete traversal must render + /// Unavailable — never an authoritative "0". + #[tokio::test] + async fn test_fetch_pins_stops_inflight_request_at_traversal_deadline() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + + let m = server + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/ipfs/pins(\?.*)?$".to_string()), + ) + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_chunked_body(|w| { + w.write_all(b"{\"pins\":[{\"cid\":\"cid-0\"").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(700)); + w.write_all(b"}]}").unwrap(); + Ok(()) + }) + .expect_at_least(1) + .create_async() + .await; + + // Budget far smaller than the response delay: the in-flight read must + // be aborted by the deadline, not allowed to complete 700 ms later. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100); + let started = std::time::Instant::now(); + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp), deadline).await; + let elapsed = started.elapsed(); + + assert!( + elapsed < std::time::Duration::from_millis(500), + "traversal must abort the in-flight request by the deadline, took {elapsed:?}" + ); + assert!( + matches!(panel, PinsPanel::Unavailable), + "an incomplete traversal with no collected rows must be Unavailable, got {panel:?}" + ); + m.assert_async().await; + } + #[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::Pins { + count: 3, + incomplete: false + }), + " Pinned CIDs: 3" + ); assert_eq!(pins_status_line(&PinsPanel::Empty), " Pinned CIDs: 0"); assert_eq!( pins_status_line(&PinsPanel::Unavailable), @@ -734,7 +1094,12 @@ mod tests { .await; let bad = PathBuf::from("/nonexistent/gl-id-xyz"); - let panel = fetch_pins(&server.url(), PinsAuth::DirUnusable(bad.clone())).await; + let panel = fetch_pins( + &server.url(), + PinsAuth::DirUnusable(bad.clone()), + far_deadline(), + ) + .await; match panel { PinsPanel::IdentityError(d) => assert_eq!(d, bad), other => panic!("expected IdentityError, got {other:?}"),