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}