@@ -10,42 +10,18 @@ use anyhow::{Context, Result};
1010use std:: collections:: { BTreeSet , HashMap , HashSet } ;
1111use std:: path:: Path ;
1212
13- /// List every (blob_oid, "/repo/relative/path") pair reachable from any commit in
14- /// `repo_path` — every ref *and* every historical commit those refs reach, not just
15- /// the ref tips. `git upload-pack` (serve) and the whole-repo pin fallback
16- /// (`git cat-file --batch-all-objects`) expose the full reachable object graph,
17- /// including a blob that only ever existed
18- /// in an older commit (a since-deleted file, a rotated secret whose previous version
19- /// is still in history). Classifying only ref-tip trees would leave those blobs
20- /// unwithheld while pin/serve still hand them out in cleartext, so we enumerate all
21- /// reachable commits and walk each commit's tree.
22- ///
23- /// `--all` covers every ref namespace (a blob reachable only through `refs/notes/*`
24- /// must not escape withholding); HEAD is added explicitly for the detached case,
25- /// where HEAD reaches commits that no ref does. `git ls-tree -r <commit>` per commit
26- /// keeps every path a blob lives at (the same blob content can appear at several
27- /// paths, and the per-path visibility check needs all of them). This is why it is
28- /// not `git rev-list --objects`, which reports only one path per object. Pairs are
29- /// de-duplicated across commits. Paths carry a leading "/" to match the glob form
30- /// used by visibility rules ("/secret/**").
13+ /// Fail closed unless every ref ultimately resolves to a commit (a ref pointing
14+ /// directly at a blob or tree, or an annotated tag — even a nested one — of such
15+ /// an object is refused). `git rev-list --all` silently *skips* such refs, but
16+ /// `git upload-pack` (serve) and the whole-repo pin fallback
17+ /// (`git cat-file --batch-all-objects`) still expose their target object, so a
18+ /// tolerant walk would under-withhold. Refuse rather than leak.
3119///
32- /// Fails closed: if commit enumeration or any tree walk fails, returns an error so
33- /// the caller aborts the serve/pin rather than producing a partial (under-withheld)
34- /// set.
35- fn blob_paths ( repo_path : & Path ) -> Result < Vec < ( String , String ) > > {
36- // 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 — 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.
20+ /// Each ref is peeled fully with `<ref>^{}` through `git cat-file --batch-check`.
21+ /// Full peeling is why this is not `for-each-ref %(*objecttype)`, which
22+ /// dereferences only one tag level and so misclassifies a tag-of-a-tag-of-a-
23+ /// commit as a non-commit.
24+ fn assert_all_refs_are_commits ( repo_path : & Path ) -> Result < ( ) > {
4925 let refs = std:: process:: Command :: new ( "git" )
5026 . args ( [ "for-each-ref" , "--format=%(refname)" ] )
5127 . current_dir ( repo_path)
@@ -63,67 +39,116 @@ fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
6339 . map ( str:: trim)
6440 . filter ( |l| !l. is_empty ( ) )
6541 . 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- ) ) ,
91- } ;
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 ( ) {
42+ if refnames. is_empty ( ) {
43+ return Ok ( ( ) ) ;
44+ }
45+
46+ // Peel every ref in one `git cat-file --batch-check` pass: one
47+ // `<refname>^{}` query per line, one output line per input line, in order.
48+ // The stdin write runs on a separate thread so this thread can drain stdout
49+ // concurrently. cat-file echoes the full query on a `<query> missing` line,
50+ // so output scales with refname length (not a fixed size per ref); writing
51+ // all of stdin before reading any stdout would deadlock both pipes once the
52+ // child's stdout buffer fills. Dropping `stdin` at the end of the closure
53+ // sends EOF.
54+ let queries = refnames
55+ . iter ( )
56+ . map ( |r| format ! ( "{r}^{{}}" ) )
57+ . collect :: < Vec < _ > > ( )
58+ . join ( "\n " ) ;
59+ use std:: io:: Write ;
60+ let mut child = std:: process:: Command :: new ( "git" )
61+ . args ( [ "cat-file" , "--batch-check=%(objecttype)" ] )
62+ . current_dir ( repo_path)
63+ . stdin ( std:: process:: Stdio :: piped ( ) )
64+ . stdout ( std:: process:: Stdio :: piped ( ) )
65+ . stderr ( std:: process:: Stdio :: piped ( ) )
66+ . spawn ( )
67+ . context ( "failed to spawn git cat-file" ) ?;
68+ // Feed stdin on a writer thread so this thread can drain stdout via
69+ // wait_with_output concurrently; a None handle (the pipe vanished) becomes a
70+ // broken-pipe write error. wait_with_output reaps the child unconditionally
71+ // before any error is surfaced, so no path drops it unwaited (#53), and the
72+ // writer is joined only after the drain so the join cannot deadlock.
73+ let writer = child
74+ . stdin
75+ . take ( )
76+ . map ( |mut stdin| std:: thread:: spawn ( move || stdin. write_all ( queries. as_bytes ( ) ) ) ) ;
77+ let peel_result = child. wait_with_output ( ) ;
78+ let write_result = match writer {
79+ Some ( handle) => handle
80+ . join ( )
81+ . map_err ( |_| anyhow:: anyhow!( "git cat-file stdin writer thread panicked" ) ) ?,
82+ None => Err ( std:: io:: Error :: new (
83+ std:: io:: ErrorKind :: BrokenPipe ,
84+ "git cat-file stdin unavailable" ,
85+ ) ) ,
86+ } ;
87+ // Surface a write error only if the process didn't already fail with a
88+ // clearer status.
89+ let peel = peel_result. context ( "git cat-file failed" ) ?;
90+ if !peel. status . success ( ) {
91+ anyhow:: bail!(
92+ "git cat-file --batch-check failed: {}" ,
93+ String :: from_utf8_lossy( & peel. stderr)
94+ ) ;
95+ }
96+ write_result. context ( "failed to write to git cat-file stdin" ) ?;
97+
98+ let peel_stdout = String :: from_utf8_lossy ( & peel. stdout ) ;
99+ let types: Vec < & str > = peel_stdout. lines ( ) . map ( str:: trim) . collect ( ) ;
100+ // A short read means at least one ref went unclassified — fail closed.
101+ if types. len ( ) != refnames. len ( ) {
102+ anyhow:: bail!(
103+ "git cat-file returned {} lines for {} refs; \
104+ refusing to produce a partial (under-withheld) set",
105+ types. len( ) ,
106+ refnames. len( )
107+ ) ;
108+ }
109+ for ( refname, kind) in refnames. iter ( ) . zip ( types. iter ( ) ) {
110+ // git emits `<query> missing` (not the objecttype) when the peel target
111+ // is absent; the status word is the last token.
112+ if kind. split_ascii_whitespace ( ) . last ( ) == Some ( "missing" ) {
95113 anyhow:: bail!(
96- "git cat-file --batch-check failed: {}" ,
97- String :: from_utf8_lossy ( & peel . stderr )
114+ "ref {refname} does not resolve to an object; \
115+ refusing to produce a partial (under-withheld) set"
98116 ) ;
99117 }
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 ( ) {
118+ if * kind != "commit" {
104119 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( )
120+ "ref {refname} resolves to a {kind}, not a commit; \
121+ refusing to produce a partial (under-withheld) set"
109122 ) ;
110123 }
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- }
126124 }
125+ Ok ( ( ) )
126+ }
127+
128+ /// List every (blob_oid, "/repo/relative/path") pair reachable from any commit in
129+ /// `repo_path` — every ref *and* every historical commit those refs reach, not just
130+ /// the ref tips. `git upload-pack` (serve) and the whole-repo pin fallback
131+ /// (`git cat-file --batch-all-objects`) expose the full reachable object graph,
132+ /// including a blob that only ever existed
133+ /// in an older commit (a since-deleted file, a rotated secret whose previous version
134+ /// is still in history). Classifying only ref-tip trees would leave those blobs
135+ /// unwithheld while pin/serve still hand them out in cleartext, so we enumerate all
136+ /// reachable commits and walk each commit's tree.
137+ ///
138+ /// `--all` covers every ref namespace (a blob reachable only through `refs/notes/*`
139+ /// must not escape withholding); HEAD is added explicitly for the detached case,
140+ /// where HEAD reaches commits that no ref does. `git ls-tree -r <commit>` per commit
141+ /// keeps every path a blob lives at (the same blob content can appear at several
142+ /// paths, and the per-path visibility check needs all of them). This is why it is
143+ /// not `git rev-list --objects`, which reports only one path per object. Pairs are
144+ /// de-duplicated across commits. Paths carry a leading "/" to match the glob form
145+ /// used by visibility rules ("/secret/**").
146+ ///
147+ /// Fails closed: if commit enumeration or any tree walk fails, returns an error so
148+ /// the caller aborts the serve/pin rather than producing a partial (under-withheld)
149+ /// set.
150+ fn blob_paths ( repo_path : & Path ) -> Result < Vec < ( String , String ) > > {
151+ assert_all_refs_are_commits ( repo_path) ?;
127152
128153 // Enumerate every reachable commit, not just ref tips. `--all` walks all refs;
129154 // append HEAD so a detached HEAD (reachable by rev-list/upload-pack but in no
@@ -671,6 +696,55 @@ mod tests {
671696 ) ;
672697 }
673698
699+ #[ test]
700+ fn fails_closed_when_a_ref_points_at_a_missing_object ( ) {
701+ let ( _td, bare, _secret, _public) = fixture ( ) ;
702+ // A ref whose target object does not exist (pruned object, corrupt ref)
703+ // peels to `<query> missing`. for-each-ref still lists it, so the guard
704+ // must fail closed rather than skip the unclassifiable ref.
705+ std:: fs:: write (
706+ bare. join ( "refs/heads/dangling" ) ,
707+ "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n " ,
708+ )
709+ . unwrap ( ) ;
710+ let rules = [ rule ( "/secret/**" , & [ ] ) ] ;
711+ let result = withheld_blob_oids ( & bare, & rules, true , OWNER , None ) ;
712+ assert ! (
713+ result. is_err( ) ,
714+ "a ref pointing at a missing object must fail closed (Err)"
715+ ) ;
716+ }
717+
718+ #[ test]
719+ fn many_long_named_unresolvable_refs_do_not_deadlock ( ) {
720+ // Regression guard for the cat-file stdin/stdout deadlock. cat-file
721+ // echoes the full query on a `<query> missing` line, so a few hundred
722+ // long-named dangling refs emit >64 KiB of stdout — enough to fill the
723+ // pipe buffer and hang a write-all-before-drain implementation. The
724+ // concurrent stdin writer must keep it live and fail closed. Bounded by
725+ // a timeout so a regression fails the test instead of hanging the suite.
726+ let ( _td, bare, _secret, _public) = fixture ( ) ;
727+ let longname = "z" . repeat ( 200 ) ;
728+ let mut packed = String :: new ( ) ;
729+ for i in 0 ..500 {
730+ packed. push_str ( & format ! (
731+ "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef refs/heads/{longname}-{i}\n "
732+ ) ) ;
733+ }
734+ std:: fs:: write ( bare. join ( "packed-refs" ) , packed) . unwrap ( ) ;
735+
736+ let ( tx, rx) = std:: sync:: mpsc:: channel ( ) ;
737+ std:: thread:: spawn ( move || {
738+ let rules = [ rule ( "/secret/**" , & [ ] ) ] ;
739+ let is_err = withheld_blob_oids ( & bare, & rules, true , OWNER , None ) . is_err ( ) ;
740+ let _ = tx. send ( is_err) ;
741+ } ) ;
742+ match rx. recv_timeout ( std:: time:: Duration :: from_secs ( 10 ) ) {
743+ Ok ( is_err) => assert ! ( is_err, "refs pointing at missing objects must fail closed" ) ,
744+ Err ( _) => panic ! ( "withheld_blob_oids did not return within 10s (deadlock?)" ) ,
745+ }
746+ }
747+
674748 #[ test]
675749 fn same_blob_at_allowed_and_denied_path_is_not_withheld ( ) {
676750 // Identical content at a denied and an allowed path shares one blob OID.
0 commit comments