Skip to content

Commit 3266eaf

Browse files
committed
fix(node): drain cat-file stdout concurrently to close the peel deadlock
The ref-type guard peeled every <ref>^{} through git cat-file --batch-check, writing all queries to stdin before draining stdout. On a repo with enough output the child's stdout pipe fills, it stops reading stdin, and write_all blocks forever — hanging every serve/pin that classifies the repo. Batching the queries does NOT bound this: cat-file echoes the full query on a '<query> missing' line, so output scales with refname length, and a few hundred long-named dangling refs already exceed the pipe buffer. Run the stdin write on a separate thread so the main thread drains stdout concurrently via wait_with_output, which reaps the child unconditionally before any error is surfaced; the writer is joined after the drain so it cannot deadlock, and a vanished stdin pipe becomes a broken-pipe write error. One pass, no batch bound, deadlock-free regardless of ref count or refname length. Extracted as assert_all_refs_are_commits with corrected doc attribution. Detect the missing-object line by its trailing status word. Adds a timeout-bounded regression test with 500 long-named dangling refs (>64 KiB of cat-file output) that fails rather than hangs if the deadlock returns.
1 parent 9333e4e commit 3266eaf

1 file changed

Lines changed: 163 additions & 89 deletions

File tree

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

Lines changed: 163 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,18 @@ use anyhow::{Context, Result};
1010
use std::collections::{BTreeSet, HashMap, HashSet};
1111
use 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

Comments
 (0)