Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4506480
fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-se…
Gravirei Jun 30, 2026
3aa7bf0
perf(ipfs): check object existence before allowed-blob walk
Gravirei Jun 30, 2026
63580c7
refactor(ipfs): improve formatting and readability in get_by_cid func…
Gravirei Jun 30, 2026
002f354
refactor(ipfs): streamline object retrieval by separating type and co…
Gravirei Jun 30, 2026
f2c91a8
docs(ipfs): update get_by_cid comment to reflect split object retrieval
Gravirei Jul 1, 2026
0b7c494
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 1, 2026
03ba714
Run cargo fmt on store.rs
Gravirei Jul 1, 2026
03c90f8
Merge remote-tracking branch 'upstream/main'
Gravirei Jul 2, 2026
1d7e046
fix(node): gate /ipfs/pins and /arweave/anchors behind authentication…
Gravirei Jun 30, 2026
abb0af0
test(node): add integration tests for /ipfs/pins and /arweave/anchors…
Gravirei Jun 30, 2026
4aefeae
resolve findings
Gravirei Jun 30, 2026
480349e
fix(node): require authentication for global anchor and pin listings
Gravirei Jun 30, 2026
417c0c2
test: format test_support.rs after CI failure
Gravirei Jul 1, 2026
66329a1
test: extract test helpers and add anchors anonymous coverage
Gravirei Jul 1, 2026
128d633
fix(node): clamp negative anchor limit and add build_router pin integ…
Gravirei Jul 1, 2026
004d9fd
fix(node): filter global anchors before limit and use deduped repo vi…
Gravirei Jul 2, 2026
888a3c8
fix: resolve rebase conflicts and update pins path to use allowed_blo…
Gravirei Jul 2, 2026
85f8f94
fix(node): resolve mirror rows in anchor per-repo path and bound glob…
Gravirei Jul 2, 2026
3fe4e95
fix(node): constrain anchor queries by owner_did to prevent cross-DID…
Gravirei Jul 2, 2026
77f42f0
fix(clippy): use is_some_and instead of map_or(false, ...)
Gravirei Jul 2, 2026
a98e544
fix(db,cli): filter arweave anchors in SQL and sign IPFS pin requests
Gravirei Jul 2, 2026
70acb16
test: add slug-collision regression test for global anchor query
Gravirei Jul 2, 2026
dbce244
fix: optimize global pin listing and verify CLI pin signatures
Gravirei Jul 3, 2026
459a29d
fix: address cargo clippy warnings
Gravirei Jul 3, 2026
b3dcc6c
fix: clamp pin limits, normalize owner DIDs, and add migration v10 up…
Gravirei Jul 3, 2026
0534d79
Merge upstream/main into bug_fix_2
Gravirei Jul 3, 2026
2759539
Potential fix for pull request finding
Gravirei Jul 3, 2026
4d1dab2
fix(node): apply Copilot review suggestions on PR 134
Gravirei Jul 3, 2026
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
120 changes: 112 additions & 8 deletions crates/gitlawb-node/src/api/arweave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

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;
use crate::visibility::{visibility_check, Decision};

#[derive(Debug, Deserialize)]
pub struct ListAnchorsQuery {
Expand All @@ -21,16 +23,118 @@ fn default_limit() -> i64 {
}

/// GET /api/v1/arweave/anchors
///
/// Returns Arweave ref-update anchors. When `?repo=<owner>/<name>` is provided,
/// the response is gated on the caller's read visibility for that repo (deny ->
/// 404). Without a `?repo=` filter, the global listing filters each row on
/// current visibility to prevent metadata disclosure when repos are made private
/// after push (#136).
///
/// Both paths resolve visibility against the deduped repo view so mirror rows
/// never bypass the canonical repo's rules.
pub async fn list_anchors(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Query(q): Query<ListAnchorsQuery>,
) -> Result<Json<serde_json::Value>> {
let limit = q.limit.min(200);
let anchors = state
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await
.map_err(crate::error::AppError::Internal)?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let limit = q.limit.clamp(0, 200);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Global listings (no ?repo=) are restricted to authenticated callers.
if q.repo.is_none() && caller.is_none() {
return Err(AppError::Unauthorized(
"authentication required for global anchor listing".into(),
));
}

let anchors = if let Some(ref repo) = q.repo {
// ── Per-repo path ──
// Resolve against the deduped repo view so mirror rows never bypass
// the canonical repo's visibility rules. Use did_matches to handle
// both full DID and bare short-form owner in the URL.
let parts: Vec<&str> = repo.splitn(2, '/').collect();
if parts.len() != 2 {
return Err(AppError::RepoNotFound(repo.clone()));
}
let (owner, name) = (parts[0], parts[1]);

// Fetch the deduped list (mirror rows collapsed, quarantined excluded).
let repos = state
.db
.list_all_repos_deduped()
.await
.map_err(AppError::Internal)?;

let record = repos
.into_iter()
.find(|r| crate::api::did_matches(owner, &r.owner_did) && r.name == name)
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;

// Quarantine gate (belt-and-suspenders — deduped already filters).
if state.db.is_repo_quarantined(&record.id).await? {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}

// Visibility gate against the canonical survivor's rules.
let rules = state.db.list_visibility_rules(&record.id).await?;
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
== Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}

// Normalize owner exactly like anchor writes do.
let owner_short = crate::db::normalize_owner_key(&record.owner_did);
let slug = Some(format!("{}/{}", owner_short, record.name));

state
.db
.list_arweave_anchors(slug.as_deref(), Some(&record.owner_did), limit)
.await
.map_err(AppError::Internal)?
} else {
// ── Global listing ──
// Build the set of readable repo slugs and owner DIDs from the deduped repo view
// (mirror rows already collapsed, quarantined excluded), then query
// anchors bounded in SQL.
let repos = state
.db
.list_all_repos_deduped()
.await
.map_err(AppError::Internal)?;
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)?;

// Build parallel vectors of readable (slug, owner_did) pairs to query in SQL.
// This avoids filter-before-limit leaks or loss of pages.
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());
}

