Skip to content

Commit 9333e4e

Browse files
committed
fix(node): share one history walk and fully peel ref types in withheld classifier
Addresses two review findings on the under-withholding classifier: - withheld_blob_recipients walked blob_paths twice (once via withheld_blob_oids, once directly). Extract withheld_from_pairs so a single history walk feeds both the withheld set and the recipient map. - The ref-type fail-closed guard used for-each-ref %(*objecttype), which peels only one tag level and so misclassified a nested annotated tag of a commit as a non-commit, refusing the whole walk (over-withhold). List refnames and peel each fully with <ref>^{} through one cat-file --batch-check; reap the child even if the stdin write fails. Fail closed on a short read or any non-commit/missing target. Adds tests: annotated + nested-tag of a commit does not fail closed, annotated tag of a blob still fails closed, and a blob at both an allowed and a denied path is not withheld.
1 parent 2332296 commit 9333e4e

1 file changed

Lines changed: 224 additions & 36 deletions

File tree

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

Lines changed: 224 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,20 @@ use std::path::Path;
3434
/// set.
3535
fn 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

Comments
 (0)