Skip to main content

mkit_git_bridge/
gitsrc.rs

1//! Reading objects out of a local git repository
2//! (SPEC-GIT-IMPORT §2): one long-lived `git cat-file --batch` child
3//! for object bytes, plus `rev-list` / `ls-refs` / config plumbing.
4//!
5//! git owns wire protocol, auth, and pack storage; this module owns
6//! only the subprocess conversation. All inputs to the child are
7//! 40-hex object ids (never user strings), so the argv/stdin surface
8//! is injection-free by construction.
9
10use crate::error::BridgeError;
11use crate::gitobj::{GitType, Sha1Id, sha1_from_hex, sha1_hex};
12use std::io::{BufRead, BufReader, Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
15
16/// Refuse single objects above this size up front (the mkit per-file
17/// cap; SPEC-GIT-IMPORT §3.1) so a hostile upstream can't make us
18/// buffer arbitrarily.
19pub const MAX_OBJECT_BYTES: u64 = 1024 * 1024 * 1024;
20
21/// Base `git` command against `repo` with the bridge's subprocess
22/// hygiene applied: `GIT_TERMINAL_PROMPT=0` (a credential prompt must
23/// fail cleanly, never hang a CI run) and user hooks neutralized via
24/// `core.hooksPath` pointed at the platform null device (a
25/// `core.hooksPath` in user config must not run arbitrary hooks
26/// against the private staging mirror). User/system gitconfig is
27/// otherwise INHERITED on purpose: credential helpers, `core.sshCommand`,
28/// and proxy settings live there and remote fetch/push need them —
29/// none of it can affect translation output (mkit generates every
30/// object byte itself).
31#[must_use]
32pub fn git_command(repo: &Path) -> Command {
33    let mut c = Command::new("git");
34    c.arg("-C").arg(repo);
35    apply_hygiene(&mut c);
36    c
37}
38
39/// Apply the subprocess hygiene (see [`git_command`]) to a caller-built
40/// `git` command without a `-C <repo>` (e.g. a bare `git --version`
41/// probe).
42pub fn apply_hygiene(c: &mut Command) {
43    c.env("GIT_TERMINAL_PROMPT", "0");
44    let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
45    c.arg("-c").arg(format!("core.hooksPath={null}"));
46}
47
48/// The git object type names `cat-file --batch` reports.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum GitObjKind {
51    Blob,
52    Tree,
53    Commit,
54    Tag,
55}
56
57impl GitObjKind {
58    fn from_name(name: &str) -> Option<Self> {
59        Some(match name {
60            "blob" => Self::Blob,
61            "tree" => Self::Tree,
62            "commit" => Self::Commit,
63            "tag" => Self::Tag,
64            _ => return None,
65        })
66    }
67}
68
69impl From<GitObjKind> for GitType {
70    fn from(kind: GitObjKind) -> Self {
71        match kind {
72            GitObjKind::Blob => Self::Blob,
73            GitObjKind::Tree => Self::Tree,
74            GitObjKind::Commit => Self::Commit,
75            GitObjKind::Tag => Self::Tag,
76        }
77    }
78}
79
80/// A long-lived `git cat-file --batch` child bound to one repository.
81///
82/// Batch protocol (verified against git ≥ 2.30): write `<oid>\n` to
83/// stdin; read `<oid> <type> <size>\n`, exactly `<size>` body bytes,
84/// then one trailing `\n`. Unknown ids answer `<oid> missing\n`
85/// (no body — the stream stays clean). Any OTHER read error leaves
86/// the stream desynchronized: treat it as fatal for this batch.
87#[derive(Debug)]
88pub struct CatFileBatch {
89    child: Child,
90    stdin: ChildStdin,
91    stdout: BufReader<ChildStdout>,
92    repo: PathBuf,
93}
94
95impl CatFileBatch {
96    /// Spawn the child against `repo` (a `.git`/bare directory).
97    pub fn open(repo: &Path) -> Result<Self, BridgeError> {
98        let mut child = git_command(repo)
99            .args(["cat-file", "--batch"])
100            .stdin(Stdio::piped())
101            .stdout(Stdio::piped())
102            .stderr(Stdio::null())
103            .spawn()
104            .map_err(|e| BridgeError::Source(format!("spawn git cat-file: {e}")))?;
105        let stdin = child
106            .stdin
107            .take()
108            .ok_or_else(|| BridgeError::Source("cat-file stdin unavailable".into()))?;
109        let stdout = child
110            .stdout
111            .take()
112            .map(BufReader::new)
113            .ok_or_else(|| BridgeError::Source("cat-file stdout unavailable".into()))?;
114        Ok(Self {
115            child,
116            stdin,
117            stdout,
118            repo: repo.to_path_buf(),
119        })
120    }
121
122    /// Read one object's kind + body bytes.
123    pub fn read(&mut self, id: &Sha1Id) -> Result<(GitObjKind, Vec<u8>), BridgeError> {
124        let hex = sha1_hex(id);
125        self.stdin
126            .write_all(format!("{hex}\n").as_bytes())
127            .and_then(|()| self.stdin.flush())
128            .map_err(|e| BridgeError::Source(format!("cat-file write: {e}")))?;
129
130        let mut header = String::new();
131        self.stdout
132            .read_line(&mut header)
133            .map_err(|e| BridgeError::Source(format!("cat-file read: {e}")))?;
134        let header = header.trim_end();
135        let mut parts = header.split(' ');
136        let (Some(echo), Some(kind_or_missing)) = (parts.next(), parts.next()) else {
137            return Err(BridgeError::Source(format!(
138                "cat-file: malformed header {header:?} (repo {})",
139                self.repo.display()
140            )));
141        };
142        if kind_or_missing == "missing" {
143            return Err(BridgeError::Source(format!("object {echo} missing")));
144        }
145        let kind = GitObjKind::from_name(kind_or_missing)
146            .ok_or_else(|| BridgeError::Source(format!("cat-file: unknown type {header:?}")))?;
147        let size: u64 = parts
148            .next()
149            .and_then(|s| s.parse().ok())
150            .ok_or_else(|| BridgeError::Source(format!("cat-file: bad size {header:?}")))?;
151        if size > MAX_OBJECT_BYTES {
152            // Drain body + trailing newline so the batch stream stays
153            // synchronized (callers keep using this child), and refuse
154            // PER-REF: one oversized object must not abort the whole
155            // import (SPEC-GIT-IMPORT §3.1).
156            // checked: a u64::MAX size would wrap `size + 1` to 0 in
157            // release, skip the drain entirely, and silently desync
158            // the batch stream — every later read returns wrong bytes.
159            let Some(mut remaining) = size.checked_add(1) else {
160                return Err(BridgeError::Source(format!(
161                    "object {echo} reports an absurd size ({size}); cat-file \
162                     stream untrustworthy"
163                )));
164            };
165            let mut sink_buf = vec![0u8; 64 * 1024];
166            while remaining > 0 {
167                let take = remaining.min(sink_buf.len() as u64);
168                #[allow(clippy::cast_possible_truncation)] // take <= 64 KiB
169                let take = take as usize;
170                self.stdout
171                    .read_exact(&mut sink_buf[..take])
172                    .map_err(|e| BridgeError::Source(format!("cat-file drain: {e}")))?;
173                remaining -= take as u64;
174            }
175            let mut obj = crate::gitobj::Sha1Id::default();
176            if let Some(parsed) = sha1_from_hex(echo) {
177                obj = parsed;
178            }
179            return Err(crate::error::Refusal::BlobTooLarge {
180                object: {
181                    let mut h = [0u8; 32];
182                    h[..20].copy_from_slice(&obj);
183                    h
184                },
185                size,
186            }
187            .into());
188        }
189        #[allow(clippy::cast_possible_truncation)] // size checked against the cap above
190        let mut body = vec![0u8; size as usize];
191        self.stdout
192            .read_exact(&mut body)
193            .map_err(|e| BridgeError::Source(format!("cat-file body: {e}")))?;
194        let mut nl = [0u8; 1];
195        self.stdout
196            .read_exact(&mut nl)
197            .map_err(|e| BridgeError::Source(format!("cat-file trailer: {e}")))?;
198        Ok((kind, body))
199    }
200}
201
202impl Drop for CatFileBatch {
203    fn drop(&mut self) {
204        // Kill + reap to avoid a zombie (stdin close alone would
205        // also end the batch loop, but kill is prompt and unconditional).
206        let _ = self.child.kill();
207        let _ = self.child.wait();
208    }
209}
210
211fn git_stdout(repo: &Path, args: &[&str]) -> Result<String, BridgeError> {
212    let out = git_command(repo)
213        .args(args)
214        .output()
215        .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
216    if !out.status.success() {
217        return Err(BridgeError::Source(format!(
218            "git {} failed: {}",
219            args.first().copied().unwrap_or(""),
220            String::from_utf8_lossy(&out.stderr).trim()
221        )));
222    }
223    String::from_utf8(out.stdout).map_err(|_| BridgeError::Source("git output not UTF-8".into()))
224}
225
226/// Commit ids reachable from `tips` minus `exclude`, parents-first
227/// (`--reverse --topo-order`), i.e. translation order.
228pub fn rev_list(
229    repo: &Path,
230    tips: &[Sha1Id],
231    exclude: &[Sha1Id],
232) -> Result<Vec<Sha1Id>, BridgeError> {
233    let mut args: Vec<String> = vec!["rev-list".into(), "--reverse".into(), "--topo-order".into()];
234    for t in tips {
235        args.push(sha1_hex(t));
236    }
237    for e in exclude {
238        args.push(format!("^{}", sha1_hex(e)));
239    }
240    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
241    let out = git_stdout(repo, &arg_refs)?;
242    out.lines()
243        .map(|l| {
244            sha1_from_hex(l.trim())
245                .ok_or_else(|| BridgeError::Source(format!("rev-list: bad id {l:?}")))
246        })
247        .collect()
248}
249
250/// One upstream ref as listed in the staging mirror.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct UpstreamRef {
253    /// Full ref name (`refs/heads/main`, `refs/tags/v1`).
254    pub name: String,
255    /// The ref's own id (a tag OBJECT id for annotated tags).
256    pub id: Sha1Id,
257    /// The peeled commit id for annotated tags (`<ref>^{}` rows).
258    pub peeled: Option<Sha1Id>,
259}
260
261/// List `refs/heads/*` and `refs/tags/*` in the mirror, with peels.
262pub fn list_refs(repo: &Path) -> Result<Vec<UpstreamRef>, BridgeError> {
263    let out = git_stdout(
264        repo,
265        &[
266            "for-each-ref",
267            "--format=%(refname) %(objectname) %(*objectname)",
268            "refs/heads",
269            "refs/tags",
270        ],
271    )?;
272    let mut refs = Vec::new();
273    for line in out.lines() {
274        let mut parts = line.split(' ');
275        let (Some(name), Some(id_hex)) = (parts.next(), parts.next()) else {
276            continue;
277        };
278        let Some(id) = sha1_from_hex(id_hex) else {
279            continue;
280        };
281        let peeled = parts
282            .next()
283            .filter(|s| !s.is_empty())
284            .and_then(sha1_from_hex);
285        refs.push(UpstreamRef {
286            name: name.to_owned(),
287            id,
288            peeled,
289        });
290    }
291    Ok(refs)
292}
293
294/// The mirror's default-branch ref (`HEAD` symref target), if any.
295pub fn default_branch(repo: &Path) -> Result<Option<String>, BridgeError> {
296    let out = git_command(repo)
297        .args(["symbolic-ref", "--quiet", "HEAD"])
298        .output()
299        .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
300    if !out.status.success() {
301        return Ok(None); // detached/unborn HEAD
302    }
303    Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()))
304}
305
306/// Is `old` an ancestor of `new` in this repo? (`git merge-base
307/// --is-ancestor`: exit 0 = yes, 1 = no.)
308pub fn is_ancestor(repo: &Path, old: &Sha1Id, new: &Sha1Id) -> Result<bool, BridgeError> {
309    let st = git_command(repo)
310        .args([
311            "merge-base",
312            "--is-ancestor",
313            &sha1_hex(old),
314            &sha1_hex(new),
315        ])
316        .stdout(Stdio::null())
317        .stderr(Stdio::null())
318        .status()
319        .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
320    match st.code() {
321        Some(0) => Ok(true),
322        Some(1) => Ok(false),
323        _ => Err(BridgeError::Source(
324            "merge-base --is-ancestor failed".into(),
325        )),
326    }
327}
328
329/// Whether the repo still has `id` (`git cat-file -e`). Exit 0 =
330/// present; any nonzero = absent (gc'd, never fetched, garbage).
331pub fn object_exists(repo: &Path, id: &Sha1Id) -> Result<bool, BridgeError> {
332    let st = git_command(repo)
333        .args(["cat-file", "-e", &sha1_hex(id)])
334        .stdout(Stdio::null())
335        .stderr(Stdio::null())
336        .status()
337        .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
338    Ok(st.code() == Some(0))
339}
340
341/// SPEC-GIT-IMPORT §2: SHA-256 upstreams refuse whole-import.
342pub fn is_sha256_repo(repo: &Path) -> Result<bool, BridgeError> {
343    let out = git_command(repo)
344        .args(["config", "extensions.objectformat"])
345        .output()
346        .map_err(|e| BridgeError::Source(format!("spawn git: {e}")))?;
347    // Unset config exits non-zero — that's the sha1 default.
348    Ok(out.status.success()
349        && String::from_utf8_lossy(&out.stdout)
350            .trim()
351            .eq_ignore_ascii_case("sha256"))
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use mkit_test_util::require_tool;
358
359    /// Build a tiny real repo: two commits + an annotated tag.
360    fn fixture() -> Option<(tempfile::TempDir, Sha1Id)> {
361        if !require_tool("git") {
362            return None;
363        }
364        let td = tempfile::tempdir().unwrap();
365        let run = |args: &[&str]| {
366            let out = Command::new("git")
367                .arg("-C")
368                .arg(td.path())
369                .args(args)
370                .env("GIT_AUTHOR_NAME", "A")
371                .env("GIT_AUTHOR_EMAIL", "a@x")
372                .env("GIT_COMMITTER_NAME", "C")
373                .env("GIT_COMMITTER_EMAIL", "c@x")
374                .env("GIT_AUTHOR_DATE", "1700000000 +0000")
375                .env("GIT_COMMITTER_DATE", "1700000000 +0000")
376                .output()
377                .unwrap();
378            assert!(out.status.success(), "git {args:?}: {out:?}");
379            String::from_utf8_lossy(&out.stdout).trim().to_owned()
380        };
381        run(&["init", "--quiet", "--initial-branch=main", "."]);
382        std::fs::write(td.path().join("a.txt"), "hello\n").unwrap();
383        run(&["add", "a.txt"]);
384        run(&["commit", "--quiet", "-m", "first"]);
385        std::fs::write(td.path().join("b.txt"), "world\n").unwrap();
386        run(&["add", "b.txt"]);
387        run(&["commit", "--quiet", "-m", "second"]);
388        run(&["tag", "-a", "v1", "-m", "tag msg"]);
389        let head = sha1_from_hex(&run(&["rev-parse", "HEAD"])).unwrap();
390        Some((td, head))
391    }
392
393    #[test]
394    fn batch_reads_kinds_and_missing() {
395        let Some((td, head)) = fixture() else { return };
396        let git_dir = td.path().join(".git");
397        let mut batch = CatFileBatch::open(&git_dir).unwrap();
398        let (kind, body) = batch.read(&head).unwrap();
399        assert_eq!(kind, GitObjKind::Commit);
400        let c = crate::gitparse::parse_commit(&body).unwrap();
401        assert_eq!(c.message, b"second\n");
402        assert_eq!(c.committer.timestamp, 1_700_000_000);
403        // The tree, then a blob through the tree.
404        let (kind, tree_body) = batch.read(&c.tree).unwrap();
405        assert_eq!(kind, GitObjKind::Tree);
406        let entries = crate::gitparse::parse_tree(&tree_body).unwrap();
407        assert_eq!(entries.len(), 2);
408        let (kind, blob) = batch.read(&entries[0].id).unwrap();
409        assert_eq!(kind, GitObjKind::Blob);
410        assert_eq!(blob, b"hello\n");
411        // Missing object errors without poisoning the stream.
412        assert!(batch.read(&[0xEEu8; 20]).is_err());
413        assert!(batch.read(&head).is_ok(), "stream survives a miss");
414    }
415
416    #[test]
417    fn rev_list_orders_parents_first_and_excludes() {
418        let Some((td, head)) = fixture() else { return };
419        let git_dir = td.path().join(".git");
420        let all = rev_list(&git_dir, &[head], &[]).unwrap();
421        assert_eq!(all.len(), 2);
422        assert_eq!(*all.last().unwrap(), head, "tip last (parents first)");
423        let inc = rev_list(&git_dir, &[head], &[all[0]]).unwrap();
424        assert_eq!(inc, vec![head], "exclusion yields the delta only");
425    }
426
427    #[test]
428    fn list_refs_peels_tags_and_default_branch() {
429        let Some((td, head)) = fixture() else { return };
430        let git_dir = td.path().join(".git");
431        let refs = list_refs(&git_dir).unwrap();
432        let tag = refs.iter().find(|r| r.name == "refs/tags/v1").unwrap();
433        assert_ne!(tag.id, head, "annotated tag has its own object id");
434        assert_eq!(tag.peeled, Some(head));
435        let main = refs.iter().find(|r| r.name == "refs/heads/main").unwrap();
436        assert_eq!(main.id, head);
437        assert_eq!(main.peeled, None);
438        assert_eq!(
439            default_branch(&git_dir).unwrap().as_deref(),
440            Some("refs/heads/main")
441        );
442        assert!(!is_sha256_repo(&git_dir).unwrap());
443    }
444
445    /// `GitObjKind -> GitType` must agree with the canonical
446    /// `GitObject::raw` framing for every kind, so the single shared
447    /// header layout stays correct (no per-call-site drift).
448    #[test]
449    fn objkind_into_gittype_frames_canonically() {
450        for (kind, name) in [
451            (GitObjKind::Blob, "blob"),
452            (GitObjKind::Tree, "tree"),
453            (GitObjKind::Commit, "commit"),
454            (GitObjKind::Tag, "tag"),
455        ] {
456            let gtype: GitType = kind.into();
457            assert_eq!(gtype.name(), name);
458            let body = b"hi".to_vec();
459            let raw = crate::gitobj::GitObject {
460                gtype,
461                body: body.clone(),
462            }
463            .raw();
464            let mut expect = Vec::new();
465            expect.extend_from_slice(name.as_bytes());
466            expect.push(b' ');
467            expect.extend_from_slice(body.len().to_string().as_bytes());
468            expect.push(0);
469            expect.extend_from_slice(&body);
470            assert_eq!(raw, expect, "{name} framing");
471        }
472    }
473}