Skip to content
Open
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
1 change: 1 addition & 0 deletions .cursor/rules/rtk-token-savings.mdc
35 changes: 35 additions & 0 deletions crates/git-remote-gitlawb/tests/real_git_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ impl Drop for Shim {
}
}

/// Wait until the shim accept loop is polling. Without this, Windows CI can
/// race: `git fetch` connects before the spawned thread enters `accept()`.
fn wait_for_shim_ready(addr: std::net::SocketAddr) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("shim at {addr} did not accept connections within 10s");
}

/// Start a minimal smart-HTTP shim serving `repo` on 127.0.0.1. Handles the
/// upload-pack advertisement (GET) and pack negotiation (POST), counting POSTs.
fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
Expand All @@ -154,6 +167,8 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
}
});

wait_for_shim_ready(addr);

Shim {
base_url,
posts,
Expand Down Expand Up @@ -247,6 +262,26 @@ fn write_response(mut stream: TcpStream, status: &str, content_type: &str, body:
/// Run `git fetch` in `clone` through the helper, with a hard timeout so a
/// regression to the deadlock fails fast instead of hanging the suite.
fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Output) {
let mut last = fetch_with_helper_once(clone, node_url);
for attempt in 1..3u32 {
if last.0 && last.1.status.success() {
return last;
}
let stderr = String::from_utf8_lossy(&last.1.stderr);
let transient = stderr.contains("10053")
|| stderr.contains("connection error")
|| stderr.contains("connection was aborted")
|| stderr.contains("error sending request");
if !transient {
return last;
}
std::thread::sleep(Duration::from_millis(250 * attempt as u64));
last = fetch_with_helper_once(clone, node_url);
}
last
}

fn fetch_with_helper_once(clone: &Path, node_url: &str) -> (bool, std::process::Output) {
let helper_bin = PathBuf::from(env!("CARGO_BIN_EXE_git-remote-gitlawb"));
let helper_dir = helper_bin.parent().unwrap().to_path_buf();
let path_env = match std::env::var_os("PATH") {
Expand Down
41 changes: 10 additions & 31 deletions crates/gitlawb-node/src/api/encrypted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,24 @@ use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
use crate::visibility::{visibility_check, Decision};

/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
/// so access control here is repo readability and decryption is gated by the
/// envelope crypto (only a real recipient can open an envelope).
///
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
/// same as issues/changelogs — so a public-but-quarantined mirror cannot leak
/// its encrypted blob index.
pub async fn list_encrypted_blobs(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
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}/{repo}")));
}
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
Expand All @@ -45,17 +40,9 @@ pub async fn get_encrypted_blob(
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo, oid)): Path<(String, String, String)>,
) -> Result<Vec<u8>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
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}/{repo}/{oid}")));
}
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let cid = state
.db
.encrypted_blob_cid(&record.id, &oid)
Expand All @@ -81,17 +68,9 @@ pub async fn replicate_encrypted_blobs(
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
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}/{repo}")));
}
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
Expand Down
72 changes: 62 additions & 10 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,21 @@ 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). We check object existence via
/// `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
/// 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). Quarantine is fail-closed in two layers:
/// `list_all_repos` drops rows with `quarantined = TRUE`, and a logical-repo
/// fold drops any surviving twin that shares owner+name with a quarantined
/// *canonical* (UUID) row — so a public mirror cannot serve while its
/// canonical twin is quarantined, without letting a quarantined mirror
/// suppress a healthy canonical (`get_repo` prefers canonical). We check object
/// existence via `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 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.
///
/// Scan completeness (F2): the 404 above is returned ONLY when every candidate
/// repo reached a VERDICT — visibility deny, probe-says-absent, walk-gate deny,
Expand Down Expand Up @@ -86,6 +91,17 @@ use crate::visibility::{visibility_check, Decision};
/// case. A stale-public mirror row still serves withheld content (tracked
/// separately, #124).
///
/// Normalize owner DID for quarantine logical-repo keys: matches [`did_matches`]
/// for `did:key:` vs bare-key pairs without scanning every quarantined row per
/// candidate repo.
fn quarantine_owner_key(owner_did: &str) -> String {
match owner_did.strip_prefix("did:key:") {
Some(k) if !k.contains(':') => k.to_string(),
_ if !owner_did.contains(':') => owner_did.to_string(),
_ => owner_did.to_string(),
}
}

