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
610use crate :: db:: VisibilityRule ;
711use 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.
133139fn 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.
233239fn 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+
343369pub 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`].
366424fn 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( ) ) ) ) ;
0 commit comments