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
15 changes: 13 additions & 2 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2886,10 +2886,21 @@ pub async fn list_refs(
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (_record, _rules) =
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;

let repo_slug = format!("{owner}/{repo}");
// Build the filter from the canonical stored slug, NOT from the raw path
// segments. `branch_cids.repo` is written as
// `{normalize_owner_key(owner_did)}/{name}` (the push path builds the same
// slug before calling `upsert_branch_cid`), and
// `get_repo` resolves both `did:key:zX` and bare `zX` to the same record, so
// a request in the full-DID form authorizes and then matches zero rows,
// returning an empty page with a 200.
let repo_slug = format!(
"{}/{}",
crate::db::normalize_owner_key(&record.owner_did),
record.name
);
let refs = state.db.list_branch_cids(&repo_slug).await?;

Ok(Json(
Expand Down
130 changes: 130 additions & 0 deletions crates/gitlawb-node/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,136 @@ mod tests {
}
}

/// `list_refs` must resolve both owner-DID forms to the SAME refs.
/// `branch_cids.repo` is written as `{normalize_owner_key(owner_did)}/{name}`
/// (`api/repos.rs:2643` -> `:2754`), and `get_repo` resolves `did:key:zX` and
/// bare `zX` to one repo, so both path forms authorize. Reading the table with
/// the raw path segments instead of the canonical slug returns an empty page
/// with a 200, which is a denial rendered as success. The second repo pins the
/// other direction: the filter must not widen to another owner's refs.
#[sqlx::test]
async fn list_refs_resolves_both_owner_did_forms_to_the_canonical_slug(pool: PgPool) {
let owner_did = "did:key:zREFSCANON";
let other_did = "did:key:zREFSOTHER";
let state = test_state(pool).await;

for did in [owner_did, other_did] {
let mut repo = seed_repo(did, "refs-canon");
repo.is_public = true;
// Same repo name under two owners, so the name-derived disk_path
// from the helper would collide on its unique constraint.
repo.disk_path = format!("/tmp/refs-canon-{}", crate::db::normalize_owner_key(did));
state.db.create_repo(&repo).await.expect("seed repo");
// Exactly the slug the production push path writes.
let slug = format!("{}/{}", crate::db::normalize_owner_key(did), "refs-canon");
let cid = format!("bafy{}", crate::db::normalize_owner_key(did));
state
.db
.upsert_branch_cid(
&slug,
"refs/heads/main",
&"1".repeat(64),
&cid,
"did:key:zNODE",
)
.await
.expect("seed branch cid");
}

async fn body_for(state: &crate::state::AppState, owner: &str) -> String {
let router = crate::server::build_router(state.clone());
let resp = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/repos/{owner}/refs-canon/refs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"both owner-DID forms resolve to the same repo, so both must authorize"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8_lossy(&bytes).into_owned()
}

for owner in ["zREFSCANON", "did:key:zREFSCANON"] {
let body = body_for(&state, owner).await;
assert!(
body.contains("bafyzREFSCANON"),
"?owner={owner} must return the ref written under the canonical slug, \
not an empty page with a 200. body was: {body}"
);
assert!(
!body.contains("bafyzREFSOTHER"),
"?owner={owner} must not return another owner's refs. body was: {body}"
);
}
}

/// The read gate on `list_refs`, driven in the deny direction. Without this
/// the slug regression above proves only that the query key is right: every
/// repo it seeds is public, so deleting `authorize_repo_read` outright leaves
/// it green. A private repo must answer an anonymous caller with the same 404
/// a missing repo gets, and must not put a CID in the body on the way out.
#[sqlx::test]
async fn list_refs_denies_an_anonymous_caller_on_a_private_repo(pool: PgPool) {
let owner_did = "did:key:zREFSPRIV";
let state = test_state(pool).await;

let mut repo = seed_repo(owner_did, "refs-private");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
state
.db
.upsert_branch_cid(
&format!(
"{}/{}",
crate::db::normalize_owner_key(owner_did),
"refs-private"
),
"refs/heads/main",
&"1".repeat(64),
"bafyzREFSPRIVSECRET",
"did:key:zNODE",
)
.await
.expect("seed branch cid");

for owner in ["zREFSPRIV", "did:key:zREFSPRIV"] {
let router = crate::server::build_router(state.clone());
let resp = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/api/v1/repos/{owner}/refs-private/refs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"anonymous read of a private repo must 404, not disclose it (owner form {owner})"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("bafyzREFSPRIVSECRET"),
"the deny body must not carry a pinned CID. body was: {body}"
);
}
}

/// Proves the harness end to end: a migrated DB, a seeded repo, and the
/// owner gate on an ALREADY-gated endpoint (`PUT /visibility`, gated by
/// `require_owner`). Non-owner is rejected; owner succeeds. Mounts the
Expand Down
Loading