Skip to main content

kranz_engine/
validator_snapshot.rs

1//! Copy-on-write immutable validator snapshot — the definitive fix for the
2//! gap ticket `validator-immutability-proof` left open (and named in its
3//! "Remaining gap" section): "read-only" validator sessions ran in the
4//! mission's REAL session checkout, so the tamper fingerprint
5//! ([`crate::validator_integrity`]) could only catch writes at session
6//! boundaries and never saw write-then-revert inside the window.
7//!
8//! With this module the validator never sees the real checkout. Before each
9//! validator session (the primary and the single retry — the same sites the
10//! fingerprint wraps) the orchestrator builds a THROWAWAY snapshot of the
11//! session checkout under the mission's gitignored `runs/` scratch:
12//!
13//! 1. `git worktree add --detach <path> <head>` — the tree at HEAD, sharing
14//!    the object store (no clone cost).
15//! 2. The worker's uncommitted state is replayed into it: `git diff
16//!    --binary HEAD` captured in the real checkout and `git apply`ed in the
17//!    snapshot (tracked edits, staged or not), plus a byte copy of every
18//!    untracked non-ignored file. Validators judge exactly the tree the
19//!    worker left.
20//! 3. The snapshot's `target/` is a COPY of the real checkout's — the
21//!    warm-target constraint. Contract commands (`cargo test …`) must not
22//!    pay a cold full-workspace rebuild (~50 min in this repo): APFS
23//!    clonefile (`cp -c`) when the filesystem supports it (instant,
24//!    copy-on-write blocks), Linux reflink (`cp --reflink=always`)
25//!    otherwise, a plain byte copy when the target is small enough, and —
26//!    logged, naming the cost — a fresh empty target when no acceleration
27//!    exists and the copy would be prohibitive. The real `target/` is NEVER
28//!    shared or symlinked: a validator poisoning shared build artifacts
29//!    would inject into the real deliverable build.
30//!
31//! The session's cwd, the validator's contract-command cwd, and the
32//! sandbox profile's `session_cwd` all point at the snapshot, so where the
33//! sandbox can express it the real checkout is not even in the writable
34//! set. Only the verdict (PASS/FAIL/findings) crosses back; the snapshot is
35//! discarded after the round regardless of outcome (RAII, mirroring
36//! `preflight.rs`'s `DisposableWorktree` idiom). The deliverable gates and
37//! the out-of-contract sweep keep running against the REAL checkout.
38//!
39//! The snapshot is physical separation, NOT containment (13th-pass review,
40//! P1 — ticket `validator-mandatory-containment`): the worktree sits
41//! underneath the real repository hierarchy and references the shared git
42//! directory, so an UNWRAPPED validator (`enforce: off`, the old default)
43//! could still walk to the real checkout, modify tests, run them, and
44//! restore the bytes — modify → use → restore leaves no drift for the
45//! tripwire to catch. The mandatory validator wrap
46//! (`crate::sandbox::resolve_validator_containment`) closes that: every
47//! validator session runs under a Seatbelt/bwrap profile regardless of
48//! `sandbox.enforce`, with the snapshot as the sole writable root, the
49//! real checkout's source tree read-denied, and the shared `.git` readable
50//! but write-denied. Where the platform or backend cannot contain, the
51//! resolution FAILS CLOSED by default (ticket
52//! `validator-containment-degrade-fail-closed`); only the explicit
53//! `validatorAllowUncontainedDegrade` opt-in runs the round anyway, with
54//! the loud degradation decision recorded and the snapshot plus the
55//! tripwire as the remaining layers.
56//!
57//! Two honest limits, both covered by the fingerprint-turned-tripwire on
58//! the real checkout:
59//!
60//! - The file tree is isolated; git REFS are not. Worktrees share the
61//!   common `.git`, so a validator `git branch -f` in the snapshot still
62//!   moves shared refs where no containment applies — which the
63//!   tripwire's `for-each-ref` half catches at session end. (Under the
64//!   mandatory wrap the ref write is hard-denied by deny-default; the
65//!   tripwire is the defense-in-depth for the degraded platforms.) A
66//!   drift event now means the isolation itself failed.
67//! - Under an enforced sandbox the snapshot's gitdir (`.git/worktrees/<n>`)
68//!   lives outside the writable `session_cwd`, so validator git commands
69//!   that try to refresh the index degrade (read-only git still works;
70//!   commits fail — which validators should never need).
71
72use crate::error::{EngineError, Result};
73use crate::git_ops::GitRepo;
74use std::path::{Path, PathBuf};
75use std::time::{Duration, Instant};
76
77/// Above this size a PLAIN byte copy of `target/` costs more in wall clock
78/// and doubled disk than the cold rebuild it exists to avoid — with no
79/// clonefile/reflink acceleration the snapshot starts with an empty target
80/// instead, and the cost is named in the `validation.snapshot` event.
81const PLAIN_COPY_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
82
83/// Which tier warmed the snapshot's `target/` — recorded on the
84/// `validation.snapshot` event.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum TargetCopyTier {
87    /// APFS clonefile (`cp -c`): instant, copy-on-write blocks.
88    Clonefile,
89    /// Linux reflink (`cp --reflink=always` succeeded).
90    Reflink,
91    /// Plain byte copy — no acceleration, but the target was small enough.
92    Copy,
93    /// No acceleration and the target was too big to copy: the snapshot
94    /// starts with an EMPTY target (cold rebuild cost, logged).
95    Fresh,
96    /// The session checkout has no `target/` dir at all.
97    Absent,
98}
99
100impl TargetCopyTier {
101    /// Wire form for the `validation.snapshot` event payload.
102    pub fn as_str(&self) -> &'static str {
103        match self {
104            TargetCopyTier::Clonefile => "clonefile",
105            TargetCopyTier::Reflink => "reflink",
106            TargetCopyTier::Copy => "copy",
107            TargetCopyTier::Fresh => "fresh",
108            TargetCopyTier::Absent => "absent",
109        }
110    }
111}
112
113/// The plain-copy-or-fresh decision, split out so each tier's fallback is
114/// testable without a >2 GiB fixture (and because on clone-capable hosts the
115/// size check never runs — the fast tiers win first).
116fn pick_plain_or_fresh(target_bytes: u64) -> TargetCopyTier {
117    if target_bytes <= PLAIN_COPY_MAX_BYTES {
118        TargetCopyTier::Copy
119    } else {
120        TargetCopyTier::Fresh
121    }
122}
123
124/// RAII guard for one validator session's throwaway checkout. Drop removes
125/// it best-effort — `git worktree remove --force` (which also deletes the
126/// directory), a dir sweep for anything git declined, and a prune of stale
127/// administrative entries — so pass, fail, tamper-block, or error return
128/// can never leak it.
129pub struct ValidatorSnapshot {
130    /// Handle the worktree was added from (the real session checkout's
131    /// repo) — removal/prune run through it.
132    repo: GitRepo,
133    path: PathBuf,
134    target_tier: TargetCopyTier,
135    detail: Option<String>,
136    creation: Duration,
137}
138
139impl ValidatorSnapshot {
140    /// `git ls-files -v` inside the snapshot, filtered to the flag tags
141    /// (`S` = skip-worktree, lowercase = assume-unchanged). The snapshot
142    /// starts with a fresh, flag-free index, so any flag present after the
143    /// session was set BY THE VALIDATOR — hidden modifications that would
144    /// corrupt the verdict while leaving no trace once the snapshot is
145    /// discarded (4th-pass review). Returns the offending lines (capped)
146    /// when any flag is set.
147    pub fn validator_set_index_flags(&self) -> Result<Vec<String>> {
148        let repo = GitRepo::open(&self.path)?;
149        let flags = repo.ls_files_v()?;
150        Ok(flags
151            .lines()
152            .filter(|line| {
153                let Some(tag) = line.chars().next() else {
154                    return false;
155                };
156                tag == 'S' || tag.is_ascii_lowercase()
157            })
158            .take(5)
159            .map(str::to_string)
160            .collect())
161    }
162
163    /// Snapshot `repo`'s checkout (HEAD + uncommitted diff + untracked
164    /// files + warmed `target/`) into a detached worktree at `path`.
165    /// Idempotent against a stale leftover from a crashed round: any prior
166    /// worktree/dir at `path` is cleared first (mirroring
167    /// `DisposableWorktree::create`'s crash sweep).
168    pub fn create(repo: &GitRepo, path: &Path) -> Result<Self> {
169        let started = Instant::now();
170        let _ = repo.remove_worktree(path);
171        let _ = std::fs::remove_dir_all(path);
172        if let Some(parent) = path.parent() {
173            std::fs::create_dir_all(parent)
174                .map_err(|e| EngineError::Git(format!("create {}: {e}", parent.display())))?;
175        }
176
177        // Capture the real checkout's state BEFORE the add, so the snapshot
178        // replays exactly what the worker left.
179        let repo = &repo.with_hooks_disabled()?;
180        let head = repo.head_sha()?;
181        let diff = repo.diff_head()?;
182        let untracked = repo.untracked_files()?;
183        // Fail closed on skip-worktree/assume-unchanged index flags
184        // (4th-pass review): `git diff HEAD` and `git status` are BLIND to
185        // flagged files, so a flagged modification can neither be replayed
186        // here nor seen by the validator — a worker could hide source or
187        // test edits from validation entirely. The snapshot cannot
188        // faithfully represent a flagged checkout, so refuse to build one.
189        let flags = repo.ls_files_v()?;
190        let flagged: Vec<&str> = flags
191            .lines()
192            .filter(|line| {
193                let Some(tag) = line.chars().next() else {
194                    return false;
195                };
196                // 'S' = skip-worktree; any lowercase tag = assume-unchanged
197                // (ls-files -v lowercases the tag for flagged entries).
198                tag == 'S' || tag.is_ascii_lowercase()
199            })
200            .collect();
201        if !flagged.is_empty() {
202            return Err(EngineError::InvalidState(format!(
203                "refusing validator snapshot over skip-worktree/assume-unchanged \
204                 index flags (modifications hidden from git): {}",
205                flagged
206                    .iter()
207                    .take(5)
208                    .copied()
209                    .collect::<Vec<_>>()
210                    .join(", ")
211            )));
212        }
213        repo.add_detached_worktree(path, &head)?;
214
215        // From here a failure must not leak the worktree: build the guard
216        // first and let an `?` drop it.
217        let mut snapshot = ValidatorSnapshot {
218            repo: repo.clone(),
219            path: path.to_path_buf(),
220            target_tier: TargetCopyTier::Absent,
221            detail: None,
222            creation: Duration::default(),
223        };
224        let (tier, detail) = snapshot.populate(repo.root(), &diff, &untracked)?;
225        snapshot.target_tier = tier;
226        snapshot.detail = detail;
227        snapshot.creation = started.elapsed();
228        Ok(snapshot)
229    }
230
231    /// Replay the worker's uncommitted state into the fresh worktree and
232    /// warm its `target/`. Separated from [`Self::create`] so the guard
233    /// exists (and cleans up) across every fallible step.
234    fn populate(
235        &self,
236        session_root: &Path,
237        diff: &str,
238        untracked: &[std::ffi::OsString],
239    ) -> Result<(TargetCopyTier, Option<String>)> {
240        let snap_repo = GitRepo::open(&self.path)?;
241        if !diff.trim().is_empty() {
242            // The patch is runtime scratch: a sibling of the snapshot under
243            // the same gitignored runs/ dir, never inside either checkout
244            // (it would show up as an untracked file in both).
245            let patch = self.path.with_extension("patch");
246            std::fs::write(&patch, diff).map_err(|e| {
247                EngineError::Git(format!("write snapshot patch {}: {e}", patch.display()))
248            })?;
249            let applied = snap_repo.apply_patch(&patch);
250            let _ = std::fs::remove_file(&patch);
251            applied?;
252        }
253        let skip_note = copy_untracked(session_root, &self.path, untracked)?;
254        let (tier, mut detail) = warm_target(session_root, &self.path);
255        if let (Some(note), Some(existing)) = (&skip_note, &mut detail) {
256            existing.push_str("; ");
257            existing.push_str(note);
258        } else if let Some(note) = skip_note {
259            detail = Some(note);
260        }
261        Ok((tier, detail))
262    }
263
264    /// Root of the throwaway checkout — the validator session's cwd.
265    pub fn path(&self) -> &Path {
266        &self.path
267    }
268
269    /// Which tier warmed the snapshot's `target/`.
270    pub fn target_tier(&self) -> TargetCopyTier {
271        self.target_tier
272    }
273
274    /// Extra context for the `validation.snapshot` event (notably the named
275    /// cost of a `fresh` tier).
276    pub fn detail(&self) -> Option<&str> {
277        self.detail.as_deref()
278    }
279
280    /// Wall-clock cost of building the snapshot.
281    pub fn creation(&self) -> Duration {
282        self.creation
283    }
284}
285
286impl Drop for ValidatorSnapshot {
287    fn drop(&mut self) {
288        let _ = self.repo.remove_worktree(&self.path);
289        let _ = std::fs::remove_dir_all(&self.path);
290        let _ = self.repo.prune_worktrees();
291    }
292}
293
294/// Byte-copy the worker's untracked non-ignored files into the snapshot,
295/// preserving repo-relative paths. A file that vanishes between the
296/// `ls-files` listing and the copy is skipped (a racing external process
297/// must not fail the round); real I/O errors propagate.
298/// Copy the untracked files into the snapshot WITHOUT following any
299/// link: only regular files cross (5th-pass review — an untracked
300/// symlink could point a denied authority file, e.g. `.kranz/serve.token`,
301/// into the snapshot where the validator reads it freely, and a FIFO or
302/// device link would block the build forever). Non-regular entries are
303/// skipped and recorded in the returned note (never opened), so the build
304/// neither follows nor hangs; the CoW design means the real checkout is
305/// untouched regardless.
306fn copy_untracked(
307    session_root: &Path,
308    snapshot_root: &Path,
309    untracked: &[std::ffi::OsString],
310) -> Result<Option<String>> {
311    let mut skipped: Vec<String> = Vec::new();
312    for rel in untracked {
313        let src = session_root.join(rel);
314        let dst = snapshot_root.join(rel);
315        let metadata = match std::fs::symlink_metadata(&src) {
316            Ok(m) => m,
317            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
318            Err(e) => {
319                return Err(EngineError::Git(format!(
320                    "snapshot untracked stat {}: {e}",
321                    src.display()
322                )))
323            }
324        };
325        if !metadata.file_type().is_file() {
326            let kind = if metadata.file_type().is_symlink() {
327                "symlink"
328            } else if metadata.file_type().is_dir() {
329                "dir"
330            } else {
331                "special"
332            };
333            skipped.push(format!("{} ({kind})", rel.to_string_lossy()));
334            continue;
335        }
336        if let Some(parent) = dst.parent() {
337            std::fs::create_dir_all(parent).map_err(|e| {
338                EngineError::Git(format!("snapshot untracked {}: {e}", parent.display()))
339            })?;
340        }
341        match std::fs::copy(&src, &dst) {
342            Ok(_) => {}
343            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
344            Err(e) => {
345                return Err(EngineError::Git(format!(
346                    "snapshot untracked copy {} -> {}: {e}",
347                    src.display(),
348                    dst.display()
349                )))
350            }
351        }
352    }
353    Ok((!skipped.is_empty()).then(|| {
354        format!(
355            "skipped {} non-regular untracked entr{}: {}",
356            skipped.len(),
357            if skipped.len() == 1 { "y" } else { "ies" },
358            skipped
359                .iter()
360                .take(5)
361                .cloned()
362                .collect::<Vec<_>>()
363                .join(", ")
364        )
365    }))
366}
367
368/// Warm the snapshot's `target/` from the session checkout's, trying each
369/// tier in cost order and falling through on failure: clonefile → reflink →
370/// plain copy (size-capped) → fresh empty target. Returns the tier and, for
371/// `fresh`, the detail naming the cost.
372fn warm_target(session_root: &Path, snapshot_root: &Path) -> (TargetCopyTier, Option<String>) {
373    let src = session_root.join("target");
374    if !src.is_dir() {
375        return (TargetCopyTier::Absent, None);
376    }
377    let dst = snapshot_root.join("target");
378    if copy_dir_clonefile(&src, &dst) {
379        return (TargetCopyTier::Clonefile, None);
380    }
381    if copy_dir_reflink(&src, &dst) {
382        return (TargetCopyTier::Reflink, None);
383    }
384    let bytes = dir_size_bytes(&src);
385    match pick_plain_or_fresh(bytes) {
386        TargetCopyTier::Copy => match copy_dir_plain(&src, &dst) {
387            Ok(()) => (TargetCopyTier::Copy, None),
388            Err(e) => {
389                let _ = std::fs::remove_dir_all(&dst);
390                let detail = format!(
391                    "target/ plain copy failed ({e}); snapshot starts with an empty target \
392                     and pays a cold rebuild"
393                );
394                tracing::warn!("{detail}");
395                (TargetCopyTier::Fresh, Some(detail))
396            }
397        },
398        _ => {
399            let detail = format!(
400                "no clonefile/reflink acceleration and target/ is {} GiB (plain-copy cap {} GiB); \
401                 snapshot starts with an empty target and pays a cold rebuild rather than copy",
402                bytes / (1024 * 1024 * 1024),
403                PLAIN_COPY_MAX_BYTES / (1024 * 1024 * 1024),
404            );
405            tracing::warn!("{detail}");
406            (TargetCopyTier::Fresh, Some(detail))
407        }
408    }
409}
410
411/// `cp -c -R src dst` (APFS clonefile). `false` on any failure — non-macOS
412/// `cp` has no `-c`, and a non-APFS volume makes clonefile itself fail — so
413/// the caller falls through to the next tier. A partial copy is swept
414/// before returning `false`. Never shares or links the source: the clone is
415/// copy-on-write, owned by the destination. Shared with the contract Cargo
416/// cache seeding ([`crate::agent_env`]), which seeds per-env copies of the
417/// operator's registry/git caches through the same tier order.
418pub(crate) fn copy_dir_clonefile(src: &Path, dst: &Path) -> bool {
419    run_cp(&["-c", "-R"], src, dst)
420}
421
422/// `cp --reflink=always -R src dst` (GNU coreutils). `always` (not `auto`)
423/// so a non-reflink filesystem FAILS LOUDLY here and the caller falls
424/// through to the size-capped plain copy instead of silently paying one.
425pub(crate) fn copy_dir_reflink(src: &Path, dst: &Path) -> bool {
426    run_cp(&["--reflink=always", "-R"], src, dst)
427}
428
429fn run_cp(extra_flags: &[&str], src: &Path, dst: &Path) -> bool {
430    let status = std::process::Command::new("cp")
431        .args(extra_flags)
432        .arg(src)
433        .arg(dst)
434        .status();
435    match status {
436        Ok(s) if s.success() => true,
437        _ => {
438            let _ = std::fs::remove_dir_all(dst);
439            false
440        }
441    }
442}
443
444/// Total byte size of `dir` (metadata walk, best-effort: unreadable entries
445/// count as zero). Cheap even on a large `target/` or Cargo cache — it reads
446/// no file contents.
447pub(crate) fn dir_size_bytes(dir: &Path) -> u64 {
448    let mut total = 0u64;
449    let mut stack = vec![dir.to_path_buf()];
450    while let Some(dir) = stack.pop() {
451        if let Ok(entries) = std::fs::read_dir(&dir) {
452            for entry in entries.flatten() {
453                if let Ok(meta) = entry.metadata() {
454                    if meta.is_dir() {
455                        stack.push(entry.path());
456                    } else {
457                        total += meta.len();
458                    }
459                }
460            }
461        }
462    }
463    total
464}
465
466/// Whether `dir`'s total byte size exceeds `limit`, stopping the metadata
467/// walk the moment the answer is known. The cheap pre-check the contract
468/// Cargo cache seeding ([`crate::agent_env`]) runs on EVERY generated child
469/// env, where a full walk of a multi-GiB cache would itself be the cost the
470/// copy ceiling exists to avoid.
471pub(crate) fn dir_size_exceeds(dir: &Path, limit: u64) -> bool {
472    let mut total = 0u64;
473    let mut stack = vec![dir.to_path_buf()];
474    while let Some(dir) = stack.pop() {
475        if let Ok(entries) = std::fs::read_dir(&dir) {
476            for entry in entries.flatten() {
477                if let Ok(meta) = entry.metadata() {
478                    if meta.is_dir() {
479                        stack.push(entry.path());
480                    } else {
481                        total += meta.len();
482                        if total > limit {
483                            return true;
484                        }
485                    }
486                }
487            }
488        }
489    }
490    false
491}
492
493/// Recursive plain byte copy of regular files and directories only. Pin
494/// each directory and open files no-follow: worker-controlled cache links
495/// must never make the engine copy denied authority into a readable snapshot.
496pub(crate) fn copy_dir_plain(src: &Path, dst: &Path) -> std::io::Result<()> {
497    use cap_fs_ext::DirExt as _;
498    let (src_parent, src_name) =
499        crate::paths::open_parent_nofollow(src).map_err(std::io::Error::other)?;
500    let source = src_parent.open_dir_nofollow(src_name)?;
501    let (dst_parent, dst_name) =
502        crate::paths::open_parent_nofollow(dst).map_err(std::io::Error::other)?;
503    match dst_parent.create_dir(&dst_name) {
504        Ok(()) => {}
505        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
506        Err(e) => return Err(e),
507    }
508    let destination = dst_parent.open_dir_nofollow(dst_name)?;
509    copy_dir_contents(&source, &destination)
510}
511
512fn copy_dir_contents(src: &cap_std::fs::Dir, dst: &cap_std::fs::Dir) -> std::io::Result<()> {
513    use cap_fs_ext::{DirExt as _, OpenOptionsFollowExt as _};
514    use cap_primitives::fs::FollowSymlinks;
515    for entry in src.entries()? {
516        let entry = entry?;
517        let name = entry.file_name();
518        let kind = entry.file_type()?;
519        if kind.is_dir() {
520            let source = src.open_dir_nofollow(&name)?;
521            dst.create_dir(&name)?;
522            let destination = dst.open_dir_nofollow(&name)?;
523            copy_dir_contents(&source, &destination)?;
524        } else if kind.is_file() {
525            let mut options = cap_std::fs::OpenOptions::new();
526            options.read(true).follow(FollowSymlinks::No);
527            #[cfg(unix)]
528            {
529                use cap_std::fs::OpenOptionsExt as _;
530                // A racing replacement with a FIFO must not block the engine.
531                options.custom_flags(libc::O_NONBLOCK);
532            }
533            let mut source = src.open_with(&name, &options)?.into_std();
534            let metadata = source.metadata()?;
535            if !metadata.is_file() {
536                return Err(std::io::Error::other("cache entry is not a regular file"));
537            }
538            let mut options = cap_std::fs::OpenOptions::new();
539            options
540                .write(true)
541                .create_new(true)
542                .follow(FollowSymlinks::No);
543            let mut destination = dst.open_with(&name, &options)?.into_std();
544            std::io::copy(&mut source, &mut destination)?;
545            destination.set_permissions(metadata.permissions())?;
546        } else {
547            return Err(std::io::Error::other(
548                "refusing a symlink or special cache entry",
549            ));
550        }
551    }
552    Ok(())
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[cfg(unix)]
560    #[test]
561    fn snapshot_plain_copy_refuses_authority_links_and_preserves_executables() {
562        use std::os::unix::fs::{symlink, PermissionsExt};
563        let dir = tempfile::tempdir().unwrap();
564        let source = dir.path().join("target");
565        std::fs::create_dir(&source).unwrap();
566        let authority = dir.path().join("serve.token");
567        std::fs::write(&authority, "fake-authority").unwrap();
568        symlink(&authority, source.join("leak")).unwrap();
569        let destination = dir.path().join("copy");
570        assert!(copy_dir_plain(&source, &destination).is_err());
571        assert!(!destination.join("leak").exists());
572        assert_eq!(
573            std::fs::read_to_string(&authority).unwrap(),
574            "fake-authority"
575        );
576
577        std::fs::remove_file(source.join("leak")).unwrap();
578        std::fs::write(source.join("test-bin"), "executable").unwrap();
579        std::fs::set_permissions(
580            source.join("test-bin"),
581            std::fs::Permissions::from_mode(0o755),
582        )
583        .unwrap();
584        copy_dir_plain(&source, &destination).unwrap();
585        assert_eq!(
586            std::fs::read(destination.join("test-bin")).unwrap(),
587            b"executable"
588        );
589        assert_ne!(
590            std::fs::metadata(destination.join("test-bin"))
591                .unwrap()
592                .permissions()
593                .mode()
594                & 0o111,
595            0
596        );
597
598        let linked_destination = dir.path().join("linked-copy");
599        symlink(&source, &linked_destination).unwrap();
600        assert!(copy_dir_plain(&source, &linked_destination).is_err());
601    }
602
603    /// A temp git repo with one committed file, or None when git is not on
604    /// PATH (mirrors the orchestrator tests' `lessons_test_repo` skip).
605    fn test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
606        let git_ok = std::process::Command::new("git")
607            .arg("--version")
608            .output()
609            .map(|o| o.status.success())
610            .unwrap_or(false);
611        if !git_ok {
612            crate::test_capability::skip(
613                crate::test_capability::capability::GIT,
614                "git is not on PATH",
615            );
616            return None;
617        }
618        let dir = tempfile::tempdir().expect("tempdir");
619        let run = |args: &[&str]| {
620            let out = std::process::Command::new("git")
621                .args(args)
622                .current_dir(dir.path())
623                .output()
624                .expect("spawn git");
625            assert!(out.status.success(), "git {args:?} failed: {out:?}");
626        };
627        if !std::process::Command::new("git")
628            .args(["init", "-b", "main"])
629            .current_dir(dir.path())
630            .output()
631            .map(|o| o.status.success())
632            .unwrap_or(false)
633        {
634            run(&["init"]);
635            run(&["symbolic-ref", "HEAD", "refs/heads/main"]);
636        }
637        run(&["config", "user.name", "test"]);
638        run(&["config", "user.email", "test@example.com"]);
639        std::fs::write(dir.path().join("README.md"), "hello\n").unwrap();
640        std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
641        run(&["add", "-A"]);
642        run(&["commit", "-m", "init"]);
643        let root = dir.path().to_path_buf();
644        Some((dir, root))
645    }
646
647    /// The tier-selection fallback: plain copy up to the cap, fresh (with
648    /// the cost named) beyond it — no clone-capable host reaches this.
649    #[test]
650    fn pick_plain_or_fresh_caps_plain_copy() {
651        assert_eq!(pick_plain_or_fresh(0), TargetCopyTier::Copy);
652        assert_eq!(
653            pick_plain_or_fresh(PLAIN_COPY_MAX_BYTES),
654            TargetCopyTier::Copy
655        );
656        assert_eq!(
657            pick_plain_or_fresh(PLAIN_COPY_MAX_BYTES + 1),
658            TargetCopyTier::Fresh
659        );
660    }
661
662    /// 4th-pass review: a checkout with skip-worktree/assume-unchanged
663    /// flags cannot be faithfully snapshotted (`git diff` and `git status`
664    /// are blind to flagged files), so creation refuses fail-closed.
665    #[test]
666    fn create_refuses_skip_worktree_flags() {
667        let Some((dir, root)) = test_repo() else {
668            return;
669        };
670        let repo = GitRepo::open(&root).unwrap();
671        let run = |args: &[&str]| {
672            let out = std::process::Command::new("git")
673                .args(args)
674                .current_dir(&root)
675                .output()
676                .expect("spawn git");
677            assert!(out.status.success(), "git {args:?} failed: {out:?}");
678        };
679        // A flagged modification is invisible to git diff/status: the
680        // snapshot would silently validate the WRONG content.
681        run(&["update-index", "--skip-worktree", "README.md"]);
682        std::fs::write(root.join("README.md"), "hidden modification\n").unwrap();
683
684        let err = match ValidatorSnapshot::create(&repo, &root.join("snap")) {
685            Ok(_) => panic!("a flagged checkout must not build a snapshot"),
686            Err(err) => err,
687        };
688        assert!(
689            err.to_string().contains("skip-worktree"),
690            "error must name the flag class: {err}"
691        );
692        drop(dir);
693    }
694
695    /// The snapshot starts flag-free, so any flag afterwards was set by the
696    /// session: validator_set_index_flags detects exactly that.
697    #[test]
698    fn validator_set_index_flags_detects_flags_set_inside_the_snapshot() {
699        let Some((_dir, root)) = test_repo() else {
700            return;
701        };
702        let repo = GitRepo::open(&root).unwrap();
703        let snapshot =
704            ValidatorSnapshot::create(&repo, &root.join("snap")).expect("clean checkout builds");
705        assert!(
706            snapshot.validator_set_index_flags().unwrap().is_empty(),
707            "a fresh snapshot has no flags"
708        );
709
710        // Simulate the validator's move INSIDE the snapshot.
711        let out = std::process::Command::new("git")
712            .args(["update-index", "--skip-worktree", "README.md"])
713            .current_dir(snapshot.path())
714            .output()
715            .expect("spawn git");
716        assert!(out.status.success(), "git update-index failed: {out:?}");
717
718        let flags = snapshot.validator_set_index_flags().unwrap();
719        assert_eq!(flags.len(), 1, "{flags:?}");
720        assert!(flags[0].starts_with('S'), "{flags:?}");
721        assert!(flags[0].contains("README.md"), "{flags:?}");
722    }
723
724    /// The snapshot sees exactly what the worker left: the committed tree,
725    /// unstaged tracked edits, staged new files, and untracked non-ignored
726    /// files — and drop removes the worktree.
727    #[test]
728    fn snapshot_replays_uncommitted_state_and_cleans_up() {
729        let Some((_dir, root)) = test_repo() else {
730            return;
731        };
732        // Mission roots are canonicalized. On Windows that produces a
733        // verbatim path, which Git cannot consume as a patch-file argument.
734        let root = std::fs::canonicalize(root).unwrap();
735        #[cfg(windows)]
736        assert!(root.to_string_lossy().starts_with(r"\\?\"));
737        let repo = GitRepo::open(&root).unwrap();
738        let head = repo.head_sha().unwrap();
739
740        // The worker's leavings: an unstaged edit, a staged new file, an
741        // untracked file in a new dir, and an ignored artifact (must NOT
742        // cross into the snapshot as an untracked copy).
743        std::fs::write(root.join("README.md"), "hello\nworker edit\n").unwrap();
744        std::fs::write(root.join("staged.rs"), "fn staged() {}\n").unwrap();
745        let staged = std::process::Command::new("git")
746            .args(["add", "staged.rs"])
747            .current_dir(&root)
748            .output()
749            .expect("git add");
750        assert!(staged.status.success(), "git add: {staged:?}");
751        std::fs::create_dir_all(root.join("notes")).unwrap();
752        std::fs::write(root.join("notes/todo.txt"), "uncommitted\n").unwrap();
753        std::fs::create_dir_all(root.join("target/debug")).unwrap();
754        std::fs::write(root.join("target/debug/obj.o"), "obj").unwrap();
755
756        let snapshot_dir = tempfile::tempdir().unwrap();
757        let snap_path = std::fs::canonicalize(snapshot_dir.path())
758            .unwrap()
759            .join("snapshot under test");
760        let snapshot = ValidatorSnapshot::create(&repo, &snap_path).unwrap();
761
762        // Content comparisons normalize EOL: windows-latest runs with
763        // core.autocrlf=true, so the worktree checkout + git apply write
764        // CRLF — the replay is about content identity, not EOL convention.
765        let read_normalized =
766            |path: &Path| std::fs::read_to_string(path).unwrap().replace("\r\n", "\n");
767        assert_eq!(
768            GitRepo::open(&snap_path).unwrap().head_sha().unwrap(),
769            head,
770            "snapshot is detached at the real checkout's HEAD"
771        );
772        assert_eq!(
773            read_normalized(&snap_path.join("README.md")),
774            "hello\nworker edit\n",
775            "unstaged tracked edit must be visible in the snapshot"
776        );
777        assert_eq!(
778            read_normalized(&snap_path.join("staged.rs")),
779            "fn staged() {}\n",
780            "staged new file must be visible in the snapshot"
781        );
782        assert_eq!(
783            read_normalized(&snap_path.join("notes/todo.txt")),
784            "uncommitted\n",
785            "untracked files must be visible in the snapshot"
786        );
787        // The warmed target copy carries the content but shares nothing:
788        // writing through the snapshot's copy must not touch the real one.
789        assert_eq!(
790            std::fs::read_to_string(snap_path.join("target/debug/obj.o")).unwrap(),
791            "obj"
792        );
793        std::fs::write(snap_path.join("target/debug/obj.o"), "poisoned").unwrap();
794        assert_eq!(
795            std::fs::read_to_string(root.join("target/debug/obj.o")).unwrap(),
796            "obj",
797            "the warmed target is a copy, never a share of the real target/"
798        );
799
800        // The real checkout is untouched by the snapshot build itself.
801        assert_eq!(
802            repo.head_sha().unwrap(),
803            head,
804            "snapshot creation must not move the real HEAD"
805        );
806
807        let snap_path_copy = snap_path.clone();
808        drop(snapshot);
809        assert!(
810            !snap_path_copy.exists(),
811            "drop discards the snapshot worktree"
812        );
813        // The administrative entry is pruned too.
814        let listed = std::process::Command::new("git")
815            .args(["worktree", "list", "--porcelain"])
816            .current_dir(&root)
817            .output()
818            .expect("git worktree list");
819        let listed = String::from_utf8_lossy(&listed.stdout);
820        assert!(!listed.contains("snap-shot-under-test"), "{listed}");
821    }
822
823    /// A stale leftover at the snapshot path (crashed round) is cleared and
824    /// replaced, mirroring `DisposableWorktree::create`'s crash sweep.
825    #[test]
826    fn create_clears_stale_leftover() {
827        let Some((_dir, root)) = test_repo() else {
828            return;
829        };
830        let repo = GitRepo::open(&root).unwrap();
831        let snap_path = root.parent().unwrap().join("snap-stale");
832        std::fs::create_dir_all(&snap_path).unwrap();
833        std::fs::write(snap_path.join("leftover.txt"), "stale").unwrap();
834
835        let snapshot = ValidatorSnapshot::create(&repo, &snap_path).unwrap();
836        assert!(snap_path.join("README.md").exists());
837        assert!(
838            !snap_path.join("leftover.txt").exists(),
839            "the stale dir is swept, not merged into"
840        );
841        drop(snapshot);
842    }
843
844    /// No `target/` in the session checkout: tier `absent`, nothing copied.
845    #[test]
846    fn warm_target_absent_without_target_dir() {
847        let dir = tempfile::tempdir().unwrap();
848        let (tier, detail) = warm_target(dir.path(), &dir.path().join("snap"));
849        assert_eq!(tier, TargetCopyTier::Absent);
850        assert_eq!(detail, None);
851    }
852
853    /// Every host reaches SOME warm tier with the content intact; the fast
854    /// tiers (clonefile/reflink) and the plain copy are indistinguishable by
855    /// bytes, so the content is the assertion and the tier is host-dependent.
856    #[test]
857    fn warm_target_copies_content_on_any_tier() {
858        let dir = tempfile::tempdir().unwrap();
859        let session = dir.path().join("session");
860        let snap = dir.path().join("snap");
861        std::fs::create_dir_all(session.join("target/debug/deps")).unwrap();
862        std::fs::write(session.join("target/debug/deps/lib.rlib"), "rlib-bytes").unwrap();
863        std::fs::create_dir_all(&snap).unwrap();
864
865        let (tier, detail) = warm_target(&session, &snap);
866        assert!(
867            matches!(
868                tier,
869                TargetCopyTier::Clonefile | TargetCopyTier::Reflink | TargetCopyTier::Copy
870            ),
871            "a small target warms via some copy tier, got {tier:?} ({detail:?})"
872        );
873        assert_eq!(
874            std::fs::read_to_string(snap.join("target/debug/deps/lib.rlib")).unwrap(),
875            "rlib-bytes",
876            "the warm copy carries the bytes regardless of tier"
877        );
878    }
879
880    /// APFS (this host, every modern macOS CI runner): the clonefile tier
881    /// wins and is effectively instant. Probes clonefile support first so a
882    /// hypothetical non-APFS mac skips rather than fails.
883    #[cfg(target_os = "macos")]
884    #[test]
885    fn warm_target_prefers_clonefile_on_apfs() {
886        let dir = tempfile::tempdir().unwrap();
887        let probe_src = dir.path().join("probe");
888        std::fs::write(&probe_src, "probe").unwrap();
889        if !copy_dir_clonefile(&probe_src, &dir.path().join("probe-clone")) {
890            eprintln!("skipping test: clonefile unsupported on this volume");
891            return;
892        }
893        let session = dir.path().join("session");
894        let snap = dir.path().join("snap");
895        std::fs::create_dir_all(session.join("target")).unwrap();
896        std::fs::write(session.join("target/artifact.o"), "bytes").unwrap();
897        std::fs::create_dir_all(&snap).unwrap();
898
899        let (tier, _) = warm_target(&session, &snap);
900        assert_eq!(tier, TargetCopyTier::Clonefile);
901        assert_eq!(
902            std::fs::read_to_string(snap.join("target/artifact.o")).unwrap(),
903            "bytes"
904        );
905    }
906
907    /// No clone acceleration + a target over the plain-copy cap: tier
908    /// `fresh` with the cost named, and NO copy attempted (dst stays absent).
909    /// The over-cap size comes from a sparse file — no real blocks.
910    #[cfg(unix)]
911    #[test]
912    fn warm_target_fresh_when_copy_prohibitive() {
913        let dir = tempfile::tempdir().unwrap();
914        let session = dir.path().join("session");
915        let snap = dir.path().join("snap");
916        std::fs::create_dir_all(session.join("target")).unwrap();
917        std::fs::write(session.join("target/big.bin"), "").unwrap();
918        std::fs::File::options()
919            .write(true)
920            .open(session.join("target/big.bin"))
921            .unwrap()
922            .set_len(PLAIN_COPY_MAX_BYTES + 1)
923            .unwrap();
924        std::fs::create_dir_all(&snap).unwrap();
925
926        // The size gate must select fresh for the over-cap sparse target…
927        assert_eq!(
928            pick_plain_or_fresh(dir_size_bytes(&session.join("target"))),
929            TargetCopyTier::Fresh,
930            "over-cap sparse target must select fresh"
931        );
932        // …and the full cascade honours it wherever no fast tier exists. On
933        // a clone-capable host (this one) a fast tier legitimately wins
934        // first — clonefile handles a sparse 2 GiB instantly, the size gate
935        // never runs — so there assert the copy happened instead.
936        let (tier, detail) = warm_target(&session, &snap);
937        if tier == TargetCopyTier::Fresh {
938            let detail = detail.expect("fresh tier names the cost");
939            assert!(detail.contains("GiB"), "{detail}");
940            assert!(detail.contains("cold rebuild"), "{detail}");
941            assert!(
942                !snap.join("target/big.bin").exists(),
943                "fresh tier does not attempt the copy"
944            );
945        } else {
946            assert!(
947                snap.join("target/big.bin").exists(),
948                "a fast tier won on this clone-capable host: the copy exists"
949            );
950        }
951    }
952}