/// One `/ipfs` request's walk admission: the global pool permit plus the
/// optional per-source sub-permit, both RAII (#174 U1).
///
Expand Down Expand Up @@ -216,6 +232,36 @@ pub async fn get_by_cid(
)));
}
};
// Logical-repo quarantine fold: when a *canonical* UUID row is quarantined
// but a public mirror twin (`owner/name`) survives `list_all_repos`, slug
// routes 404 via `authorize_repo_read` (prefers canonical) while CID serve
// would otherwise still hit the mirror. Drop candidates that share
// owner+name with a quarantined *canonical* only — a quarantined mirror
// must not suppress a healthy canonical (same preference as get_repo).
let quarantined = match tokio::time::timeout(
request_deadline.saturating_duration_since(std::time::Instant::now()),
state.db.list_quarantined_repos(),
)
.await
{
Ok(Ok(rows)) => rows,
Ok(Err(e)) => return Err(AppError::Internal(e)),
Err(_elapsed) => {
tracing::warn!(
budget_secs = state.config.ipfs_request_budget_secs,
"/ipfs list_quarantined_repos exceeded the request budget \
(GITLAWB_IPFS_REQUEST_BUDGET_SECS); shedding a retryable 503 and freeing the walk permit"
);
return Err(AppError::Overloaded(format!(
"ipfs scan incomplete (budget) for CID {cid_str}; retry shortly"
)));
}
};
let quarantined_canonical_keys: HashSet<(String, String)> = quarantined
.iter()
.filter(|q| !q.id.contains('/'))
.map(|q| (quarantine_owner_key(&q.owner_did), q.name.clone()))
.collect();

// 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
Expand Down Expand Up @@ -320,6 +366,12 @@ pub async fn get_by_cid(
let mut repos_visited: usize = 0;

for repo in &repos {
if quarantined_canonical_keys
.contains(&(quarantine_owner_key(&repo.owner_did), repo.name.clone()))
{
continue;
}

// Repo-level read gate against THIS row's own rules (KTD2a). Deny is a
// VERDICT: this repo would never serve the caller, so skipping it cannot
// hide content from them.
Expand Down
7 changes: 5 additions & 2 deletions crates/gitlawb-node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,16 @@ mod authz_guard {
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
(protect, "protect_branch", "did_matches("),
(protect, "unprotect_branch", "did_matches("),
(visibility, "set_visibility", "authorize_repo_read("),
(visibility, "set_visibility", "require_owner("),
(visibility, "remove_visibility", "authorize_repo_read("),
(visibility, "remove_visibility", "require_owner("),
(visibility, "list_visibility", "authorize_repo_read("),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(visibility, "list_visibility", "require_owner("),
];

// The visibility rows prove require_owner is CALLED; this proves the helper
// itself does DID-safe matching, not a raw/trailing-segment compare.
// Rows above prove each visibility handler calls authorize_repo_read and
// require_owner; this proves the helper itself does DID-safe matching.
assert!(
fn_body(visibility, "require_owner").contains("did_matches("),
"visibility::require_owner must use did_matches for DID-safe owner matching"
Expand Down
38 changes: 32 additions & 6 deletions crates/gitlawb-node/src/api/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ pub async fn create_task(
updated_at: now,
deadline: body.deadline,
};
state.db.create_task(&task).await.map_err(|e| {
let task = state.db.create_task(&task).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
Expand Down Expand Up @@ -175,16 +175,28 @@ pub async fn claim_task(
return Err(forbidden("assignee_did must be the authenticated signer"));
}
let task = state.db.claim_task(&id, &auth.0).await.map_err(|e| {
if e.downcast_ref::<crate::db::TaskReservedForOtherAssignee>()
.is_some()
{
return forbidden("task not claimable: reserved for another assignee");
}
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| auth.0.clone());
let _ = state.task_event_tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did: auth.0,
by_did,
at: Utc::now().to_rfc3339(),
});
Ok(Json(task_to_json(&task)))
Expand Down Expand Up @@ -219,21 +231,28 @@ pub async fn complete_task(
})?;
if !crate::api::did_matches(
&auth.0,
existing.assignee_did.as_deref().unwrap_or_default(),
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
) {
return Err(forbidden("only the task assignee can complete it"));
}
let by_did = auth.0;
let task = state
.db
.finish_task(&id, "completed", body.result.as_deref())
.finish_task(&id, "completed", body.result.as_deref(), &by_did)
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or(by_did);
let _ = state.task_event_tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
Expand Down Expand Up @@ -272,22 +291,29 @@ pub async fn fail_task(
})?;
if !crate::api::did_matches(
&auth.0,
existing.assignee_did.as_deref().unwrap_or_default(),
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
) {
return Err(forbidden("only the task assignee can fail it"));
}
let by_did = auth.0;
let reason = body.reason.unwrap_or_default();
let task = state
.db
.finish_task(&id, "failed", Some(&reason))
.finish_task(&id, "failed", Some(&reason), &by_did)
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or(by_did);
let _ = state.task_event_tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
Expand Down
Loading
Loading