Skip to main content

magi/
incremental.rs

1//! Disposable incremental generations with creation-time, per-file provenance.
2//!
3//! Legacy incremental directories are never adopted. A generation is fresh for
4//! one compiler invocation; incomplete or changed generations consume their slot
5//! until an operator investigates. This is a bound, not authority to wipe them.
6use anyhow::{Context, Result, ensure};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10use std::ffi::OsString;
11use std::fs::{self, File, OpenOptions};
12use std::io::Read;
13use std::path::{Path, PathBuf};
14
15/// Stable resource-admission marker emitted by the private compiler wrapper.
16pub const BLOCKED: &str = "incremental cache admission blocked";
17
18const SLOTS: usize = 4;
19const GENERATION_BYTES: u64 = 128 * 1024 * 1024;
20const STORE: &str = "magi-incremental";
21
22/// Forked subprocesses can retain a copy until exec closes it. Explicitly
23/// release our flock at the ownership boundary rather than waiting for every
24/// inherited descriptor to close. Session acquisition queries POSIX locks and
25/// then holds flock, so all these guards use the same release protocol.
26#[derive(Debug)]
27struct FileLease(File);
28
29impl std::ops::Deref for FileLease {
30    type Target = File;
31    fn deref(&self) -> &Self::Target {
32        &self.0
33    }
34}
35
36#[cfg(unix)]
37impl Drop for FileLease {
38    fn drop(&mut self) {
39        use std::os::fd::AsRawFd;
40        // SAFETY: the guard retains its live descriptor through unlock.
41        unsafe {
42            libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
43        }
44    }
45}
46
47pub(crate) fn default_environment(command: &mut tokio::process::Command) {
48    let explicit = command
49        .as_std()
50        .get_envs()
51        .find(|(key, _)| *key == "CARGO_INCREMENTAL");
52    let supplied = match explicit {
53        Some((_, value)) => value.is_some(),
54        None => std::env::var_os("CARGO_INCREMENTAL").is_some(),
55    };
56    if !supplied {
57        command.env("CARGO_INCREMENTAL", "0");
58    }
59}
60
61/// Configure a managed command, preserving explicit environment overrides and
62/// Cargo's workspace wrapper chain. The wrapper sees shell-level opt-ins too.
63pub(crate) fn configure(command: &mut tokio::process::Command, worktree: &Path) -> Result<()> {
64    default_environment(command);
65    #[cfg(not(unix))]
66    let _ = worktree;
67    #[cfg(unix)]
68    let env: BTreeMap<_, _> = command
69        .as_std()
70        .get_envs()
71        .map(|(k, v)| (k.to_owned(), v.map(ToOwned::to_owned)))
72        .collect();
73    #[cfg(unix)]
74    let value = |key: &str| {
75        env.get(std::ffi::OsStr::new(key))
76            .cloned()
77            .unwrap_or_else(|| std::env::var_os(key))
78    };
79    #[cfg(unix)]
80    if worktree.join(".git").is_file() {
81        use std::os::unix::fs::PermissionsExt;
82        let git = git_dir(worktree)?;
83        let executable = std::env::current_exe()?;
84        // A stable script name per executable also handles worker generations.
85        let hash: String = Sha256::digest(executable.as_os_str().as_encoded_bytes())
86            .iter()
87            .map(|b| format!("{b:02x}"))
88            .collect();
89        let name = format!("magi-rustc-{hash}.sh");
90        let script = git.join(name);
91        let quote = |p: &Path| format!("'{}'", p.to_string_lossy().replace('\'', "'\\''"));
92        let body = format!(
93            "#!/bin/sh\nexec {} __magi_incremental \"$@\"\n",
94            quote(&executable)
95        );
96        let temporary = git.join(format!(
97            "magi-rustc-{}.tmp",
98            crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
99        ));
100        let publish = || -> Result<()> {
101            use std::io::Write;
102            let mut file = OpenOptions::new()
103                .write(true)
104                .create_new(true)
105                .open(&temporary)?;
106            file.write_all(body.as_bytes())?;
107            file.set_permissions(fs::Permissions::from_mode(0o700))?;
108            // Link only a complete script, without replacing an existing path.
109            // Concurrent command preparation must never observe a partial body.
110            match fs::hard_link(&temporary, &script) {
111                Ok(()) => Ok(()),
112                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
113                    ensure!(
114                        plain(&script)?.is_file() && fs::read(&script)? == body.as_bytes(),
115                        "incremental wrapper changed"
116                    );
117                    Ok(())
118                }
119                Err(e) => Err(e.into()),
120            }
121        };
122        let published = publish();
123        let _ = fs::remove_file(&temporary);
124        published?;
125        let previous = value("RUSTC_WRAPPER");
126        let previous = if previous.is_some() && previous == value("MAGI_INCREMENTAL_WRAPPER") {
127            value("MAGI_PREVIOUS_RUSTC_WRAPPER")
128        } else {
129            previous
130        };
131        command.env_remove("MAGI_PREVIOUS_RUSTC_WRAPPER");
132        if let Some(previous) = previous.filter(|v| !v.is_empty()) {
133            // Re-entering a managed command must not wrap our own wrapper.
134            if previous != script.as_os_str() {
135                command.env("MAGI_PREVIOUS_RUSTC_WRAPPER", previous);
136            }
137        }
138        command
139            .env("MAGI_INCREMENTAL_WORKTREE", worktree.canonicalize()?)
140            .env("MAGI_INCREMENTAL_WRAPPER", &script)
141            .env("RUSTC_WRAPPER", script);
142    }
143    Ok(())
144}
145
146fn git_dir(worktree: &Path) -> Result<PathBuf> {
147    let output = std::process::Command::new("git")
148        .current_dir(worktree)
149        .args(["rev-parse", "--absolute-git-dir"])
150        .output()?;
151    ensure!(
152        output.status.success(),
153        "incremental provenance requires a Git worktree"
154    );
155    Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
156}
157
158fn plain(path: &Path) -> Result<fs::Metadata> {
159    let metadata = fs::symlink_metadata(path)?;
160    ensure!(
161        !metadata.file_type().is_symlink() && path.canonicalize()? == path,
162        "incremental path is not canonical: {}",
163        path.display()
164    );
165    Ok(metadata)
166}
167
168#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170struct Identity {
171    bytes: u64,
172    digest: Vec<u8>,
173    #[cfg(unix)]
174    device: u64,
175    #[cfg(unix)]
176    inode: u64,
177}
178
179fn identity(path: &Path) -> Result<Identity> {
180    let metadata = plain(path)?;
181    ensure!(
182        metadata.is_file(),
183        "incremental entry is not a regular file"
184    );
185    let mut file = File::open(path)?;
186    let mut hash = Sha256::new();
187    let mut buffer = [0; 65536];
188    loop {
189        let n = file.read(&mut buffer)?;
190        if n == 0 {
191            break;
192        }
193        hash.update(&buffer[..n]);
194    }
195    #[cfg(unix)]
196    use std::os::unix::fs::MetadataExt;
197    Ok(Identity {
198        bytes: metadata.len(),
199        digest: hash.finalize().to_vec(),
200        #[cfg(unix)]
201        device: metadata.dev(),
202        #[cfg(unix)]
203        inode: metadata.ino(),
204    })
205}
206
207#[derive(Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209struct Proof {
210    version: u32,
211    worktree: PathBuf,
212    generation: PathBuf,
213    files: BTreeMap<PathBuf, Identity>,
214    directories: std::collections::BTreeSet<PathBuf>,
215}
216
217pub(crate) struct Generation {
218    path: PathBuf,
219    record: PathBuf,
220    // The session lock is outside the cache, and lives through compiler exit.
221    _lock: FileLease,
222}
223
224impl Generation {
225    pub(crate) fn create(worktree: &Path, parent: &Path) -> Result<Self> {
226        let target = worktree.join("target");
227        ensure!(
228            [
229                target.join("debug/incremental"),
230                target.join("release/incremental")
231            ]
232            .contains(&parent.to_owned()),
233            "explicit incremental requires a local managed target; shared/custom targets remain non-incremental"
234        );
235        // Cargo creates these parents before invoking rustc. Never follow an alias.
236        ensure!(
237            plain(&target)?.is_dir() && plain(parent)?.is_dir(),
238            "incremental parent is not a directory"
239        );
240        let store = git_dir(worktree)?.join(STORE);
241        fs::create_dir_all(&store)?;
242        ensure!(
243            plain(&store)?.is_dir(),
244            "invalid incremental provenance store"
245        );
246        // Hash immutable completed generations before taking the namespace
247        // lock. Shared owner guards keep these snapshots stable while other
248        // compiler admissions independently inspect the same completions.
249        let mut completed = BTreeMap::new();
250        let mut inspected = 0usize;
251        for entry in fs::read_dir(&store)? {
252            let owner = entry?.path();
253            if owner.extension().is_some_and(|x| x == "owner") {
254                inspected += 1;
255                if inspected > 1024 {
256                    break;
257                }
258                if let Ok(snapshot) = completed_charge(worktree, &owner) {
259                    completed.insert(owner, snapshot);
260                }
261            }
262        }
263        let mut options = OpenOptions::new();
264        #[cfg(unix)]
265        {
266            use std::os::unix::fs::OpenOptionsExt;
267            options.custom_flags(libc::O_NOFOLLOW);
268        }
269        let admission = FileLease(
270            options
271                .create(true)
272                .truncate(false)
273                .read(true)
274                .write(true)
275                .open(store.join("admission.lock"))?,
276        );
277        wait_for_admission(&admission, std::time::Duration::from_secs(30))?;
278        // Reset/clean can remove sealed cache data without its Git-side proof.
279        // Forget only completed, absent generations, never unsealed attempts.
280        for entry in fs::read_dir(&store)? {
281            let record = entry?.path();
282            if record.extension().is_none_or(|x| x != "json") {
283                continue;
284            }
285            let cleanup = || -> Result<()> {
286                ensure!(
287                    plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
288                    "invalid incremental proof"
289                );
290                let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
291                validate_proof(worktree, &record, &proof)?;
292                ensure!(
293                    matches!(fs::symlink_metadata(&proof.generation), Err(e) if e.kind() == std::io::ErrorKind::NotFound),
294                    "generation still exists or is uninspectable"
295                );
296                let owner = record.with_extension("owner");
297                ensure!(
298                    plain(&owner)?.is_file() && fs::metadata(&owner)?.len() == 0,
299                    "invalid incremental owner"
300                );
301                let lock = FileLease(OpenOptions::new().read(true).write(true).open(&owner)?);
302                crate::pr_head::try_lock_file(&lock)?;
303                fs::remove_file(&record)?;
304                fs::remove_file(owner)?;
305                Ok(())
306            };
307            let _ = cleanup(); // Refusal retains both proof and its reservation.
308        }
309        // Live and unproven attempts reserve a full generation. Completed,
310        // unchanged generations consume their actual bytes, not a lifetime slot.
311        let mut charged = 0u64;
312        let mut records = 0usize;
313        for entry in fs::read_dir(&store)? {
314            let owner = entry?.path();
315            if owner.extension().is_some_and(|x| x == "owner") {
316                records += 1;
317                let bytes = completed
318                    .get(&owner)
319                    .and_then(|(bytes, guard)| snapshot_charge(&owner, *bytes, guard))
320                    .unwrap_or(GENERATION_BYTES);
321                charged = charged.saturating_add(bytes);
322            }
323        }
324        ensure!(
325            records < 1024,
326            "managed incremental provenance record limit exhausted; inspect retention report"
327        );
328        ensure!(
329            charged.saturating_add(GENERATION_BYTES) <= SLOTS as u64 * GENERATION_BYTES,
330            "managed incremental budget exhausted (512 MiB including active/unproven reservations); retain unproven caches and inspect retention report"
331        );
332        let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
333        let record = store.join(format!("{token}.json"));
334        let lock = FileLease(
335            OpenOptions::new()
336                .create_new(true)
337                .read(true)
338                .write(true)
339                .open(record.with_extension("owner"))?,
340        );
341        crate::pr_head::try_lock_file(&lock)?;
342        let path = parent.join(format!("magi-{token}"));
343        fs::create_dir(&path)?;
344        Ok(Self {
345            path,
346            record,
347            _lock: lock,
348        })
349    }
350
351    pub(crate) fn seal(&self, worktree: &Path) -> Result<()> {
352        finalized_sessions(&self.path)?;
353        let files = inventory(&self.path, true)?;
354        let bytes = files
355            .values()
356            .fold(0u64, |sum, f| sum.saturating_add(f.bytes));
357        ensure!(
358            bytes <= GENERATION_BYTES,
359            "managed incremental generation exceeds its 128 MiB budget"
360        );
361        let proof = Proof {
362            version: 1,
363            worktree: worktree.to_owned(),
364            generation: self.path.clone(),
365            files,
366            directories: directory_inventory(&self.path)?,
367        };
368        let temp = self.record.with_extension("tmp");
369        fs::write(&temp, serde_json::to_vec(&proof)?)?;
370        fs::rename(temp, &self.record)?;
371        Ok(())
372    }
373}
374
375// A successful compiler may leave an abandoned working session. Creation-time
376// ownership alone does not make arbitrary directories or lock names disposable.
377fn finalized_sessions(root: &Path) -> Result<()> {
378    for entry in fs::read_dir(root)? {
379        let crate_dir = entry?.path();
380        ensure!(
381            plain(&crate_dir)?.is_dir(),
382            "unknown incremental root entry"
383        );
384        let mut sessions = Vec::new();
385        let mut locks = Vec::new();
386        for entry in fs::read_dir(&crate_dir)? {
387            let path = entry?.path();
388            let name = path
389                .file_name()
390                .context("missing incremental name")?
391                .to_str()
392                .context("invalid incremental name")?
393                .to_owned();
394            if plain(&path)?.is_dir() {
395                ensure!(
396                    name.starts_with("s-")
397                        && !name.ends_with("-working")
398                        && name.split('-').count() == 4,
399                    "incremental session is unfinished or unknown"
400                );
401                for output in fs::read_dir(&path)? {
402                    ensure!(
403                        plain(&output?.path())?.is_file(),
404                        "unknown incremental session directory"
405                    );
406                }
407                sessions.push(name);
408            } else {
409                ensure!(
410                    name.starts_with("s-")
411                        && name.ends_with(".lock")
412                        && name.split('-').count() == 3,
413                    "unknown incremental session lock"
414                );
415                locks.push(name);
416            }
417        }
418        ensure!(
419            !sessions.is_empty() && sessions.len() == locks.len(),
420            "incremental session/lock inventory is incomplete"
421        );
422        for lock in locks {
423            let prefix = format!("{}-", lock.strip_suffix(".lock").unwrap());
424            ensure!(
425                sessions.iter().any(|name| name.starts_with(&prefix)),
426                "incremental session lock has no completed session"
427            );
428        }
429    }
430    Ok(())
431}
432
433// Contention is a short serialization wait, distinct from insufficient budget.
434fn wait_for_admission(file: &File, timeout: std::time::Duration) -> Result<()> {
435    let started = std::time::Instant::now();
436    loop {
437        match crate::pr_head::try_lock_file(file) {
438            Ok(()) => return Ok(()),
439            Err(error)
440                if matches!(
441                    error.kind(),
442                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
443                ) => {}
444            Err(error) => return Err(error).context("incremental generation admission lock"),
445        }
446        ensure!(
447            started.elapsed() < timeout,
448            "incremental generation admission wait timed out after {} ms",
449            timeout.as_millis()
450        );
451        std::thread::sleep(
452            std::time::Duration::from_millis(10).min(timeout.saturating_sub(started.elapsed())),
453        );
454    }
455}
456
457fn snapshot_charge(owner: &Path, bytes: u64, guard: &File) -> Option<u64> {
458    let current = plain(owner).ok()?;
459    let held = guard.metadata().ok()?;
460    #[cfg(unix)]
461    {
462        use std::os::unix::fs::MetadataExt;
463        (current.is_file()
464            && current.len() == 0
465            && current.dev() == held.dev()
466            && current.ino() == held.ino())
467        .then_some(bytes)
468    }
469    #[cfg(not(unix))]
470    {
471        let _ = (current, held, bytes);
472        None
473    }
474}
475
476fn lock_for_accounting(file: &File) -> std::io::Result<()> {
477    #[cfg(unix)]
478    {
479        use std::os::fd::AsRawFd;
480        // SAFETY: this live descriptor is retained with the completed snapshot.
481        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 {
482            Ok(())
483        } else {
484            Err(std::io::Error::last_os_error())
485        }
486    }
487    #[cfg(not(unix))]
488    {
489        crate::pr_head::try_lock_file(file)
490    }
491}
492
493// Accounting grants no deletion authority. A shared owner guard and an exact
494// completion proof may reduce the conservative full-generation reservation.
495fn completed_charge(worktree: &Path, owner: &Path) -> Result<(u64, FileLease)> {
496    ensure!(
497        plain(owner)?.is_file() && fs::metadata(owner)?.len() == 0,
498        "invalid incremental owner"
499    );
500    let record = owner.with_extension("json");
501    // An unpublished owner may still be acquiring its initial exclusive lock.
502    // Do not briefly share-lock it before a completion proof even exists.
503    ensure!(
504        plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
505        "invalid incremental proof"
506    );
507    let lock = FileLease(OpenOptions::new().read(true).write(true).open(owner)?);
508    lock_for_accounting(&lock)?;
509    let proof: Proof = serde_json::from_slice(&fs::read(record)?)?;
510    validate_proof(worktree, &owner.with_extension("json"), &proof)?;
511    let actual = inventory(&proof.generation, false)?;
512    ensure!(
513        directory_inventory(&proof.generation)?.is_subset(&proof.directories)
514            && actual
515                .iter()
516                .all(|(p, identity)| proof.files.get(p) == Some(identity)),
517        "incremental proof changed"
518    );
519    let bytes = actual
520        .values()
521        .fold(0u64, |bytes, identity| bytes.saturating_add(identity.bytes));
522    Ok((bytes, lock))
523}
524
525fn inventory(root: &Path, recognize: bool) -> Result<BTreeMap<PathBuf, Identity>> {
526    let mut files = BTreeMap::new();
527    let mut bytes = 0u64;
528    let mut dirs = vec![root.to_owned()];
529    while let Some(dir) = dirs.pop() {
530        ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
531        for entry in fs::read_dir(&dir)? {
532            let path = entry?.path();
533            let metadata = plain(&path)?;
534            if metadata.is_dir() {
535                dirs.push(path);
536            } else {
537                ensure!(
538                    metadata.is_file(),
539                    "incremental entry is not a regular file"
540                );
541                bytes = bytes.saturating_add(metadata.len());
542                ensure!(
543                    bytes <= GENERATION_BYTES,
544                    "incremental generation exceeds its budget"
545                );
546                ensure!(files.len() < 10000, "incremental inventory too large");
547                if recognize {
548                    ensure!(
549                        recognized(&path)?,
550                        "unknown incremental output: {}",
551                        path.display()
552                    );
553                }
554                files.insert(path.strip_prefix(root)?.to_owned(), identity(&path)?);
555            }
556        }
557    }
558    Ok(files)
559}
560
561fn directory_inventory(root: &Path) -> Result<std::collections::BTreeSet<PathBuf>> {
562    let mut found = std::collections::BTreeSet::new();
563    let mut dirs = vec![root.to_owned()];
564    while let Some(dir) = dirs.pop() {
565        ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
566        for entry in fs::read_dir(dir)? {
567            let path = entry?.path();
568            if plain(&path)?.is_dir() {
569                ensure!(
570                    found.len() < 10000,
571                    "incremental directory inventory too large"
572                );
573                found.insert(path.strip_prefix(root)?.to_owned());
574                dirs.push(path);
575            }
576        }
577    }
578    Ok(found)
579}
580
581fn recognized(path: &Path) -> Result<bool> {
582    let name = path.file_name().unwrap_or_default().to_string_lossy();
583    if name.ends_with(".lock") {
584        return Ok(fs::metadata(path)?.len() == 0);
585    }
586    let mut header = [0; 8];
587    let n = File::open(path)?.read(&mut header)?;
588    if matches!(
589        name.as_ref(),
590        "dep-graph.bin" | "query-cache.bin" | "work-products.bin"
591    ) {
592        return Ok(header[..n].starts_with(b"RSIC"));
593    }
594    if path.extension().is_some_and(|e| e == "o") || name == "metadata.rmeta" {
595        return crate::retention::artifact_signature(path);
596    }
597    Ok(false)
598}
599
600/// Check both Unix lock protocols used by compilers. Query the POSIX lock
601/// without acquiring it: holding both protocols can conflict on BSD. Retain
602/// the flock guard through unlink, alongside the caller's output/Cargo locks.
603/// See https://doc.rust-lang.org/stable/nightly-rustc/rustc_data_structures/flock/.
604#[cfg(unix)]
605fn lock_session(file: &File) -> std::io::Result<()> {
606    use std::os::fd::AsRawFd;
607    // SAFETY: the descriptor is live and the initialized record covers the
608    // whole file. F_GETLK only inspects locks; it does not change them.
609    let mut lock: libc::flock = unsafe { std::mem::zeroed() };
610    lock.l_type = libc::F_WRLCK as _;
611    lock.l_whence = libc::SEEK_SET as _;
612    lock.l_start = 0;
613    lock.l_len = 0;
614    if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETLK, &mut lock) } == -1 {
615        return Err(std::io::Error::last_os_error());
616    }
617    let unlocked: libc::c_short = libc::F_UNLCK as _;
618    if lock.l_type != unlocked {
619        return Err(std::io::Error::new(
620            std::io::ErrorKind::WouldBlock,
621            "incremental session has a POSIX record lock",
622        ));
623    }
624    crate::pr_head::try_lock_file(file)
625}
626
627#[cfg(all(unix, test))]
628fn lock_posix_session(file: &File) -> std::io::Result<()> {
629    use std::os::fd::AsRawFd;
630    // SAFETY: zero initializes reserved fields; fcntl borrows the lock record
631    // only during this call and the test helper retains the live descriptor.
632    let mut lock: libc::flock = unsafe { std::mem::zeroed() };
633    lock.l_type = libc::F_WRLCK as _;
634    lock.l_whence = libc::SEEK_SET as _;
635    lock.l_start = 0;
636    lock.l_len = 0;
637    if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock) } == -1 {
638        return Err(std::io::Error::last_os_error());
639    }
640    Ok(())
641}
642
643#[cfg(not(unix))]
644fn lock_session(file: &File) -> std::io::Result<()> {
645    crate::pr_head::try_lock_file(file)
646}
647
648/// Handle the private wrapper entrypoint before normal CLI startup. Existing
649/// wrappers (including Cargo's workspace wrapper argument) stay in the chain.
650pub fn wrapper(args: Vec<OsString>) -> Result<i32> {
651    tokio::runtime::Builder::new_current_thread()
652        .enable_all()
653        .build()?
654        .block_on(wrapper_inner(args))
655}
656
657async fn wrapper_inner(args: Vec<OsString>) -> Result<i32> {
658    let worktree = PathBuf::from(
659        std::env::var_os("MAGI_INCREMENTAL_WORKTREE").context("missing incremental worktree")?,
660    );
661    let mut args = args;
662    let mut generation = None;
663    for index in 0..args.len() {
664        let text = args[index].to_string_lossy();
665        let value = if index > 0 && args[index - 1] == "-C" {
666            text.strip_prefix("incremental=").map(str::to_owned)
667        } else {
668            text.strip_prefix("-Cincremental=").map(str::to_owned)
669        };
670        if let Some(value) = value {
671            ensure!(
672                generation.is_none(),
673                "multiple incremental compiler paths are unsupported"
674            );
675            let parent = PathBuf::from(value);
676            let parent = if parent.is_absolute() {
677                parent
678            } else {
679                std::env::current_dir()?.join(parent)
680            };
681            // Cargo passes the profile incremental root; rustc creates its own
682            // crate/session subdirectories beneath our fresh generation.
683            let made = Generation::create(&worktree, &parent)?;
684            let prefix = if text.starts_with("-C") {
685                "-Cincremental="
686            } else {
687                "incremental="
688            };
689            args[index] = format!("{prefix}{}", made.path.display()).into();
690            generation = Some(made);
691        }
692    }
693    let command = if let Some(previous) =
694        std::env::var_os("MAGI_PREVIOUS_RUSTC_WRAPPER").filter(|s| !s.is_empty())
695    {
696        let mut command = std::process::Command::new(previous);
697        command.args(&args);
698        command
699    } else {
700        let mut command = std::process::Command::new(args.first().context("missing compiler")?);
701        command.args(&args[1..]);
702        command
703    };
704    let mut running =
705        crate::seat_process::Running::spawn_operation(command.into(), None, Vec::new())?;
706    let result: Result<_> = async {
707        loop {
708            if let Some(status) = running.child().try_wait()? {
709                break Ok(status);
710            }
711            if let Some(generation) = &generation
712                && crate::disk::dir_size(&generation.path) > GENERATION_BYTES
713            {
714                anyhow::bail!(
715                    "managed incremental compiler exceeded 128 MiB; retained unproven output"
716                );
717            }
718            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
719        }
720    }
721    .await;
722    // Keep the generation owner through confirmation of the entire invocation's
723    // descendants, including a non-exec wrapper's compiler and escaped tools.
724    running.finish().await;
725    let status = result?;
726    if status.success()
727        && let Some(generation) = generation
728    {
729        generation.seal(&worktree)?;
730    }
731    Ok(status.code().unwrap_or(1))
732}
733
734/// Validate independently of dep-info, with the caller holding owner, driver,
735/// output and Cargo locks. Keep all generation/session locks through unlink.
736#[derive(Default)]
737pub(crate) struct Reclaim {
738    pub(crate) files: Vec<PathBuf>,
739    _locks: Vec<FileLease>,
740    finished: Vec<(PathBuf, PathBuf, std::collections::BTreeSet<PathBuf>)>,
741}
742
743impl Reclaim {
744    pub(crate) fn finish(&self) -> Result<()> {
745        for (record, generation, allowed_dirs) in &self.finished {
746            if generation.exists() {
747                ensure!(
748                    inventory(generation, false)?.is_empty(),
749                    "incremental generation still contains data"
750                );
751                ensure!(
752                    directory_inventory(generation)?.is_subset(allowed_dirs),
753                    "unknown incremental directory appeared before removal"
754                );
755                let mut dirs = vec![generation.clone()];
756                let mut index = 0;
757                while index < dirs.len() {
758                    for entry in fs::read_dir(&dirs[index])? {
759                        let path = entry?.path();
760                        ensure!(plain(&path)?.is_dir(), "incremental generation changed");
761                        dirs.push(path);
762                    }
763                    index += 1;
764                }
765                for dir in dirs.into_iter().rev() {
766                    fs::remove_dir(dir)?;
767                }
768            }
769            let owner = record.with_extension("owner");
770            ensure!(
771                plain(&owner)?.len() == 0,
772                "incremental owner changed before removal"
773            );
774            fs::remove_file(record)?;
775            fs::remove_file(owner)?;
776        }
777        Ok(())
778    }
779}
780
781fn validate_proof(worktree: &Path, record: &Path, proof: &Proof) -> Result<()> {
782    ensure!(
783        proof.version == 1 && proof.worktree == worktree,
784        "incremental ownership changed"
785    );
786    let parent = proof
787        .generation
788        .parent()
789        .context("missing generation parent")?;
790    ensure!(
791        [
792            worktree.join("target/debug/incremental"),
793            worktree.join("target/release/incremental")
794        ]
795        .contains(&parent.to_owned()),
796        "incremental generation outside managed target"
797    );
798    ensure!(
799        proof
800            .generation
801            .file_name()
802            .is_some_and(|n| n.to_string_lossy()
803                == format!("magi-{}", record.file_stem().unwrap().to_string_lossy())),
804        "incremental generation identity changed"
805    );
806    Ok(())
807}
808
809pub(crate) fn candidates(worktree: &Path) -> Result<Reclaim> {
810    let store = git_dir(worktree)?.join(STORE);
811    if !store.exists() {
812        return Ok(Reclaim::default());
813    }
814    ensure!(
815        plain(&store)?.is_dir(),
816        "invalid incremental provenance store"
817    );
818    let mut outputs = Vec::new();
819    let mut locks = Vec::new();
820    let mut finished = Vec::new();
821    for entry in fs::read_dir(store)? {
822        let record = entry?.path();
823        if record.extension().is_none_or(|x| x != "json") {
824            continue;
825        }
826        ensure!(
827            plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
828            "invalid incremental provenance"
829        );
830        let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
831        validate_proof(worktree, &record, &proof)?;
832        let lock_path = record.with_extension("owner");
833        let owner_metadata = plain(&lock_path)?;
834        ensure!(
835            owner_metadata.is_file() && owner_metadata.len() == 0,
836            "incremental owner is missing or changed"
837        );
838        let owner = FileLease(OpenOptions::new().read(true).write(true).open(lock_path)?);
839        crate::pr_head::try_lock_file(&owner).context("incremental compiler is live")?;
840        locks.push(owner);
841        finished.push((
842            record.clone(),
843            proof.generation.clone(),
844            proof.directories.clone(),
845        ));
846        if !proof.generation.exists() {
847            continue;
848        }
849        let actual = inventory(&proof.generation, false)?;
850        ensure!(
851            directory_inventory(&proof.generation)?.is_subset(&proof.directories),
852            "unknown incremental directory"
853        );
854        // Partial deletion can be retried. Added, replaced or modified files
855        // refuse the entire generation, even when their signatures look valid.
856        ensure!(
857            actual.iter().all(|(p, id)| proof.files.get(p) == Some(id)),
858            "incremental provenance mismatch or unknown data"
859        );
860        for relative in actual.keys() {
861            let path = proof.generation.join(relative);
862            if path.extension().is_some_and(|x| x == "lock") {
863                let file = OpenOptions::new().read(true).write(true).open(&path)?;
864                lock_session(&file).context("incremental session is live")?;
865                locks.push(FileLease(file));
866            }
867            outputs.push(path);
868        }
869    }
870    Ok(Reclaim {
871        files: outputs,
872        _locks: locks,
873        finished,
874    })
875}
876
877#[cfg(all(test, unix))]
878pub(crate) mod tests {
879    use super::*;
880
881    // A real, dependency-free Cargo crate keeps this regression below a few MiB.
882    // No production paths or inherited Cargo configuration participate.
883    fn cargo_generation() -> (tempfile::TempDir, PathBuf, Generation) {
884        let temp = tempfile::tempdir().unwrap();
885        let worktree = temp.path().canonicalize().unwrap();
886        let git = std::process::Command::new("git")
887            .arg("init")
888            .arg(&worktree)
889            .output()
890            .unwrap();
891        assert!(git.status.success());
892        let generation = generate(&worktree);
893        (temp, worktree, generation)
894    }
895
896    pub(crate) fn generate(worktree: &Path) -> Generation {
897        fs::create_dir(worktree.join("src")).unwrap();
898        fs::write(
899            worktree.join("Cargo.toml"),
900            "[package]\nname='cache-fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n",
901        )
902        .unwrap();
903        fs::write(
904            worktree.join("src/lib.rs"),
905            "pub fn answer() -> usize { (0..100).sum() }\n",
906        )
907        .unwrap();
908        let parent = worktree.join("target/debug/incremental");
909        fs::create_dir_all(&parent).unwrap();
910        let generation = Generation::create(worktree, &parent).unwrap();
911        let mut command = std::process::Command::new("cargo");
912        command.env_clear();
913        for key in ["PATH", "HOME", "RUSTUP_HOME"] {
914            if let Some(value) = std::env::var_os(key) {
915                command.env(key, value);
916            }
917        }
918        let output = command
919            .current_dir(worktree)
920            .env("RUSTUP_TOOLCHAIN", "stable")
921            .env("CARGO_HOME", worktree.join("cargo-home"))
922            .env("CARGO_INCREMENTAL", "0")
923            .env(
924                "RUSTFLAGS",
925                format!("-C incremental={}", generation.path.display()),
926            )
927            .args(["build", "--offline", "--jobs", "1", "--target-dir"])
928            .arg(worktree.join("target"))
929            .output()
930            .unwrap();
931        assert!(
932            output.status.success(),
933            "{}",
934            String::from_utf8_lossy(&output.stderr)
935        );
936        assert!(crate::disk::dir_size(&generation.path) > 0);
937        generation.seal(worktree).unwrap();
938        generation
939    }
940
941    #[test]
942    fn admission_contention_waits_and_timeout_is_distinct_from_budget_exhaustion() {
943        use std::time::Duration;
944        let temp = tempfile::tempdir().unwrap();
945        let path = temp.path().join("admission.lock");
946        let held = FileLease(
947            OpenOptions::new()
948                .create_new(true)
949                .read(true)
950                .write(true)
951                .open(&path)
952                .unwrap(),
953        );
954        crate::pr_head::try_lock_file(&held).unwrap();
955        std::thread::scope(|scope| {
956            let (ready_tx, ready_rx) = std::sync::mpsc::channel();
957            let (done_tx, done_rx) = std::sync::mpsc::channel();
958            let path = path.clone();
959            scope.spawn(move || {
960                let waiting = FileLease(
961                    OpenOptions::new()
962                        .read(true)
963                        .write(true)
964                        .open(&path)
965                        .unwrap(),
966                );
967                ready_tx.send(()).unwrap();
968                let result = wait_for_admission(&waiting, Duration::from_secs(5));
969                done_tx.send(result).unwrap();
970            });
971            ready_rx.recv_timeout(Duration::from_secs(1)).unwrap();
972            assert!(done_rx.recv_timeout(Duration::from_millis(40)).is_err());
973            drop(held);
974            done_rx
975                .recv_timeout(Duration::from_secs(5))
976                .unwrap()
977                .unwrap();
978        });
979        let held = FileLease(
980            OpenOptions::new()
981                .read(true)
982                .write(true)
983                .open(&path)
984                .unwrap(),
985        );
986        crate::pr_head::try_lock_file(&held).unwrap();
987        let waiting = OpenOptions::new()
988            .read(true)
989            .write(true)
990            .open(&path)
991            .unwrap();
992        let error = wait_for_admission(&waiting, Duration::from_millis(20))
993            .unwrap_err()
994            .to_string();
995        assert!(error.contains("wait timed out"));
996        assert!(!error.contains("budget exhausted"));
997    }
998
999    #[test]
1000    fn completed_snapshots_share_owner_locks_without_blocking_parallel_admission() {
1001        let (_temp, worktree, generation) = cargo_generation();
1002        let owner = generation.record.with_extension("owner");
1003        drop(generation);
1004        let first = completed_charge(&worktree, &owner).unwrap();
1005        let second = completed_charge(&worktree, &owner).unwrap();
1006        assert!(first.0 > 0);
1007        assert_eq!(first.0, second.0);
1008        let exclusive = FileLease(
1009            OpenOptions::new()
1010                .read(true)
1011                .write(true)
1012                .open(&owner)
1013                .unwrap(),
1014        );
1015        assert!(crate::pr_head::try_lock_file(&exclusive).is_err());
1016        let parent = worktree.join("target/debug/incremental");
1017        let fresh = Generation::create(&worktree, &parent).unwrap();
1018        assert!(fresh.path.is_dir());
1019        drop(first);
1020        drop(second);
1021        assert!(crate::pr_head::try_lock_file(&exclusive).is_ok());
1022    }
1023
1024    #[test]
1025    fn ownership_release_is_not_delayed_by_descriptor_copies() {
1026        let (_temp, worktree, generation) = cargo_generation();
1027        let store = git_dir(&worktree).unwrap().join(STORE);
1028        let open = |path: &Path| {
1029            OpenOptions::new()
1030                .read(true)
1031                .write(true)
1032                .open(path)
1033                .unwrap()
1034        };
1035        let admission_path = store.join("admission.lock");
1036        let admission = FileLease(open(&admission_path));
1037        crate::pr_head::try_lock_file(&admission).unwrap();
1038        // dup and fork share the same open-file description. Keep a copy open
1039        // deterministically, without depending on another test's fork timing.
1040        let inherited_admission = admission.try_clone().unwrap();
1041        let next = FileLease(open(&admission_path));
1042        assert!(crate::pr_head::try_lock_file(&next).is_err());
1043        drop(admission);
1044        crate::pr_head::try_lock_file(&next).unwrap();
1045        drop(next);
1046        drop(inherited_admission);
1047
1048        let owner = generation.record.with_extension("owner");
1049        let inherited_generation = generation._lock.try_clone().unwrap();
1050        assert!(completed_charge(&worktree, &owner).is_err());
1051        drop(generation);
1052        let first = completed_charge(&worktree, &owner).unwrap();
1053        let second = completed_charge(&worktree, &owner).unwrap();
1054        let inherited_first = first.1.try_clone().unwrap();
1055        let inherited_second = second.1.try_clone().unwrap();
1056        let exclusive = FileLease(open(&owner));
1057        drop(first);
1058        assert!(
1059            crate::pr_head::try_lock_file(&exclusive).is_err(),
1060            "another actual accounting owner still excludes reclamation"
1061        );
1062        drop(second);
1063        crate::pr_head::try_lock_file(&exclusive).unwrap();
1064        drop(exclusive);
1065        drop((inherited_generation, inherited_first, inherited_second));
1066
1067        let reclaim = candidates(&worktree).unwrap();
1068        let inherited_reclaim: Vec<_> = reclaim
1069            ._locks
1070            .iter()
1071            .map(|lock| lock.try_clone().unwrap())
1072            .collect();
1073        assert!(candidates(&worktree).is_err());
1074        drop(reclaim);
1075        let retry = candidates(&worktree).unwrap();
1076        assert!(!retry.files.is_empty());
1077        drop(retry);
1078        drop(inherited_reclaim);
1079    }
1080
1081    #[test]
1082    fn default_disables_incremental_and_explicit_environment_is_preserved() {
1083        let temp = tempfile::tempdir().unwrap();
1084        let mut command = tokio::process::Command::new("cargo");
1085        command.env_remove("CARGO_INCREMENTAL");
1086        configure(&mut command, temp.path()).unwrap();
1087        let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1088        assert_eq!(
1089            env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1090            Some(std::ffi::OsStr::new("0"))
1091        );
1092        command.env("CARGO_INCREMENTAL", "1");
1093        configure(&mut command, temp.path()).unwrap();
1094        let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1095        assert_eq!(
1096            env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1097            Some(std::ffi::OsStr::new("1"))
1098        );
1099    }
1100
1101    #[test]
1102    fn concurrent_configuration_publishes_complete_wrappers() {
1103        let temp = tempfile::tempdir().unwrap();
1104        let root = temp.path().canonicalize().unwrap();
1105        let worktree = root.join("worktree");
1106        assert!(
1107            std::process::Command::new("git")
1108                .args(["init", "--separate-git-dir"])
1109                .arg(root.join("metadata"))
1110                .arg(&worktree)
1111                .output()
1112                .unwrap()
1113                .status
1114                .success()
1115        );
1116        std::thread::scope(|scope| {
1117            let handles: Vec<_> = (0..8)
1118                .map(|_| {
1119                    scope.spawn(|| {
1120                        let mut command = tokio::process::Command::new("cargo");
1121                        configure(&mut command, &worktree).unwrap();
1122                        let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1123                        let wrapper =
1124                            PathBuf::from(env[std::ffi::OsStr::new("RUSTC_WRAPPER")].unwrap());
1125                        assert!(fs::read_to_string(wrapper).unwrap().ends_with("\"$@\"\n"));
1126                    })
1127                })
1128                .collect();
1129            for handle in handles {
1130                handle.join().unwrap();
1131            }
1132        });
1133    }
1134
1135    #[test]
1136    fn sealing_refuses_unfinished_sessions_and_unknown_directories() {
1137        let (_temp, worktree, generation) = cargo_generation();
1138        let unknown = generation.path.join("operator-empty");
1139        fs::create_dir(&unknown).unwrap();
1140        assert!(generation.seal(&worktree).is_err());
1141        fs::remove_dir(unknown).unwrap();
1142        let crate_dir = fs::read_dir(&generation.path)
1143            .unwrap()
1144            .next()
1145            .unwrap()
1146            .unwrap()
1147            .path();
1148        let working = crate_dir.join("s-unfinished-cache-working");
1149        fs::create_dir(&working).unwrap();
1150        assert!(generation.seal(&worktree).is_err());
1151        assert!(working.exists());
1152        drop(generation);
1153        assert!(candidates(&worktree).is_err());
1154    }
1155
1156    #[test]
1157    fn unknown_empty_directories_added_after_inventory_are_retained() {
1158        let (_temp, worktree, generation) = cargo_generation();
1159        let path = generation.path.clone();
1160        drop(generation);
1161        let reclaim = candidates(&worktree).unwrap();
1162        for file in &reclaim.files {
1163            fs::remove_file(file).unwrap();
1164        }
1165        let unknown = path.join("operator-empty");
1166        fs::create_dir(&unknown).unwrap();
1167        assert!(reclaim.finish().is_err());
1168        assert!(unknown.is_dir());
1169        fs::remove_dir(unknown).unwrap();
1170        reclaim.finish().unwrap();
1171        assert!(!path.exists());
1172    }
1173
1174    #[test]
1175    fn fcntl_session_lock_is_respected_across_processes() {
1176        use std::io::{Read, Write};
1177        use std::process::{Command, Stdio};
1178        if let Some(path) = std::env::var_os("MAGI_TEST_SESSION_FILE") {
1179            let file = OpenOptions::new()
1180                .read(true)
1181                .write(true)
1182                .open(path)
1183                .unwrap();
1184            // Deliberately do not take flock: both compiler protocols must
1185            // exclude reclamation, even on hosts where they are independent.
1186            lock_posix_session(&file).unwrap();
1187            fs::write(
1188                std::env::var_os("MAGI_TEST_SESSION_READY").unwrap(),
1189                "ready",
1190            )
1191            .unwrap();
1192            std::io::stdin().read_exact(&mut [0]).unwrap();
1193            return;
1194        }
1195        let (_temp, worktree, generation) = cargo_generation();
1196        drop(generation);
1197        let candidates = candidates(&worktree).unwrap();
1198        let session = candidates
1199            .files
1200            .iter()
1201            .find(|p| p.extension().is_some_and(|x| x == "lock"))
1202            .unwrap()
1203            .clone();
1204        drop(candidates);
1205        let ready = worktree.join("session-ready");
1206        let mut child = Command::new(std::env::current_exe().unwrap())
1207            .args([
1208                "--exact",
1209                "incremental::tests::fcntl_session_lock_is_respected_across_processes",
1210            ])
1211            .env("MAGI_TEST_SESSION_FILE", &session)
1212            .env("MAGI_TEST_SESSION_READY", &ready)
1213            .stdin(Stdio::piped())
1214            .stdout(Stdio::null())
1215            .spawn()
1216            .unwrap();
1217        let started = std::time::Instant::now();
1218        while !ready.exists() && started.elapsed() < std::time::Duration::from_secs(10) {
1219            if child.try_wait().unwrap().is_some() {
1220                break;
1221            }
1222            std::thread::sleep(std::time::Duration::from_millis(10));
1223        }
1224        let observed = ready.exists();
1225        let blocked = observed
1226            && super::candidates(&worktree)
1227                .err()
1228                .is_some_and(|e| e.to_string().contains("session is live"));
1229        if observed {
1230            child.stdin.take().unwrap().write_all(b"x").unwrap();
1231        } else {
1232            child.kill().ok();
1233        }
1234        let status = child.wait().unwrap();
1235        assert!(
1236            observed && status.success(),
1237            "session helper did not acquire its lock"
1238        );
1239        assert!(blocked, "POSIX session lock must exclude reclamation");
1240        assert!(!super::candidates(&worktree).unwrap().files.is_empty());
1241        assert!(session.exists());
1242    }
1243
1244    #[test]
1245    fn real_cargo_generations_require_exact_provenance_and_live_owner_exclusion() {
1246        let (_temp, worktree, generation) = cargo_generation();
1247        assert!(
1248            candidates(&worktree).is_err(),
1249            "compiler owner still holds the generation"
1250        );
1251        let path = generation.path.clone();
1252        drop(generation);
1253        let reclaim = candidates(&worktree).unwrap();
1254        let session = reclaim
1255            .files
1256            .iter()
1257            .find(|p| p.extension().is_some_and(|x| x == "lock"))
1258            .unwrap()
1259            .clone();
1260        drop(reclaim);
1261        let live_session = FileLease(
1262            OpenOptions::new()
1263                .read(true)
1264                .write(true)
1265                .open(session)
1266                .unwrap(),
1267        );
1268        crate::pr_head::try_lock_file(&live_session).unwrap();
1269        assert!(
1270            candidates(&worktree)
1271                .err()
1272                .unwrap()
1273                .to_string()
1274                .contains("session is live")
1275        );
1276        drop(live_session);
1277        let unknown = path.join("operator-note");
1278        fs::write(&unknown, "keep unknown data").unwrap();
1279        assert!(candidates(&worktree).is_err());
1280        assert_eq!(fs::read_to_string(&unknown).unwrap(), "keep unknown data");
1281        fs::remove_file(&unknown).unwrap();
1282        let unknown_dir = path.join("operator-empty");
1283        fs::create_dir(&unknown_dir).unwrap();
1284        assert!(candidates(&worktree).is_err());
1285        fs::remove_dir(unknown_dir).unwrap();
1286        let reclaim = candidates(&worktree).unwrap();
1287        assert!(!reclaim.files.is_empty());
1288        assert!(
1289            candidates(&worktree).is_err(),
1290            "maintenance retains session/owner locks"
1291        );
1292        // Partial deletion can resume, but changed bytes cannot be adopted.
1293        let removed = reclaim.files[0].clone();
1294        fs::remove_file(removed).unwrap();
1295        drop(reclaim);
1296        let reclaim = candidates(&worktree).unwrap();
1297        let changed = reclaim
1298            .files
1299            .iter()
1300            .find(|p| p.extension().is_some_and(|e| e == "bin"))
1301            .unwrap()
1302            .clone();
1303        let original = fs::read(&changed).unwrap();
1304        fs::write(&changed, b"modified proof input").unwrap();
1305        drop(reclaim);
1306        assert!(candidates(&worktree).is_err());
1307        fs::write(&changed, original).unwrap();
1308        let reclaim = candidates(&worktree).unwrap();
1309        for file in &reclaim.files {
1310            fs::remove_file(file).unwrap();
1311        }
1312        reclaim.finish().unwrap();
1313        assert!(!path.exists());
1314        assert!(candidates(&worktree).unwrap().files.is_empty());
1315        assert!(worktree.join("src/lib.rs").is_file());
1316    }
1317
1318    #[test]
1319    fn externally_removed_sealed_generation_does_not_strand_admission() {
1320        let (_temp, worktree, generation) = cargo_generation();
1321        let path = generation.path.clone();
1322        drop(generation);
1323        let reclaim = candidates(&worktree).unwrap();
1324        for file in &reclaim.files {
1325            fs::remove_file(file).unwrap();
1326        }
1327        // Simulate a clean/reset removing only empty cache directories, while
1328        // leaving the completion proof in the worktree Git directory.
1329        let mut dirs = directory_inventory(&path)
1330            .unwrap()
1331            .into_iter()
1332            .collect::<Vec<_>>();
1333        dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
1334        for relative in dirs {
1335            fs::remove_dir(path.join(relative)).unwrap();
1336        }
1337        fs::remove_dir(&path).unwrap();
1338        drop(reclaim);
1339        let parent = worktree.join("target/debug/incremental");
1340        let fresh = Generation::create(&worktree, &parent).unwrap();
1341        assert_eq!(
1342            fs::read_dir(git_dir(&worktree).unwrap().join(STORE))
1343                .unwrap()
1344                .filter_map(|e| e.ok())
1345                .filter(|e| e.path().extension().is_some_and(|x| x == "owner"))
1346                .count(),
1347            1
1348        );
1349        assert!(fresh.path.is_dir());
1350    }
1351
1352    #[test]
1353    fn unsealed_attempts_consume_the_bounded_generation_budget() {
1354        let temp = tempfile::tempdir().unwrap();
1355        let worktree = temp.path().canonicalize().unwrap();
1356        assert!(
1357            std::process::Command::new("git")
1358                .arg("init")
1359                .arg(&worktree)
1360                .output()
1361                .unwrap()
1362                .status
1363                .success()
1364        );
1365        let parent = worktree.join("target/debug/incremental");
1366        fs::create_dir_all(&parent).unwrap();
1367        for _ in 0..SLOTS {
1368            let generation = Generation::create(&worktree, &parent).unwrap();
1369            fs::write(generation.path.join("unknown"), "preserve").unwrap();
1370        }
1371        assert!(Generation::create(&worktree, &parent).is_err());
1372        assert!(
1373            candidates(&worktree).unwrap().files.is_empty(),
1374            "unsealed output never grants deletion authority"
1375        );
1376    }
1377
1378    #[cfg(unix)]
1379    #[test]
1380    fn generation_symlinks_and_replaced_inodes_refuse_reclamation() {
1381        let (_temp, worktree, generation) = cargo_generation();
1382        let path = generation.path.clone();
1383        drop(generation);
1384        let files = candidates(&worktree).unwrap().files;
1385        let file = files
1386            .iter()
1387            .find(|p| p.extension().is_some_and(|e| e == "bin"))
1388            .unwrap();
1389        let saved = worktree.join("retained-input");
1390        fs::rename(file, &saved).unwrap();
1391        std::os::unix::fs::symlink(&saved, file).unwrap();
1392        assert!(candidates(&worktree).is_err());
1393        fs::remove_file(file).unwrap();
1394        fs::copy(&saved, file).unwrap();
1395        assert!(
1396            candidates(&worktree).is_err(),
1397            "same bytes at a replaced inode are insufficient"
1398        );
1399        assert!(path.exists());
1400        assert!(saved.is_file());
1401    }
1402}