Skip to main content

rac_engine/
revisions.rs

1//! Git revision materialization (`decided.services.revisions`) — the only
2//! git-consuming module of the watchkeeper path (ADR-043). A revision name
3//! becomes a temporary directory holding the corpus subpath at that
4//! revision, via `git archive --format=tar` (never mutates `.git`: no
5//! worktree registration, no locks) piped through a minimal in-process tar
6//! reader (no tar binary, no new dependencies).
7//!
8//! Contract mirrored from the oracle:
9//! - `git rev-parse --show-toplevel` (cwd = the corpus directory) finds the
10//!   work-tree root; failure -> `not a git repository: <directory>`; a
11//!   missing git binary -> `git executable not found` (both exit 2 at the
12//!   CLI as `decided: <msg>`).
13//! - `git rev-parse --verify --quiet <rev>^{commit}` (cwd = repo root);
14//!   nonzero -> `unknown revision: <rev>`.
15//! - `git archive --format=tar <rev> -- <pathspec>` (cwd = repo root); a
16//!   NONZERO exit is not an error — the subpath does not exist at that
17//!   revision and an EMPTY corpus is materialized (the fresh-adoption
18//!   "everything added" comparison).
19//! - The temporary directory is prefixed `decided-watchkeeper-` and removed
20//!   when the materialization guard drops. Its path never appears in any
21//!   output surface (all reported paths are corpus-relative).
22
23use std::io;
24use std::path::{Path, PathBuf};
25use std::process::{Command, Output, Stdio};
26use std::sync::atomic::{AtomicU64, Ordering};
27
28/// The two usage-error surfaces of revision resolution; `message()` is the
29/// text after the CLI's `decided: ` prefix.
30#[derive(Debug)]
31pub enum RevisionError {
32    /// `NotAGitRepository` — not inside a git work tree, or no git binary.
33    NotAGitRepository(String),
34    /// `RevisionNotFound` — the name does not resolve to a commit.
35    RevisionNotFound(String),
36}
37
38impl RevisionError {
39    pub fn message(&self) -> &str {
40        match self {
41            RevisionError::NotAGitRepository(m) => m,
42            RevisionError::RevisionNotFound(m) => m,
43        }
44    }
45}
46
47/// `_run_git(args, cwd)` — capture both streams, never check. Only a
48/// missing binary maps to `NotAGitRepository("git executable not found")`,
49/// like the oracle's `FileNotFoundError` arm.
50fn run_git(args: &[&str], cwd: &Path) -> Result<Output, RevisionError> {
51    Command::new("git")
52        .args(args)
53        .current_dir(cwd)
54        .stdin(Stdio::null())
55        .output()
56        .map_err(|_| {
57            // FileNotFoundError -> "git executable not found"; the oracle
58            // would crash on any other spawn failure — degrade to the same
59            // user-facing class (PORT-CONTRACT decision 3).
60            RevisionError::NotAGitRepository("git executable not found".to_string())
61        })
62}
63
64/// `repository_root(directory)` — the work-tree root containing `directory`.
65pub fn repository_root(directory: &str) -> Result<String, RevisionError> {
66    let out = run_git(&["rev-parse", "--show-toplevel"], Path::new(directory))?;
67    if !out.status.success() {
68        return Err(RevisionError::NotAGitRepository(format!(
69            "not a git repository: {directory}"
70        )));
71    }
72    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
73}
74
75/// One materialized revision: the guard owns the temporary directory and
76/// removes it (best effort) on drop, like the oracle's
77/// `tempfile.TemporaryDirectory` context.
78pub struct MaterializedRevision {
79    root: PathBuf,
80    /// The corpus directory inside the temp tree (`tmp/<subpath>`).
81    pub corpus: PathBuf,
82}
83
84impl Drop for MaterializedRevision {
85    fn drop(&mut self) {
86        let _ = std::fs::remove_dir_all(&self.root);
87    }
88}
89
90/// A fresh `decided-watchkeeper-` temp directory under the platform temp root
91/// (std honors TMPDIR like `tempfile` does).
92fn make_temp_dir() -> io::Result<PathBuf> {
93    static COUNTER: AtomicU64 = AtomicU64::new(0);
94    let base = std::env::temp_dir();
95    let pid = std::process::id();
96    loop {
97        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
98        let candidate = base.join(format!("decided-watchkeeper-{pid}-{n}"));
99        match std::fs::create_dir(&candidate) {
100            Ok(()) => return Ok(candidate),
101            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
102            Err(e) => return Err(e),
103        }
104    }
105}
106
107/// `materialized_revision(repo_root, rev, subpath)` — verify the commit,
108/// archive the subpath, extract into a temp tree, and yield `tmp/<subpath>`
109/// (created empty when the archive had nothing to say).
110pub fn materialize_revision(
111    repo_root: &str,
112    rev: &str,
113    subpath: &str,
114) -> Result<MaterializedRevision, RevisionError> {
115    let root = Path::new(repo_root);
116    let verify = run_git(
117        &["rev-parse", "--verify", "--quiet", &format!("{rev}^{{commit}}")],
118        root,
119    )?;
120    if !verify.status.success() {
121        return Err(RevisionError::RevisionNotFound(format!(
122            "unknown revision: {rev}"
123        )));
124    }
125
126    let pathspec = if subpath.is_empty() || subpath == "." {
127        "."
128    } else {
129        subpath
130    };
131    let archive = run_git(&["archive", "--format=tar", rev, "--", pathspec], root)?;
132
133    let tmp = make_temp_dir().map_err(|e| {
134        // No oracle-comparable surface exists for a failing temp root; the
135        // closest degrade is the not-a-repository class (never hit by the
136        // parity fixtures).
137        RevisionError::NotAGitRepository(format!("not a git repository: {e}"))
138    })?;
139    let guard_root = tmp.clone();
140    if archive.status.success() {
141        extract_tar(&archive.stdout, &tmp);
142    }
143    // A nonzero archive exit means the subpath does not exist at `rev`:
144    // materialize an empty corpus rather than failing the comparison.
145    let corpus = if pathspec == "." {
146        tmp
147    } else {
148        guard_root.join(subpath)
149    };
150    let _ = std::fs::create_dir_all(&corpus);
151    Ok(MaterializedRevision {
152        root: guard_root,
153        corpus,
154    })
155}
156
157// ---------------------------------------------------------------------------
158// Minimal tar reader — enough for `git archive --format=tar` output: ustar
159// headers with the split name/prefix fields, the pax global header git
160// always emits ('g', skipped), pax extended headers ('x', `path=` override),
161// GNU longname ('L'), directories ('5'), regular files ('0'/NUL), and
162// symlinks ('2', created best-effort). Entries with absolute or `..`
163// components are skipped defensively (tarfile's `filter="data"` would raise
164// there; git archive never produces them).
165// ---------------------------------------------------------------------------
166
167fn octal_field(bytes: &[u8]) -> u64 {
168    let mut out: u64 = 0;
169    for &b in bytes {
170        if matches!(b, b'0'..=b'7') {
171            out = out * 8 + u64::from(b - b'0');
172        }
173    }
174    out
175}
176
177fn cstr_field(bytes: &[u8]) -> String {
178    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
179    String::from_utf8_lossy(&bytes[..end]).into_owned()
180}
181
182/// Parse a pax extended-header payload (`<len> <key>=<value>\n` records)
183/// and return the `path` override, if any.
184fn pax_path(data: &[u8]) -> Option<String> {
185    let mut i = 0;
186    while i < data.len() {
187        // "<decimal-length> <key>=<value>\n" — length covers the whole record.
188        let space = data[i..].iter().position(|&b| b == b' ')?;
189        let len: usize = std::str::from_utf8(&data[i..i + space])
190            .ok()?
191            .parse()
192            .ok()?;
193        if len == 0 || i + len > data.len() {
194            return None;
195        }
196        let record = &data[i + space + 1..i + len];
197        if let Some(eq) = record.iter().position(|&b| b == b'=') {
198            let key = &record[..eq];
199            if key == b"path" {
200                let mut value = &record[eq + 1..];
201                if value.last() == Some(&b'\n') {
202                    value = &value[..value.len() - 1];
203                }
204                return Some(String::from_utf8_lossy(value).into_owned());
205            }
206        }
207        i += len;
208    }
209    None
210}
211
212/// True when every component of the '/'-separated relative path is safe to
213/// join under the extraction root.
214fn safe_relative(name: &str) -> bool {
215    !name.starts_with('/')
216        && !name
217            .split('/')
218            .any(|c| c == ".." || c.chars().any(|ch| ch == '\\'))
219}
220
221/// Extract a tar stream under `target`. Unknown/unsafe entries are skipped;
222/// extraction is best-effort (the oracle's crash surfaces here are out of
223/// the refereed contract — git-produced archives are always well-formed).
224fn extract_tar(data: &[u8], target: &Path) {
225    let mut offset = 0usize;
226    let mut pending_path: Option<String> = None;
227    while offset + 512 <= data.len() {
228        let header = &data[offset..offset + 512];
229        offset += 512;
230        if header.iter().all(|&b| b == 0) {
231            break; // end-of-archive zero block
232        }
233        let size = octal_field(&header[124..136]) as usize;
234        let padded = size.div_ceil(512) * 512;
235        if offset + size > data.len() {
236            break; // truncated
237        }
238        let body = &data[offset..offset + size];
239        let typeflag = header[156];
240        match typeflag {
241            b'g' => {} // pax global header (git's comment=<sha>) — skip
242            b'x' => {
243                if let Some(p) = pax_path(body) {
244                    pending_path = Some(p);
245                }
246            }
247            b'L' => {
248                // GNU longname: NUL-terminated name for the next entry.
249                pending_path = Some(cstr_field(body));
250            }
251            _ => {
252                let mut name = match pending_path.take() {
253                    Some(p) => p,
254                    None => {
255                        let base = cstr_field(&header[0..100]);
256                        let prefix = cstr_field(&header[345..500]);
257                        if prefix.is_empty() {
258                            base
259                        } else {
260                            format!("{prefix}/{base}")
261                        }
262                    }
263                };
264                let is_dir_name = name.ends_with('/');
265                while name.ends_with('/') {
266                    name.pop();
267                }
268                if !name.is_empty() && safe_relative(&name) {
269                    let dest = target.join(&name);
270                    match typeflag {
271                        b'5' => {
272                            let _ = std::fs::create_dir_all(&dest);
273                        }
274                        b'0' | 0 | b'7' if !is_dir_name => {
275                            if let Some(parent) = dest.parent() {
276                                let _ = std::fs::create_dir_all(parent);
277                            }
278                            let _ = std::fs::write(&dest, body);
279                        }
280                        b'2' => {
281                            if let Some(parent) = dest.parent() {
282                                let _ = std::fs::create_dir_all(parent);
283                            }
284                            #[cfg(unix)]
285                            {
286                                let link = cstr_field(&header[157..257]);
287                                let _ = std::os::unix::fs::symlink(&link, &dest);
288                            }
289                        }
290                        _ => {} // hardlinks/devices: never in git archives
291                    }
292                }
293            }
294        }
295        offset += padded;
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn octal_parses_padded_fields() {
305        assert_eq!(octal_field(b"0000644\0"), 0o644);
306        assert_eq!(octal_field(b"00000000173 "), 0o173);
307    }
308
309    #[test]
310    fn pax_path_record() {
311        let payload = b"33 path=decisions/some-long-name\n";
312        assert_eq!(pax_path(payload).as_deref(), Some("decisions/some-long-name"));
313    }
314
315    #[test]
316    fn rejects_escaping_names() {
317        assert!(!safe_relative("/abs"));
318        assert!(!safe_relative("a/../b"));
319        assert!(safe_relative("decisions/d1.md"));
320    }
321}