Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 86 additions & 3 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,47 @@
//! `git cat-file <type> <sha256>` (i.e. without the git framing header).
//! This is consistent with how `gitlawb_core::cid::Cid::from_git_object_bytes`
//! computes CIDs when objects are pushed.
//!
//! Serving is access-controlled: an object is returned only from a repo row the
//! requesting caller is permitted to read (per-caller path-scoped visibility,
//! see `get_by_cid`).

use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
Extension, Json,
};
use cid::CidGeneric;
use std::collections::{HashMap, HashSet};
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::state::AppState;
use crate::visibility::{visibility_check, Decision};

/// GET /ipfs/{cid}
///
/// Search all repos on the node for a git object whose SHA-256 hash matches
/// the given CIDv1. Returns the raw object content bytes with appropriate
/// headers if found, or 404 if not found.
/// 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_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.
///
/// Scope: this closes the direct unauthenticated scan. A stale-public mirror
/// row still serves withheld content (tracked separately, #124).
pub async fn get_by_cid(
Path(cid_str): Path<String>,
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Response> {
// 1. Decode the CID and extract the SHA-256 digest
let cid = CidGeneric::<64>::from_str(&cid_str)
Expand All @@ -46,6 +65,8 @@ pub async fn get_by_cid(
}

let sha256_hex = hex::encode(mh.digest());
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let caller_owned = caller.map(|c| c.to_string());

// 2. Search all repos for an object with this SHA-256
let repos = state
Expand All @@ -54,12 +75,74 @@ pub async fn get_by_cid(
.await
.map_err(AppError::Internal)?;

// Fetch every repo's visibility rules in one query rather than one per row
// (the gate runs each row against its OWN rules — KTD2a). A row absent from
// the map has no rules.
let repo_ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = state
.db
.list_visibility_rules_for_repos(&repo_ids)
.await
.map_err(AppError::Internal)?;

// Request-scoped memo of the per-repo withheld set (KTD1). The caller is
// constant for one request, so `repo.id` alone is a safe, sufficient key —
// never a coarse caller "class", which `visibility_check`'s exact full-DID
// reader match would make unsafe.
let mut withheld_memo: HashMap<String, HashSet<String>> = HashMap::new();

for repo in &repos {
// Repo-level read gate against THIS row's own rules (KTD2a).
let rules: &[crate::db::VisibilityRule] = rules_by_repo
.get(&repo.id)
.map(Vec::as_slice)
.unwrap_or(&[]);
if visibility_check(rules, repo.is_public, &repo.owner_did, caller, "/") == Decision::Deny {
continue;
}

let repo_path = match state.repo_store.acquire(&repo.owner_did, &repo.name).await {
Ok(p) => p,
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;
}
}

Comment on lines +109 to +145

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid running the full withheld walk before proving this repo contains the CID.

Right now any anonymous request for an absent/random CID can force withheld_blob_oids across every visible path-scoped repo before store::read_object is attempted. Move the cheap object-presence check before the full-history walk, then apply the withheld filter before returning bytes.

Suggested shape
+        let object = match store::read_object(&repo_path, &sha256_hex) {
+            Ok(Some(object)) => object,
+            Ok(None) => continue,
+            Err(e) => {
+                tracing::warn!(repo = %repo.name, err = %e, "error reading git object");
+                continue;
+            }
+        };
+
         // Per-blob withholding only applies when a path-scoped rule exists (KTD4).
         if has_path_scoped_rule(rules) {
             ...
             if withheld_memo
                 .get(&repo.id)
                 .is_some_and(|set| set.contains(&sha256_hex))
             {
                 continue;
             }
         }
 
-        match store::read_object(&repo_path, &sha256_hex) {
-            Ok(Some((_obj_type, content))) => {
+        let (_obj_type, content) = object;
+        {
             ...
-            }
-            Ok(None) => continue,
-            Err(e) => {
-                tracing::warn!(repo = %repo.name, err = %e, "error reading git object");
-                continue;
-            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/ipfs.rs` around lines 109 - 145, Move the cheap
object-presence check in the IPFS read flow before the expensive
withheld-history scan so random/absent CIDs do not trigger `withheld_blob_oids`
across repos. In the `crates/gitlawb-node/src/api/ipfs.rs` read path, use the
existing `store::read_object`/CID presence logic first, then only run the
path-scoped withheld filter for repos that actually contain the object, and keep
the final bytes-returning path gated by that withheld result.

match store::read_object(&repo_path, &sha256_hex) {
Ok(Some((_obj_type, content))) => {
// 3. Return the content with IPFS-style headers
Expand Down
4 changes: 4 additions & 0 deletions crates/gitlawb-node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ mod authz_guard {
let profiles = include_str!("profiles.rs");
let repos = include_str!("repos.rs");
let register = include_str!("register.rs");
let ipfs = include_str!("ipfs.rs");

// (source, handler, expected gate marker)
let rows: &[(&str, &str, &str)] = &[
Expand All @@ -186,6 +187,9 @@ mod authz_guard {
(issues, "create_issue", "authorize_repo_read("),
(bounties, "create_bounty", "authorize_repo_read("),
(repos, "fork_repo", "authorize_repo_read("),
// get_by_cid gates each iterated repo row directly via visibility_check
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
(ipfs, "get_by_cid", "visibility_check("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
Expand Down
7 changes: 6 additions & 1 deletion crates/gitlawb-node/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,14 @@ pub fn build_router(state: AppState) -> Router {
);

// ── IPFS content-addressed retrieval and pin listing ──────────────────
// `/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).
let ipfs_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
.layer(middleware::from_fn(auth::optional_signature))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));

// ── Arweave permanent anchors ──────────────────────────────────────────
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
Expand Down
Loading
Loading