Skip to main content

znippy_plugin_git/
git_oracle.rs

1//! **Stock `git` as the arbiter for a pack this crate emitted β€” inside a
2//! repository, and never as a segfault mistaken for a verdict.**
3//!
4//! # πŸ”΄ Why this module exists at all
5//!
6//! `git index-pack --strict` **crashes with SIGSEGV** when it is run outside a
7//! git repository and its strictness has anything to say. Measured on oden with
8//! git 2.53.0, 2026-08-11:
9//!
10//! ```text
11//! pack holding one tree whose blob is absent
12//!   in a bare repo   β†’ exit 128  fatal: did not receive expected object ce013625…
13//!   outside a repo   β†’ exit 139  (SIGSEGV, and NOT ONE BYTE of output)
14//! ```
15//!
16//! It is not specific to `--check-self-contained-and-connected`; plain
17//! `--strict` does it too, because both reach the same `fsck_walk` and it needs
18//! a repository under it. The pack has to be *rejectable* for the crash to
19//! happen β€” a good pack indexes fine outside a repo β€” so the failure appears
20//! **only when the oracle was about to be useful.**
21//!
22//! That is not a hypothetical. It is why the bug `8c2679d` fixed shipped: the
23//! subset test in [`crate::store`] ran the oracle in a plain temporary directory,
24//! hit the crash, and was *narrowed to three objects until it stopped crashing*
25//! β€” the comment on that line read `// see the ignore note: larger subsets
26//! segfault git`. With three objects there was no tree naming an absent child,
27//! the oracle had nothing to say, and a narrowed clone shipped broken for four
28//! sessions. **A test whose oracle segfaults is a test that cannot fail**, and
29//! the reflex that shrinks the input until the crash goes away removes the
30//! coverage rather than the crash.
31//!
32//! So every call goes through here, and here does two things nothing did before:
33//! it runs git **inside a freshly initialised bare repository**, and it treats a
34//! **death by signal as an oracle failure in its own right** rather than as a
35//! non-zero exit code with an empty message.
36//!
37//! # Not a production path
38//!
39//! Nothing in this crate's serving, storing or indexing paths calls any of this,
40//! and nothing may: it forks `git`. It is `pub` only because
41//! `tests/concurrent_push.rs` is an integration test and compiles against the
42//! library rather than into it, and one shared oracle beats four copies of the
43//! same twenty lines (LAW 5). The caller supplies the scratch directory, so this
44//! needs no `tempfile` and stays out of the dependency graph.
45
46use std::path::{Path, PathBuf};
47use std::process::{Command, Output};
48
49use anyhow::{anyhow, bail, Context, Result};
50
51/// How hard git should look at the pack.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Strictness {
54    /// Plain `index-pack`: **every delta in the pack resolves inside it**, and
55    /// nothing else. The honest question to ask of a set that is not
56    /// reachability-closed β€” an arbitrary subset's tree will name a blob the
57    /// subset does not carry, and a connectivity walk would then be judging the
58    /// *caller's selection* rather than this crate's emitter.
59    SelfContained,
60    /// `--strict --check-self-contained-and-connected`: what `git clone` itself
61    /// runs. Every received object's links are walked and each one demanded, so
62    /// this is the arbiter for a request that **is** closed β€” a clone, a fetch,
63    /// or a whole repository.
64    Connected,
65}
66
67impl Strictness {
68    fn args(self) -> &'static [&'static str] {
69        match self {
70            Strictness::SelfContained => &["index-pack"],
71            Strictness::Connected => &[
72                "index-pack",
73                "--strict",
74                "--check-self-contained-and-connected",
75            ],
76        }
77    }
78}
79
80/// A freshly initialised **bare repository** under `scratch`, named `name`.
81///
82/// Fresh and empty on purpose: a connectivity verdict is only about the pack if
83/// the repository brings no objects of its own to satisfy a link with.
84pub fn empty_bare_repo(scratch: &Path, name: &str) -> Result<PathBuf> {
85    let repo = scratch.join(name);
86    let out = Command::new("git")
87        .args(["init", "-q", "--bare"])
88        .arg(&repo)
89        .output()
90        .context("running `git init --bare` for the oracle's repository")?;
91    verdict(&out, "git init --bare")?;
92    Ok(repo)
93}
94
95/// **Hand `pack` to stock git, inside a repository, and return its verdict.**
96///
97/// `Ok(())` means git accepted it. `Err` names why, and β€” the part this module
98/// exists for β€” an oracle that *crashed* is an `Err` that says so, instead of an
99/// exit code nobody looks at behind an empty stderr.
100pub fn git_accepts(scratch: &Path, name: &str, pack: &[u8], how: Strictness) -> Result<()> {
101    let repo = empty_bare_repo(scratch, name)?;
102    let path = repo.join("oracle.pack");
103    std::fs::write(&path, pack)
104        .with_context(|| format!("writing the pack under test to {}", path.display()))?;
105    let out = Command::new("git")
106        .args(how.args())
107        .arg(&path)
108        .current_dir(&repo)
109        .output()
110        .context("running `git index-pack` as the oracle")?;
111    verdict(&out, "git index-pack")
112        .with_context(|| format!("{how:?}, in the repository at {}", repo.display()))
113}
114
115/// The same, as an assertion, because that is what a test wants.
116///
117/// `#[track_caller]` so the panic points at the test rather than at this line.
118#[track_caller]
119pub fn assert_git_accepts(scratch: &Path, name: &str, pack: &[u8], how: Strictness) {
120    if let Err(e) = git_accepts(scratch, name, pack, how) {
121        panic!("stock git refused a pack this crate emitted: {e:#}");
122    }
123}
124
125/// **What stock git reads back out of a pack this crate emitted**: every object
126/// in it, by oid, as `(kind, bytes)`.
127///
128/// `git_accepts` proves a pack is *well formed and connected*. It cannot prove
129/// the bytes are the **right** bytes, and that is precisely the failure mode a
130/// computed delta introduces: a wrong copy offset produces a pack `index-pack`
131/// indexes happily, because the oid it files the entry under is the one it
132/// computed from the entry's own content. Only reading the content back and
133/// comparing it against what the store holds can see it β€” `P-001`, applied
134/// output rather than a state round-trip.
135///
136/// The pack goes in through `index-pack --stdin`, which lands it in the
137/// repository's `objects/pack` where `cat-file` can reach it;
138/// [`git_accepts`] deliberately does not, because a verdict must not depend on
139/// the repository having adopted the objects.
140pub fn git_reads_back(scratch: &Path, name: &str, pack: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
141    use std::io::Write as _;
142
143    let repo = empty_bare_repo(scratch, name)?;
144    let mut child = Command::new("git")
145        .args(["index-pack", "--stdin"])
146        .current_dir(&repo)
147        .stdin(std::process::Stdio::piped())
148        .stdout(std::process::Stdio::piped())
149        .stderr(std::process::Stdio::piped())
150        .spawn()
151        .context("spawning `git index-pack --stdin` to adopt the pack")?;
152    child
153        .stdin
154        .take()
155        .ok_or_else(|| anyhow!("git index-pack --stdin has no stdin"))?
156        .write_all(pack)
157        .context("streaming the pack to `git index-pack --stdin`")?;
158    let out = child
159        .wait_with_output()
160        .context("waiting for `git index-pack --stdin`")?;
161    verdict(&out, "git index-pack --stdin")?;
162
163    let listed = Command::new("git")
164        .args(["cat-file", "--batch-all-objects", "--batch"])
165        .current_dir(&repo)
166        .output()
167        .context("running `git cat-file --batch-all-objects --batch`")?;
168    verdict(&listed, "git cat-file --batch")?;
169
170    // `<oid> <type> <size>\n<size bytes>\n`, repeated.
171    let buf = listed.stdout;
172    let mut at = 0usize;
173    let mut objects = Vec::new();
174    while at < buf.len() {
175        let nl = buf[at..]
176            .iter()
177            .position(|b| *b == b'\n')
178            .ok_or_else(|| anyhow!("a cat-file record has no header terminator"))?
179            + at;
180        let header = std::str::from_utf8(&buf[at..nl]).context("a cat-file header is not utf-8")?;
181        let mut f = header.split_whitespace();
182        let oid = f
183            .next()
184            .ok_or_else(|| anyhow!("a cat-file header has no oid: {header:?}"))?
185            .to_owned();
186        let size: usize = f
187            .nth(1)
188            .ok_or_else(|| anyhow!("a cat-file header has no size: {header:?}"))?
189            .parse()
190            .with_context(|| format!("a cat-file size is not a number: {header:?}"))?;
191        let from = nl + 1;
192        let to = from
193            .checked_add(size)
194            .filter(|to| *to <= buf.len())
195            .ok_or_else(|| anyhow!("a cat-file body of {size} bytes runs off the output"))?;
196        objects.push((oid, buf[from..to].to_vec()));
197        at = to + 1;
198    }
199    Ok(objects)
200}
201
202/// Turn a finished child into a verdict, **refusing to read a crash as a
203/// judgement**.
204///
205/// A process killed by a signal has `ExitStatus::code() == None` on unix, and
206/// that is the case that has to be named rather than folded into "non-zero":
207/// `assert!(status.success())` is technically false for a SIGSEGV, but it prints
208/// an empty stderr and a `None` code, which reads exactly like a tool that
209/// declined to explain itself β€” and the documented response to it in this
210/// repository was to shrink the input until it stopped.
211fn verdict(out: &Output, what: &str) -> Result<()> {
212    #[cfg(unix)]
213    {
214        use std::os::unix::process::ExitStatusExt as _;
215        if let Some(signal) = out.status.signal() {
216            bail!(
217                "`{what}` DIED ON SIGNAL {signal} instead of judging the pack. That is the \
218                 oracle crashing, not the pack failing, and the two must never be confused: \
219                 outside a repository git 2.53.0 segfaults on any pack `--strict` would have \
220                 rejected, so a green here would have meant nothing. Do NOT make this go away \
221                 by shrinking the input.\nstderr: {}",
222                String::from_utf8_lossy(&out.stderr).trim()
223            );
224        }
225    }
226    if !out.status.success() {
227        return Err(anyhow!(
228            "`{what}` exited {:?}\nstderr: {}\nstdout: {}",
229            out.status.code(),
230            String::from_utf8_lossy(&out.stderr).trim(),
231            String::from_utf8_lossy(&out.stdout).trim()
232        ));
233    }
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::object::{canonical, GitHashKind, GitObjectKind};
241    use crate::pack_walk::{emit_pack, EmitEntry};
242    use crate::store::tests::tmpdir;
243
244    /// One pack holding exactly `bodies`, every entry whole, with a real
245    /// trailer β€” `emit_pack` computes it, which is why this does not hand-roll
246    /// one.
247    fn whole_pack(bodies: &[(GitObjectKind, Vec<u8>)]) -> Vec<u8> {
248        use std::io::Write as _;
249        let hash = GitHashKind::Sha1;
250        let entries: Vec<EmitEntry> = bodies
251            .iter()
252            .enumerate()
253            .map(|(i, (kind, body))| {
254                let t = crate::serve::resolved_type(*kind);
255                let mut stored = Vec::new();
256                crate::pack_walk::encode_type_and_size(&mut stored, t, body.len() as u64);
257                let mut z =
258                    flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
259                z.write_all(body).unwrap();
260                stored.extend_from_slice(&z.finish().unwrap());
261                EmitEntry {
262                    oid: hash.oid_of(&canonical(*kind, body)),
263                    // Hand-built bytes that no archive holds β€” the
264                    // `EntryBytes::Owned` exception, in its test shape.
265                    stored: crate::pack_walk::EntryBytes::Owned(stored),
266                    obj_type: t,
267                    uncompressed_size: body.len() as u64,
268                    delta_base: 0,
269                    offset: i as u64 + 1,
270                    recompressed: false,
271                    deltified: false,
272                }
273            })
274            .collect();
275        let mut out = Vec::new();
276        emit_pack(&entries, hash, &mut out, &|i| {
277            crate::pack_walk::resolve_against(&entries[i], &[])
278        })
279        .expect("emitting the fixture pack");
280        out
281    }
282
283    /// πŸ”΄ **The oracle can FAIL, and it fails with a sentence rather than a
284    /// signal.**
285    ///
286    /// The pack is one tree naming a blob that is not in it β€” the exact shape
287    /// that broke a narrowed clone in production, and the exact shape that
288    /// segfaults `git index-pack --strict` outside a repository.
289    ///
290    /// Three assertions, and the third is the one this module was written for:
291    /// [`Strictness::Connected`] must **reject** it; the rejection must name the
292    /// missing object; and it must **not** be a signal death, because a signal
293    /// death is the oracle crashing rather than the pack failing, and the
294    /// documented reflex to it was to shrink the input until it stopped.
295    #[test]
296    fn the_oracle_rejects_a_pack_whose_tree_owes_a_child_and_does_not_crash_doing_it() {
297        let hash = GitHashKind::Sha1;
298        let orphan = b"a blob that is deliberately left out of the pack\n".to_vec();
299        let orphan_oid = hash.oid_of(&canonical(GitObjectKind::Blob, &orphan));
300        let mut tree = b"100644 f.txt\0".to_vec();
301        tree.extend_from_slice(&orphan_oid);
302
303        let scratch = tmpdir("oracle-red");
304        let pack = whole_pack(&[(GitObjectKind::Tree, tree.clone())]);
305
306        let err = git_accepts(&scratch, "red.git", &pack, Strictness::Connected)
307            .expect_err("a tree whose child is absent must be refused");
308        let msg = format!("{err:#}");
309        assert!(
310            msg.contains(&hex::encode(&orphan_oid)),
311            "the refusal must name the object the pack owes; got: {msg}"
312        );
313        assert!(
314            !msg.contains("DIED ON SIGNAL"),
315            "the oracle crashed instead of judging β€” it is not running inside a repository: \
316             {msg}"
317        );
318
319        // …and the same pack is fine when nothing is asking about connectivity:
320        // every delta in it resolves inside it, because there are no deltas.
321        // Without this the test could not tell a working oracle from one that
322        // rejects everything.
323        git_accepts(&scratch, "green.git", &pack, Strictness::SelfContained)
324            .expect("a self-containment check has nothing to complain about here");
325
326        // The premise, stated last because it is the one that would make the
327        // whole module pointless if it stopped being true: **outside** a
328        // repository this same command dies on a signal with no output at all.
329        let bare_dir = scratch.join("not-a-repo");
330        std::fs::create_dir_all(&bare_dir).unwrap();
331        let path = bare_dir.join("oracle.pack");
332        std::fs::write(&path, &pack).unwrap();
333        let out = Command::new("git")
334            .args(Strictness::Connected.args())
335            .arg(&path)
336            .current_dir(&bare_dir)
337            .output()
338            .expect("running git index-pack outside a repository");
339        let outside = verdict(&out, "git index-pack")
340            .expect_err("outside a repository this cannot possibly succeed");
341        assert!(
342            format!("{outside:#}").contains("DIED ON SIGNAL"),
343            "git stopped segfaulting outside a repository β€” this module's premise has changed \
344             and its docs must be re-measured, not deleted. It said: {outside:#}"
345        );
346    }
347}