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
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