Skip to content
42 changes: 42 additions & 0 deletions crates/gitlawb-node/src/api/visibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,48 @@ pub async fn list_visibility(
})))
}

/// GET /api/v1/repos/{owner}/{repo}/withheld-paths
///
/// Returns the path globs the (optionally authenticated) caller is denied
/// (`withheld`) plus any more-specific globs that are allowed underneath a
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
/// denied subtrees while re-including the allowed nested paths. Unlike
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
pub async fn withheld_paths(
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 rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());

// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
// matching the git read endpoints, so this never discloses a private repo's
// existence or its path layout to an unauthorized caller.
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
== crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}

let withheld =
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
let reinclude =
crate::visibility::reincluded_globs(&rules, record.is_public, &record.owner_did, caller);

Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"withheld": withheld,
"reinclude": reinclude,
})))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::validate_path_glob;
Expand Down
4 changes: 4 additions & 0 deletions crates/gitlawb-node/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ pub fn build_router(state: AppState) -> Router {
"/{owner}/{repo}/git-upload-pack",
post(repos::git_upload_pack),
)
.route(
"/api/v1/repos/{owner}/{repo}/withheld-paths",
axum::routing::get(visibility::withheld_paths),
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::optional_signature));
Expand Down
109 changes: 109 additions & 0 deletions crates/gitlawb-node/src/visibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,78 @@ pub fn visibility_check(
}
}

/// The subtree path globs that `caller` (None = anonymous) may NOT read, given
/// the repo's rules. Whole-repo ("/") rules are excluded: a denied whole-repo
/// read is handled by the 404 gate before a clone ever starts. Each remaining
/// rule is reported when `visibility_check` denies the caller at the glob's
/// representative path. Used by the clean-clone client to sparse-exclude the
/// private paths from checkout.
pub fn withheld_globs(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Vec<String> {
rules
.iter()
.filter(|r| r.path_glob != "/")
.filter(|r| {
let probe = glob_prefix(&r.path_glob);
visibility_check(rules, is_public, owner_did, caller, probe) == Decision::Deny
})
.map(|r| r.path_glob.clone())
.collect()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// The allowed globs that sit strictly underneath a denied glob. A clean-clone
/// client sparse-excludes everything in `withheld_globs`, which would also hide
/// these nested allowed paths; re-including them restores the caller's access.
/// Example: with `/secret/**` denied and `/secret/public/**` allowed for the
/// same caller, `/secret/public/**` is returned here so the client re-includes
/// it after excluding `/secret/`.
pub fn reincluded_globs(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Vec<String> {
let denied: Vec<&str> = rules
.iter()
.filter(|r| r.path_glob != "/")
.filter(|r| {
visibility_check(
rules,
is_public,
owner_did,
caller,
glob_prefix(&r.path_glob),
) == Decision::Deny
})
.map(|r| glob_prefix(&r.path_glob))
.collect();

rules
.iter()
.filter(|r| r.path_glob != "/")
.filter(|r| {
visibility_check(
rules,
is_public,
owner_did,
caller,
glob_prefix(&r.path_glob),
) == Decision::Allow
})
.filter(|r| {
let p = glob_prefix(&r.path_glob);
denied
.iter()
.any(|d| *d != p && p.starts_with(&format!("{d}/")))
})
.map(|r| r.path_glob.clone())
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -116,6 +188,43 @@ mod tests {

const OWNER: &str = "did:key:z6MkOwner";

#[test]
fn withheld_globs_lists_only_denied_subtrees() {
let rules = [
rule("/secret/**", VisibilityMode::B, &["did:key:z6MkFriend"]),
rule("/docs/**", VisibilityMode::B, &["did:key:z6MkStranger"]),
];
// Stranger is denied /secret but allowed /docs.
let mut got = withheld_globs(&rules, true, OWNER, Some("did:key:z6MkStranger"));
got.sort();
assert_eq!(got, vec!["/secret/**".to_string()]);
// Owner is denied nothing.
assert!(withheld_globs(&rules, true, OWNER, Some(OWNER)).is_empty());
// Anonymous is denied both.
let mut anon = withheld_globs(&rules, true, OWNER, None);
anon.sort();
assert_eq!(anon, vec!["/docs/**".to_string(), "/secret/**".to_string()]);
}

#[test]
fn reincluded_globs_restores_allowed_nested_path() {
let rules = [
rule("/secret/**", VisibilityMode::B, &["did:key:z6MkFriend"]),
rule(
"/secret/public/**",
VisibilityMode::B,
&["did:key:z6MkFriend", "did:key:z6MkStranger"],
),
];
// Stranger is denied /secret/** but allowed the nested /secret/public/**.
let withheld = withheld_globs(&rules, true, OWNER, Some("did:key:z6MkStranger"));
assert_eq!(withheld, vec!["/secret/**".to_string()]);
let reinc = reincluded_globs(&rules, true, OWNER, Some("did:key:z6MkStranger"));
assert_eq!(reinc, vec!["/secret/public/**".to_string()]);
// Owner is denied nothing, so there is nothing to re-include.
assert!(reincluded_globs(&rules, true, OWNER, Some(OWNER)).is_empty());
}

#[test]
fn no_rules_public_allows_anonymous() {
assert_eq!(
Expand Down
Loading
Loading