Skip to content

Commit 985e2af

Browse files
author
t
committed
perf(node,visibility): single-pass tree-set walk; tighten #135 test + docs
Review follow-up on the #135 tree gate: - tree_paths computes reachable_commits ONCE and threads it to both the ls-tree walk and the root-tree pass (was computing it twice), and resolves all root trees in one `git log --no-walk --format=%T` pass instead of a per-commit `git rev-parse ^{tree}` fan-out — a tree-set walk now costs the same subprocess order as the blob walk. - Extract the shared allowed-set inner loop (allowed_set_from_pairs). - The deny test's raw-byte checks on the 404 body were vacuous (the 404 body is an opaque error string); move the structure witness to the reader's 200 response, where the tree body genuinely carries b.txt + the raw child oid. Status stays the load-bearing deny check (mutation-recertified RED). - Fix the module doc that still claimed trees are never withheld.
1 parent 9607fad commit 985e2af

2 files changed

Lines changed: 107 additions & 70 deletions

File tree

‎crates/gitlawb-node/src/git/visibility_pack.rs‎

Lines changed: 93 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
//! Resolve which blob OIDs must be withheld from a caller because every path
2-
//! at which the blob appears is denied by the repo's visibility rules. Trees
3-
//! and commits are never withheld (mode B keeps SHAs intact); only blob
4-
//! content is held back.
1+
//! Resolve which git objects must be withheld from a caller under the repo's
2+
//! visibility rules. A blob is withheld when every path at which it appears is
3+
//! denied. Since #135 a tree is likewise withheld on the CID serve surface
4+
//! (`GET /ipfs/{cid}`) when every path at which it appears is denied, via the
5+
//! parallel `allowed_tree_set_for_caller` — so a withheld subtree's structure does
6+
//! not leak by content address. Commits and tags are never withheld here, and
7+
//! trees still replicate freely on the pin path (the withheld-tree pin gap is
8+
//! tracked as #172). Mode B keeps all object SHAs intact; only readability is gated.
59
610
use crate::db::VisibilityRule;
711
use crate::git::store;
@@ -129,8 +133,11 @@ fn assert_all_refs_are_commits(repo_path: &Path) -> Result<()> {
129133
/// where HEAD reaches commits no ref does). Empty on an unborn branch. The single
130134
/// commit-enumeration seam shared by `object_paths` and the root-tree derivation, so
131135
/// the tree walk and the root-tree inclusion can never disagree on which commits are
132-
/// reachable. Fails closed on a rev-list error.
136+
/// reachable — callers compute the set ONCE and pass it to both. Runs the fail-closed
137+
/// ref guard first (a ref pointing at a non-commit aborts), then enumerates. Fails
138+
/// closed on a rev-list error.
133139
fn reachable_commits(repo_path: &Path) -> Result<Vec<String>> {
140+
assert_all_refs_are_commits(repo_path)?;
134141
let head = store::head_commit(repo_path).context("resolve HEAD failed")?;
135142
let mut rev_args = vec!["rev-list", "--all"];
136143
if head.is_some() {
@@ -173,16 +180,15 @@ fn reachable_commits(repo_path: &Path) -> Result<Vec<String>> {
173180
/// and paths carry a leading "/" to match the glob form of visibility rules
174181
/// ("/secret/**").
175182
///
176-
/// Fails closed: if commit enumeration or any tree walk fails — or a path is not
177-
/// valid UTF-8 — it returns an error so the caller aborts the serve/pin rather than
178-
/// producing a partial (under-withheld) set.
179-
fn object_paths(repo_path: &Path) -> Result<HashSet<(String, String, String)>> {
180-
assert_all_refs_are_commits(repo_path)?;
181-
183+
/// Fails closed: if any tree walk fails — or a path is not valid UTF-8 — it returns
184+
/// an error so the caller aborts the serve/pin rather than producing a partial
185+
/// (under-withheld) set. Takes the `commits` from [`reachable_commits`] (which runs
186+
/// the ref guard) so blob and tree walks share one commit enumeration.
187+
fn object_paths(repo_path: &Path, commits: &[String]) -> Result<HashSet<(String, String, String)>> {
182188
let mut out: HashSet<(String, String, String)> = HashSet::new();
183-
for commit in reachable_commits(repo_path)? {
189+
for commit in commits {
184190
let listing = std::process::Command::new("git")
185-
.args(["ls-tree", "-rzt", &commit])
191+
.args(["ls-tree", "-rzt", commit])
186192
.current_dir(repo_path)
187193
.output()
188194
.context("git ls-tree -rzt failed")?;
@@ -231,7 +237,8 @@ fn object_paths(repo_path: &Path) -> Result<HashSet<(String, String, String)>> {
231237
/// (`withheld_blob_oids`, `replicable_blob_set`, `allowed_blob_set_for_caller`) is
232238
/// unaffected. See [`object_paths`] for the walk and fail-closed semantics.
233239
fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
234-
Ok(object_paths(repo_path)?
240+
let commits = reachable_commits(repo_path)?;
241+
Ok(object_paths(repo_path, &commits)?
235242
.into_iter()
236243
.filter(|(_, _, kind)| kind == "blob")
237244
.map(|(oid, path, _)| (oid, path))
@@ -340,54 +347,88 @@ pub fn replicable_blob_set(
340347
/// elsewhere (its content is readable to this caller elsewhere). This set is
341348
/// blobs only; trees have the parallel `allowed_tree_set_for_caller` (#135), and
342349
/// commits/tags are served as root-level structure once the `"/"` gate passes.
350+
/// The OIDs from a `(oid, "/path")` listing that visibility ALLOWS `caller` at some
351+
/// path — the shared inner loop of the blob and tree allowed-sets. An oid reachable
352+
/// at an allowed path is kept even when also reachable at a denied one.
353+
fn allowed_set_from_pairs<'a>(
354+
pairs: impl IntoIterator<Item = &'a (String, String)>,
355+
rules: &[VisibilityRule],
356+
is_public: bool,
357+
owner_did: &str,
358+
caller: Option<&str>,
359+
) -> HashSet<String> {
360+
pairs
361+
.into_iter()
362+
.filter(|(_, path)| {
363+
visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow
364+
})
365+
.map(|(oid, _)| oid.clone())
366+
.collect()
367+
}
368+
343369
pub fn allowed_blob_set_for_caller(
344370
repo_path: &Path,
345371
rules: &[VisibilityRule],
346372
is_public: bool,
347373
owner_did: &str,
348374
caller: Option<&str>,
349375
) -> Result<HashSet<String>> {
350-
let pairs = blob_paths(repo_path)?;
351-
let mut allowed = HashSet::new();
352-
for (oid, path) in &pairs {
353-
if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow {
354-
allowed.insert(oid.clone());
376+
Ok(allowed_set_from_pairs(
377+
&blob_paths(repo_path)?,
378+
rules,
379+
is_public,
380+
owner_did,
381+
caller,
382+
))
383+
}
384+
385+
/// Root tree oid of every reachable commit, at "/". `ls-tree` never emits a commit's
386+
/// own root tree (it lists entries *under* a tree), so it is added explicitly here.
387+
/// Resolved in ONE `git log --no-walk --format=%T` pass over the shared commit set —
388+
/// not a per-commit `rev-parse` — so a tree-set walk costs the same subprocess order
389+
/// as the blob walk. A commit whose root tree git cannot resolve fails the pass
390+
/// (bail), failing closed.
391+
fn root_tree_pairs(repo_path: &Path, commits: &[String]) -> Result<HashSet<(String, String)>> {
392+
if commits.is_empty() {
393+
return Ok(HashSet::new());
394+
}
395+
let mut args: Vec<&str> = vec!["log", "--no-walk=unsorted", "--format=%T"];
396+
args.extend(commits.iter().map(String::as_str));
397+
let out = std::process::Command::new("git")
398+
.args(&args)
399+
.current_dir(repo_path)
400+
.output()
401+
.context("git log --no-walk --format=%T failed")?;
402+
if !out.status.success() {
403+
anyhow::bail!(
404+
"git log --no-walk --format=%T failed: {}",
405+
String::from_utf8_lossy(&out.stderr)
406+
);
407+
}
408+
let mut set = HashSet::new();
409+
for line in String::from_utf8_lossy(&out.stdout).lines() {
410+
let oid = line.trim();
411+
if !oid.is_empty() {
412+
set.insert((oid.to_string(), "/".to_string()));
355413
}
356414
}
357-
Ok(allowed)
415+
Ok(set)
358416
}
359417

360418
/// Every `(tree_oid, "/path")` pair reachable in `repo_path`: the `kind == "tree"`
361419
/// slice of [`object_paths`] (subtree trees at their directory paths) PLUS every
362-
/// reachable commit's root tree at "/". `ls-tree` never emits a commit's own root
363-
/// tree (it lists entries *under* a tree), so the root is added explicitly via
364-
/// `<commit>^{tree}` over the same [`reachable_commits`] set — without it the root
365-
/// tree would be wrongly denied. The tree analog of [`blob_paths`].
420+
/// reachable commit's root tree at "/" (see [`root_tree_pairs`]). Computes the
421+
/// reachable-commit set ONCE and drives both the ls-tree walk and the root-tree pass
422+
/// from it, so the two cannot diverge and neither re-enumerates. The tree analog of
423+
/// [`blob_paths`].
366424
fn tree_paths(repo_path: &Path) -> Result<HashSet<(String, String)>> {
367-
let mut out: HashSet<(String, String)> = object_paths(repo_path)?
425+
let commits = reachable_commits(repo_path)?;
426+
let mut out: HashSet<(String, String)> = object_paths(repo_path, &commits)?
368427
.into_iter()
369428
.filter(|(_, _, kind)| kind == "tree")
370429
.map(|(oid, path, _)| (oid, path))
371430
.collect();
372-
// Root tree of each reachable commit, at "/". Derived from the same commit set
373-
// object_paths walks, so reachability cannot diverge between the two.
374-
for commit in reachable_commits(repo_path)? {
375-
let resolved = std::process::Command::new("git")
376-
.args(["rev-parse", &format!("{commit}^{{tree}}")])
377-
.current_dir(repo_path)
378-
.output()
379-
.context("git rev-parse <commit>^{tree} failed")?;
380-
if !resolved.status.success() {
381-
anyhow::bail!(
382-
"git rev-parse {commit}^{{tree}} failed: {}",
383-
String::from_utf8_lossy(&resolved.stderr)
384-
);
385-
}
386-
let oid = String::from_utf8_lossy(&resolved.stdout).trim().to_string();
387-
if !oid.is_empty() {
388-
out.insert((oid, "/".to_string()));
389-
}
390-
}
431+
out.extend(root_tree_pairs(repo_path, &commits)?);
391432
Ok(out)
392433
}
393434

@@ -406,14 +447,13 @@ pub fn allowed_tree_set_for_caller(
406447
owner_did: &str,
407448
caller: Option<&str>,
408449
) -> Result<HashSet<String>> {
409-
let pairs = tree_paths(repo_path)?;
410-
let mut allowed = HashSet::new();
411-
for (oid, path) in &pairs {
412-
if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow {
413-
allowed.insert(oid.clone());
414-
}
415-
}
416-
Ok(allowed)
450+
Ok(allowed_set_from_pairs(
451+
&tree_paths(repo_path)?,
452+
rules,
453+
is_public,
454+
owner_did,
455+
caller,
456+
))
417457
}
418458

419459
/// Objects safe to replicate, failing closed on blobs (#99). A candidate
@@ -764,7 +804,7 @@ mod tests {
764804
#[test]
765805
fn object_paths_emits_trees_and_blob_paths_is_the_blob_slice() {
766806
let (_td, bare, secret_oid, public_oid) = fixture();
767-
let objs = object_paths(&bare).unwrap();
807+
let objs = object_paths(&bare, &reachable_commits(&bare).unwrap()).unwrap();
768808

769809
// Blob records survive the `-rzt` change, at their paths (unchanged).
770810
assert!(objs.contains(&(secret_oid.clone(), "/secret/b.txt".into(), "blob".into())));

‎crates/gitlawb-node/src/test_support.rs‎

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,10 +2061,11 @@ mod tests {
20612061
assert_eq!(st, StatusCode::OK, "listed reader reads the blob");
20622062
assert!(body.contains("TOP SECRET"));
20632063

2064-
// #135: anon tree CID under withheld /secret → 404; structure must NOT leak.
2065-
// The tree body lists `b.txt` -> secret_oid (32 RAW bytes), so witness the
2066-
// leak on the raw body, never the lossy-decoded string (hex never appears).
2067-
let (st, body) = cid_bytes(
2064+
// #135: anon tree CID under withheld /secret → 404. The 404 body is an opaque
2065+
// error string (never the object), so status is the load-bearing deny check;
2066+
// the real leak witness is the CONTRAST with the reader below, who DOES get a
2067+
// 200 carrying the child structure that anon is denied.
2068+
let (st, _) = cid_parts(
20682069
cid_router(&state)
20692070
.oneshot(cid_anon(&tree_cid))
20702071
.await
@@ -2076,18 +2077,14 @@ mod tests {
20762077
StatusCode::NOT_FOUND,
20772078
"withheld subtree tree must not be served to anon (#135)"
20782079
);
2079-
assert!(
2080-
!bytes_contain(&body, b"b.txt"),
2081-
"child filename must not leak in the 404 body"
2082-
);
2083-
let secret_raw = hex::decode(&fx.secret_oid).expect("hex oid");
2084-
assert!(
2085-
!bytes_contain(&body, &secret_raw),
2086-
"child oid (raw bytes) must not leak in the 404 body"
2087-
);
20882080

2089-
// Over-denial guards — the tree gate must NOT break legitimate reads.
2090-
// Listed reader (signed) still sees the withheld subtree's tree structure.
2081+
// Over-denial guard + positive leak witness: the listed reader (signed) DOES
2082+
// read the withheld subtree's tree, and its body carries the exact child
2083+
// structure anon was denied — the child filename plus the child oid as the 32
2084+
// RAW bytes a git tree stores (witnessed on raw bytes, since cid_parts's lossy
2085+
// decode would mangle them). This proves b.txt / secret_raw are the real leak
2086+
// markers and that the anon 404 above actually withheld them.
2087+
let secret_raw = hex::decode(&fx.secret_oid).expect("hex oid");
20912088
let (st, body) = cid_bytes(
20922089
cid_router(&state)
20932090
.oneshot(cid_signed(&reader, &tree_cid))
@@ -2101,8 +2098,8 @@ mod tests {
21012098
"listed reader reads the withheld subtree tree"
21022099
);
21032100
assert!(
2104-
bytes_contain(&body, b"b.txt"),
2105-
"reader's tree body contains the child structure"
2101+
bytes_contain(&body, b"b.txt") && bytes_contain(&body, &secret_raw),
2102+
"reader's tree body carries the child filename and raw child oid"
21062103
);
21072104

21082105
// Root tree (path "/") stays served to anon who passes the "/" gate.

0 commit comments

Comments
 (0)