1use 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
15pub 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#[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 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
61pub(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 let git = git_dir(worktree)?;
82 let executable = std::env::current_exe()?;
83 let hash: String = Sha256::digest(executable.as_os_str().as_encoded_bytes())
85 .iter()
86 .map(|b| format!("{b:02x}"))
87 .collect();
88 let name = format!("magi-rustc-{hash}.sh");
89 let script = git.join(name);
90 let quote = |p: &Path| format!("'{}'", p.to_string_lossy().replace('\'', "'\\''"));
91 let body = format!(
92 "#!/bin/sh\nexec {} __magi_incremental \"$@\"\n",
93 quote(&executable)
94 );
95 publish_wrapper(&script, &body, || {
96 use std::sync::atomic::{AtomicU64, Ordering};
97 static NEXT: AtomicU64 = AtomicU64::new(0);
98 git.join(format!(
99 "magi-rustc-{}-{}-{}.tmp",
100 std::process::id(),
101 NEXT.fetch_add(1, Ordering::Relaxed),
102 crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
103 ))
104 })?;
105 let previous = value("RUSTC_WRAPPER");
106 let previous = if previous.is_some() && previous == value("MAGI_INCREMENTAL_WRAPPER") {
107 value("MAGI_PREVIOUS_RUSTC_WRAPPER")
108 } else {
109 previous
110 };
111 command.env_remove("MAGI_PREVIOUS_RUSTC_WRAPPER");
112 if let Some(previous) = previous.filter(|v| !v.is_empty()) {
113 if previous != script.as_os_str() {
115 command.env("MAGI_PREVIOUS_RUSTC_WRAPPER", previous);
116 }
117 }
118 command
119 .env("MAGI_INCREMENTAL_WORKTREE", worktree.canonicalize()?)
120 .env("MAGI_INCREMENTAL_WRAPPER", &script)
121 .env("RUSTC_WRAPPER", script);
122 }
123 Ok(())
124}
125
126#[cfg(unix)]
129struct WrapperTemporary {
130 path: PathBuf,
131 file: File,
132}
133
134#[cfg(unix)]
135impl WrapperTemporary {
136 fn create(mut candidate: impl FnMut() -> PathBuf) -> std::io::Result<Self> {
137 for _ in 0..128 {
138 let path = candidate();
139 match OpenOptions::new().write(true).create_new(true).open(&path) {
140 Ok(file) => return Ok(Self { path, file }),
141 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
142 Err(error) => return Err(error),
143 }
144 }
145 Err(std::io::Error::new(
146 std::io::ErrorKind::AlreadyExists,
147 "could not reserve an incremental wrapper temporary file",
148 ))
149 }
150}
151
152#[cfg(unix)]
153impl Drop for WrapperTemporary {
154 fn drop(&mut self) {
155 let _ = fs::remove_file(&self.path);
156 }
157}
158
159#[cfg(unix)]
160fn publish_wrapper(script: &Path, body: &str, candidate: impl FnMut() -> PathBuf) -> Result<()> {
161 use std::io::Write;
162 use std::os::unix::fs::PermissionsExt;
163 let mut temporary = WrapperTemporary::create(candidate)?;
164 temporary.file.write_all(body.as_bytes())?;
165 temporary
166 .file
167 .set_permissions(fs::Permissions::from_mode(0o700))?;
168 match fs::hard_link(&temporary.path, script) {
171 Ok(()) => Ok(()),
172 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
173 ensure!(
174 plain(script)?.is_file() && fs::read(script)? == body.as_bytes(),
175 "incremental wrapper changed"
176 );
177 Ok(())
178 }
179 Err(error) => Err(error.into()),
180 }
181}
182
183fn git_dir(worktree: &Path) -> Result<PathBuf> {
184 let output = std::process::Command::new("git")
185 .current_dir(worktree)
186 .args(["rev-parse", "--absolute-git-dir"])
187 .output()?;
188 ensure!(
189 output.status.success(),
190 "incremental provenance requires a Git worktree"
191 );
192 Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
193}
194
195fn plain(path: &Path) -> Result<fs::Metadata> {
196 let metadata = fs::symlink_metadata(path)?;
197 ensure!(
198 !metadata.file_type().is_symlink() && path.canonicalize()? == path,
199 "incremental path is not canonical: {}",
200 path.display()
201 );
202 Ok(metadata)
203}
204
205#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207struct Identity {
208 bytes: u64,
209 digest: Vec<u8>,
210 #[cfg(unix)]
211 device: u64,
212 #[cfg(unix)]
213 inode: u64,
214}
215
216fn identity(path: &Path) -> Result<Identity> {
217 let metadata = plain(path)?;
218 ensure!(
219 metadata.is_file(),
220 "incremental entry is not a regular file"
221 );
222 let mut file = File::open(path)?;
223 let mut hash = Sha256::new();
224 let mut buffer = [0; 65536];
225 loop {
226 let n = file.read(&mut buffer)?;
227 if n == 0 {
228 break;
229 }
230 hash.update(&buffer[..n]);
231 }
232 #[cfg(unix)]
233 use std::os::unix::fs::MetadataExt;
234 Ok(Identity {
235 bytes: metadata.len(),
236 digest: hash.finalize().to_vec(),
237 #[cfg(unix)]
238 device: metadata.dev(),
239 #[cfg(unix)]
240 inode: metadata.ino(),
241 })
242}
243
244#[derive(Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246struct Proof {
247 version: u32,
248 worktree: PathBuf,
249 generation: PathBuf,
250 files: BTreeMap<PathBuf, Identity>,
251 directories: std::collections::BTreeSet<PathBuf>,
252}
253
254pub(crate) struct Generation {
255 path: PathBuf,
256 record: PathBuf,
257 _lock: FileLease,
259}
260
261impl Generation {
262 pub(crate) fn create(worktree: &Path, parent: &Path) -> Result<Self> {
263 let target = worktree.join("target");
264 ensure!(
265 [
266 target.join("debug/incremental"),
267 target.join("release/incremental")
268 ]
269 .contains(&parent.to_owned()),
270 "explicit incremental requires a local managed target; shared/custom targets remain non-incremental"
271 );
272 ensure!(
274 plain(&target)?.is_dir() && plain(parent)?.is_dir(),
275 "incremental parent is not a directory"
276 );
277 let store = git_dir(worktree)?.join(STORE);
278 fs::create_dir_all(&store)?;
279 ensure!(
280 plain(&store)?.is_dir(),
281 "invalid incremental provenance store"
282 );
283 let mut completed = BTreeMap::new();
287 let mut inspected = 0usize;
288 for entry in fs::read_dir(&store)? {
289 let owner = entry?.path();
290 if owner.extension().is_some_and(|x| x == "owner") {
291 inspected += 1;
292 if inspected > 1024 {
293 break;
294 }
295 if let Ok(snapshot) = completed_charge(worktree, &owner) {
296 completed.insert(owner, snapshot);
297 }
298 }
299 }
300 let mut options = OpenOptions::new();
301 #[cfg(unix)]
302 {
303 use std::os::unix::fs::OpenOptionsExt;
304 options.custom_flags(libc::O_NOFOLLOW);
305 }
306 let admission = FileLease(
307 options
308 .create(true)
309 .truncate(false)
310 .read(true)
311 .write(true)
312 .open(store.join("admission.lock"))?,
313 );
314 wait_for_admission(&admission, std::time::Duration::from_secs(30))?;
315 for entry in fs::read_dir(&store)? {
318 let record = entry?.path();
319 if record.extension().is_none_or(|x| x != "json") {
320 continue;
321 }
322 let cleanup = || -> Result<()> {
323 ensure!(
324 plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
325 "invalid incremental proof"
326 );
327 let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
328 validate_proof(worktree, &record, &proof)?;
329 ensure!(
330 matches!(fs::symlink_metadata(&proof.generation), Err(e) if e.kind() == std::io::ErrorKind::NotFound),
331 "generation still exists or is uninspectable"
332 );
333 let owner = record.with_extension("owner");
334 ensure!(
335 plain(&owner)?.is_file() && fs::metadata(&owner)?.len() == 0,
336 "invalid incremental owner"
337 );
338 let lock = FileLease(OpenOptions::new().read(true).write(true).open(&owner)?);
339 crate::pr_head::try_lock_file(&lock)?;
340 fs::remove_file(&record)?;
341 fs::remove_file(owner)?;
342 Ok(())
343 };
344 let _ = cleanup(); }
346 let mut charged = 0u64;
349 let mut records = 0usize;
350 for entry in fs::read_dir(&store)? {
351 let owner = entry?.path();
352 if owner.extension().is_some_and(|x| x == "owner") {
353 records += 1;
354 let bytes = completed
355 .get(&owner)
356 .and_then(|(bytes, guard)| snapshot_charge(&owner, *bytes, guard))
357 .unwrap_or(GENERATION_BYTES);
358 charged = charged.saturating_add(bytes);
359 }
360 }
361 ensure!(
362 records < 1024,
363 "managed incremental provenance record limit exhausted; inspect retention report"
364 );
365 ensure!(
366 charged.saturating_add(GENERATION_BYTES) <= SLOTS as u64 * GENERATION_BYTES,
367 "managed incremental budget exhausted (512 MiB including active/unproven reservations); retain unproven caches and inspect retention report"
368 );
369 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
370 let record = store.join(format!("{token}.json"));
371 let lock = FileLease(
372 OpenOptions::new()
373 .create_new(true)
374 .read(true)
375 .write(true)
376 .open(record.with_extension("owner"))?,
377 );
378 crate::pr_head::try_lock_file(&lock)?;
379 let path = parent.join(format!("magi-{token}"));
380 fs::create_dir(&path)?;
381 Ok(Self {
382 path,
383 record,
384 _lock: lock,
385 })
386 }
387
388 pub(crate) fn seal(&self, worktree: &Path) -> Result<()> {
389 finalized_sessions(&self.path)?;
390 let files = inventory(&self.path, true)?;
391 let bytes = files
392 .values()
393 .fold(0u64, |sum, f| sum.saturating_add(f.bytes));
394 ensure!(
395 bytes <= GENERATION_BYTES,
396 "managed incremental generation exceeds its 128 MiB budget"
397 );
398 let proof = Proof {
399 version: 1,
400 worktree: worktree.to_owned(),
401 generation: self.path.clone(),
402 files,
403 directories: directory_inventory(&self.path)?,
404 };
405 let temp = self.record.with_extension("tmp");
406 fs::write(&temp, serde_json::to_vec(&proof)?)?;
407 fs::rename(temp, &self.record)?;
408 Ok(())
409 }
410}
411
412fn finalized_sessions(root: &Path) -> Result<()> {
415 for entry in fs::read_dir(root)? {
416 let crate_dir = entry?.path();
417 ensure!(
418 plain(&crate_dir)?.is_dir(),
419 "unknown incremental root entry"
420 );
421 let mut sessions = Vec::new();
422 let mut locks = Vec::new();
423 for entry in fs::read_dir(&crate_dir)? {
424 let path = entry?.path();
425 let name = path
426 .file_name()
427 .context("missing incremental name")?
428 .to_str()
429 .context("invalid incremental name")?
430 .to_owned();
431 if plain(&path)?.is_dir() {
432 ensure!(
433 name.starts_with("s-")
434 && !name.ends_with("-working")
435 && name.split('-').count() == 4,
436 "incremental session is unfinished or unknown"
437 );
438 for output in fs::read_dir(&path)? {
439 ensure!(
440 plain(&output?.path())?.is_file(),
441 "unknown incremental session directory"
442 );
443 }
444 sessions.push(name);
445 } else {
446 ensure!(
447 name.starts_with("s-")
448 && name.ends_with(".lock")
449 && name.split('-').count() == 3,
450 "unknown incremental session lock"
451 );
452 locks.push(name);
453 }
454 }
455 ensure!(
456 !sessions.is_empty() && sessions.len() == locks.len(),
457 "incremental session/lock inventory is incomplete"
458 );
459 for lock in locks {
460 let prefix = format!("{}-", lock.strip_suffix(".lock").unwrap());
461 ensure!(
462 sessions.iter().any(|name| name.starts_with(&prefix)),
463 "incremental session lock has no completed session"
464 );
465 }
466 }
467 Ok(())
468}
469
470fn wait_for_admission(file: &File, timeout: std::time::Duration) -> Result<()> {
472 let started = std::time::Instant::now();
473 loop {
474 match crate::pr_head::try_lock_file(file) {
475 Ok(()) => return Ok(()),
476 Err(error)
477 if matches!(
478 error.kind(),
479 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
480 ) => {}
481 Err(error) => return Err(error).context("incremental generation admission lock"),
482 }
483 ensure!(
484 started.elapsed() < timeout,
485 "incremental generation admission wait timed out after {} ms",
486 timeout.as_millis()
487 );
488 std::thread::sleep(
489 std::time::Duration::from_millis(10).min(timeout.saturating_sub(started.elapsed())),
490 );
491 }
492}
493
494fn snapshot_charge(owner: &Path, bytes: u64, guard: &File) -> Option<u64> {
495 let current = plain(owner).ok()?;
496 let held = guard.metadata().ok()?;
497 #[cfg(unix)]
498 {
499 use std::os::unix::fs::MetadataExt;
500 (current.is_file()
501 && current.len() == 0
502 && current.dev() == held.dev()
503 && current.ino() == held.ino())
504 .then_some(bytes)
505 }
506 #[cfg(not(unix))]
507 {
508 let _ = (current, held, bytes);
509 None
510 }
511}
512
513fn lock_for_accounting(file: &File) -> std::io::Result<()> {
514 #[cfg(unix)]
515 {
516 use std::os::fd::AsRawFd;
517 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 {
519 Ok(())
520 } else {
521 Err(std::io::Error::last_os_error())
522 }
523 }
524 #[cfg(not(unix))]
525 {
526 crate::pr_head::try_lock_file(file)
527 }
528}
529
530fn completed_charge(worktree: &Path, owner: &Path) -> Result<(u64, FileLease)> {
533 ensure!(
534 plain(owner)?.is_file() && fs::metadata(owner)?.len() == 0,
535 "invalid incremental owner"
536 );
537 let record = owner.with_extension("json");
538 ensure!(
541 plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
542 "invalid incremental proof"
543 );
544 let lock = FileLease(OpenOptions::new().read(true).write(true).open(owner)?);
545 lock_for_accounting(&lock)?;
546 let proof: Proof = serde_json::from_slice(&fs::read(record)?)?;
547 validate_proof(worktree, &owner.with_extension("json"), &proof)?;
548 let actual = inventory(&proof.generation, false)?;
549 ensure!(
550 directory_inventory(&proof.generation)?.is_subset(&proof.directories)
551 && actual
552 .iter()
553 .all(|(p, identity)| proof.files.get(p) == Some(identity)),
554 "incremental proof changed"
555 );
556 let bytes = actual
557 .values()
558 .fold(0u64, |bytes, identity| bytes.saturating_add(identity.bytes));
559 Ok((bytes, lock))
560}
561
562fn inventory(root: &Path, recognize: bool) -> Result<BTreeMap<PathBuf, Identity>> {
563 let mut files = BTreeMap::new();
564 let mut bytes = 0u64;
565 let mut dirs = vec![root.to_owned()];
566 while let Some(dir) = dirs.pop() {
567 ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
568 for entry in fs::read_dir(&dir)? {
569 let path = entry?.path();
570 let metadata = plain(&path)?;
571 if metadata.is_dir() {
572 dirs.push(path);
573 } else {
574 ensure!(
575 metadata.is_file(),
576 "incremental entry is not a regular file"
577 );
578 bytes = bytes.saturating_add(metadata.len());
579 ensure!(
580 bytes <= GENERATION_BYTES,
581 "incremental generation exceeds its budget"
582 );
583 ensure!(files.len() < 10000, "incremental inventory too large");
584 if recognize {
585 ensure!(
586 recognized(&path)?,
587 "unknown incremental output: {}",
588 path.display()
589 );
590 }
591 files.insert(path.strip_prefix(root)?.to_owned(), identity(&path)?);
592 }
593 }
594 }
595 Ok(files)
596}
597
598fn directory_inventory(root: &Path) -> Result<std::collections::BTreeSet<PathBuf>> {
599 let mut found = std::collections::BTreeSet::new();
600 let mut dirs = vec![root.to_owned()];
601 while let Some(dir) = dirs.pop() {
602 ensure!(plain(&dir)?.is_dir(), "invalid incremental directory");
603 for entry in fs::read_dir(dir)? {
604 let path = entry?.path();
605 if plain(&path)?.is_dir() {
606 ensure!(
607 found.len() < 10000,
608 "incremental directory inventory too large"
609 );
610 found.insert(path.strip_prefix(root)?.to_owned());
611 dirs.push(path);
612 }
613 }
614 }
615 Ok(found)
616}
617
618fn recognized(path: &Path) -> Result<bool> {
619 let name = path.file_name().unwrap_or_default().to_string_lossy();
620 if name.ends_with(".lock") {
621 return Ok(fs::metadata(path)?.len() == 0);
622 }
623 let mut header = [0; 8];
624 let n = File::open(path)?.read(&mut header)?;
625 if matches!(
626 name.as_ref(),
627 "dep-graph.bin" | "query-cache.bin" | "work-products.bin"
628 ) {
629 return Ok(header[..n].starts_with(b"RSIC"));
630 }
631 if path.extension().is_some_and(|e| e == "o") || name == "metadata.rmeta" {
632 return crate::retention::artifact_signature(path);
633 }
634 Ok(false)
635}
636
637#[cfg(unix)]
642fn lock_session(file: &File) -> std::io::Result<()> {
643 use std::os::fd::AsRawFd;
644 let mut lock: libc::flock = unsafe { std::mem::zeroed() };
647 lock.l_type = libc::F_WRLCK as _;
648 lock.l_whence = libc::SEEK_SET as _;
649 lock.l_start = 0;
650 lock.l_len = 0;
651 if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETLK, &mut lock) } == -1 {
652 return Err(std::io::Error::last_os_error());
653 }
654 let unlocked: libc::c_short = libc::F_UNLCK as _;
655 if lock.l_type != unlocked {
656 return Err(std::io::Error::new(
657 std::io::ErrorKind::WouldBlock,
658 "incremental session has a POSIX record lock",
659 ));
660 }
661 crate::pr_head::try_lock_file(file)
662}
663
664#[cfg(all(unix, test))]
665fn lock_posix_session(file: &File) -> std::io::Result<()> {
666 use std::os::fd::AsRawFd;
667 let mut lock: libc::flock = unsafe { std::mem::zeroed() };
670 lock.l_type = libc::F_WRLCK as _;
671 lock.l_whence = libc::SEEK_SET as _;
672 lock.l_start = 0;
673 lock.l_len = 0;
674 if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock) } == -1 {
675 return Err(std::io::Error::last_os_error());
676 }
677 Ok(())
678}
679
680#[cfg(not(unix))]
681fn lock_session(file: &File) -> std::io::Result<()> {
682 crate::pr_head::try_lock_file(file)
683}
684
685pub fn wrapper(args: Vec<OsString>) -> Result<i32> {
688 tokio::runtime::Builder::new_current_thread()
689 .enable_all()
690 .build()?
691 .block_on(wrapper_inner(args))
692}
693
694async fn wrapper_inner(args: Vec<OsString>) -> Result<i32> {
695 let worktree = PathBuf::from(
696 std::env::var_os("MAGI_INCREMENTAL_WORKTREE").context("missing incremental worktree")?,
697 );
698 let mut args = args;
699 let mut generation = None;
700 for index in 0..args.len() {
701 let text = args[index].to_string_lossy();
702 let value = if index > 0 && args[index - 1] == "-C" {
703 text.strip_prefix("incremental=").map(str::to_owned)
704 } else {
705 text.strip_prefix("-Cincremental=").map(str::to_owned)
706 };
707 if let Some(value) = value {
708 ensure!(
709 generation.is_none(),
710 "multiple incremental compiler paths are unsupported"
711 );
712 let parent = PathBuf::from(value);
713 let parent = if parent.is_absolute() {
714 parent
715 } else {
716 std::env::current_dir()?.join(parent)
717 };
718 let made = Generation::create(&worktree, &parent)?;
721 let prefix = if text.starts_with("-C") {
722 "-Cincremental="
723 } else {
724 "incremental="
725 };
726 args[index] = format!("{prefix}{}", made.path.display()).into();
727 generation = Some(made);
728 }
729 }
730 let command = if let Some(previous) =
731 std::env::var_os("MAGI_PREVIOUS_RUSTC_WRAPPER").filter(|s| !s.is_empty())
732 {
733 let mut command = std::process::Command::new(previous);
734 command.args(&args);
735 command
736 } else {
737 let mut command = std::process::Command::new(args.first().context("missing compiler")?);
738 command.args(&args[1..]);
739 command
740 };
741 let mut running =
742 crate::seat_process::Running::spawn_operation(command.into(), None, Vec::new())?;
743 let result: Result<_> = async {
744 loop {
745 if let Some(status) = running.child().try_wait()? {
746 break Ok(status);
747 }
748 if let Some(generation) = &generation
749 && crate::disk::dir_size(&generation.path) > GENERATION_BYTES
750 {
751 anyhow::bail!(
752 "managed incremental compiler exceeded 128 MiB; retained unproven output"
753 );
754 }
755 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
756 }
757 }
758 .await;
759 running.finish().await;
762 let status = result?;
763 if status.success()
764 && let Some(generation) = generation
765 {
766 generation.seal(&worktree)?;
767 }
768 Ok(status.code().unwrap_or(1))
769}
770
771#[derive(Default)]
774pub(crate) struct Reclaim {
775 pub(crate) files: Vec<PathBuf>,
776 _locks: Vec<FileLease>,
777 finished: Vec<(PathBuf, PathBuf, std::collections::BTreeSet<PathBuf>)>,
778}
779
780impl Reclaim {
781 pub(crate) fn finish(&self) -> Result<()> {
782 for (record, generation, allowed_dirs) in &self.finished {
783 if generation.exists() {
784 ensure!(
785 inventory(generation, false)?.is_empty(),
786 "incremental generation still contains data"
787 );
788 ensure!(
789 directory_inventory(generation)?.is_subset(allowed_dirs),
790 "unknown incremental directory appeared before removal"
791 );
792 let mut dirs = vec![generation.clone()];
793 let mut index = 0;
794 while index < dirs.len() {
795 for entry in fs::read_dir(&dirs[index])? {
796 let path = entry?.path();
797 ensure!(plain(&path)?.is_dir(), "incremental generation changed");
798 dirs.push(path);
799 }
800 index += 1;
801 }
802 for dir in dirs.into_iter().rev() {
803 fs::remove_dir(dir)?;
804 }
805 }
806 let owner = record.with_extension("owner");
807 ensure!(
808 plain(&owner)?.len() == 0,
809 "incremental owner changed before removal"
810 );
811 fs::remove_file(record)?;
812 fs::remove_file(owner)?;
813 }
814 Ok(())
815 }
816}
817
818fn validate_proof(worktree: &Path, record: &Path, proof: &Proof) -> Result<()> {
819 ensure!(
820 proof.version == 1 && proof.worktree == worktree,
821 "incremental ownership changed"
822 );
823 let parent = proof
824 .generation
825 .parent()
826 .context("missing generation parent")?;
827 ensure!(
828 [
829 worktree.join("target/debug/incremental"),
830 worktree.join("target/release/incremental")
831 ]
832 .contains(&parent.to_owned()),
833 "incremental generation outside managed target"
834 );
835 ensure!(
836 proof
837 .generation
838 .file_name()
839 .is_some_and(|n| n.to_string_lossy()
840 == format!("magi-{}", record.file_stem().unwrap().to_string_lossy())),
841 "incremental generation identity changed"
842 );
843 Ok(())
844}
845
846pub(crate) fn candidates(worktree: &Path) -> Result<Reclaim> {
847 let store = git_dir(worktree)?.join(STORE);
848 if !store.exists() {
849 return Ok(Reclaim::default());
850 }
851 ensure!(
852 plain(&store)?.is_dir(),
853 "invalid incremental provenance store"
854 );
855 let mut outputs = Vec::new();
856 let mut locks = Vec::new();
857 let mut finished = Vec::new();
858 for entry in fs::read_dir(store)? {
859 let record = entry?.path();
860 if record.extension().is_none_or(|x| x != "json") {
861 continue;
862 }
863 ensure!(
864 plain(&record)?.is_file() && fs::metadata(&record)?.len() < 4 * 1024 * 1024,
865 "invalid incremental provenance"
866 );
867 let proof: Proof = serde_json::from_slice(&fs::read(&record)?)?;
868 validate_proof(worktree, &record, &proof)?;
869 let lock_path = record.with_extension("owner");
870 let owner_metadata = plain(&lock_path)?;
871 ensure!(
872 owner_metadata.is_file() && owner_metadata.len() == 0,
873 "incremental owner is missing or changed"
874 );
875 let owner = FileLease(OpenOptions::new().read(true).write(true).open(lock_path)?);
876 crate::pr_head::try_lock_file(&owner).context("incremental compiler is live")?;
877 locks.push(owner);
878 finished.push((
879 record.clone(),
880 proof.generation.clone(),
881 proof.directories.clone(),
882 ));
883 if !proof.generation.exists() {
884 continue;
885 }
886 let actual = inventory(&proof.generation, false)?;
887 ensure!(
888 directory_inventory(&proof.generation)?.is_subset(&proof.directories),
889 "unknown incremental directory"
890 );
891 ensure!(
894 actual.iter().all(|(p, id)| proof.files.get(p) == Some(id)),
895 "incremental provenance mismatch or unknown data"
896 );
897 for relative in actual.keys() {
898 let path = proof.generation.join(relative);
899 if path.extension().is_some_and(|x| x == "lock") {
900 let file = OpenOptions::new().read(true).write(true).open(&path)?;
901 lock_session(&file).context("incremental session is live")?;
902 locks.push(FileLease(file));
903 }
904 outputs.push(path);
905 }
906 }
907 Ok(Reclaim {
908 files: outputs,
909 _locks: locks,
910 finished,
911 })
912}
913
914#[cfg(all(test, unix))]
915pub(crate) mod tests {
916 use super::*;
917
918 fn cargo_generation() -> (tempfile::TempDir, PathBuf, Generation) {
921 let temp = tempfile::tempdir().unwrap();
922 let worktree = temp.path().canonicalize().unwrap();
923 let git = std::process::Command::new("git")
924 .arg("init")
925 .arg(&worktree)
926 .output()
927 .unwrap();
928 assert!(git.status.success());
929 let generation = generate(&worktree);
930 (temp, worktree, generation)
931 }
932
933 pub(crate) fn generate(worktree: &Path) -> Generation {
934 fs::create_dir(worktree.join("src")).unwrap();
935 fs::write(
936 worktree.join("Cargo.toml"),
937 "[package]\nname='cache-fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n",
938 )
939 .unwrap();
940 fs::write(
941 worktree.join("src/lib.rs"),
942 "pub fn answer() -> usize { (0..100).sum() }\n",
943 )
944 .unwrap();
945 let parent = worktree.join("target/debug/incremental");
946 fs::create_dir_all(&parent).unwrap();
947 let generation = Generation::create(worktree, &parent).unwrap();
948 let mut command = std::process::Command::new("cargo");
949 command.env_clear();
950 for key in ["PATH", "HOME", "RUSTUP_HOME"] {
951 if let Some(value) = std::env::var_os(key) {
952 command.env(key, value);
953 }
954 }
955 let output = command
956 .current_dir(worktree)
957 .env("RUSTUP_TOOLCHAIN", "stable")
958 .env("CARGO_HOME", worktree.join("cargo-home"))
959 .env("CARGO_INCREMENTAL", "0")
960 .env(
961 "RUSTFLAGS",
962 format!("-C incremental={}", generation.path.display()),
963 )
964 .args(["build", "--offline", "--jobs", "1", "--target-dir"])
965 .arg(worktree.join("target"))
966 .output()
967 .unwrap();
968 assert!(
969 output.status.success(),
970 "{}",
971 String::from_utf8_lossy(&output.stderr)
972 );
973 assert!(crate::disk::dir_size(&generation.path) > 0);
974 generation.seal(worktree).unwrap();
975 generation
976 }
977
978 #[test]
979 fn admission_contention_waits_and_timeout_is_distinct_from_budget_exhaustion() {
980 use std::time::Duration;
981 let temp = tempfile::tempdir().unwrap();
982 let path = temp.path().join("admission.lock");
983 let held = FileLease(
984 OpenOptions::new()
985 .create_new(true)
986 .read(true)
987 .write(true)
988 .open(&path)
989 .unwrap(),
990 );
991 crate::pr_head::try_lock_file(&held).unwrap();
992 std::thread::scope(|scope| {
993 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
994 let (done_tx, done_rx) = std::sync::mpsc::channel();
995 let path = path.clone();
996 scope.spawn(move || {
997 let waiting = FileLease(
998 OpenOptions::new()
999 .read(true)
1000 .write(true)
1001 .open(&path)
1002 .unwrap(),
1003 );
1004 ready_tx.send(()).unwrap();
1005 let result = wait_for_admission(&waiting, Duration::from_secs(5));
1006 done_tx.send(result).unwrap();
1007 });
1008 ready_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1009 assert!(done_rx.recv_timeout(Duration::from_millis(40)).is_err());
1010 drop(held);
1011 done_rx
1012 .recv_timeout(Duration::from_secs(5))
1013 .unwrap()
1014 .unwrap();
1015 });
1016 let held = FileLease(
1017 OpenOptions::new()
1018 .read(true)
1019 .write(true)
1020 .open(&path)
1021 .unwrap(),
1022 );
1023 crate::pr_head::try_lock_file(&held).unwrap();
1024 let waiting = OpenOptions::new()
1025 .read(true)
1026 .write(true)
1027 .open(&path)
1028 .unwrap();
1029 let error = wait_for_admission(&waiting, Duration::from_millis(20))
1030 .unwrap_err()
1031 .to_string();
1032 assert!(error.contains("wait timed out"));
1033 assert!(!error.contains("budget exhausted"));
1034 }
1035
1036 #[test]
1037 fn completed_snapshots_share_owner_locks_without_blocking_parallel_admission() {
1038 let (_temp, worktree, generation) = cargo_generation();
1039 let owner = generation.record.with_extension("owner");
1040 drop(generation);
1041 let first = completed_charge(&worktree, &owner).unwrap();
1042 let second = completed_charge(&worktree, &owner).unwrap();
1043 assert!(first.0 > 0);
1044 assert_eq!(first.0, second.0);
1045 let exclusive = FileLease(
1046 OpenOptions::new()
1047 .read(true)
1048 .write(true)
1049 .open(&owner)
1050 .unwrap(),
1051 );
1052 assert!(crate::pr_head::try_lock_file(&exclusive).is_err());
1053 let parent = worktree.join("target/debug/incremental");
1054 let fresh = Generation::create(&worktree, &parent).unwrap();
1055 assert!(fresh.path.is_dir());
1056 drop(first);
1057 drop(second);
1058 assert!(crate::pr_head::try_lock_file(&exclusive).is_ok());
1059 }
1060
1061 #[test]
1062 fn ownership_release_is_not_delayed_by_descriptor_copies() {
1063 let (_temp, worktree, generation) = cargo_generation();
1064 let store = git_dir(&worktree).unwrap().join(STORE);
1065 let open = |path: &Path| {
1066 OpenOptions::new()
1067 .read(true)
1068 .write(true)
1069 .open(path)
1070 .unwrap()
1071 };
1072 let admission_path = store.join("admission.lock");
1073 let admission = FileLease(open(&admission_path));
1074 crate::pr_head::try_lock_file(&admission).unwrap();
1075 let inherited_admission = admission.try_clone().unwrap();
1078 let next = FileLease(open(&admission_path));
1079 assert!(crate::pr_head::try_lock_file(&next).is_err());
1080 drop(admission);
1081 crate::pr_head::try_lock_file(&next).unwrap();
1082 drop(next);
1083 drop(inherited_admission);
1084
1085 let owner = generation.record.with_extension("owner");
1086 let inherited_generation = generation._lock.try_clone().unwrap();
1087 assert!(completed_charge(&worktree, &owner).is_err());
1088 drop(generation);
1089 let first = completed_charge(&worktree, &owner).unwrap();
1090 let second = completed_charge(&worktree, &owner).unwrap();
1091 let inherited_first = first.1.try_clone().unwrap();
1092 let inherited_second = second.1.try_clone().unwrap();
1093 let exclusive = FileLease(open(&owner));
1094 drop(first);
1095 assert!(
1096 crate::pr_head::try_lock_file(&exclusive).is_err(),
1097 "another actual accounting owner still excludes reclamation"
1098 );
1099 drop(second);
1100 crate::pr_head::try_lock_file(&exclusive).unwrap();
1101 drop(exclusive);
1102 drop((inherited_generation, inherited_first, inherited_second));
1103
1104 let reclaim = candidates(&worktree).unwrap();
1105 let inherited_reclaim: Vec<_> = reclaim
1106 ._locks
1107 .iter()
1108 .map(|lock| lock.try_clone().unwrap())
1109 .collect();
1110 assert!(candidates(&worktree).is_err());
1111 drop(reclaim);
1112 let retry = candidates(&worktree).unwrap();
1113 assert!(!retry.files.is_empty());
1114 drop(retry);
1115 drop(inherited_reclaim);
1116 }
1117
1118 #[test]
1119 fn default_disables_incremental_and_explicit_environment_is_preserved() {
1120 let temp = tempfile::tempdir().unwrap();
1121 let mut command = tokio::process::Command::new("cargo");
1122 command.env_remove("CARGO_INCREMENTAL");
1123 configure(&mut command, temp.path()).unwrap();
1124 let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1125 assert_eq!(
1126 env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1127 Some(std::ffi::OsStr::new("0"))
1128 );
1129 command.env("CARGO_INCREMENTAL", "1");
1130 configure(&mut command, temp.path()).unwrap();
1131 let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1132 assert_eq!(
1133 env[std::ffi::OsStr::new("CARGO_INCREMENTAL")],
1134 Some(std::ffi::OsStr::new("1"))
1135 );
1136 }
1137
1138 #[test]
1139 fn wrapper_temporary_collision_preserves_the_other_publisher() {
1140 use std::io::Write;
1141 let temp = tempfile::tempdir().unwrap();
1142 let root = temp.path().canonicalize().unwrap();
1143 let occupied = root.join("occupied.tmp");
1144 let fresh = root.join("fresh.tmp");
1145 let script = root.join("wrapper.sh");
1146 let barrier = std::sync::Barrier::new(2);
1147 std::thread::scope(|scope| {
1148 scope.spawn(|| {
1149 let mut owner = WrapperTemporary::create(|| occupied.clone()).unwrap();
1150 owner.file.write_all(b"other publisher").unwrap();
1151 barrier.wait();
1152 barrier.wait();
1153 assert_eq!(fs::read(&occupied).unwrap(), b"other publisher");
1155 });
1156 barrier.wait();
1157 let mut attempts = 0;
1158 let result = publish_wrapper(&script, "complete wrapper", || {
1159 attempts += 1;
1160 if attempts == 1 {
1161 occupied.clone()
1162 } else {
1163 fresh.clone()
1164 }
1165 });
1166 barrier.wait();
1168 result.unwrap();
1169 assert_eq!(attempts, 2);
1170 assert_eq!(fs::read(&script).unwrap(), b"complete wrapper");
1171 assert!(!fresh.exists());
1172 });
1173 assert!(!occupied.exists());
1174 }
1175
1176 #[test]
1177 fn wrapper_temporary_retries_only_collisions_and_leaves_them_intact() {
1178 let temp = tempfile::tempdir().unwrap();
1179 let occupied = temp.path().join("occupied.tmp");
1180 fs::write(&occupied, b"reserved").unwrap();
1181 let mut attempts = 0;
1182 let error = WrapperTemporary::create(|| {
1183 attempts += 1;
1184 occupied.clone()
1185 })
1186 .err()
1187 .unwrap();
1188 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
1189 assert_eq!(attempts, 128);
1190 assert_eq!(fs::read(&occupied).unwrap(), b"reserved");
1191 attempts = 0;
1192 let error = WrapperTemporary::create(|| {
1193 attempts += 1;
1194 temp.path().join("missing/temporary")
1195 })
1196 .err()
1197 .unwrap();
1198 assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
1199 assert_eq!(attempts, 1);
1200 }
1201
1202 #[test]
1203 fn wrapper_publication_preserves_existing_destinations() {
1204 use std::os::unix::fs::{MetadataExt, symlink};
1205 let temp = tempfile::tempdir().unwrap();
1206 let root = temp.path().canonicalize().unwrap();
1207 let script = root.join("wrapper.sh");
1208 let temporary = root.join("temporary");
1209 let body = "#!/bin/sh\nexit 0\n";
1210 fs::write(&script, body).unwrap();
1211 let inode = fs::metadata(&script).unwrap().ino();
1212 publish_wrapper(&script, body, || temporary.clone()).unwrap();
1213 assert_eq!(fs::metadata(&script).unwrap().ino(), inode);
1214 assert!(!temporary.exists());
1215 fs::write(&script, "different").unwrap();
1216 assert!(publish_wrapper(&script, body, || temporary.clone()).is_err());
1217 assert_eq!(fs::read_to_string(&script).unwrap(), "different");
1218 assert!(!temporary.exists());
1219 fs::remove_file(&script).unwrap();
1220 fs::create_dir(&script).unwrap();
1221 assert!(publish_wrapper(&script, body, || temporary.clone()).is_err());
1222 assert!(script.is_dir());
1223 assert!(!temporary.exists());
1224 fs::remove_dir(&script).unwrap();
1225 let target = root.join("target");
1226 fs::write(&target, body).unwrap();
1227 symlink(&target, &script).unwrap();
1228 assert!(publish_wrapper(&script, body, || temporary.clone()).is_err());
1229 assert_eq!(fs::read_link(&script).unwrap(), target);
1230 assert_eq!(fs::read_to_string(&target).unwrap(), body);
1231 assert!(!temporary.exists());
1232 }
1233
1234 #[test]
1235 fn concurrent_configuration_publishes_complete_wrappers() {
1236 let temp = tempfile::tempdir().unwrap();
1237 let root = temp.path().canonicalize().unwrap();
1238 let worktree = root.join("worktree");
1239 assert!(
1240 std::process::Command::new("git")
1241 .args(["init", "--separate-git-dir"])
1242 .arg(root.join("metadata"))
1243 .arg(&worktree)
1244 .output()
1245 .unwrap()
1246 .status
1247 .success()
1248 );
1249 use std::os::unix::fs::PermissionsExt;
1250 let executable = std::env::current_exe().unwrap();
1251 let quoted = executable.to_string_lossy().replace('\'', "'\\''");
1252 let body = format!("#!/bin/sh\nexec '{quoted}' __magi_incremental \"$@\"\n");
1253 let barrier = std::sync::Barrier::new(8);
1254 let wrappers = std::thread::scope(|scope| {
1255 let handles: Vec<_> = (0..8)
1256 .map(|_| {
1257 scope.spawn(|| {
1258 let mut last = None;
1259 barrier.wait();
1260 for _ in 0..16 {
1261 let mut command = tokio::process::Command::new("cargo");
1262 configure(&mut command, &worktree).unwrap();
1263 let env: BTreeMap<_, _> = command.as_std().get_envs().collect();
1264 let wrapper =
1265 PathBuf::from(env[std::ffi::OsStr::new("RUSTC_WRAPPER")].unwrap());
1266 assert_eq!(fs::read_to_string(&wrapper).unwrap(), body);
1267 assert_eq!(
1268 fs::metadata(&wrapper).unwrap().permissions().mode() & 0o777,
1269 0o700
1270 );
1271 if let Some(previous) = last.replace(wrapper.clone()) {
1272 assert_eq!(previous, wrapper);
1273 }
1274 }
1275 last.unwrap()
1276 })
1277 })
1278 .collect();
1279 handles
1280 .into_iter()
1281 .map(|handle| handle.join().unwrap())
1282 .collect::<Vec<_>>()
1283 });
1284 assert!(wrappers.iter().all(|path| path == &wrappers[0]));
1285 assert!(
1286 fs::read_dir(git_dir(&worktree).unwrap())
1287 .unwrap()
1288 .all(|entry| {
1289 !entry
1290 .unwrap()
1291 .file_name()
1292 .to_string_lossy()
1293 .ends_with(".tmp")
1294 })
1295 );
1296 }
1297
1298 #[test]
1299 fn sealing_refuses_unfinished_sessions_and_unknown_directories() {
1300 let (_temp, worktree, generation) = cargo_generation();
1301 let unknown = generation.path.join("operator-empty");
1302 fs::create_dir(&unknown).unwrap();
1303 assert!(generation.seal(&worktree).is_err());
1304 fs::remove_dir(unknown).unwrap();
1305 let crate_dir = fs::read_dir(&generation.path)
1306 .unwrap()
1307 .next()
1308 .unwrap()
1309 .unwrap()
1310 .path();
1311 let working = crate_dir.join("s-unfinished-cache-working");
1312 fs::create_dir(&working).unwrap();
1313 assert!(generation.seal(&worktree).is_err());
1314 assert!(working.exists());
1315 drop(generation);
1316 assert!(candidates(&worktree).is_err());
1317 }
1318
1319 #[test]
1320 fn unknown_empty_directories_added_after_inventory_are_retained() {
1321 let (_temp, worktree, generation) = cargo_generation();
1322 let path = generation.path.clone();
1323 drop(generation);
1324 let reclaim = candidates(&worktree).unwrap();
1325 for file in &reclaim.files {
1326 fs::remove_file(file).unwrap();
1327 }
1328 let unknown = path.join("operator-empty");
1329 fs::create_dir(&unknown).unwrap();
1330 assert!(reclaim.finish().is_err());
1331 assert!(unknown.is_dir());
1332 fs::remove_dir(unknown).unwrap();
1333 reclaim.finish().unwrap();
1334 assert!(!path.exists());
1335 }
1336
1337 #[test]
1338 fn fcntl_session_lock_is_respected_across_processes() {
1339 use std::io::{Read, Write};
1340 use std::process::{Command, Stdio};
1341 if let Some(path) = std::env::var_os("MAGI_TEST_SESSION_FILE") {
1342 let file = OpenOptions::new()
1343 .read(true)
1344 .write(true)
1345 .open(path)
1346 .unwrap();
1347 lock_posix_session(&file).unwrap();
1350 fs::write(
1351 std::env::var_os("MAGI_TEST_SESSION_READY").unwrap(),
1352 "ready",
1353 )
1354 .unwrap();
1355 std::io::stdin().read_exact(&mut [0]).unwrap();
1356 return;
1357 }
1358 let (_temp, worktree, generation) = cargo_generation();
1359 drop(generation);
1360 let candidates = candidates(&worktree).unwrap();
1361 let session = candidates
1362 .files
1363 .iter()
1364 .find(|p| p.extension().is_some_and(|x| x == "lock"))
1365 .unwrap()
1366 .clone();
1367 drop(candidates);
1368 let ready = worktree.join("session-ready");
1369 let mut child = Command::new(std::env::current_exe().unwrap())
1370 .args([
1371 "--exact",
1372 "incremental::tests::fcntl_session_lock_is_respected_across_processes",
1373 ])
1374 .env("MAGI_TEST_SESSION_FILE", &session)
1375 .env("MAGI_TEST_SESSION_READY", &ready)
1376 .stdin(Stdio::piped())
1377 .stdout(Stdio::null())
1378 .spawn()
1379 .unwrap();
1380 let started = std::time::Instant::now();
1381 while !ready.exists() && started.elapsed() < std::time::Duration::from_secs(10) {
1382 if child.try_wait().unwrap().is_some() {
1383 break;
1384 }
1385 std::thread::sleep(std::time::Duration::from_millis(10));
1386 }
1387 let observed = ready.exists();
1388 let blocked = observed
1389 && super::candidates(&worktree)
1390 .err()
1391 .is_some_and(|e| e.to_string().contains("session is live"));
1392 if observed {
1393 child.stdin.take().unwrap().write_all(b"x").unwrap();
1394 } else {
1395 child.kill().ok();
1396 }
1397 let status = child.wait().unwrap();
1398 assert!(
1399 observed && status.success(),
1400 "session helper did not acquire its lock"
1401 );
1402 assert!(blocked, "POSIX session lock must exclude reclamation");
1403 assert!(!super::candidates(&worktree).unwrap().files.is_empty());
1404 assert!(session.exists());
1405 }
1406
1407 #[test]
1408 fn real_cargo_generations_require_exact_provenance_and_live_owner_exclusion() {
1409 let (_temp, worktree, generation) = cargo_generation();
1410 assert!(
1411 candidates(&worktree).is_err(),
1412 "compiler owner still holds the generation"
1413 );
1414 let path = generation.path.clone();
1415 drop(generation);
1416 let reclaim = candidates(&worktree).unwrap();
1417 let session = reclaim
1418 .files
1419 .iter()
1420 .find(|p| p.extension().is_some_and(|x| x == "lock"))
1421 .unwrap()
1422 .clone();
1423 drop(reclaim);
1424 let live_session = FileLease(
1425 OpenOptions::new()
1426 .read(true)
1427 .write(true)
1428 .open(session)
1429 .unwrap(),
1430 );
1431 crate::pr_head::try_lock_file(&live_session).unwrap();
1432 assert!(
1433 candidates(&worktree)
1434 .err()
1435 .unwrap()
1436 .to_string()
1437 .contains("session is live")
1438 );
1439 drop(live_session);
1440 let unknown = path.join("operator-note");
1441 fs::write(&unknown, "keep unknown data").unwrap();
1442 assert!(candidates(&worktree).is_err());
1443 assert_eq!(fs::read_to_string(&unknown).unwrap(), "keep unknown data");
1444 fs::remove_file(&unknown).unwrap();
1445 let unknown_dir = path.join("operator-empty");
1446 fs::create_dir(&unknown_dir).unwrap();
1447 assert!(candidates(&worktree).is_err());
1448 fs::remove_dir(unknown_dir).unwrap();
1449 let reclaim = candidates(&worktree).unwrap();
1450 assert!(!reclaim.files.is_empty());
1451 assert!(
1452 candidates(&worktree).is_err(),
1453 "maintenance retains session/owner locks"
1454 );
1455 let removed = reclaim.files[0].clone();
1457 fs::remove_file(removed).unwrap();
1458 drop(reclaim);
1459 let reclaim = candidates(&worktree).unwrap();
1460 let changed = reclaim
1461 .files
1462 .iter()
1463 .find(|p| p.extension().is_some_and(|e| e == "bin"))
1464 .unwrap()
1465 .clone();
1466 let original = fs::read(&changed).unwrap();
1467 fs::write(&changed, b"modified proof input").unwrap();
1468 drop(reclaim);
1469 assert!(candidates(&worktree).is_err());
1470 fs::write(&changed, original).unwrap();
1471 let reclaim = candidates(&worktree).unwrap();
1472 for file in &reclaim.files {
1473 fs::remove_file(file).unwrap();
1474 }
1475 reclaim.finish().unwrap();
1476 assert!(!path.exists());
1477 assert!(candidates(&worktree).unwrap().files.is_empty());
1478 assert!(worktree.join("src/lib.rs").is_file());
1479 }
1480
1481 #[test]
1482 fn externally_removed_sealed_generation_does_not_strand_admission() {
1483 let (_temp, worktree, generation) = cargo_generation();
1484 let path = generation.path.clone();
1485 drop(generation);
1486 let reclaim = candidates(&worktree).unwrap();
1487 for file in &reclaim.files {
1488 fs::remove_file(file).unwrap();
1489 }
1490 let mut dirs = directory_inventory(&path)
1493 .unwrap()
1494 .into_iter()
1495 .collect::<Vec<_>>();
1496 dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
1497 for relative in dirs {
1498 fs::remove_dir(path.join(relative)).unwrap();
1499 }
1500 fs::remove_dir(&path).unwrap();
1501 drop(reclaim);
1502 let parent = worktree.join("target/debug/incremental");
1503 let fresh = Generation::create(&worktree, &parent).unwrap();
1504 assert_eq!(
1505 fs::read_dir(git_dir(&worktree).unwrap().join(STORE))
1506 .unwrap()
1507 .filter_map(|e| e.ok())
1508 .filter(|e| e.path().extension().is_some_and(|x| x == "owner"))
1509 .count(),
1510 1
1511 );
1512 assert!(fresh.path.is_dir());
1513 }
1514
1515 #[test]
1516 fn unsealed_attempts_consume_the_bounded_generation_budget() {
1517 let temp = tempfile::tempdir().unwrap();
1518 let worktree = temp.path().canonicalize().unwrap();
1519 assert!(
1520 std::process::Command::new("git")
1521 .arg("init")
1522 .arg(&worktree)
1523 .output()
1524 .unwrap()
1525 .status
1526 .success()
1527 );
1528 let parent = worktree.join("target/debug/incremental");
1529 fs::create_dir_all(&parent).unwrap();
1530 for _ in 0..SLOTS {
1531 let generation = Generation::create(&worktree, &parent).unwrap();
1532 fs::write(generation.path.join("unknown"), "preserve").unwrap();
1533 }
1534 assert!(Generation::create(&worktree, &parent).is_err());
1535 assert!(
1536 candidates(&worktree).unwrap().files.is_empty(),
1537 "unsealed output never grants deletion authority"
1538 );
1539 }
1540
1541 #[cfg(unix)]
1542 #[test]
1543 fn generation_symlinks_and_replaced_inodes_refuse_reclamation() {
1544 let (_temp, worktree, generation) = cargo_generation();
1545 let path = generation.path.clone();
1546 drop(generation);
1547 let files = candidates(&worktree).unwrap().files;
1548 let file = files
1549 .iter()
1550 .find(|p| p.extension().is_some_and(|e| e == "bin"))
1551 .unwrap();
1552 let saved = worktree.join("retained-input");
1553 fs::rename(file, &saved).unwrap();
1554 std::os::unix::fs::symlink(&saved, file).unwrap();
1555 assert!(candidates(&worktree).is_err());
1556 fs::remove_file(file).unwrap();
1557 fs::copy(&saved, file).unwrap();
1558 assert!(
1559 candidates(&worktree).is_err(),
1560 "same bytes at a replaced inode are insufficient"
1561 );
1562 assert!(path.exists());
1563 assert!(saved.is_file());
1564 }
1565}