From 45064806741001bf120a2e414adcdb9a5250f3a8 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 30 Jun 2026 19:52:16 +0600 Subject: [PATCH 1/8] fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126) The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating only reachable blobs), so a dangling/unreachable blob was absent from the set and served in cleartext to anonymous callers. Flip to an allowed-set (allowed_blob_set_for_caller) that enumerates reachable blobs the caller may read: a dangling blob has no path, is never in the set, and 404s. --- crates/gitlawb-node/src/api/ipfs.rs | 115 ++++++++++-------- .../gitlawb-node/src/git/visibility_pack.rs | 96 ++++++++++++++- crates/gitlawb-node/src/test_support.rs | 112 +++++++++++++++++ 3 files changed, 273 insertions(+), 50 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 6a43cb58..405eaedf 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -27,7 +27,7 @@ use std::str::FromStr; use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; use crate::git::store; -use crate::git::visibility_pack::{has_path_scoped_rule, withheld_blob_oids}; +use crate::git::visibility_pack::{allowed_blob_set_for_caller, has_path_scoped_rule}; use crate::state::AppState; use crate::visibility::{visibility_check, Decision}; @@ -36,16 +36,22 @@ use crate::visibility::{visibility_check, Decision}; /// Search all repos on the node for a git object whose SHA-256 hash matches /// the given CIDv1, returning its raw content if the caller may read it. /// -/// Visibility (#110): the object is served only from a repo row the caller -/// passes. For each iterated row we gate against that row's OWN rules +/// Visibility (#110, #126): the object is served only from a repo row the +/// caller passes. For each iterated row we gate against that row's OWN rules /// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` /// — `get_repo`'s fuzzy match could otherwise authorize a different physical -/// row than the one read (KTD2a). When the row carries path-scoped rules, a -/// blob withheld from the caller (`withheld_blob_oids`) is skipped. Denial and -/// genuine not-found both fall through to an opaque 404. +/// row than the one read (KTD2a). When the row carries path-scoped rules +/// (KTD4) the served object must be either a non-blob (trees/commits are +/// structural; KTD3) OR a blob in the caller's *reachable* allowed-set +/// (`allowed_blob_set_for_caller`). The reachable allowed-set excludes +/// dangling blobs — a blob written via `git hash-object -w` and never +/// committed has no path to gate, so it is fail-closed 404'd under +/// path-scoped rules (#126). Denial and genuine not-found both fall through +/// to an opaque 404. /// -/// Scope: this closes the direct unauthenticated scan. A stale-public mirror -/// row still serves withheld content (tracked separately, #124). +/// Scope: this closes the direct unauthenticated scan, including the dangling +/// case. A stale-public mirror row still serves withheld content (tracked +/// separately, #124). pub async fn get_by_cid( Path(cid_str): Path, State(state): State, @@ -85,11 +91,16 @@ pub async fn get_by_cid( .await .map_err(AppError::Internal)?; - // Request-scoped memo of the per-repo withheld set (KTD1). The caller is - // constant for one request, so `repo.id` alone is a safe, sufficient key — - // never a coarse caller "class", which `visibility_check`'s exact full-DID - // reader match would make unsafe. - let mut withheld_memo: HashMap> = HashMap::new(); + // Request-scoped memo of the per-repo allowed-blob set (KTD1, #126). The + // caller is constant for one request, so `repo.id` alone is a safe, + // sufficient key — never a coarse caller "class", which + // `visibility_check`'s exact full-DID reader match would make unsafe. + // + // We flipped from a deny-set (`withheld_blob_oids`) to an allowed-set + // (`allowed_blob_set_for_caller`) so dangling blobs — never enumerated by + // the reachable walk — fail closed instead of slipping through an empty + // deny entry (#126). + let mut allowed_memo: HashMap> = HashMap::new(); for repo in &repos { // Repo-level read gate against THIS row's own rules (KTD2a). @@ -106,45 +117,51 @@ pub async fn get_by_cid( Err(_) => continue, }; - // Per-blob withholding only applies when a path-scoped rule exists (KTD4). - if has_path_scoped_rule(rules) { - if !withheld_memo.contains_key(&repo.id) { - let rp = repo_path.clone(); - let r = rules.to_vec(); - let is_public = repo.is_public; - let owner = repo.owner_did.clone(); - let caller_for_walk = caller_owned.clone(); - // Full-history walk shells out to git — keep it off the async runtime. - let walk = tokio::task::spawn_blocking(move || { - withheld_blob_oids(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) - }) - .await; - // Fail closed on EITHER a task panic (JoinError) or a walk error: - // we cannot prove the caller may read here, so skip this repo and - // let a public copy (if any) serve. Never serve on an unproven gate. - let set = match walk { - Ok(Ok(set)) => set, - Ok(Err(e)) => { - tracing::warn!(repo = %repo.name, err = %e, "withheld walk failed; skipping repo"); - continue; - } - Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "withheld walk task panicked; skipping repo"); - continue; - } - }; - withheld_memo.insert(repo.id.clone(), set); - } - if withheld_memo - .get(&repo.id) - .is_some_and(|set| set.contains(&sha256_hex)) - { - continue; - } + // Per-blob gating only applies when a path-scoped rule exists (KTD4). + // Without any path-scoped rule, the "/" gate above is the whole story. + let path_scoped = has_path_scoped_rule(rules); + if path_scoped && !allowed_memo.contains_key(&repo.id) { + let rp = repo_path.clone(); + let r = rules.to_vec(); + let is_public = repo.is_public; + let owner = repo.owner_did.clone(); + let caller_for_walk = caller_owned.clone(); + // Full-history walk shells out to git — keep it off the async runtime. + let walk = tokio::task::spawn_blocking(move || { + allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + }) + .await; + // Fail closed on EITHER a task panic (JoinError) or a walk error: + // we cannot prove the caller may read here, so skip this repo and + // let a public copy (if any) serve. Never serve on an unproven gate. + let set = match walk { + Ok(Ok(set)) => set, + Ok(Err(e)) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo"); + continue; + } + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo"); + continue; + } + }; + allowed_memo.insert(repo.id.clone(), set); } match store::read_object(&repo_path, &sha256_hex) { - Ok(Some((_obj_type, content))) => { + Ok(Some((obj_type, content))) => { + // Path-scoped rules: serve trees/commits unconditionally + // (structural; KTD3); a blob must be in the reachable + // allowed-set, which excludes dangling blobs (#126). + if path_scoped && obj_type == "blob" { + let in_allowed = allowed_memo + .get(&repo.id) + .is_some_and(|set| set.contains(&sha256_hex)); + if !in_allowed { + continue; + } + } + // 3. Return the content with IPFS-style headers let mut headers = HeaderMap::new(); headers.insert( diff --git a/crates/gitlawb-node/src/git/visibility_pack.rs b/crates/gitlawb-node/src/git/visibility_pack.rs index 578ee40b..cb70e39c 100644 --- a/crates/gitlawb-node/src/git/visibility_pack.rs +++ b/crates/gitlawb-node/src/git/visibility_pack.rs @@ -309,11 +309,33 @@ pub fn replicable_blob_set( rules: &[VisibilityRule], is_public: bool, owner_did: &str, +) -> Result> { + allowed_blob_set_for_caller(repo_path, rules, is_public, owner_did, None) +} + +/// Reachable blob OIDs that visibility ALLOWS `caller` at some path. The +/// caller-aware generalization of `replicable_blob_set` (which is the anonymous +/// `caller = None` case). Used by `GET /ipfs/{cid}` to gate fail-closed against +/// dangling/unreachable blobs (#126): a blob written via `git hash-object -w` +/// but unreferenced is absent from the reachable walk, so it is never in this +/// set and the IPFS serve path drops it — even from the owner, who has no path +/// to authorize the blob at. +/// +/// A blob reachable at an allowed path is included even when also denied +/// elsewhere (its content is readable to this caller elsewhere). Trees and +/// commits are NOT included here; the caller decides per object type whether +/// the allow-set applies (it does not for trees/commits — KTD3). +pub fn allowed_blob_set_for_caller( + repo_path: &Path, + rules: &[VisibilityRule], + is_public: bool, + owner_did: &str, + caller: Option<&str>, ) -> Result> { let pairs = blob_paths(repo_path)?; let mut allowed = HashSet::new(); for (oid, path) in &pairs { - if visibility_check(rules, is_public, owner_did, None, path) == Decision::Allow { + if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow { allowed.insert(oid.clone()); } } @@ -743,6 +765,78 @@ mod tests { ); } + #[test] + fn allowed_set_excludes_dangling_blob_for_every_caller() { + // #126: a blob written via `git hash-object -w` but never referenced has + // no path to gate on, so it is absent from the reachable allowed-set — + // for anonymous callers, listed readers, AND the owner. The IPFS serve + // path relies on this fail-closed property to drop dangling withheld + // blobs that the deny-set model leaked. + let td = TempDir::new().unwrap(); + let work = td.path().join("work"); + std::fs::create_dir_all(work.join("public")).unwrap(); + std::fs::write(work.join("public/a.txt"), b"public bytes\n").unwrap(); + let run = |args: &[&str]| { + assert!( + Command::new("git") + .args(args) + .current_dir(&work) + .status() + .unwrap() + .success(), + "git {args:?} failed" + ); + }; + run(&["init", "-q"]); + run(&["config", "user.email", "t@t"]); + run(&["config", "user.name", "t"]); + run(&["add", "."]); + run(&["commit", "-qm", "init"]); + let oid_of = |rev: &str| { + let out = Command::new("git") + .args(["rev-parse", rev]) + .current_dir(&work) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + let public_oid = oid_of("HEAD:public/a.txt"); + + std::fs::write(work.join("orphan.bin"), b"DANGLING SECRET\n").unwrap(); + let dangling_oid = { + let out = Command::new("git") + .args(["hash-object", "-w", "orphan.bin"]) + .current_dir(&work) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + assert!( + matches!(dangling_oid.len(), 40 | 64), + "precondition: hash-object stored the dangling blob" + ); + + // Path-scoped rule: /secret/** denied to anon, allowed to a listed reader. + let reader = "did:key:zReader"; + let rules = [rule("/secret/**", &[reader])]; + + // Every gate-relevant caller: anonymous, listed reader, owner. None of + // them can put the dangling blob in the allowed set — it has no path. + for caller in [None, Some(reader), Some(OWNER)] { + let allowed = allowed_blob_set_for_caller(&work, &rules, true, OWNER, caller).unwrap(); + assert!( + !allowed.contains(&dangling_oid), + "dangling blob must be absent from allowed-set (caller={caller:?})" + ); + // Sanity: the reachable public blob is still in the set for every + // caller (the rule does not deny /public/**). + assert!( + allowed.contains(&public_oid), + "reachable public blob must be in allowed-set (caller={caller:?})" + ); + } + } + #[test] fn recipients_are_owner_plus_allowed_readers_only() { let (_td, repo, secret_oid, public_oid) = fixture(); diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 98fccc56..6de9b0f0 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -1842,4 +1842,116 @@ mod tests { "walk error fails closed: repo skipped, even the public blob is not served" ); } + + /// #126: a dangling blob (written via `git hash-object -w`, never referenced + /// by any commit/tree) must 404 through `GET /ipfs/{cid}` under path-scoped + /// rules — for anon AND the owner. The pre-#126 deny-set was fail-open by + /// construction: dangling oids were absent from the reachable enumeration + /// and thus absent from the deny-set, so the handler served 200. The + /// allowed-set is fail-closed: dangling oids are absent from the reachable + /// allowed-set, so the handler 404s (per team memory: the owner shift to + /// 404 is the accepted fail-closed default — owners can still + /// `git cat-file` directly). + #[sqlx::test] + async fn ipfs_cid_dangling_blob_fails_closed_under_path_rules(pool: PgPool) { + use crate::db::VisibilityMode; + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let owner_did = owner.did().to_string(); + let slug = owner_did.replace([':', '/'], "_"); + let short = owner_did.split(':').next_back().unwrap().to_string(); + let state = test_state(pool).await; + + // Seed a normal repo with `secret/b.txt` reachable from HEAD, so the + // path-scoped rule has something to match — without this the rule has + // no anchor and we'd be testing nothing. + let _fx = seed_cid_repos(&slug, &short, &["dangling"]); + let bare = std::path::PathBuf::from("/tmp") + .join(&slug) + .join("dangling.git"); + + // Write a dangling blob: `git hash-object -w --stdin` adds it to the + // object DB but nothing references it, so the reachable walk never + // enumerates it. + let mut cmd = std::process::Command::new("git"); + cmd.args(["hash-object", "-w", "--stdin"]) + .current_dir(&bare) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()); + let mut child = cmd.spawn().expect("spawn git hash-object"); + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("stdin"); + stdin.write_all(b"DANGLING SECRET\n").expect("write stdin"); + } + let out = child.wait_with_output().expect("hash-object output"); + assert!( + out.status.success(), + "git hash-object: {}", + String::from_utf8_lossy(&out.stderr) + ); + let dangling_oid = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // Sanity: must be a 64-hex sha256 oid, since the repo is sha256-format. + assert_eq!( + dangling_oid.len(), + 64, + "expected sha256 oid: {dangling_oid}" + ); + let dangling_cid = cid_for_oid(&dangling_oid); + + state + .db + .create_repo(&seed_repo(&owner_did, "dangling")) + .await + .expect("seed repo"); + let rec = state + .db + .get_repo(&owner_did, "dangling") + .await + .unwrap() + .unwrap(); + // Path-scoped rule triggers the per-blob allowed-set gate (KTD4). + state + .db + .set_visibility_rule(&rec.id, "/secret/**", VisibilityMode::B, &[], &owner_did) + .await + .expect("deny rule"); + + // anon: the dangling blob is absent from the reachable allowed-set → + // 404, no leak. Pre-#126 (deny-set) would serve 200. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_anon(&dangling_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "dangling blob must 404 under path-scoped rules" + ); + assert!( + !body.contains("DANGLING SECRET"), + "404 body must not leak the dangling content" + ); + + // owner (signed): same 404. The dangling blob has no path, so it's + // never visibility-checked → never in the allowed set, even for the + // owner. This is the accepted fail-closed shift documented in the PR. + let (st, body) = cid_parts( + cid_router(&state) + .oneshot(cid_signed(&owner, &dangling_cid)) + .await + .unwrap(), + ) + .await; + assert_eq!( + st, + StatusCode::NOT_FOUND, + "owner also 404s on dangling blobs under path-scoped rules (fail-closed default)" + ); + assert!(!body.contains("DANGLING SECRET")); + } } From 3aa7bf06f8cd9acfc26f6b7cea99441760a4716b Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 30 Jun 2026 20:07:58 +0600 Subject: [PATCH 2/8] perf(ipfs): check object existence before allowed-blob walk Move store::read_object before the allowed_blob_set_for_caller spawn_blocking call so random-CID spray against repos with path-scoped rules cannot trigger full-history git walks on repos that don't carry the object. --- crates/gitlawb-node/src/api/ipfs.rs | 141 ++++++++++++++-------------- 1 file changed, 72 insertions(+), 69 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 405eaedf..2177350a 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -40,14 +40,16 @@ use crate::visibility::{visibility_check, Decision}; /// caller passes. For each iterated row we gate against that row's OWN rules /// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` /// — `get_repo`'s fuzzy match could otherwise authorize a different physical -/// row than the one read (KTD2a). When the row carries path-scoped rules -/// (KTD4) the served object must be either a non-blob (trees/commits are -/// structural; KTD3) OR a blob in the caller's *reachable* allowed-set -/// (`allowed_blob_set_for_caller`). The reachable allowed-set excludes -/// dangling blobs — a blob written via `git hash-object -w` and never -/// committed has no path to gate, so it is fail-closed 404'd under -/// path-scoped rules (#126). Denial and genuine not-found both fall through -/// to an opaque 404. +/// row than the one read (KTD2a). We check object existence via +/// `store::read_object` *before* the expensive reachability walk so random-CID +/// spray cannot trigger full-history git walks on repos that don't carry the +/// object. When the row carries path-scoped rules (KTD4) the served object +/// must be either a non-blob (trees/commits are structural; KTD3) OR a blob +/// in the caller's *reachable* allowed-set (`allowed_blob_set_for_caller`). +/// The reachable allowed-set excludes dangling blobs — a blob written via +/// `git hash-object -w` and never committed has no path to gate, so it is +/// fail-closed 404'd under path-scoped rules (#126). Denial and genuine +/// not-found both fall through to an opaque 404. /// /// Scope: this closes the direct unauthenticated scan, including the dangling /// case. A stale-public mirror row still serves withheld content (tracked @@ -117,76 +119,77 @@ pub async fn get_by_cid( Err(_) => continue, }; + // Check whether the object exists in this repo before any expensive + // reachability walk. This prevents random-CID spray from triggering + // full-history git walks on repos that don't carry the object. + let object = store::read_object(&repo_path, &sha256_hex); + let (obj_type, content) = match object { + Ok(Some(t)) => t, + Ok(None) => continue, + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "error reading git object"); + continue; + } + }; + // Per-blob gating only applies when a path-scoped rule exists (KTD4). // Without any path-scoped rule, the "/" gate above is the whole story. + // Trees/commits are always served under path-scoped rules (KTD3). let path_scoped = has_path_scoped_rule(rules); - if path_scoped && !allowed_memo.contains_key(&repo.id) { - let rp = repo_path.clone(); - let r = rules.to_vec(); - let is_public = repo.is_public; - let owner = repo.owner_did.clone(); - let caller_for_walk = caller_owned.clone(); - // Full-history walk shells out to git — keep it off the async runtime. - let walk = tokio::task::spawn_blocking(move || { - allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) - }) - .await; - // Fail closed on EITHER a task panic (JoinError) or a walk error: - // we cannot prove the caller may read here, so skip this repo and - // let a public copy (if any) serve. Never serve on an unproven gate. - let set = match walk { - Ok(Ok(set)) => set, - Ok(Err(e)) => { - tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo"); - continue; - } - Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo"); - continue; - } - }; - allowed_memo.insert(repo.id.clone(), set); - } - - match store::read_object(&repo_path, &sha256_hex) { - Ok(Some((obj_type, content))) => { - // Path-scoped rules: serve trees/commits unconditionally - // (structural; KTD3); a blob must be in the reachable - // allowed-set, which excludes dangling blobs (#126). - if path_scoped && obj_type == "blob" { - let in_allowed = allowed_memo - .get(&repo.id) - .is_some_and(|set| set.contains(&sha256_hex)); - if !in_allowed { + if path_scoped && obj_type == "blob" { + if !allowed_memo.contains_key(&repo.id) { + let rp = repo_path.clone(); + let r = rules.to_vec(); + let is_public = repo.is_public; + let owner = repo.owner_did.clone(); + let caller_for_walk = caller_owned.clone(); + // Full-history walk shells out to git — keep it off the async runtime. + let walk = tokio::task::spawn_blocking(move || { + allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + }) + .await; + // Fail closed on EITHER a task panic (JoinError) or a walk error: + // we cannot prove the caller may read here, so skip this repo and + // let a public copy (if any) serve. Never serve on an unproven gate. + let set = match walk { + Ok(Ok(set)) => set, + Ok(Err(e)) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo"); + continue; + } + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo"); continue; } - } - - // 3. Return the content with IPFS-style headers - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("content-type"), - HeaderValue::from_static("application/octet-stream"), - ); - headers.insert( - HeaderName::from_static("x-content-cid"), - HeaderValue::from_str(&cid_str) - .unwrap_or_else(|_| HeaderValue::from_static("invalid")), - ); - headers.insert( - HeaderName::from_static("x-git-hash"), - HeaderValue::from_str(&sha256_hex) - .unwrap_or_else(|_| HeaderValue::from_static("invalid")), - ); - - return Ok((StatusCode::OK, headers, content).into_response()); + }; + allowed_memo.insert(repo.id.clone(), set); } - Ok(None) => continue, - Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "error reading git object"); + let in_allowed = allowed_memo + .get(&repo.id) + .is_some_and(|set| set.contains(&sha256_hex)); + if !in_allowed { continue; } } + + // 3. Return the content with IPFS-style headers + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + HeaderName::from_static("x-content-cid"), + HeaderValue::from_str(&cid_str) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ); + headers.insert( + HeaderName::from_static("x-git-hash"), + HeaderValue::from_str(&sha256_hex) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ); + + return Ok((StatusCode::OK, headers, content).into_response()); } // Not found in any repo From 63580c730da890851324a45d872ede1c3c7d9a62 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 30 Jun 2026 23:10:27 +0600 Subject: [PATCH 3/8] refactor(ipfs): improve formatting and readability in get_by_cid function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✓ P3 blocker fixed: cargo fmt applied — the format gate will pass. ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays. • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here. --- crates/gitlawb-node/src/api/ipfs.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 2177350a..d6405fa0 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -145,7 +145,13 @@ pub async fn get_by_cid( let caller_for_walk = caller_owned.clone(); // Full-history walk shells out to git — keep it off the async runtime. let walk = tokio::task::spawn_blocking(move || { - allowed_blob_set_for_caller(&rp, &r, is_public, &owner, caller_for_walk.as_deref()) + allowed_blob_set_for_caller( + &rp, + &r, + is_public, + &owner, + caller_for_walk.as_deref(), + ) }) .await; // Fail closed on EITHER a task panic (JoinError) or a walk error: @@ -180,8 +186,7 @@ pub async fn get_by_cid( ); headers.insert( HeaderName::from_static("x-content-cid"), - HeaderValue::from_str(&cid_str) - .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + HeaderValue::from_str(&cid_str).unwrap_or_else(|_| HeaderValue::from_static("invalid")), ); headers.insert( HeaderName::from_static("x-git-hash"), From 002f35405874898dc538773f0dcda13bafb56f49 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 1 Jul 2026 02:25:29 +0600 Subject: [PATCH 4/8] refactor(ipfs): streamline object retrieval by separating type and content reading --- crates/gitlawb-node/src/api/ipfs.rs | 14 +++++++++--- crates/gitlawb-node/src/git/store.rs | 34 ++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index d6405fa0..46cbf739 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -122,12 +122,11 @@ pub async fn get_by_cid( // Check whether the object exists in this repo before any expensive // reachability walk. This prevents random-CID spray from triggering // full-history git walks on repos that don't carry the object. - let object = store::read_object(&repo_path, &sha256_hex); - let (obj_type, content) = match object { + let obj_type = match store::object_type(&repo_path, &sha256_hex) { Ok(Some(t)) => t, Ok(None) => continue, Err(e) => { - tracing::warn!(repo = %repo.name, err = %e, "error reading git object"); + tracing::warn!(repo = %repo.name, err = %e, "error checking git object type"); continue; } }; @@ -178,6 +177,15 @@ pub async fn get_by_cid( } } + // Now that we've passed the gate, read the content. + let content = match store::read_object_content(&repo_path, &sha256_hex, &obj_type) { + Ok(c) => c, + Err(e) => { + tracing::warn!(repo = %repo.name, err = %e, "error reading git object content"); + continue; + } + }; + // 3. Return the content with IPFS-style headers let mut headers = HeaderMap::new(); headers.insert( diff --git a/crates/gitlawb-node/src/git/store.rs b/crates/gitlawb-node/src/git/store.rs index b9759147..290da6cc 100644 --- a/crates/gitlawb-node/src/git/store.rs +++ b/crates/gitlawb-node/src/git/store.rs @@ -271,9 +271,8 @@ pub struct TreeEntry { /// `/ipfs/` is computed from these same content bytes via /// `gitlawb_core::cid::Cid::from_git_object_bytes`. /// -/// Returns `None` if the object does not exist in this repo. -pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result)>> { - // First check if the object exists and get its type +/// Get just the object type. Returns `None` if the object doesn't exist. +pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result> { let type_output = Command::new("git") .args(["cat-file", "-t", sha256_hex]) .current_dir(repo_path) @@ -284,13 +283,13 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result Result> { let content_output = Command::new("git") - .args(["cat-file", &obj_type, sha256_hex]) + .args(["cat-file", obj_type, sha256_hex]) .current_dir(repo_path) .output() .context("failed to run git cat-file ")?; @@ -300,7 +299,24 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result` is computed from these same content bytes via +/// `gitlawb_core::cid::Cid::from_git_object_bytes`. +/// +/// Returns `None` if the object does not exist in this repo. +pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result)>> { + let obj_type = match object_type(repo_path, sha256_hex)? { + Some(t) => t, + None => return Ok(None), + }; + let content = read_object_content(repo_path, sha256_hex, &obj_type)?; + Ok(Some((obj_type, content))) } /// Get the diff between two branches: changes on source_branch not in target_branch. From f2c91a868afb57b7e2a2b949536015490492cef7 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 1 Jul 2026 07:22:24 +0600 Subject: [PATCH 5/8] docs(ipfs): update get_by_cid comment to reflect split object retrieval --- crates/gitlawb-node/src/api/ipfs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index 46cbf739..f3de7570 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -41,7 +41,7 @@ use crate::visibility::{visibility_check, Decision}; /// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read` /// — `get_repo`'s fuzzy match could otherwise authorize a different physical /// row than the one read (KTD2a). We check object existence via -/// `store::read_object` *before* the expensive reachability walk so random-CID +/// `store::object_type` *before* the expensive reachability walk so random-CID /// spray cannot trigger full-history git walks on repos that don't carry the /// object. When the row carries path-scoped rules (KTD4) the served object /// must be either a non-blob (trees/commits are structural; KTD3) OR a blob From 03ba7149fc248798df15f9fb26bf897fd2901b4d Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 1 Jul 2026 23:40:07 +0600 Subject: [PATCH 6/8] Run cargo fmt on store.rs --- crates/gitlawb-node/src/git/store.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/git/store.rs b/crates/gitlawb-node/src/git/store.rs index 290da6cc..229ee695 100644 --- a/crates/gitlawb-node/src/git/store.rs +++ b/crates/gitlawb-node/src/git/store.rs @@ -283,7 +283,11 @@ pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result> return Ok(None); } - Ok(Some(String::from_utf8_lossy(&type_output.stdout).trim().to_string())) + Ok(Some( + String::from_utf8_lossy(&type_output.stdout) + .trim() + .to_string(), + )) } /// Read an object's content if its type is already known. From 14ed8ef5cb655477b6e233c959a748be2e2365fd Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 9 Jul 2026 20:24:58 +0600 Subject: [PATCH 7/8] fix(bounties): add tests for claim_bounty repo-read gate (#160) --- crates/gitlawb-node/src/test_support.rs | 100 ++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 14a61203..d692f308 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -3547,4 +3547,104 @@ mod tests { .unwrap(); assert!(resp.status().is_success()); } + + #[sqlx::test] + async fn claim_bounty_gate_denies_non_reader_on_private(pool: PgPool) { + let state = test_state(pool).await; + let owner = "did:key:zCLAIMDENYOWNERRRRRRRRRRRRRRRRRRRRRRRRR"; + state + .db + .create_repo(&seed_private_repo(owner, "secret-repo")) + .await + .unwrap(); + let bounty = crate::db::BountyRecord { + id: "claim-bounty-deny".into(), + repo_owner: owner.into(), + repo_name: "secret-repo".into(), + issue_id: None, + title: "Secret Claim Bounty".into(), + amount: 100, + creator_did: owner.into(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-01T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }; + state.db.create_bounty(&bounty).await.unwrap(); + + // A stranger (not repo owner/reader) tries to claim the bounty + let stranger_kp = gitlawb_core::identity::Keypair::generate(); + let uri = "/api/v1/bounties/claim-bounty-deny/claim"; + let body = b"{}"; + let sig = gitlawb_core::http_sig::sign_request(&stranger_kp, "POST", uri, body); + let req = Request::builder() + .method(Method::POST) + .uri(uri) + .header("content-type", "application/json") + .header("content-digest", sig.content_digest) + .header("signature-input", sig.signature_input) + .header("signature", sig.signature) + .body(Body::from(body.to_vec())) + .unwrap(); + + let router = crate::server::build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[sqlx::test] + async fn claim_bounty_gate_admits_owner_on_private(pool: PgPool) { + let state = test_state(pool).await; + let kp = gitlawb_core::identity::Keypair::generate(); + let owner = kp.did().to_string(); + state + .db + .create_repo(&seed_private_repo(&owner, "secret-repo")) + .await + .unwrap(); + let bounty = crate::db::BountyRecord { + id: "claim-bounty-admit".into(), + repo_owner: owner.clone(), + repo_name: "secret-repo".into(), + issue_id: None, + title: "Owner Claim Bounty".into(), + amount: 200, + creator_did: owner.clone(), + claimant_did: None, + claimant_wallet: None, + pr_id: None, + status: "open".into(), + created_at: "2026-01-01T00:00:00Z".into(), + claimed_at: None, + submitted_at: None, + completed_at: None, + deadline_secs: 86400, + tx_hash: None, + }; + state.db.create_bounty(&bounty).await.unwrap(); + + // The owner claims their own bounty + let uri = "/api/v1/bounties/claim-bounty-admit/claim"; + let body = b"{}"; + let sig = gitlawb_core::http_sig::sign_request(&kp, "POST", uri, body); + let req = Request::builder() + .method(Method::POST) + .uri(uri) + .header("content-type", "application/json") + .header("content-digest", sig.content_digest) + .header("signature-input", sig.signature_input) + .header("signature", sig.signature) + .body(Body::from(body.to_vec())) + .unwrap(); + + let router = crate::server::build_router(state); + let resp = router.oneshot(req).await.unwrap(); + assert!(resp.status().is_success()); + } } From ee2968c53d1531b197d52bb3d9659170199b4b6b Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 9 Jul 2026 20:58:11 +0600 Subject: [PATCH 8/8] fix(profiles): remove redundant borrow in format arg --- crates/gitlawb-node/src/api/profiles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs index 6f3a12a9..4e42c6e4 100644 --- a/crates/gitlawb-node/src/api/profiles.rs +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -98,7 +98,7 @@ pub async fn set_profile( &state.http_client, &state.config.pinata_upload_url, &state.config.pinata_jwt, - &format!("profile-{}", &did), + &format!("profile-{}", did), &profile_json, ) .await