if query_repos.is_empty() {
Vec::new()
} else {
state
.db
.list_arweave_anchors_for_repos(&query_repos, &query_owner_dids, limit)
.await
.map_err(AppError::Internal)?
}
};

Ok(Json(serde_json::json!({
"anchors": anchors,
Expand Down
149 changes: 145 additions & 4 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//! see `get_by_cid`).

use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Extension, Json,
Expand All @@ -30,6 +30,7 @@ use crate::git::store;
use crate::git::visibility_pack::{allowed_blob_set_for_caller, has_path_scoped_rule};
use crate::state::AppState;
use crate::visibility::{visibility_check, Decision};
use serde::Deserialize;

/// GET /ipfs/{cid}
///
Expand Down Expand Up @@ -216,12 +217,152 @@ pub async fn get_by_cid(
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
let pins = state
///
/// Requires authentication: the global pin index would otherwise disclose
/// metadata for every object ever pushed to the node (#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.
#[derive(Debug, Deserialize)]
pub struct ListPinsQuery {
#[serde(default = "default_limit")]
pub limit: i64,
}

fn default_limit() -> i64 {
1000
}

/// 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.
///
/// Requires authentication: the global pin index would otherwise disclose
/// metadata for every object ever pushed to the node (#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.
pub async fn list_pins(
State(state): State<AppState>,
Query(query): Query<ListPinsQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
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_owned = caller.map(|c| c.to_string());

// 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_pinned_cids()
.list_all_repos_deduped()
.await
.map_err(AppError::Internal)?;
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)?;

// Build parallel vectors of readable (slug, owner_did) pairs to query in SQL.
// This avoids filter-before-limit leaks or loss of pages.
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());
}

let limit = query.limit.clamp(0, 200);
let raw_pins = if query_repos.is_empty() {
Vec::new()
} else {
state
.db
.list_pinned_cids_for_repos(&query_repos, &query_owner_dids, limit)
.await
.map_err(AppError::Internal)?
};

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));
}

let mut filtered_pins = Vec::new();
let mut allowed_blobs_by_repo: HashMap<String, HashSet<String>> = HashMap::new();

for pin in raw_pins {
let Some((repo, rules)) = repos_by_slug.get(&pin.repo) else {
continue;
};

if !has_path_scoped_rule(rules) {
filtered_pins.push(pin);
continue;
}

let allowed_set = if let Some(set) = allowed_blobs_by_repo.get(&repo.id) {
set
} else {
let set = match state.repo_store.acquire(&repo.owner_did, &repo.name).await {
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();

match tokio::task::spawn_blocking(move || {
allowed_blob_set_for_caller(
&rp_clone,
&r_clone,
is_public,
&owner,
caller_for_walk.as_deref(),
)
})
.await
{
Ok(Ok(s)) => s,
_ => HashSet::new(),
}
}
Err(_) => HashSet::new(),
};
allowed_blobs_by_repo.insert(repo.id.clone(), set);
allowed_blobs_by_repo.get(&repo.id).unwrap()
};

if allowed_set.contains(&pin.sha256_hex) {
filtered_pins.push(pin);
}
}

let pins = filtered_pins;

Ok(Json(serde_json::json!({
"pins": pins,
Expand Down
6 changes: 6 additions & 0 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,12 +1047,16 @@ pub async fn git_receive_pack(
let node_did_str = state.node_did.to_string();
let node_seed = state.node_keypair.to_seed();
let repo_name = record.name.clone();
let owner_short = crate::db::normalize_owner_key(&owner_did);
let slug = format!("{}/{}", owner_short, repo_name);
tokio::spawn(async move {
let pinned = crate::ipfs_pin::pin_new_objects(
&ipfs_api,
&repo_path_clone,
object_list_ipfs,
&db_clone,
&slug,
&owner_did,
)
.await;
if !pinned.is_empty() {
Expand Down Expand Up @@ -1163,6 +1167,8 @@ pub async fn git_receive_pack(
&repo_path_clone,
object_list_pinata,
&db_clone,
&repo_slug,
&owner_did_for_arweave,
)
.await
} else {
Expand Down
Loading
Loading