@@ -34,17 +34,20 @@ use std::path::Path;
3434/// set.
3535fn blob_paths ( repo_path : & Path ) -> Result < Vec < ( String , String ) > > {
3636 // Fail closed on any ref that does not resolve to a commit (a ref pointing
37- // directly at a blob or tree, or an annotated tag of one). `git rev-list --all`
38- // silently *skips* such refs, but `git upload-pack` (serve) and the whole-repo
39- // pin fallback (`git cat-file --batch-all-objects`) still expose their target
40- // object, so a tolerant walk would under-withhold.
41- // Refuse rather than leak — this is the same guarantee the per-ref `ls-tree`
42- // walk gave before, which errored on a non-tree-ish ref.
37+ // directly at a blob or tree, or an annotated tag — even a nested one — of
38+ // such an object). `git rev-list --all` silently *skips* such refs, but
39+ // `git upload-pack` (serve) and the whole-repo pin fallback
40+ // (`git cat-file --batch-all-objects`) still expose their target object, so
41+ // a tolerant walk would under-withhold. Refuse rather than leak — this is
42+ // the same guarantee the per-ref `ls-tree` walk gave before.
43+ //
44+ // We list refnames (which never contain whitespace or newlines) and peel
45+ // each one fully with `<ref>^{}` through a single `git cat-file
46+ // --batch-check`. Full peeling is why this is not `for-each-ref
47+ // %(*objecttype)`, which dereferences only one tag level and so misclassifies
48+ // a tag-of-a-tag-of-a-commit as a non-commit.
4349 let refs = std:: process:: Command :: new ( "git" )
44- . args ( [
45- "for-each-ref" ,
46- "--format=%(objecttype) %(*objecttype) %(refname)" ,
47- ] )
50+ . args ( [ "for-each-ref" , "--format=%(refname)" ] )
4851 . current_dir ( repo_path)
4952 . output ( )
5053 . context ( "git for-each-ref failed" ) ?;
@@ -54,26 +57,72 @@ fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
5457 String :: from_utf8_lossy( & refs. stderr)
5558 ) ;
5659 }
57- for line in String :: from_utf8_lossy ( & refs. stdout ) . lines ( ) {
58- // "<objecttype> [<peeled objecttype>] <refname>"; an annotated tag carries
59- // the peeled type, a lightweight ref does not. Refnames cannot contain
60- // whitespace, so split_whitespace is unambiguous.
61- let toks: Vec < & str > = line. split_whitespace ( ) . collect ( ) ;
62- let Some ( & objtype) = toks. first ( ) else {
63- continue ;
64- } ;
65- let effective = if objtype == "tag" && toks. len ( ) >= 3 {
66- toks[ 1 ]
67- } else {
68- objtype
60+ let refs_stdout = String :: from_utf8_lossy ( & refs. stdout ) ;
61+ let refnames: Vec < & str > = refs_stdout
62+ . lines ( )
63+ . map ( str:: trim)
64+ . filter ( |l| !l. is_empty ( ) )
65+ . collect ( ) ;
66+ if !refnames. is_empty ( ) {
67+ // One `<refname>^{}` peel query per line; `cat-file` emits one output
68+ // line per input line, in order.
69+ let queries = refnames
70+ . iter ( )
71+ . map ( |r| format ! ( "{r}^{{}}" ) )
72+ . collect :: < Vec < _ > > ( )
73+ . join ( "\n " ) ;
74+ use std:: io:: Write ;
75+ let mut child = std:: process:: Command :: new ( "git" )
76+ . args ( [ "cat-file" , "--batch-check=%(objecttype)" ] )
77+ . current_dir ( repo_path)
78+ . stdin ( std:: process:: Stdio :: piped ( ) )
79+ . stdout ( std:: process:: Stdio :: piped ( ) )
80+ . stderr ( std:: process:: Stdio :: piped ( ) )
81+ . spawn ( )
82+ . context ( "failed to spawn git cat-file" ) ?;
83+ // Always reap the child even if the stdin write fails, so a write error
84+ // can't drop the Child unwaited and leak a zombie (#53).
85+ let write_result = match child. stdin . take ( ) {
86+ Some ( mut stdin) => stdin. write_all ( queries. as_bytes ( ) ) ,
87+ None => Err ( std:: io:: Error :: new (
88+ std:: io:: ErrorKind :: BrokenPipe ,
89+ "git cat-file stdin unavailable" ,
90+ ) ) ,
6991 } ;
70- if effective != "commit" {
71- let refname = toks. last ( ) . copied ( ) . unwrap_or ( "<unknown>" ) ;
92+ let peel = child. wait_with_output ( ) . context ( "git cat-file failed" ) ?;
93+ write_result. context ( "failed to write to git cat-file stdin" ) ?;
94+ if !peel. status . success ( ) {
7295 anyhow:: bail!(
73- "ref {refname} resolves to a {effective}, not a commit; \
74- refusing to produce a partial (under-withheld) set"
96+ "git cat-file --batch-check failed: {}" ,
97+ String :: from_utf8_lossy ( & peel . stderr )
7598 ) ;
7699 }
100+ let peel_stdout = String :: from_utf8_lossy ( & peel. stdout ) ;
101+ let types: Vec < & str > = peel_stdout. lines ( ) . map ( str:: trim) . collect ( ) ;
102+ // A short read means at least one ref went unclassified — fail closed.
103+ if types. len ( ) != refnames. len ( ) {
104+ anyhow:: bail!(
105+ "git cat-file returned {} lines for {} refs; \
106+ refusing to produce a partial (under-withheld) set",
107+ types. len( ) ,
108+ refnames. len( )
109+ ) ;
110+ }
111+ for ( refname, kind) in refnames. iter ( ) . zip ( types. iter ( ) ) {
112+ // An unresolvable query prints `<input> missing`.
113+ if kind. ends_with ( "missing" ) {
114+ anyhow:: bail!(
115+ "ref {refname} does not resolve to an object; \
116+ refusing to produce a partial (under-withheld) set"
117+ ) ;
118+ }
119+ if * kind != "commit" {
120+ anyhow:: bail!(
121+ "ref {refname} resolves to a {kind}, not a commit; \
122+ refusing to produce a partial (under-withheld) set"
123+ ) ;
124+ }
125+ }
77126 }
78127
79128 // Enumerate every reachable commit, not just ref tips. `--all` walks all refs;
@@ -146,19 +195,37 @@ pub fn withheld_blob_oids(
146195 owner_did : & str ,
147196 caller : Option < & str > ,
148197) -> Result < HashSet < String > > {
198+ let pairs = blob_paths ( repo_path) ?;
199+ Ok ( withheld_from_pairs (
200+ & pairs, rules, is_public, owner_did, caller,
201+ ) )
202+ }
203+
204+ /// Withheld set from an already-computed (oid, "/path") listing: a blob is
205+ /// withheld only when visibility denies the caller at *every* path it appears
206+ /// at. Split out so a caller that already walked `blob_paths` (e.g.
207+ /// `withheld_blob_recipients`) reuses the listing instead of walking history
208+ /// again.
209+ fn withheld_from_pairs (
210+ pairs : & [ ( String , String ) ] ,
211+ rules : & [ VisibilityRule ] ,
212+ is_public : bool ,
213+ owner_did : & str ,
214+ caller : Option < & str > ,
215+ ) -> HashSet < String > {
149216 let mut denied: HashSet < String > = HashSet :: new ( ) ;
150217 let mut allowed: HashSet < String > = HashSet :: new ( ) ;
151- for ( oid, path) in blob_paths ( repo_path ) ? {
152- match visibility_check ( rules, is_public, owner_did, caller, & path) {
218+ for ( oid, path) in pairs {
219+ match visibility_check ( rules, is_public, owner_did, caller, path) {
153220 Decision :: Deny => {
154- denied. insert ( oid) ;
221+ denied. insert ( oid. clone ( ) ) ;
155222 }
156223 Decision :: Allow => {
157- allowed. insert ( oid) ;
224+ allowed. insert ( oid. clone ( ) ) ;
158225 }
159226 }
160227 }
161- Ok ( denied. difference ( & allowed) . cloned ( ) . collect ( ) )
228+ denied. difference ( & allowed) . cloned ( ) . collect ( )
162229}
163230
164231/// Objects that may replicate to the public: everything not in `withheld`.
@@ -181,7 +248,9 @@ pub fn withheld_blob_recipients(
181248 is_public : bool ,
182249 owner_did : & str ,
183250) -> Result < HashMap < String , BTreeSet < String > > > {
184- let withheld = withheld_blob_oids ( repo_path, rules, is_public, owner_did, None ) ?;
251+ // One history walk feeds both the withheld set and the recipient mapping.
252+ let pairs = blob_paths ( repo_path) ?;
253+ let withheld = withheld_from_pairs ( & pairs, rules, is_public, owner_did, None ) ;
185254 if withheld. is_empty ( ) {
186255 return Ok ( HashMap :: new ( ) ) ;
187256 }
@@ -192,14 +261,14 @@ pub fn withheld_blob_recipients(
192261 }
193262 }
194263 let mut out: HashMap < String , BTreeSet < String > > = HashMap :: new ( ) ;
195- for ( oid, path) in blob_paths ( repo_path ) ? {
196- if !withheld. contains ( & oid) {
264+ for ( oid, path) in & pairs {
265+ if !withheld. contains ( oid) {
197266 continue ;
198267 }
199- let entry = out. entry ( oid) . or_default ( ) ;
268+ let entry = out. entry ( oid. clone ( ) ) . or_default ( ) ;
200269 entry. insert ( owner_did. to_string ( ) ) ;
201270 for did in & candidates {
202- if visibility_check ( rules, is_public, owner_did, Some ( did) , & path) == Decision :: Allow {
271+ if visibility_check ( rules, is_public, owner_did, Some ( did) , path) == Decision :: Allow {
203272 entry. insert ( did. clone ( ) ) ;
204273 }
205274 }
@@ -542,4 +611,123 @@ mod tests {
542611 "a ref that cannot be traversed must fail closed (Err)"
543612 ) ;
544613 }
614+
615+ #[ test]
616+ fn annotated_tag_to_commit_does_not_fail_closed ( ) {
617+ let ( _td, bare, secret_oid, _public) = fixture ( ) ;
618+ // An annotated tag — even one nested over another annotated tag —
619+ // ultimately resolves to a commit, so it must NOT trip the non-commit
620+ // fail-closed guard. A one-level `%(*objecttype)` peel would misread the
621+ // nested tag as a non-commit and refuse the whole walk.
622+ let run = |args : & [ & str ] | {
623+ assert ! (
624+ Command :: new( "git" )
625+ . args( args)
626+ . current_dir( & bare)
627+ . status( )
628+ . unwrap( )
629+ . success( ) ,
630+ "git {args:?} failed"
631+ ) ;
632+ } ;
633+ run ( & [ "config" , "user.email" , "t@t" ] ) ;
634+ run ( & [ "config" , "user.name" , "t" ] ) ;
635+ run ( & [ "tag" , "-a" , "-m" , "inner" , "v1" , "HEAD" ] ) ;
636+ run ( & [ "tag" , "-a" , "-m" , "outer" , "v2" , "v1" ] ) ;
637+
638+ let rules = [ rule ( "/secret/**" , & [ ] ) ] ;
639+ let withheld = withheld_blob_oids ( & bare, & rules, true , OWNER , None ) . unwrap ( ) ;
640+ assert ! (
641+ withheld. contains( & secret_oid) ,
642+ "secret blob must still be withheld with annotated and nested tags present"
643+ ) ;
644+ }
645+
646+ #[ test]
647+ fn fails_closed_on_annotated_tag_of_a_blob ( ) {
648+ let ( _td, bare, secret, _public) = fixture ( ) ;
649+ // An annotated tag whose target peels to a blob is not a commit; the
650+ // guard must fail closed rather than skip the ref.
651+ let run = |args : & [ & str ] | {
652+ assert ! (
653+ Command :: new( "git" )
654+ . args( args)
655+ . current_dir( & bare)
656+ . status( )
657+ . unwrap( )
658+ . success( ) ,
659+ "git {args:?} failed"
660+ ) ;
661+ } ;
662+ run ( & [ "config" , "user.email" , "t@t" ] ) ;
663+ run ( & [ "config" , "user.name" , "t" ] ) ;
664+ run ( & [ "tag" , "-a" , "-m" , "blobtag" , "blobtag" , & secret] ) ;
665+
666+ let rules = [ rule ( "/secret/**" , & [ ] ) ] ;
667+ let result = withheld_blob_oids ( & bare, & rules, true , OWNER , None ) ;
668+ assert ! (
669+ result. is_err( ) ,
670+ "an annotated tag of a blob must fail closed (Err)"
671+ ) ;
672+ }
673+
674+ #[ test]
675+ fn same_blob_at_allowed_and_denied_path_is_not_withheld ( ) {
676+ // Identical content at a denied and an allowed path shares one blob OID.
677+ // A blob reachable through ANY allowed path must not be withheld.
678+ let td = TempDir :: new ( ) . unwrap ( ) ;
679+ let work = td. path ( ) . join ( "work" ) ;
680+ let bare = td. path ( ) . join ( "bare.git" ) ;
681+ let run = |args : & [ & str ] , dir : & Path | {
682+ assert ! (
683+ Command :: new( "git" )
684+ . args( args)
685+ . current_dir( dir)
686+ . status( )
687+ . unwrap( )
688+ . success( ) ,
689+ "git {args:?} failed"
690+ ) ;
691+ } ;
692+ std:: fs:: create_dir_all ( work. join ( "secret" ) ) . unwrap ( ) ;
693+ std:: fs:: create_dir_all ( work. join ( "public" ) ) . unwrap ( ) ;
694+ std:: fs:: write ( work. join ( "secret/shared.txt" ) , b"SHARED\n " ) . unwrap ( ) ;
695+ std:: fs:: write ( work. join ( "public/shared.txt" ) , b"SHARED\n " ) . unwrap ( ) ;
696+ run ( & [ "init" , "-q" ] , & work) ;
697+ run ( & [ "config" , "user.email" , "t@t" ] , & work) ;
698+ run ( & [ "config" , "user.name" , "t" ] , & work) ;
699+ run ( & [ "add" , "." ] , & work) ;
700+ run ( & [ "commit" , "-qm" , "init" ] , & work) ;
701+ let oid = |path : & str | {
702+ let out = Command :: new ( "git" )
703+ . args ( [ "rev-parse" , & format ! ( "HEAD:{path}" ) ] )
704+ . current_dir ( & work)
705+ . output ( )
706+ . unwrap ( ) ;
707+ String :: from_utf8_lossy ( & out. stdout ) . trim ( ) . to_string ( )
708+ } ;
709+ let shared_oid = oid ( "secret/shared.txt" ) ;
710+ assert_eq ! (
711+ shared_oid,
712+ oid( "public/shared.txt" ) ,
713+ "precondition: identical content shares one blob OID"
714+ ) ;
715+ run (
716+ & [
717+ "clone" ,
718+ "-q" ,
719+ "--bare" ,
720+ work. to_str ( ) . unwrap ( ) ,
721+ bare. to_str ( ) . unwrap ( ) ,
722+ ] ,
723+ td. path ( ) ,
724+ ) ;
725+
726+ let rules = [ rule ( "/secret/**" , & [ ] ) ] ;
727+ let withheld = withheld_blob_oids ( & bare, & rules, true , OWNER , None ) . unwrap ( ) ;
728+ assert ! (
729+ !withheld. contains( & shared_oid) ,
730+ "a blob also reachable via an allowed path must not be withheld"
731+ ) ;
732+ }
545733}
0 commit comments