Skip to main content

epicenter_cli/
vault.rs

1//! The vault: a durable store of Cargo unit groups, addressed by Cargo's
2//! own fingerprint hashes.
3//!
4//! A *unit group* is exactly what Cargo needs to consider one built unit
5//! fresh: the 16-hex fingerprint hash file, the serialized `Fingerprint`
6//! JSON, the binary dep-info, and the unit's artifacts (`deps/…`, and the
7//! `build/<anchor>/…` tree for build-script units). The vault keeps one
8//! directory per generation, where a generation is one toolchain plus one
9//! flag environment (the *parity key*):
10//!
11//! ```text
12//! {cargo-cache-home}/epicenter/
13//!   store/<parity-key>/
14//!     manifest.json                file list, sizes, canonical stamps
15//!     units/<pkg>-<unit-hash>/     one unit group
16//!       <memo-file>                the authoritative 16-hex hash
17//!       <memo-file>.json           the serialized Fingerprint
18//!       dep-<memo-file>            EncodedDepInfo (mandatory for freshness)
19//!       deps/…  build/…            artifacts, as the profile dir holds them
20//!   build/…                        the shared working dir (a disposable view)
21//! ```
22//!
23//! Timestamps are the only lever besides the hash in Cargo's freshness
24//! check, so every file of a unit group carries ONE canonical second
25//! (`stamp`): the ceiling of the group's metadata output (`.rmeta`) when it
26//! has one — the moment dependents can compile against it — else of its
27//! newest file, and always ≥ every dependency's stamp (harvest clamps the
28//! store). Restore never stamps hard links: they share the store's inode,
29//! and a `touch` on a restored file would rewrite the store itself.
30
31use std::fs;
32use std::io::Write;
33use std::path::{Path, PathBuf};
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use anyhow::{Context, Result};
37
38use crate::util::{self, FileLock};
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use walkdir::WalkDir;
42
43use crate::fingerprint::{self, ArtifactInfo, Unit};
44
45/// Seed for the flag digest, versioned: bumping it retires old generations.
46const PARITY_SEED: &str = "epicenter-parity-v3";
47
48// ── toolchain identity ──────────────────────────────────────────────────────
49
50/// The `rustc -vV` facts that name a vault generation.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Toolchain {
53    /// `1.99.0`
54    pub release: String,
55    /// Short commit hash (`b940084d7`) as rustc prints it.
56    pub commit: String,
57    /// `x86_64-unknown-linux-gnu`
58    pub host: String,
59}
60
61impl Toolchain {
62    /// `rustc 1.99.0 (b940084d7)` — for humans, not for keys.
63    pub fn version_line(&self) -> String {
64        format!("rustc {} ({})", self.release, self.commit)
65    }
66}
67
68/// Parse `rustc -vV` (first line + `host:`) for the default toolchain.
69pub fn toolchain() -> Result<Toolchain> {
70    toolchain_of(None)
71}
72
73/// Parse `rustc -vV` for a rustup selector (`nightly`, `1.98.0`, …;
74/// `None` = the default toolchain). `exec` uses this for `cargo +nightly`.
75pub fn toolchain_of(selector: Option<&str>) -> Result<Toolchain> {
76    let mut command = util::command_for("rustc")?;
77    if let Some(selector) = selector {
78        command.arg(format!("+{selector}"));
79    }
80    let output = command.arg("-vV").output().context("running `rustc -vV`")?;
81    if !output.status.success() {
82        anyhow::bail!("`rustc -vV` failed — is a toolchain installed?");
83    }
84    let text = String::from_utf8_lossy(&output.stdout);
85
86    let mut release = "unknown".to_string();
87    let mut commit = "unknown".to_string();
88    let mut host = "unknown".to_string();
89    for line in text.lines() {
90        if let Some(rest) = line.strip_prefix("rustc ") {
91            let mut parts = rest.split_whitespace();
92            if let Some(version) = parts.next() {
93                release = version.to_string();
94            }
95            // `rustc 1.99.0 (b940084d7 2026-09-28)` — the token starts `(`.
96            if let Some(commit_part) = parts.next() {
97                let value = commit_part.trim_start_matches('(');
98                if !value.is_empty() {
99                    commit = value.to_string();
100                }
101            }
102        } else if let Some(value) = line.strip_prefix("host: ") {
103            host = value.trim().to_string();
104        }
105    }
106    util::validate_component(&release, "rustc release")?;
107    util::validate_component(&commit, "rustc commit")?;
108    util::validate_component(&host, "rustc host")?;
109    Ok(Toolchain {
110        release,
111        commit,
112        host,
113    })
114}
115
116/// Install time of the active toolchain: the real `rustc` binary in the
117/// sysroot (falling back to the sysroot directory). This is the reference
118/// for "was this unit built by the installed toolchain?".
119pub fn toolchain_install_time() -> Option<SystemTime> {
120    toolchain_install_time_of(None)
121}
122
123/// Install time of `selector`'s toolchain (same shape as the default).
124pub fn toolchain_install_time_of(selector: Option<&str>) -> Option<SystemTime> {
125    let mut command = util::command_for("rustc").ok()?;
126    if let Some(selector) = selector {
127        command.arg(format!("+{selector}"));
128    }
129    let output = command.args(["--print", "sysroot"]).output().ok()?;
130    if !output.status.success() {
131        return None;
132    }
133    let sysroot = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
134    let rustc = sysroot
135        .join("bin")
136        .join(if cfg!(windows) { "rustc.exe" } else { "rustc" });
137    fs::metadata(&rustc)
138        .and_then(|meta| meta.modified())
139        .ok()
140        .or_else(|| fs::metadata(&sysroot).and_then(|meta| meta.modified()).ok())
141}
142
143// ── parity key ──────────────────────────────────────────────────────────────
144
145/// One vault generation = toolchain + host + flag environment:
146/// `1.99.0-b940084d7-x86_64-unknown-linux-gnu-3f9a2c1b`.
147pub fn parity_key() -> Result<String> {
148    parity_key_of(None)
149}
150
151/// Parity key for a rustup selector (`exec` with a `+toolchain` child).
152pub fn parity_key_of(selector: Option<&str>) -> Result<String> {
153    let tc = toolchain_of(selector)?;
154    parity_key_with(&tc)
155}
156
157/// Parity key for an already-resolved toolchain (avoid a second `rustc`).
158fn parity_key_with(tc: &Toolchain) -> Result<String> {
159    let key = format!(
160        "{}-{}-{}-{}",
161        tc.release,
162        tc.commit,
163        tc.host,
164        flags_digest()
165    );
166    // The key becomes a directory name: never allow traversal via a fake
167    // `rustc -vV` on PATH (e.g. release `../../x`).
168    util::validate_component(&key, "parity key")?;
169    Ok(key)
170}
171
172/// Digest of the flags that live outside the fingerprint: the flag
173/// environment plus the flag-relevant keys of Cargo's global config.
174fn flags_digest() -> String {
175    let mut input = String::from(PARITY_SEED);
176    for name in [
177        "RUSTFLAGS",
178        "RUSTDOCFLAGS",
179        "CARGO_BUILD_RUSTFLAGS",
180        "CARGO_BUILD_RUSTDOCFLAGS",
181        "CARGO_BUILD_TARGET",
182        "RUSTC_WRAPPER",
183        "CARGO_BUILD_RUSTC_WRAPPER",
184        "RUSTC",
185    ] {
186        input.push('\0');
187        input.push_str(name);
188        input.push('=');
189        input.push_str(&std::env::var(name).unwrap_or_default());
190    }
191    if let Ok(path) = crate::config::cargo_config_path() {
192        if let Ok(text) = crate::util::read_trusted_config(&path, 1024 * 1024) {
193            for key in ["rustflags", "rustdocflags", "target", "rustc-wrapper"] {
194                if let Some(value) = crate::config::read_table_key(&text, "build", key) {
195                    input.push('\0');
196                    input.push_str(key);
197                    input.push('=');
198                    input.push_str(&value);
199                }
200            }
201        }
202    }
203    if let Ok(mut cargo) = util::command_for("cargo") {
204        if let Ok(output) = cargo.arg("-V").output() {
205            if output.status.success() {
206                input.push('\0');
207                input.push_str("cargo=");
208                input.push_str(String::from_utf8_lossy(&output.stdout).trim());
209            }
210        }
211    }
212    format!("{:08x}", (fnv1a64(input.as_bytes()) >> 32) as u32)
213}
214
215fn fnv1a64(bytes: &[u8]) -> u64 {
216    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
217    for byte in bytes {
218        hash ^= *byte as u64;
219        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
220    }
221    hash
222}
223
224// ── layout ──────────────────────────────────────────────────────────────────
225
226/// `<build>/../store` — the vault lives beside the shared build dir, so
227/// `cargo clean` can never touch it.
228pub fn store_root(build_root: &Path) -> PathBuf {
229    build_root.parent().unwrap_or(build_root).join("store")
230}
231
232/// `store/<parity-key>/units`.
233pub fn units_dir(store_root: &Path, parity: &str) -> PathBuf {
234    store_root.join(parity).join("units")
235}
236
237fn manifest_path(store_root: &Path, parity: &str) -> PathBuf {
238    store_root.join(parity).join("manifest.json")
239}
240
241/// The per-generation lock lives **outside** the generation dir so an empty
242/// generation can be removed while the lock is held (Windows).
243fn store_lock_path(store_root: &Path, parity: &str) -> PathBuf {
244    store_root.join(format!(".lock-{parity}"))
245}
246
247/// Remove the per-generation lock file when its generation directory is
248/// gone and no live process holds the lock. A held lock is left alone.
249pub(crate) fn remove_generation_lock(store_root: &Path, generation: &str) {
250    let dir = store_root.join(generation);
251    let path = store_lock_path(store_root, generation);
252    if dir.exists() || !path.exists() {
253        return;
254    }
255    let Ok(Some(_lock)) = FileLock::try_acquire(&path) else {
256        return; // a live harvest holds it
257    };
258    if !dir.exists() {
259        let _ = fs::remove_file(&path);
260    }
261}
262
263/// Clean the debris a crash or a manual deletion leaves in the store root:
264/// `.lock-*` files whose generation is gone, and empty manifest-less
265/// generation directories. Existing generations and held locks are kept.
266pub fn sweep_store_debris(store_root: &Path) {
267    let Ok(entries) = fs::read_dir(store_root) else {
268        return;
269    };
270    for entry in entries.flatten() {
271        let name = entry.file_name().to_string_lossy().into_owned();
272        if let Some(generation) = name.strip_prefix(".lock-") {
273            if !generation.is_empty() {
274                remove_generation_lock(store_root, generation);
275            }
276        }
277    }
278    let Ok(names) = generation_names(store_root) else {
279        return;
280    };
281    for generation in names {
282        let units = units_dir(store_root, &generation);
283        let empty = fs::read_dir(&units)
284            .map(|mut entries| entries.next().is_none())
285            .unwrap_or(true);
286        // Only manifest-less husks: a manifest is owned by `drop_units`,
287        // which removes its generation itself.
288        if !empty || manifest_path(store_root, &generation).is_file() {
289            continue;
290        }
291        let Ok(Some(_lock)) = FileLock::try_acquire(&store_lock_path(store_root, &generation))
292        else {
293            continue;
294        };
295        let still_empty = fs::read_dir(&units)
296            .map(|mut entries| entries.next().is_none())
297            .unwrap_or(true);
298        if still_empty {
299            let _ = crate::util::force_remove_dir(&store_root.join(&generation));
300            drop(_lock);
301            remove_generation_lock(store_root, &generation);
302        }
303    }
304}
305
306/// Delete one whole generation under its lock: units, manifest and the
307/// lock file. Returns `false` when a live harvest holds the generation lock.
308pub fn drop_generation(store_root: &Path, parity: &str) -> Result<bool> {
309    util::validate_component(parity, "generation")?;
310    if store_root.exists() {
311        check_store_trust(store_root)?;
312    }
313    let dir = store_root.join(parity);
314    let meta = match fs::symlink_metadata(&dir) {
315        Ok(meta) => meta,
316        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
317        Err(err) => return Err(err).context("inspecting generation"),
318    };
319    if !meta.is_dir() || meta.file_type().is_symlink() || crate::util::is_reparse_point(&meta) {
320        anyhow::bail!("refusing symlinked/reparse generation {}", dir.display());
321    }
322    let lock_path = store_lock_path(store_root, parity);
323    let Ok(Some(lock)) = FileLock::try_acquire(&lock_path) else {
324        return Ok(false);
325    };
326    crate::util::force_remove_dir(&dir)?;
327    drop(lock);
328    remove_generation_lock(store_root, parity);
329    Ok(true)
330}
331
332/// Unit dir names of one generation: the manifest when readable, else the
333/// `units/` directory (so an orphan generation can still be fully deleted).
334pub fn generation_unit_names(store_root: &Path, parity: &str) -> Result<Vec<String>> {
335    util::validate_component(parity, "generation")?;
336    if let Ok(Some(manifest)) = load_manifest(store_root, parity) {
337        return Ok(manifest
338            .units
339            .iter()
340            .map(|entry| entry.name.clone())
341            .collect());
342    }
343    let mut names = Vec::new();
344    if let Ok(entries) = fs::read_dir(units_dir(store_root, parity)) {
345        for entry in entries.flatten() {
346            if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
347                continue;
348            }
349            let name = entry.file_name().to_string_lossy().into_owned();
350            if util::validate_component(&name, "unit name").is_ok() {
351                names.push(name);
352            }
353        }
354    }
355    names.sort();
356    Ok(names)
357}
358
359fn ensure_store_root(store_root: &Path) -> Result<()> {
360    util::ensure_windows_user_path(store_root, "store")?;
361    match fs::symlink_metadata(store_root) {
362        Ok(meta) => {
363            if !meta.is_dir() || meta.file_type().is_symlink() {
364                anyhow::bail!(
365                    "store root {} is not a real directory",
366                    store_root.display()
367                );
368            }
369            util::ensure_private_dir(store_root)?;
370        }
371        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
372            util::create_private_dir_all(store_root)?;
373        }
374        Err(err) => {
375            return Err(err).with_context(|| format!("inspecting {}", store_root.display()));
376        }
377    }
378    Ok(())
379}
380
381// ── manifest ────────────────────────────────────────────────────────────────
382
383/// `manifest.json` of one generation.
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct Manifest {
386    pub parity_key: String,
387    pub toolchain: String,
388    /// Unix seconds of the first harvest of this generation.
389    pub created: u64,
390    /// Unix seconds of the last harvest.
391    pub updated: u64,
392    pub units: Vec<UnitEntry>,
393    /// HMAC-SHA256 over the manifest body (excluding this field), keyed by
394    /// the per-user vault key. An empty value is old/untrusted and refused.
395    #[serde(default, skip_serializing_if = "String::is_empty")]
396    pub mac: String,
397}
398
399/// One stored unit group.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct UnitEntry {
402    /// Fingerprint dir name: `<pkg>-<16hex>`.
403    pub name: String,
404    pub profile: String,
405    /// Memo file name, e.g. `lib-serde`.
406    pub memo: String,
407    /// Memo file content — the authoritative 16-hex hash.
408    pub hash: String,
409    /// Canonical mtime of the whole group (whole seconds).
410    pub stamp: u64,
411    /// Build-dir layout this group reconstructs. Absent in manifests written
412    /// before the new layout; inferred from the destinations when missing.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub layout: Option<fingerprint::UnitLayout>,
415    pub files: Vec<FileEntry>,
416}
417
418impl UnitEntry {
419    /// The layout this group reconstructs (recorded, or inferred).
420    pub fn layout(&self) -> fingerprint::UnitLayout {
421        self.layout.unwrap_or_else(|| {
422            if self
423                .files
424                .iter()
425                .any(|file| file.dest.starts_with(".fingerprint/"))
426            {
427                fingerprint::UnitLayout::Classic
428            } else if self
429                .files
430                .iter()
431                .any(|file| file.dest.contains("/fingerprint/"))
432            {
433                fingerprint::UnitLayout::New
434            } else {
435                fingerprint::UnitLayout::Classic
436            }
437        })
438    }
439}
440
441/// One stored file.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct FileEntry {
444    /// Path inside the unit group.
445    pub path: String,
446    /// Path relative to the profile dir in a build dir.
447    pub dest: String,
448    pub size: u64,
449    /// SHA-256 of the file content (empty only in pre-fix manifests).
450    #[serde(default)]
451    pub sha256: String,
452    /// `memo` | `fingerprint` | `dep-info` | `artifact` | `build-tree`.
453    pub role: String,
454}
455
456impl Manifest {
457    /// Reject every path that could escape the store or the view. This runs
458    /// before any consumer (restore/verify/evict) touches a manifest path.
459    pub fn validate(&self) -> Result<()> {
460        for unit in &self.units {
461            util::validate_component(&unit.name, "unit name")?;
462            util::validate_component(&unit.profile, "profile")?;
463            util::validate_component(&unit.memo, "memo file")?;
464            if !unit.hash.is_empty() && !fingerprint::looks_like_hash(&unit.hash) {
465                anyhow::bail!("invalid memo hash {:?} in unit {}", unit.hash, unit.name);
466            }
467            for file in &unit.files {
468                util::validate_rel(&file.path, "stored file path")?;
469                util::validate_rel(&file.dest, "destination path")?;
470                if !file.sha256.is_empty()
471                    && (file.sha256.len() != 64
472                        || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()))
473                {
474                    anyhow::bail!("invalid SHA-256 for {} in unit {}", file.path, unit.name);
475                }
476            }
477        }
478        Ok(())
479    }
480}
481
482/// Where the per-user vault MAC key lives. Outside the store on purpose:
483/// a store-write attacker must not be able to rewrite the key as well.
484fn manifest_key_path() -> Result<PathBuf> {
485    let path = crate::paths::cargo_home()?
486        .join("epicenter")
487        .join("manifest.key");
488    #[cfg(windows)]
489    crate::util::ensure_windows_user_path(&path, "vault key")?;
490    Ok(path)
491}
492
493fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
494    let mut block = [0u8; 64];
495    if key.len() > block.len() {
496        let digest = Sha256::digest(key);
497        block[..32].copy_from_slice(&digest);
498    } else {
499        block[..key.len()].copy_from_slice(key);
500    }
501    let mut inner = Sha256::new();
502    let mut outer = Sha256::new();
503    let mut ipad = [0x36u8; 64];
504    let mut opad = [0x5cu8; 64];
505    for index in 0..64 {
506        ipad[index] ^= block[index];
507        opad[index] ^= block[index];
508    }
509    inner.update(ipad);
510    inner.update(data);
511    let inner = inner.finalize();
512    outer.update(opad);
513    outer.update(inner);
514    let out = outer.finalize();
515    let mut result = [0u8; 32];
516    result.copy_from_slice(&out);
517    result
518}
519
520fn hex32(bytes: &[u8; 32]) -> String {
521    let mut out = String::with_capacity(64);
522    for byte in bytes {
523        out.push_str(&format!("{byte:02x}"));
524    }
525    out
526}
527
528fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
529    if left.len() != right.len() {
530        return false;
531    }
532    let mut diff = 0u8;
533    for (a, b) in left.iter().zip(right) {
534        diff |= a ^ b;
535    }
536    diff == 0
537}
538
539fn read_key_file(path: &Path) -> Result<Vec<u8>> {
540    let mut options = fs::OpenOptions::new();
541    options.read(true);
542    #[cfg(unix)]
543    {
544        use std::os::unix::fs::OpenOptionsExt;
545        options.custom_flags(libc::O_NOFOLLOW);
546    }
547    let mut file = options
548        .open(path)
549        .with_context(|| format!("opening vault key {}", path.display()))?;
550    let meta = file.metadata()?;
551    if !meta.is_file() || util::is_reparse_point(&meta) {
552        anyhow::bail!("vault key {} is not a regular file", path.display());
553    }
554    if !util::owned_by_us(&meta) {
555        anyhow::bail!("vault key {} is owned by another user", path.display());
556    }
557    #[cfg(unix)]
558    {
559        use std::os::unix::fs::MetadataExt;
560        if meta.mode() & 0o077 != 0 {
561            anyhow::bail!(
562                "vault key {} is accessible by other users (mode {:o})",
563                path.display(),
564                meta.mode() & 0o777
565            );
566        }
567    }
568    let mut key = Vec::new();
569    std::io::Read::read_to_end(&mut file, &mut key)?;
570    if key.len() != 32 {
571        anyhow::bail!("vault key {} is malformed", path.display());
572    }
573    Ok(key)
574}
575
576fn manifest_key() -> Result<Vec<u8>> {
577    let path = manifest_key_path()?;
578    if let Ok(key) = read_key_file(&path) {
579        return Ok(key);
580    }
581    let parent = path
582        .parent()
583        .with_context(|| format!("{} has no parent", path.display()))?;
584    util::create_private_dir_all(parent)?;
585    let mut key = [0u8; 32];
586    getrandom::fill(&mut key).map_err(|err| anyhow::anyhow!("generating vault key: {err}"))?;
587    let mut options = fs::OpenOptions::new();
588    options.write(true).create_new(true);
589    #[cfg(unix)]
590    {
591        use std::os::unix::fs::OpenOptionsExt;
592        options.mode(0o600);
593    }
594    match options.open(&path) {
595        Ok(mut file) => {
596            file.write_all(&key)?;
597            file.sync_all()?;
598            Ok(key.to_vec())
599        }
600        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => read_key_file(&path),
601        Err(err) => Err(err).with_context(|| format!("creating vault key {}", path.display())),
602    }
603}
604
605fn manifest_mac(manifest: &Manifest) -> Result<String> {
606    let mut unsigned = manifest.clone();
607    unsigned.mac.clear();
608    let body = serde_json::to_vec(&unsigned)?;
609    Ok(hex32(&hmac_sha256(&manifest_key()?, &body)))
610}
611
612fn load_manifest(store_root: &Path, parity: &str) -> Result<Option<Manifest>> {
613    let path = manifest_path(store_root, parity);
614    let meta = match fs::symlink_metadata(&path) {
615        Ok(meta) => meta,
616        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
617        Err(err) => {
618            return Err(err).with_context(|| format!("inspecting {}", path.display()));
619        }
620    };
621    // The manifest is the list of files that will be executed/linked. Never
622    // parse one that is a symlink, foreign-owned, or group/world-writable.
623    if !meta.is_file() || meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
624        anyhow::bail!("{} is not a regular manifest file", path.display());
625    }
626    if !util::owned_by_us(&meta) {
627        anyhow::bail!("{} is owned by another user", path.display());
628    }
629    #[cfg(unix)]
630    {
631        use std::os::unix::fs::MetadataExt;
632        if meta.mode() & 0o022 != 0 {
633            anyhow::bail!(
634                "{} is group/world-writable (mode {:o})",
635                path.display(),
636                meta.mode() & 0o777
637            );
638        }
639    }
640    let text = crate::util::read_trusted_config(&path, 16 * 1024 * 1024)
641        .with_context(|| format!("reading {}", path.display()))?;
642    let manifest: Manifest =
643        serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
644    if manifest.mac.len() != 64 {
645        anyhow::bail!(
646            "manifest {} is unsigned (re-harvest this generation)",
647            path.display()
648        );
649    }
650    let expected = manifest_mac(&manifest)?;
651    if !constant_time_eq(manifest.mac.as_bytes(), expected.as_bytes()) {
652        anyhow::bail!("manifest {} failed its MAC check", path.display());
653    }
654    manifest
655        .validate()
656        .with_context(|| format!("validating {}", path.display()))?;
657    Ok(Some(manifest))
658}
659
660pub(crate) fn save_manifest(store_root: &Path, parity: &str, manifest: &Manifest) -> Result<()> {
661    util::validate_component(parity, "generation")?;
662    let dir = store_root.join(parity);
663    util::create_private_dir_all(&dir)?;
664    let mut signed = manifest.clone();
665    signed.mac.clear();
666    signed.mac = manifest_mac(&signed)?;
667    let what = serde_json::to_string_pretty(&signed)?;
668    // O_EXCL + a symlink-free path: never write through a planted temp link.
669    let mut tmp = None;
670    for attempt in 0..100u32 {
671        let candidate = dir.join(format!(
672            "manifest.json.tmp-{}-{attempt}",
673            std::process::id()
674        ));
675        match fs::OpenOptions::new()
676            .write(true)
677            .create_new(true)
678            .open(&candidate)
679        {
680            Ok(file) => {
681                #[cfg(unix)]
682                {
683                    use std::os::unix::fs::PermissionsExt;
684                    let _ = file.set_permissions(fs::Permissions::from_mode(0o600));
685                }
686                tmp = Some((candidate, file));
687                break;
688            }
689            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
690            Err(err) => {
691                return Err(err).with_context(|| format!("creating {}", candidate.display()));
692            }
693        }
694    }
695    let Some((tmp, mut file)) = tmp else {
696        anyhow::bail!(
697            "could not create a unique manifest temp file in {}",
698            dir.display()
699        );
700    };
701    file.write_all(what.as_bytes())?;
702    file.write_all(b"\n")?;
703    file.sync_all()?;
704    drop(file);
705    fs::rename(&tmp, manifest_path(store_root, parity))?;
706    #[cfg(unix)]
707    if let Ok(parent) = fs::File::open(&dir) {
708        let _ = parent.sync_all();
709    }
710    Ok(())
711}
712
713/// Names of the store generations (directories under `store/`).
714pub fn generation_names(store_root: &Path) -> Result<Vec<String>> {
715    if !store_root.is_dir() {
716        return Ok(Vec::new());
717    }
718    let mut names = Vec::new();
719    for entry in
720        fs::read_dir(store_root).with_context(|| format!("reading {}", store_root.display()))?
721    {
722        let entry = entry?;
723        if !entry.file_type()?.is_dir() {
724            continue;
725        }
726        let name = entry.file_name().to_string_lossy().into_owned();
727        if !name.starts_with('.') {
728            names.push(name);
729        }
730    }
731    names.sort();
732    Ok(names)
733}
734
735// ── stored units (read / remove) ─────────────────────────────────────────────
736
737/// One unit as the vault holds it.
738#[derive(Debug, Clone)]
739pub struct StoredUnit {
740    /// The generation directory the unit lives in (the parity key).
741    pub generation: String,
742    /// The manifest entry: name, profile, hash, stamp, files.
743    pub entry: UnitEntry,
744    /// `store/<generation>/units/<name>`.
745    pub dir: PathBuf,
746}
747
748/// Every stored unit across all generations, manifest-driven.
749pub fn stored_units(store_root: &Path) -> Result<Vec<StoredUnit>> {
750    if store_root.exists() {
751        check_store_trust(store_root)?;
752    }
753    let mut out = Vec::new();
754    for generation in generation_names(store_root)? {
755        let Some(manifest) = load_manifest(store_root, &generation)? else {
756            continue;
757        };
758        let units = units_dir(store_root, &generation);
759        for entry in manifest.units {
760            let dir = units.join(&entry.name);
761            out.push(StoredUnit {
762                generation: generation.clone(),
763                entry,
764                dir,
765            });
766        }
767    }
768    Ok(out)
769}
770
771/// The `rustc` identity recorded in a stored unit's Fingerprint JSON.
772pub fn stored_unit_rustc(unit: &StoredUnit) -> Option<String> {
773    let dir_meta = fs::symlink_metadata(&unit.dir).ok()?;
774    if !dir_meta.is_dir() || dir_meta.file_type().is_symlink() || util::is_reparse_point(&dir_meta)
775    {
776        return None;
777    }
778    let rel = PathBuf::from(format!("{}.json", unit.entry.memo));
779    util::check_regular_chain(&unit.dir, &rel).ok()?;
780    let text = fs::read_to_string(unit.dir.join(&rel)).ok()?;
781    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
782    match value.get("rustc") {
783        Some(serde_json::Value::Number(number)) => Some(number.to_string()),
784        Some(serde_json::Value::String(text)) => Some(text.clone()),
785        _ => None,
786    }
787}
788
789/// Remove stored unit groups: their directories go, the manifest is pruned,
790/// and a generation left without units disappears entirely. Returns how many
791/// groups were dropped; groups that could not be removed stay recorded.
792pub fn drop_units(store_root: &Path, generation: &str, names: &[String]) -> Result<usize> {
793    util::validate_component(generation, "generation")?;
794    if store_root.exists() {
795        check_store_trust(store_root)?;
796    }
797    let lock = FileLock::acquire(&store_lock_path(store_root, generation))?;
798    let Some(mut manifest) = load_manifest(store_root, generation)? else {
799        return Ok(0);
800    };
801    let units = units_dir(store_root, generation);
802    match fs::symlink_metadata(&units) {
803        Ok(meta)
804            if meta.is_dir()
805                && !meta.file_type().is_symlink()
806                && !util::is_reparse_point(&meta) => {}
807        Ok(_) => {
808            anyhow::bail!(
809                "units dir {} is a symlink/reparse or not a directory",
810                units.display()
811            );
812        }
813        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
814            // Nothing to remove; the manifest may still be pruned.
815        }
816        Err(err) => {
817            return Err(err).with_context(|| format!("inspecting {}", units.display()));
818        }
819    }
820    let mut kept = Vec::with_capacity(manifest.units.len());
821    let mut dropped = 0;
822    for entry in manifest.units.drain(..) {
823        if !names.contains(&entry.name) {
824            kept.push(entry);
825            continue;
826        }
827        util::validate_component(&entry.name, "unit name")?;
828        let dir = units.join(&entry.name);
829        match fs::symlink_metadata(&dir) {
830            Ok(meta) if meta.file_type().is_symlink() || util::is_reparse_point(&meta) => {
831                eprintln!(
832                    "could not remove symlinked/reparse unit {}: refused",
833                    dir.display()
834                );
835                kept.push(entry);
836            }
837            Ok(meta) if meta.is_dir() => {
838                // Defense in depth even after the component validator: the
839                // target must be a direct child of the units directory.
840                if let Ok(base) = fs::canonicalize(&units) {
841                    match fs::canonicalize(&dir) {
842                        Ok(canon) if canon.parent() == Some(base.as_path()) && canon != base => {}
843                        _ => {
844                            eprintln!(
845                                "could not remove {}: escapes {}",
846                                dir.display(),
847                                units.display()
848                            );
849                            kept.push(entry);
850                            continue;
851                        }
852                    }
853                }
854                match fs::remove_dir_all(&dir) {
855                    Ok(()) => {
856                        dropped += 1;
857                    }
858                    Err(err) => {
859                        eprintln!("could not remove {}: {err}", dir.display());
860                        kept.push(entry);
861                    }
862                }
863            }
864            Ok(_) => {
865                eprintln!("could not remove {}: not a directory", dir.display());
866                kept.push(entry);
867            }
868            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
869                dropped += 1;
870            }
871            Err(err) => {
872                eprintln!("could not remove {}: {err}", dir.display());
873                kept.push(entry);
874            }
875        }
876    }
877    manifest.units = kept;
878    let empty = manifest.units.is_empty();
879    if empty {
880        let _ = crate::util::force_remove_dir(&store_root.join(generation));
881    } else if dropped > 0 {
882        manifest.updated = now_secs();
883        save_manifest(store_root, generation, &manifest)?;
884    }
885    drop(lock);
886    if empty {
887        // The parity-key store is gone; its outside lock file is debris.
888        remove_generation_lock(store_root, generation);
889    }
890    Ok(dropped)
891}
892// ── harvest ─────────────────────────────────────────────────────────────────
893
894/// What one harvest did.
895#[derive(Debug, Default)]
896pub struct HarvestReport {
897    pub parity: String,
898    pub toolchain: String,
899    pub stored: usize,
900    pub refreshed: usize,
901    pub unchanged: usize,
902    pub skipped_stale: usize,
903    pub skipped_incomplete: usize,
904    pub files: usize,
905    pub bytes: u64,
906    pub note: Option<String>,
907}
908
909/// Store unit groups from a build dir into the vault.
910///
911/// Only units built by the installed toolchain are stored: the newest unit's
912/// rustc cohort marks the active generation, and a cache that as a whole
913/// predates the installed toolchain produces a note instead of stale copies
914/// filed under the current parity key.
915pub fn harvest(
916    build_root: &Path,
917    store_root: &Path,
918    unit_filter: Option<&str>,
919    dry_run: bool,
920) -> Result<HarvestReport> {
921    harvest_with(build_root, store_root, unit_filter, dry_run, None, None)
922}
923
924/// Harvest for a specific toolchain and/or layout (`exec` with a `+toolchain`
925/// child). The plain [`harvest`] uses the default toolchain and derives the
926/// layout from `cargo --version`.
927pub fn harvest_with(
928    build_root: &Path,
929    store_root: &Path,
930    unit_filter: Option<&str>,
931    dry_run: bool,
932    selector: Option<&str>,
933    layout: Option<fingerprint::UnitLayout>,
934) -> Result<HarvestReport> {
935    let tc = toolchain_of(selector)?;
936    let parity = parity_key_with(&tc)?;
937    let mut report = HarvestReport {
938        parity: parity.clone(),
939        toolchain: tc.version_line(),
940        ..Default::default()
941    };
942
943    // Serialize every manifest read-modify-write. Parallel `exec --harvest`
944    // jobs otherwise lose each other's units (last writer wins).
945    let _lock = if dry_run {
946        None
947    } else {
948        ensure_store_root(store_root)?;
949        util::create_private_dir_all(&store_root.join(&parity))?;
950        util::create_private_dir_all(&units_dir(store_root, &parity))?;
951        let lock = FileLock::acquire(&store_lock_path(store_root, &parity))?;
952        // Self-heal debris from a crashed or failed store run (dot-dirs whose
953        // copies may have inherited read-only sources) before planning.
954        sweep_staging(&units_dir(store_root, &parity));
955        Some(lock)
956    };
957
958    let rows: Vec<(Unit, ArtifactInfo)> = fingerprint::read_units(build_root)?
959        .into_iter()
960        .map(|unit| {
961            let arts = fingerprint::artifacts_for(build_root, &unit);
962            (unit, arts)
963        })
964        .collect();
965
966    // A view that mixes both layouts is ambiguous: keep the layout of the
967    // harvest toolchain (stable classic vs nightly new) and drop the other
968    // before the cohort gate, so those units are never filed under this
969    // parity by accident. A single-layout view is harvested as-is.
970    let mixed_layouts = rows
971        .iter()
972        .any(|(unit, _)| unit.layout == fingerprint::UnitLayout::Classic)
973        && rows
974            .iter()
975            .any(|(unit, _)| unit.layout == fingerprint::UnitLayout::New);
976    let expected_layout =
977        layout.or_else(|| mixed_layouts.then(|| fingerprint::layout_for_cargo(&tc.release, &[])));
978    let rows: Vec<(Unit, ArtifactInfo)> = match expected_layout {
979        Some(wanted) => {
980            let before = rows.len();
981            let kept: Vec<(Unit, ArtifactInfo)> = rows
982                .into_iter()
983                .filter(|(unit, _)| unit.layout == wanted)
984                .collect();
985            report.skipped_stale += before - kept.len();
986            kept
987        }
988        None => rows,
989    };
990
991    // Freshness gate for the whole cache: nothing built since the toolchain
992    // was installed means the cache belongs to an older generation.
993    let newest_activity = rows.iter().filter_map(|(_, arts)| arts.newest).max();
994    if let (Some(newest), Some(installed)) = (newest_activity, toolchain_install_time_of(selector))
995    {
996        if newest < installed {
997            report.note = Some(format!(
998                "the shared cache predates the installed toolchain ({}) — build once, then harvest",
999                tc.version_line()
1000            ));
1001            return Ok(report);
1002        }
1003    }
1004
1005    // Active generation = the rustc cohort of the most recently built unit
1006    // that carries a fingerprint JSON. Units without one can't be placed and
1007    // are harvested as-is (fail-open) rather than mislabeled.
1008    let active_gen = rows
1009        .iter()
1010        .filter(|(unit, _)| crate::who::rustc_of(unit) != "unknown")
1011        .max_by_key(|(_, arts)| arts.newest)
1012        .map(|(unit, _)| crate::who::rustc_of(unit))
1013        .unwrap_or_else(|| "unknown".into());
1014
1015    let mut manifest = load_manifest(store_root, &parity)?.unwrap_or_else(|| Manifest {
1016        parity_key: parity.clone(),
1017        toolchain: tc.version_line(),
1018        created: now_secs(),
1019        updated: 0,
1020        units: Vec::new(),
1021        mac: String::new(),
1022    });
1023
1024    // ── pass 1: collect the groups to store and their base stamps ──────────
1025    struct GroupPlan {
1026        unit: Unit,
1027        files: Vec<(PathBuf, String, String, String)>,
1028        memo: String,
1029        hash: String,
1030        base: u64,
1031    }
1032    let mut plans: Vec<GroupPlan> = Vec::new();
1033
1034    for (unit, arts) in &rows {
1035        if let Some(query) = unit_filter {
1036            if !unit_name_matches(&unit.dir_name, query) {
1037                continue;
1038            }
1039        }
1040        let cohort = crate::who::rustc_of(unit);
1041        if cohort != "unknown" && active_gen != "unknown" && cohort != active_gen {
1042            report.skipped_stale += 1;
1043            continue;
1044        }
1045
1046        let unit_dir = &unit.fingerprint_dir;
1047        let profile_dir = build_root.join(&unit.profile);
1048        let memo = unit.memo_file.clone();
1049        let mut files: Vec<(PathBuf, String, String, String)> = Vec::new();
1050        // (source, store path, destination relative to the profile dir, role)
1051
1052        // The whole fingerprint dir except the volatile timestamp: hash
1053        // files, their serialized fingerprints, and dep-info. Shared dirs
1054        // (several memo files) are stored completely, so every mode of the
1055        // unit stays recognizable to Cargo.
1056        for entry in
1057            fs::read_dir(&unit_dir).with_context(|| format!("reading {}", unit_dir.display()))?
1058        {
1059            let entry = entry?;
1060            if !entry.file_type()?.is_file() {
1061                continue;
1062            }
1063            let name = entry.file_name().to_string_lossy().into_owned();
1064            if is_volatile(&name) {
1065                continue;
1066            }
1067            let role = if name.starts_with("dep-") {
1068                "dep-info"
1069            } else if name.ends_with(".json") {
1070                "fingerprint"
1071            } else if fs::read_to_string(entry.path())
1072                .map(|text| crate::fingerprint::looks_like_hash(text.trim()))
1073                .unwrap_or(false)
1074            {
1075                "memo"
1076            } else {
1077                // Cached diagnostics (`output-*`) and other cargo state.
1078                "extra"
1079            };
1080            files.push((
1081                entry.path(),
1082                name.clone(),
1083                format!("{}/{name}", unit.fingerprint_rel()),
1084                role.into(),
1085            ));
1086        }
1087
1088        // Artifacts: `deps/…` and the `build/<anchor>/…` tree of build
1089        // scripts. Wrapper internals and rustc's `.d` side files stay out.
1090        let mut artifact_count = 0;
1091        for (path, _, _) in &arts.paths {
1092            let name = path
1093                .file_name()
1094                .map(|name| name.to_string_lossy().into_owned())
1095                .unwrap_or_default();
1096            if is_volatile(&name) {
1097                continue;
1098            }
1099            let Ok(rel) = path.strip_prefix(&profile_dir) else {
1100                continue;
1101            };
1102            let rel = rel.to_string_lossy().replace('\\', "/");
1103            // rustc's `.d` side files are not needed for freshness: classic
1104            // kept them in `deps/`, the new layout keeps them in `out/`.
1105            if rel.ends_with(".d")
1106                && (rel.starts_with("deps/") || unit.layout == fingerprint::UnitLayout::New)
1107            {
1108                continue;
1109            }
1110            let role = if rel.starts_with("deps/") || rel.contains("/out/") {
1111                "artifact"
1112            } else if rel.starts_with("build/") {
1113                "build-tree"
1114            } else {
1115                "artifact"
1116            };
1117            files.push((path.clone(), rel.clone(), rel, role.into()));
1118            artifact_count += 1;
1119        }
1120        if artifact_count == 0 {
1121            report.skipped_incomplete += 1;
1122            continue;
1123        }
1124
1125        plans.push(GroupPlan {
1126            base: group_stamp(&files)?,
1127            memo,
1128            hash: unit.memo_hash.clone().unwrap_or_default(),
1129            files,
1130            unit: unit.clone(),
1131        });
1132    }
1133
1134    // ── pass 2: enforce `T(unit) ≥ every dependency's T` (§11) ─────────────
1135    //
1136    // Base stamps restore the natural order inside one build view; a store
1137    // fed from several views can still invert edges (a dependency harvested
1138    // later than its dependent — e.g. a fragmented multi-project cache).
1139    // Resolve the edges (a dependency's memo hash → the unit that owns it)
1140    // and raise dependents until nothing is older than what it consumed.
1141    // `verify`'s ordering audit stays the guard for anything left over.
1142    let stored_stamps: std::collections::HashMap<String, u64> = manifest
1143        .units
1144        .iter()
1145        .map(|entry| (entry.hash.clone(), entry.stamp))
1146        .collect();
1147    let index: std::collections::HashMap<&str, usize> = plans
1148        .iter()
1149        .enumerate()
1150        .map(|(position, plan)| (plan.hash.as_str(), position))
1151        .collect();
1152    let mut plan_deps: Vec<Vec<usize>> = Vec::with_capacity(plans.len());
1153    let mut stamps: Vec<u64> = Vec::with_capacity(plans.len());
1154    for plan in &plans {
1155        let mut deps = Vec::new();
1156        let mut stamp = plan.base;
1157        for dep in plan
1158            .unit
1159            .json
1160            .as_ref()
1161            .map(|json| json.deps.as_slice())
1162            .unwrap_or(&[])
1163        {
1164            let Some(hash) = dep.memo_hash() else {
1165                continue;
1166            };
1167            if let Some(&at) = index.get(hash.as_str()) {
1168                deps.push(at);
1169            } else if let Some(known) = stored_stamps.get(hash.as_str()) {
1170                stamp = stamp.max(*known);
1171            }
1172        }
1173        plan_deps.push(deps);
1174        stamps.push(stamp);
1175    }
1176    // Dependencies before dependents; a cycle (never observed) keeps its base
1177    // order and the ordering audit reports whatever is left.
1178    let mut order: Vec<usize> = Vec::with_capacity(plans.len());
1179    let mut settled = vec![false; plans.len()];
1180    loop {
1181        let mut progressed = false;
1182        for at in 0..plans.len() {
1183            if settled[at] || !plan_deps[at].iter().all(|&dep| settled[dep]) {
1184                continue;
1185            }
1186            settled[at] = true;
1187            order.push(at);
1188            progressed = true;
1189        }
1190        if !progressed {
1191            break;
1192        }
1193    }
1194    for at in 0..plans.len() {
1195        if !settled[at] {
1196            order.push(at);
1197        }
1198    }
1199    for &at in &order {
1200        for dep in &plan_deps[at] {
1201            stamps[at] = stamps[at].max(stamps[*dep]);
1202        }
1203    }
1204
1205    // ── pass 3: store (or refresh) one group at a time ─────────────────────
1206    for (at, plan) in plans.iter().enumerate() {
1207        let unit = &plan.unit;
1208        let files = &plan.files;
1209        let stamp = stamps[at];
1210        let group_dir = units_dir(store_root, &parity).join(&unit.dir_name);
1211
1212        // Unchanged = same hash, profile and stamp, same (path, role) file
1213        // set, all present. Anything else is refreshed as a whole group.
1214        let mut new_entries: Vec<(&str, &str, &str)> = files
1215            .iter()
1216            .map(|(_, path, dest, role)| (path.as_str(), dest.as_str(), role.as_str()))
1217            .collect();
1218        new_entries.sort_unstable();
1219        let already = manifest
1220            .units
1221            .iter()
1222            .find(|entry| entry.name == unit.dir_name)
1223            .map(|entry| {
1224                let mut stored: Vec<(&str, &str, &str)> = entry
1225                    .files
1226                    .iter()
1227                    .map(|file| (file.path.as_str(), file.dest.as_str(), file.role.as_str()))
1228                    .collect();
1229                stored.sort_unstable();
1230                entry.hash == plan.hash
1231                    && entry.profile == unit.profile
1232                    && entry.stamp == stamp
1233                    && stored == new_entries
1234                    && entry
1235                        .files
1236                        .iter()
1237                        .all(|file| !file.sha256.is_empty() && group_dir.join(&file.path).is_file())
1238            })
1239            .unwrap_or(false);
1240        if already {
1241            report.unchanged += 1;
1242            continue;
1243        }
1244
1245        let bytes: u64 = files
1246            .iter()
1247            .filter_map(|(src, _, _, _)| fs::metadata(src).ok())
1248            .map(|meta| meta.len())
1249            .sum();
1250        if dry_run {
1251            report.stored += 1;
1252            report.files += files.len();
1253            report.bytes += bytes;
1254            continue;
1255        }
1256
1257        let mut entries = Vec::with_capacity(files.len());
1258        store_group(
1259            store_root,
1260            &parity,
1261            &unit.dir_name,
1262            stamp,
1263            files,
1264            &mut entries,
1265        )
1266        .with_context(|| format!("storing unit {}", unit.dir_name))?;
1267
1268        let entry = UnitEntry {
1269            name: unit.dir_name.clone(),
1270            profile: unit.profile.clone(),
1271            memo: plan.memo.clone(),
1272            hash: plan.hash.clone(),
1273            stamp,
1274            layout: Some(unit.layout),
1275            files: entries,
1276        };
1277        match manifest
1278            .units
1279            .iter_mut()
1280            .find(|existing| existing.name == entry.name)
1281        {
1282            Some(slot) => {
1283                *slot = entry;
1284                report.refreshed += 1;
1285            }
1286            None => {
1287                manifest.units.push(entry);
1288                report.stored += 1;
1289            }
1290        }
1291        report.files += files.len();
1292        report.bytes += bytes;
1293    }
1294
1295    if !dry_run {
1296        manifest.units.sort_by(|a, b| {
1297            (a.profile.as_str(), a.name.as_str()).cmp(&(b.profile.as_str(), b.name.as_str()))
1298        });
1299        manifest.updated = now_secs();
1300        save_manifest(store_root, &parity, &manifest)?;
1301    }
1302    Ok(report)
1303}
1304
1305/// Copy one unit group into its final place: a temporary directory next to
1306/// the target, stamped read-only files, then a swap that never leaves the
1307/// group half-written under its real name.
1308fn store_group(
1309    store_root: &Path,
1310    parity: &str,
1311    name: &str,
1312    stamp: u64,
1313    files: &[(PathBuf, String, String, String)],
1314    entries: &mut Vec<FileEntry>,
1315) -> Result<()> {
1316    util::validate_component(name, "unit name")?;
1317    let units = units_dir(store_root, parity);
1318    let final_dir = units.join(name);
1319    // Unique staging names + O_EXCL directory creation: a pre-planted symlink
1320    // or temp dir can never be followed/truncated.
1321    let mut tmp = None;
1322    for attempt in 0..100u32 {
1323        let candidate = units.join(format!(".{name}.tmp-{}-{attempt}", std::process::id()));
1324        match fs::create_dir(&candidate) {
1325            Ok(()) => {
1326                tmp = Some(candidate);
1327                break;
1328            }
1329            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1330            Err(err) => {
1331                return Err(err)
1332                    .with_context(|| format!("creating staging dir {}", candidate.display()));
1333            }
1334        }
1335    }
1336    let Some(tmp) = tmp else {
1337        anyhow::bail!(
1338            "could not create a unique staging dir in {}",
1339            units.display()
1340        );
1341    };
1342    #[cfg(unix)]
1343    {
1344        use std::os::unix::fs::PermissionsExt;
1345        let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o700));
1346    }
1347
1348    if let Err(err) = stage_group(&tmp, files, stamp, entries) {
1349        // Never leave a half-written staging dir behind: it would keep a
1350        // failed unit's bytes on disk and hide the failure from the next run.
1351        let _ = crate::util::force_remove_dir(&tmp);
1352        return Err(err);
1353    }
1354
1355    let old = units.join(format!(".{name}.old-{}-{}", std::process::id(), now_secs()));
1356    match fs::symlink_metadata(&final_dir) {
1357        Ok(meta) if meta.file_type().is_symlink() || util::is_reparse_point(&meta) => {
1358            let _ = crate::util::force_remove_dir(&tmp);
1359            anyhow::bail!(
1360                "refusing symlinked/reparse store unit {}",
1361                final_dir.display()
1362            );
1363        }
1364        Ok(meta) if meta.is_dir() => fs::rename(&final_dir, &old)?,
1365        Ok(_) => {
1366            let _ = crate::util::force_remove_dir(&tmp);
1367            anyhow::bail!("store unit path {} is not a directory", final_dir.display());
1368        }
1369        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1370        Err(err) => return Err(err).context("inspecting store unit"),
1371    }
1372    fs::rename(&tmp, &final_dir)?;
1373    if fs::symlink_metadata(&old)
1374        .map(|meta| {
1375            meta.is_dir() && !meta.file_type().is_symlink() && !util::is_reparse_point(&meta)
1376        })
1377        .unwrap_or(false)
1378    {
1379        let _ = fs::remove_dir_all(&old);
1380    }
1381    Ok(())
1382}
1383
1384/// Copy one unit group's files into its staging dir. Every file is made
1385/// writable before it is stamped and read-only again (on Unix) once the group
1386/// is in place.
1387///
1388/// `fs::copy` carries the source's attributes along. A read-only source — a
1389/// wrapper's hard-linked store file, a file restored from a backup — makes
1390/// `File::set_modified` fail with "access denied" on Windows even though the
1391/// copy lives in our own staging dir.
1392fn stage_group(
1393    tmp: &Path,
1394    files: &[(PathBuf, String, String, String)],
1395    stamp: u64,
1396    entries: &mut Vec<FileEntry>,
1397) -> Result<()> {
1398    for (src, path, dest, role) in files {
1399        let rel = util::validate_rel(path, "stored file path")?;
1400        let dst = tmp.join(&rel);
1401        if let Some(parent) = dst.parent() {
1402            util::create_private_dir_all(parent)?;
1403        }
1404        let (size, sha256) = util::copy_file_hashed(src, &dst)
1405            .with_context(|| format!("copying {} -> {}", src.display(), dst.display()))?;
1406        make_writable(&dst).with_context(|| format!("making {} writable", dst.display()))?;
1407        set_stamp(&dst, stamp)?;
1408        make_read_only(&dst)?;
1409        entries.push(FileEntry {
1410            path: path.clone(),
1411            dest: dest.clone(),
1412            size,
1413            sha256,
1414            role: role.clone(),
1415        });
1416    }
1417    Ok(())
1418}
1419
1420/// Remove staging leftovers (`.{unit}.tmp-{pid}`, `.{unit}.old-{pid}`) that a
1421/// crashed or failed run left in the units dir. Fresh dirs are left alone: a
1422/// concurrent harvest may be filling them right now.
1423fn sweep_staging(units: &Path) {
1424    let Ok(entries) = fs::read_dir(units) else {
1425        return;
1426    };
1427    let now = SystemTime::now();
1428    for entry in entries.flatten() {
1429        let name = entry.file_name();
1430        let name = name.to_string_lossy();
1431        if !(name.contains(".tmp-") || name.contains(".old-")) {
1432            continue;
1433        }
1434        let meta = match fs::symlink_metadata(entry.path()) {
1435            Ok(meta) => meta,
1436            Err(_) => continue,
1437        };
1438        // Unlink a planted symlink itself; never traverse it.
1439        if meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1440            let _ = fs::remove_file(entry.path());
1441            continue;
1442        }
1443        if !meta.is_dir() {
1444            continue;
1445        }
1446        let stale = meta
1447            .modified()
1448            .ok()
1449            .and_then(|modified| now.duration_since(modified).ok())
1450            .is_some_and(|age| age > Duration::from_secs(600));
1451        if stale {
1452            let _ = crate::util::force_remove_dir(&entry.path());
1453        }
1454    }
1455}
1456
1457/// The canonical stamp of a group: the ceiling (whole seconds) of its
1458/// newest file, so every stored file is stamped at or after its true mtime
1459/// and cross-filesystem second-resolution round-trips cannot make an output
1460/// look newer than its own group.
1461fn group_stamp(files: &[(PathBuf, String, String, String)]) -> Result<u64> {
1462    let mut newest: Option<SystemTime> = None;
1463    let mut metadata: Option<SystemTime> = None;
1464    for (src, path, _, _) in files {
1465        let modified = fs::metadata(src)
1466            .and_then(|meta| meta.modified())
1467            .with_context(|| format!("reading mtime of {}", src.display()))?;
1468        if newest.map_or(true, |current| modified > current) {
1469            newest = Some(modified);
1470        }
1471        if path.ends_with(".rmeta") && metadata.map_or(true, |current| modified > current) {
1472            metadata = Some(modified);
1473        }
1474    }
1475    // The metadata output, when present, is the moment dependents can compile
1476    // against this unit: rustc writes `.rmeta` first and `.rlib` last, and a
1477    // dependent routinely finishes before its dependency's `.rlib` exists.
1478    // Stamping with the `.rlib` would make the group look newer than its own
1479    // dependents and force spurious rebuilds after a restore.
1480    Ok(match metadata.or(newest) {
1481        Some(time) => ceil_secs(time),
1482        None => now_secs(),
1483    })
1484}
1485
1486/// Round a timestamp up to whole seconds.
1487fn ceil_secs(time: SystemTime) -> u64 {
1488    match time.duration_since(UNIX_EPOCH) {
1489        Ok(duration) => {
1490            let secs = duration.as_secs();
1491            if duration.subsec_nanos() > 0 {
1492                secs + 1
1493            } else {
1494                secs
1495            }
1496        }
1497        Err(_) => 0,
1498    }
1499}
1500
1501fn now_secs() -> u64 {
1502    SystemTime::now()
1503        .duration_since(UNIX_EPOCH)
1504        .map(|duration| duration.as_secs())
1505        .unwrap_or(0)
1506}
1507
1508fn set_stamp(path: &Path, stamp: u64) -> Result<()> {
1509    let time = UNIX_EPOCH + Duration::from_secs(stamp);
1510    // Unix allows futimens on a read-only fd owned by us; Windows requires a
1511    // handle with FILE_WRITE_ATTRIBUTES (GENERIC_READ's set_modified is
1512    // denied). A 0444 store file must still be stampable on Windows.
1513    #[cfg(windows)]
1514    let file = {
1515        use std::os::windows::fs::OpenOptionsExt;
1516        fs::OpenOptions::new()
1517            .access_mode(0x0100) // FILE_WRITE_ATTRIBUTES
1518            .open(path)
1519            .or_else(|_| fs::OpenOptions::new().write(true).open(path))
1520            .with_context(|| format!("opening {} to stamp it", path.display()))?
1521    };
1522    #[cfg(not(windows))]
1523    let file =
1524        fs::File::open(path).with_context(|| format!("opening {} to stamp it", path.display()))?;
1525    file.set_modified(time)
1526        .with_context(|| format!("stamping {}", path.display()))?;
1527    Ok(())
1528}
1529
1530/// The store is read-only by contract; restored state must not be.
1531#[cfg(unix)]
1532fn make_writable(path: &Path) -> std::io::Result<()> {
1533    use std::os::unix::fs::PermissionsExt;
1534    let mut perms = fs::metadata(path)?.permissions();
1535    perms.set_mode(0o644);
1536    fs::set_permissions(path, perms)
1537}
1538
1539#[cfg(not(unix))]
1540fn make_writable(path: &Path) -> std::io::Result<()> {
1541    let mut perms = fs::metadata(path)?.permissions();
1542    perms.set_readonly(false);
1543    fs::set_permissions(path, perms)
1544}
1545
1546/// On Unix the store is read-only (owner can still stamp and delete; only
1547/// content edits are refused). On Windows a read-only file blocks rename
1548/// and delete, so the store keeps normal permissions there.
1549fn make_read_only(path: &Path) -> Result<()> {
1550    #[cfg(unix)]
1551    {
1552        use std::os::unix::fs::PermissionsExt;
1553        fs::set_permissions(path, fs::Permissions::from_mode(0o444))?;
1554    }
1555    #[cfg(not(unix))]
1556    {
1557        let _ = path;
1558    }
1559    Ok(())
1560}
1561
1562/// Files that never enter the store: the volatile invocation timestamp,
1563/// and wrapper bookkeeping (`*.kache-*`, `.kache*`) that lives in the build
1564/// dir but is no part of Cargo's state.
1565fn is_volatile(name: &str) -> bool {
1566    name == "invoked.timestamp" || name.contains(".kache")
1567}
1568
1569// ── restore ─────────────────────────────────────────────────────────────────
1570
1571/// What one restore did.
1572#[derive(Debug, Default)]
1573pub struct RestoreReport {
1574    pub parity: String,
1575    pub target: PathBuf,
1576    pub units_linked: usize,
1577    pub units_already: usize,
1578    pub files_linked: usize,
1579    pub files_copied: usize,
1580    pub files_existing: usize,
1581    pub files_missing: usize,
1582    pub bytes: u64,
1583    pub errors: usize,
1584    /// Integrity failures (hash/size/owner/symlink). These abort `exec` and
1585    /// make `restore` exit non-zero; a merely missing unit stays fail-open.
1586    pub fatal_errors: usize,
1587    pub note: Option<String>,
1588}
1589
1590#[cfg(unix)]
1591enum RestoreOutcome {
1592    Existing,
1593    Missing,
1594    Linked,
1595    Copied,
1596}
1597
1598/// fd-relative restore on Unix: destination parents are opened with
1599/// openat(O_NOFOLLOW), the source inode is opened once, and the final name is
1600/// installed with linkat. No path component can be swapped for a symlink
1601/// after the check.
1602#[cfg(unix)]
1603fn restore_file_at(
1604    view_root: &Path,
1605    dest_rel: &Path,
1606    src: &Path,
1607    stamp: u64,
1608    file: &FileEntry,
1609    shared: bool,
1610) -> Result<RestoreOutcome> {
1611    let parent_rel = dest_rel.parent().unwrap_or_else(|| Path::new(""));
1612    let dst_name = dest_rel
1613        .file_name()
1614        .with_context(|| format!("destination {} has no file name", dest_rel.display()))?;
1615    let parent = util::open_dir_chain_at(view_root, parent_rel)?;
1616    if parent.exists(dst_name)? {
1617        return Ok(RestoreOutcome::Existing);
1618    }
1619    let mut source = match util::open_file_nofollow(src) {
1620        Ok(file) => file,
1621        Err(err)
1622            if err
1623                .root_cause()
1624                .downcast_ref::<std::io::Error>()
1625                .map_or(false, |io| io.kind() == std::io::ErrorKind::NotFound) =>
1626        {
1627            return Ok(RestoreOutcome::Missing);
1628        }
1629        Err(err)
1630            if err
1631                .downcast_ref::<std::io::Error>()
1632                .map_or(false, |io| io.kind() == std::io::ErrorKind::NotFound) =>
1633        {
1634            return Ok(RestoreOutcome::Missing);
1635        }
1636        Err(err) => return Err(err),
1637    };
1638    let meta = source.metadata()?;
1639    if !meta.is_file() || !util::owned_by_us(&meta) {
1640        anyhow::bail!("stored file {} is not a regular owned file", src.display());
1641    }
1642    if meta.len() != file.size {
1643        anyhow::bail!(
1644            "stored file {} size drift (recorded {}, found {})",
1645            src.display(),
1646            file.size,
1647            meta.len()
1648        );
1649    }
1650    if shared {
1651        if file.sha256.is_empty() {
1652            anyhow::bail!("{} has no recorded SHA-256", src.display());
1653        }
1654        let found = util::sha256_file_fd(&mut source)?;
1655        if found != file.sha256 {
1656            anyhow::bail!("{} failed its SHA-256 check", src.display());
1657        }
1658        parent.link_path(src, dst_name)?;
1659        Ok(RestoreOutcome::Linked)
1660    } else {
1661        // Write the private copy directly at the final name with O_EXCL. The
1662        // destination name is created once, at the held parent fd; on any
1663        // integrity/stamp failure the partial file is removed. No temp-path
1664        // lookup is needed, which keeps this portable on non-Linux Unix.
1665        let mut tmp = parent.create_excl(dst_name, 0o600)?;
1666        let result = util::copy_fd_verified(&mut source, &mut tmp, file.size, &file.sha256)
1667            .and_then(|_| util::set_stamp_fd(&tmp, stamp).map_err(anyhow::Error::from));
1668        if let Err(err) = result {
1669            let _ = parent.unlink(dst_name);
1670            return Err(err);
1671        }
1672        Ok(RestoreOutcome::Copied)
1673    }
1674}
1675
1676/// Link matching unit groups from the vault into a working dir.
1677///
1678/// `deps/` link outputs (`.rlib`, proc-macro `.so`) are hard-linked — rustc
1679/// only ever replaces those atomically. Fingerprint state, build-script
1680/// trees and `deps/*.rmeta` become private writable copies: rustc writes
1681/// `.rmeta` in place, so a read-only link turns any rebuild of that unit
1682/// into `output file … .rmeta is not writeable`. Hard links share the
1683/// store's canonical stamp; private copies are stamped after copying. Never
1684/// overwrites a file that already exists, never touches sources, and a
1685/// partial vault is fine: Cargo recompiles whatever is missing.
1686///
1687/// `unit_filter` narrows by substring (`restore --unit`); `packages` narrows
1688/// to a package set (`exec` passes the project's `Cargo.lock` packages, so a
1689/// job never inherits every other project's units). Unit names that cannot be
1690/// split into package + hash are kept rather than dropped (fail-open).
1691pub fn restore(
1692    view_root: &Path,
1693    store_root: &Path,
1694    unit_filter: Option<&str>,
1695    packages: Option<&std::collections::HashSet<String>>,
1696    dry_run: bool,
1697) -> Result<RestoreReport> {
1698    restore_with(
1699        view_root,
1700        store_root,
1701        unit_filter,
1702        packages,
1703        None,
1704        None,
1705        dry_run,
1706    )
1707}
1708
1709/// Restore for a specific toolchain/layout (`exec` with a `+toolchain` or a
1710/// new-layout child). `layout` skips units stored in the other build-dir
1711/// layout; the plain [`restore`] accepts every stored unit.
1712pub fn restore_with(
1713    view_root: &Path,
1714    store_root: &Path,
1715    unit_filter: Option<&str>,
1716    packages: Option<&std::collections::HashSet<String>>,
1717    layout: Option<fingerprint::UnitLayout>,
1718    selector: Option<&str>,
1719    dry_run: bool,
1720) -> Result<RestoreReport> {
1721    let parity = parity_key_of(selector)?;
1722    let mut report = RestoreReport {
1723        parity: parity.clone(),
1724        target: view_root.to_path_buf(),
1725        ..Default::default()
1726    };
1727    // Verify store ownership/mode before parsing its manifest. A fresh store
1728    // is allowed to be absent (cold start); a present-but-unsafe one is not.
1729    if store_root.exists() {
1730        check_store_trust(store_root)?;
1731    }
1732    let Some(manifest) = load_manifest(store_root, &parity)? else {
1733        report.note = Some(
1734            "the vault holds no units for this generation yet — run `epicenter harvest` after a build"
1735                .into(),
1736        );
1737        return Ok(report);
1738    };
1739    if !dry_run {
1740        prepare_view_root(view_root)?;
1741    }
1742
1743    let units = units_dir(store_root, &parity);
1744    for entry in &manifest.units {
1745        if let Some(query) = unit_filter {
1746            if !unit_name_matches(&entry.name, query) {
1747                continue;
1748            }
1749        }
1750        if let Some(packages) = packages {
1751            if let Some(package) = unit_package(&entry.name) {
1752                if !packages.contains(package) {
1753                    continue;
1754                }
1755            }
1756        }
1757        if let Some(layout) = layout {
1758            if entry.layout() != layout {
1759                continue;
1760            }
1761        }
1762        // The memo's destination in the manifest is layout-agnostic.
1763        let already = entry
1764            .files
1765            .iter()
1766            .find(|file| file.path == entry.memo)
1767            .and_then(|file| read_small_regular(&view_root.join(&entry.profile).join(&file.dest)))
1768            .map(|text| text.trim() == entry.hash)
1769            .unwrap_or(false);
1770        if already {
1771            report.units_already += 1;
1772            continue;
1773        }
1774
1775        let mut unit_touched = false;
1776        for file in &entry.files {
1777            // Both components were validated by Manifest::validate; keep the
1778            // join explicit and reject any symlink in the destination chain.
1779            let dest_rel = Path::new(&entry.profile).join(&file.dest);
1780            let dst = view_root.join(&dest_rel);
1781            match fs::symlink_metadata(&dst) {
1782                Ok(meta) => {
1783                    if meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1784                        report.errors += 1;
1785                        report.fatal_errors += 1;
1786                        eprintln!(
1787                            "restore: refusing symlink/reparse destination {}",
1788                            dst.display()
1789                        );
1790                    } else {
1791                        report.files_existing += 1;
1792                    }
1793                    continue;
1794                }
1795                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1796                Err(err) => {
1797                    report.errors += 1;
1798                    eprintln!("restore: inspecting {}: {err}", dst.display());
1799                    continue;
1800                }
1801            }
1802            // Only link outputs (`.rlib`, proc-macro `.so`) may share the
1803            // store inode — rustc replaces those atomically. `.rmeta` and
1804            // everything else are written in place by rustc, cargo or build
1805            // scripts: private verified copies only. Classic keeps link
1806            // outputs in `deps/`; the new layout keeps them in `out/`.
1807            let shared = if file.path.starts_with("deps/") {
1808                !file.path.ends_with(".rmeta")
1809            } else if file.path.contains("/out/") {
1810                file.path.ends_with(".rlib") || file.path.ends_with(".so")
1811            } else {
1812                false
1813            };
1814            if dry_run {
1815                unit_touched = true;
1816                if shared {
1817                    report.files_linked += 1;
1818                } else {
1819                    report.files_copied += 1;
1820                }
1821                report.bytes += file.size;
1822                continue;
1823            }
1824            let src = units.join(&entry.name).join(&file.path);
1825            let rel = Path::new(&entry.name).join(&file.path);
1826            #[cfg(unix)]
1827            {
1828                if let Err(err) = util::check_regular_chain(&units, &rel) {
1829                    report.errors += 1;
1830                    report.fatal_errors += 1;
1831                    eprintln!("restore: writing {}: {err:#}", dst.display());
1832                    continue;
1833                }
1834                match restore_file_at(view_root, &dest_rel, &src, entry.stamp, file, shared) {
1835                    Ok(RestoreOutcome::Existing) => {
1836                        report.files_existing += 1;
1837                    }
1838                    Ok(RestoreOutcome::Missing) => {
1839                        report.files_missing += 1;
1840                    }
1841                    Ok(RestoreOutcome::Linked) => {
1842                        report.files_linked += 1;
1843                        report.bytes += file.size;
1844                        unit_touched = true;
1845                    }
1846                    Ok(RestoreOutcome::Copied) => {
1847                        report.files_copied += 1;
1848                        report.bytes += file.size;
1849                        unit_touched = true;
1850                    }
1851                    Err(err) => {
1852                        report.errors += 1;
1853                        report.fatal_errors += 1;
1854                        eprintln!("restore: writing {}: {err:#}", dst.display());
1855                    }
1856                }
1857                continue;
1858            }
1859            #[cfg(not(unix))]
1860            {
1861                if let Some(parent) = dest_rel.parent() {
1862                    if let Err(err) = util::create_dir_chain_nofollow(view_root, parent) {
1863                        report.errors += 1;
1864                        report.fatal_errors += 1;
1865                        eprintln!("restore: creating {}: {err}", parent.display());
1866                        continue;
1867                    }
1868                }
1869                match fs::symlink_metadata(&src) {
1870                    Ok(_) => {}
1871                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1872                        // Fail-open: a partial vault means Cargo rebuilds it.
1873                        report.files_missing += 1;
1874                        continue;
1875                    }
1876                    Err(err) => {
1877                        report.errors += 1;
1878                        report.fatal_errors += 1;
1879                        eprintln!("restore: inspecting {}: {err}", src.display());
1880                        continue;
1881                    }
1882                }
1883                let result = if shared {
1884                    util::check_regular_chain(&units, &rel)
1885                        .and_then(|_| verify_store_file(&src, file))
1886                        .and_then(|_| fs::hard_link(&src, &dst).map_err(Into::into))
1887                } else {
1888                    util::check_regular_chain(&units, &rel)
1889                        .and_then(|_| install_private_copy(&src, &dst, entry.stamp, file))
1890                };
1891                match result {
1892                    Ok(()) => {
1893                        if shared {
1894                            report.files_linked += 1;
1895                        } else {
1896                            report.files_copied += 1;
1897                        }
1898                        report.bytes += file.size;
1899                        unit_touched = true;
1900                    }
1901                    Err(err) => {
1902                        report.errors += 1;
1903                        report.fatal_errors += 1;
1904                        eprintln!("restore: writing {}: {err:#}", dst.display());
1905                    }
1906                }
1907            }
1908        }
1909        if unit_touched {
1910            report.units_linked += 1;
1911        }
1912    }
1913    Ok(report)
1914}
1915
1916/// The store is a trust boundary: refuse a symlinked, foreign-owned, or
1917/// group/world-writable store. A root process must not consume a user-owned
1918/// vault (that is the hard-link privesc this fix closes).
1919pub(crate) fn check_store_trust(store_root: &Path) -> Result<()> {
1920    util::ensure_windows_user_path(store_root, "store")?;
1921    let meta = fs::symlink_metadata(store_root)
1922        .with_context(|| format!("inspecting store {}", store_root.display()))?;
1923    if !meta.is_dir() || meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1924        anyhow::bail!("store {} is not a real directory", store_root.display());
1925    }
1926    if !util::owned_by_us(&meta) {
1927        anyhow::bail!(
1928            "store {} is owned by another user — refusing to execute its artifacts",
1929            store_root.display()
1930        );
1931    }
1932    // Repair the common pre-fix 0755 store instead of making every old vault
1933    // unusable; the directory is ours, so 0700 is always safe (and keeps
1934    // artifacts unreadable by other local users).
1935    util::ensure_private_dir(store_root)?;
1936    Ok(())
1937}
1938
1939fn prepare_view_root(view_root: &Path) -> Result<()> {
1940    match fs::symlink_metadata(view_root) {
1941        Ok(meta) => {
1942            if !meta.is_dir() || meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
1943                anyhow::bail!("view {} is not a real directory", view_root.display());
1944            }
1945            if !util::owned_by_us(&meta) {
1946                anyhow::bail!(
1947                    "view {} is owned by another user — refusing to write into it",
1948                    view_root.display()
1949                );
1950            }
1951            #[cfg(unix)]
1952            {
1953                use std::os::unix::fs::MetadataExt;
1954                if meta.mode() & 0o022 != 0 {
1955                    anyhow::bail!(
1956                        "view {} is group/world-writable (mode {:o}) — refusing a shared restore target; use a 0700 dir",
1957                        view_root.display(),
1958                        meta.mode() & 0o777
1959                    );
1960                }
1961            }
1962        }
1963        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1964            util::create_private_dir_all(view_root)?;
1965        }
1966        Err(err) => {
1967            return Err(err).with_context(|| format!("inspecting view {}", view_root.display()));
1968        }
1969    }
1970    // Do NOT chmod an explicitly supplied view root here: `restore --into /`
1971    // or `--into ~` must never change the mode of a system/home directory.
1972    // New view roots are created 0700; the default cache root is repaired by
1973    // `cmd_restore` (which knows it is the shared cache), and per-job work
1974    // dirs are created 0700 by `exec`.
1975    Ok(())
1976}
1977
1978#[cfg(not(unix))]
1979fn verify_store_file(src: &Path, file: &FileEntry) -> Result<()> {
1980    let meta = fs::symlink_metadata(src)
1981        .with_context(|| format!("inspecting stored file {}", src.display()))?;
1982    if !meta.file_type().is_file() || meta.file_type().is_symlink() || util::is_reparse_point(&meta)
1983    {
1984        anyhow::bail!("{} is not a regular file", src.display());
1985    }
1986    if !util::owned_by_us(&meta) {
1987        anyhow::bail!("{} is owned by another user", src.display());
1988    }
1989    if meta.len() != file.size {
1990        anyhow::bail!(
1991            "{} size drift (recorded {}, found {})",
1992            src.display(),
1993            file.size,
1994            meta.len()
1995        );
1996    }
1997    if file.sha256.is_empty() {
1998        anyhow::bail!("{} has no recorded SHA-256", src.display());
1999    }
2000    let found = util::sha256_file(src)?;
2001    if found != file.sha256 {
2002        anyhow::bail!("{} failed its SHA-256 check", src.display());
2003    }
2004    Ok(())
2005}
2006
2007#[cfg(not(unix))]
2008fn install_private_copy(src: &Path, dst: &Path, stamp: u64, file: &FileEntry) -> Result<()> {
2009    let parent = dst
2010        .parent()
2011        .with_context(|| format!("destination {} has no parent", dst.display()))?;
2012    let nonce = SystemTime::now()
2013        .duration_since(UNIX_EPOCH)
2014        .map(|duration| duration.as_nanos())
2015        .unwrap_or(0);
2016    let tmp = parent.join(format!(".epicenter-copy-{}-{nonce}", std::process::id()));
2017    util::copy_file_verified(src, &tmp, file.size, &file.sha256)?;
2018    make_writable(&tmp)?;
2019    set_stamp(&tmp, stamp)?;
2020    // Private copies stay writable: rustc/cargo rewrite .rmeta and
2021    // fingerprint state in place. Only shared `deps/` artifacts are 0444.
2022    // Link, never rename: this keeps "never overwrite an existing file".
2023    let installed = fs::hard_link(&tmp, dst);
2024    let _ = fs::remove_file(&tmp);
2025    installed.with_context(|| format!("installing {}", dst.display()))?;
2026    Ok(())
2027}
2028
2029/// Read a small regular file without following a symlink.
2030fn read_small_regular(path: &Path) -> Option<String> {
2031    util::read_small_text(path, 4 * 1024 * 1024).ok()
2032}
2033
2034// ── verify ──────────────────────────────────────────────────────────────────
2035
2036/// Result of auditing one generation.
2037#[derive(Debug, Default)]
2038pub struct VerifyReport {
2039    pub parity: String,
2040    pub units: usize,
2041    pub files: usize,
2042    pub bytes: u64,
2043    /// Human lines: missing files, size/hash/stamp drift, orphan groups.
2044    pub problems: Vec<String>,
2045    /// Not a failure, but worth knowing (e.g. unknown files lying around).
2046    pub warnings: Vec<String>,
2047    /// Units older than one of their dependencies. Cargo recompiles exactly
2048    /// these after a restore ("the dependency `X` was rebuilt") — a quality
2049    /// fact, not an integrity failure.
2050    pub ordering: Vec<String>,
2051}
2052
2053impl VerifyReport {
2054    pub fn is_clean(&self) -> bool {
2055        self.problems.is_empty()
2056    }
2057}
2058
2059/// Audit every generation in the vault: every manifest file present with the
2060/// recorded size, the canonical stamp, and — for memo files — the recorded
2061/// hash content; plus a count of unit groups the manifest doesn't know.
2062pub fn verify(store_root: &Path) -> Result<Vec<VerifyReport>> {
2063    let mut reports = Vec::new();
2064    for parity in generation_names(store_root)? {
2065        let mut report = VerifyReport {
2066            parity: parity.clone(),
2067            ..Default::default()
2068        };
2069        if let Err(err) = check_store_trust(store_root) {
2070            report
2071                .problems
2072                .push(format!("unsafe store: {err:#} — no artifact was read"));
2073            reports.push(report);
2074            continue;
2075        }
2076        let Some(manifest) = load_manifest(store_root, &parity)? else {
2077            report.problems.push(format!(
2078                "generation {parity} has no readable manifest.json — it cannot be audited"
2079            ));
2080            reports.push(report);
2081            continue;
2082        };
2083        let units = units_dir(store_root, &parity);
2084        for entry in &manifest.units {
2085            for file in &entry.files {
2086                let path = units.join(&entry.name).join(&file.path);
2087                let rel = Path::new(&entry.name).join(&file.path);
2088                if let Err(err) = util::check_regular_chain(&units, &rel) {
2089                    report.problems.push(format!(
2090                        "unsafe path: {} ({err:#})",
2091                        display_join(&path, &units)
2092                    ));
2093                    continue;
2094                }
2095                let meta = match fs::symlink_metadata(&path) {
2096                    Ok(meta) => meta,
2097                    Err(_) => {
2098                        report
2099                            .problems
2100                            .push(format!("missing: {}", display_join(&path, units.as_path())));
2101                        continue;
2102                    }
2103                };
2104                if meta.file_type().is_symlink() || util::is_reparse_point(&meta) {
2105                    report.problems.push(format!(
2106                        "symlink/reparse in store (refused): {}",
2107                        display_join(&path, &units)
2108                    ));
2109                    continue;
2110                }
2111                if !meta.is_file() {
2112                    report.problems.push(format!(
2113                        "not a regular file: {}",
2114                        display_join(&path, &units)
2115                    ));
2116                    continue;
2117                }
2118                if !util::owned_by_us(&meta) {
2119                    report.problems.push(format!(
2120                        "foreign-owned file: {}",
2121                        display_join(&path, &units)
2122                    ));
2123                }
2124                report.files += 1;
2125                report.bytes += meta.len();
2126                if meta.len() != file.size {
2127                    report.problems.push(format!(
2128                        "size drift: {} (recorded {}, found {})",
2129                        display_join(&path, &units),
2130                        file.size,
2131                        meta.len()
2132                    ));
2133                }
2134                let found_stamp = meta.modified().map(ceil_secs).unwrap_or(0);
2135                if found_stamp != entry.stamp {
2136                    report.problems.push(format!(
2137                        "stamp drift: {} (recorded {}, found {})",
2138                        display_join(&path, &units),
2139                        entry.stamp,
2140                        found_stamp
2141                    ));
2142                }
2143                if file.sha256.is_empty() {
2144                    report.problems.push(format!(
2145                        "no recorded SHA-256: {} (re-harvest this generation)",
2146                        display_join(&path, &units)
2147                    ));
2148                } else {
2149                    match util::sha256_file(&path) {
2150                        Ok(found) if found != file.sha256 => report.problems.push(format!(
2151                            "content drift: {} (recorded {}, found {})",
2152                            display_join(&path, &units),
2153                            &file.sha256[..file.sha256.len().min(12)],
2154                            &found[..found.len().min(12)]
2155                        )),
2156                        Ok(_) => {}
2157                        Err(err) => report.problems.push(format!(
2158                            "unreadable: {}: {err}",
2159                            display_join(&path, &units)
2160                        )),
2161                    }
2162                }
2163                if file.role == "memo" {
2164                    match read_small_regular(&path) {
2165                        Some(text) if file.path == entry.memo && text.trim() != entry.hash => {
2166                            let found = text.trim();
2167                            let detail = if crate::fingerprint::looks_like_hash(found) {
2168                                format!("found {found}")
2169                            } else {
2170                                "found non-hash content (not printed)".to_string()
2171                            };
2172                            report.problems.push(format!(
2173                                "hash drift: {} (recorded {}; {detail})",
2174                                display_join(&path, &units),
2175                                entry.hash
2176                            ));
2177                        }
2178                        Some(text)
2179                            if file.path != entry.memo
2180                                && !crate::fingerprint::looks_like_hash(text.trim()) =>
2181                        {
2182                            report.problems.push(format!(
2183                                "malformed memo file: {} (content is not a 16-hex hash)",
2184                                display_join(&path, &units)
2185                            ));
2186                        }
2187                        Some(_) => {}
2188                        None => report.problems.push(format!(
2189                            "unreadable/symlinked memo file: {}",
2190                            display_join(&path, &units)
2191                        )),
2192                    }
2193                }
2194            }
2195        }
2196        // Reject extra files in recorded unit dirs too: a manifest hash
2197        // list is only meaningful if the set of files is exact.
2198        for entry in &manifest.units {
2199            let unit_dir = units.join(&entry.name);
2200            let expected: std::collections::BTreeSet<String> =
2201                entry.files.iter().map(|file| file.path.clone()).collect();
2202            let walk = match WalkDir::new(&unit_dir)
2203                .min_depth(1)
2204                .into_iter()
2205                .collect::<Result<Vec<_>, _>>()
2206            {
2207                Ok(walk) => walk,
2208                Err(err) => {
2209                    report
2210                        .problems
2211                        .push(format!("unreadable unit dir: units/{} ({err})", entry.name));
2212                    continue;
2213                }
2214            };
2215            for file in walk {
2216                if !file.file_type().is_file() {
2217                    continue;
2218                }
2219                let rel = file
2220                    .path()
2221                    .strip_prefix(&unit_dir)
2222                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
2223                    .unwrap_or_default();
2224                if !expected.contains(&rel) {
2225                    report
2226                        .problems
2227                        .push(format!("unrecorded file: units/{}/{}", entry.name, rel));
2228                }
2229            }
2230        }
2231
2232        // Ordering audit: a unit must be at least as new as its
2233        // dependencies, or cargo recompiles it after a restore ("the
2234        // dependency `X` was rebuilt"). Edges come from the stored
2235        // fingerprints; the join key is the dependency's memo hash — the
2236        // same value serde's `serde_core` edge (9416646317397223793) and
2237        // serde_core's memo file (71a9f323efa5ae82) share.
2238        let by_hash: std::collections::HashMap<&str, usize> = manifest
2239            .units
2240            .iter()
2241            .enumerate()
2242            .map(|(index, entry)| (entry.hash.as_str(), index))
2243            .collect();
2244        for entry in &manifest.units {
2245            let json = entry
2246                .files
2247                .iter()
2248                .find(|file| file.path.ends_with(".json"))
2249                .and_then(|file| read_small_regular(&units.join(&entry.name).join(&file.path)))
2250                .and_then(|text| crate::fingerprint::parse_fingerprint(&text));
2251            let Some(json) = json else { continue };
2252            for dep in &json.deps {
2253                let Some(dep_index) = dep
2254                    .memo_hash()
2255                    .and_then(|hash| by_hash.get(hash.as_str()))
2256                    .copied()
2257                else {
2258                    continue;
2259                };
2260                let dep_entry = &manifest.units[dep_index];
2261                if dep_entry.stamp > entry.stamp {
2262                    report.ordering.push(format!(
2263                        "{} is older than its dependency {} ({} older) — cargo recompiles it after a restore",
2264                        entry.name,
2265                        dep_entry.name,
2266                        crate::util::human_duration(Duration::from_secs(dep_entry.stamp - entry.stamp))
2267                    ));
2268                }
2269            }
2270        }
2271        report.units = manifest.units.len();
2272
2273        // Unit groups on disk that the manifest doesn't record.
2274        match fs::symlink_metadata(&units) {
2275            Ok(meta)
2276                if meta.is_dir()
2277                    && !meta.file_type().is_symlink()
2278                    && !util::is_reparse_point(&meta) => {}
2279            Ok(_) => {
2280                report.problems.push(format!(
2281                    "units directory {} is a symlink/reparse or not a directory",
2282                    units.display()
2283                ));
2284            }
2285            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2286            Err(err) => report.problems.push(format!(
2287                "unreadable units directory {}: {err}",
2288                units.display()
2289            )),
2290        }
2291        if units.is_dir() {
2292            let known: std::collections::BTreeSet<&str> = manifest
2293                .units
2294                .iter()
2295                .map(|unit| unit.name.as_str())
2296                .collect();
2297            for entry in fs::read_dir(&units)? {
2298                let entry = entry?;
2299                if !entry.file_type()?.is_dir() {
2300                    continue;
2301                }
2302                let name = entry.file_name().to_string_lossy().into_owned();
2303                if name.starts_with('.') {
2304                    continue; // in-flight temp dirs of a crashed harvest
2305                }
2306                if !known.contains(name.as_str()) {
2307                    // An unrecorded group can be an orphan from a lost
2308                    // harvest or an injected file set. Treat it as a problem,
2309                    // not a cosmetic warning: `verify` must fail before a
2310                    // consumer trusts the generation.
2311                    report
2312                        .problems
2313                        .push(format!("unrecorded unit group: units/{name}"));
2314                }
2315            }
2316        }
2317        reports.push(report);
2318    }
2319    Ok(reports)
2320}
2321
2322/// Path for humans: relative to `base` when possible.
2323fn display_join(path: &Path, base: &Path) -> String {
2324    path.strip_prefix(base)
2325        .unwrap_or(path)
2326        .to_string_lossy()
2327        .replace('\\', "/")
2328}
2329
2330// ── status ──────────────────────────────────────────────────────────────────
2331
2332/// One generation as the `vault` command shows it.
2333#[derive(Debug, Default)]
2334pub struct GenerationInfo {
2335    pub parity: String,
2336    /// Matches the installed toolchain + flag environment.
2337    pub active: bool,
2338    pub units: usize,
2339    pub files: usize,
2340    /// Bytes as the store holds them; a file linked into views counts once.
2341    pub bytes: u64,
2342    /// Store files that are also linked elsewhere (`nlink > 1`).
2343    pub linked: usize,
2344    pub created: Option<u64>,
2345    pub updated: Option<u64>,
2346}
2347
2348/// Summarize every generation of the vault.
2349pub fn generations(store_root: &Path) -> Result<Vec<GenerationInfo>> {
2350    let active = parity_key().ok();
2351    let mut out = Vec::new();
2352    for parity in generation_names(store_root)? {
2353        let mut info = GenerationInfo {
2354            parity: parity.clone(),
2355            active: active.as_deref() == Some(parity.as_str()),
2356            ..Default::default()
2357        };
2358        if let Some(manifest) = load_manifest(store_root, &parity)? {
2359            info.units = manifest.units.len();
2360            info.created = Some(manifest.created);
2361            info.updated = Some(manifest.updated);
2362        }
2363        let units = units_dir(store_root, &parity);
2364        if units.is_dir() {
2365            for entry in WalkDir::new(&units).min_depth(1) {
2366                let entry = entry?;
2367                if !entry.file_type().is_file() {
2368                    continue;
2369                }
2370                let meta = entry.metadata()?;
2371                info.files += 1;
2372                info.bytes += meta.len();
2373                if link_count(&meta) > 1 {
2374                    info.linked += 1;
2375                }
2376            }
2377        }
2378        out.push(info);
2379    }
2380    Ok(out)
2381}
2382
2383/// Hard links of one inode as the file system knows it (1 where unsupported).
2384pub(crate) fn link_count(meta: &fs::Metadata) -> u64 {
2385    #[cfg(unix)]
2386    {
2387        use std::os::unix::fs::MetadataExt;
2388        meta.nlink()
2389    }
2390    #[cfg(not(unix))]
2391    {
2392        let _ = meta;
2393        1
2394    }
2395}
2396
2397/// The vault's own matcher: package prefix or hash fragment of a group name.
2398fn unit_name_matches(name: &str, query: &str) -> bool {
2399    name.contains(query)
2400}
2401
2402/// The package part of a unit dir name (`serde_core-b468be6fa8c1f936` →
2403/// `serde_core`); `None` for a name that does not end in a 16-hex hash.
2404fn unit_package(name: &str) -> Option<&str> {
2405    let (package, hash) = name.rsplit_once('-')?;
2406    (hash.len() == 16 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(package)
2407}
2408
2409#[cfg(test)]
2410mod tests {
2411    use super::*;
2412
2413    /// A build dir in the shapes real Cargo writes: a lib unit, a
2414    /// build-script compile unit, and a build-script run unit without
2415    /// dep-info — plus volatile files and wrapper internals that must be
2416    /// filtered out.
2417    fn synthetic_build_dir(root: &Path) -> Result<()> {
2418        let jsons =
2419            r#"{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","deps":[]}"#;
2420
2421        let lib = root.join("debug/.fingerprint/serde-102a33f055a8d229");
2422        fs::create_dir_all(&lib)?;
2423        fs::write(lib.join("lib-serde"), "5d82713a7f2d05e2")?;
2424        fs::write(lib.join("lib-serde.json"), jsons)?;
2425        fs::write(lib.join("dep-lib-serde"), [0u8; 24])?;
2426        fs::write(lib.join("invoked.timestamp"), "")?;
2427        fs::create_dir_all(root.join("debug/deps"))?;
2428        fs::write(
2429            root.join("debug/deps/libserde-102a33f055a8d229.rlib"),
2430            [7u8; 64],
2431        )?;
2432        fs::write(
2433            root.join("debug/deps/libserde-102a33f055a8d229.rmeta"),
2434            [6u8; 48],
2435        )?;
2436        fs::write(
2437            root.join("debug/deps/serde-102a33f055a8d229.d"),
2438            b"side file",
2439        )?;
2440        fs::write(
2441            root.join("debug/deps/libserde-102a33f055a8d229.kache-real"),
2442            b"wrapper",
2443        )?;
2444
2445        let compile = root.join("debug/.fingerprint/x-1122334455667788");
2446        fs::create_dir_all(&compile)?;
2447        fs::write(
2448            compile.join("build-script-build-script-build"),
2449            "aaaabbbbccccdddd",
2450        )?;
2451        fs::write(compile.join("build-script-build-script-build.json"), jsons)?;
2452        fs::write(
2453            compile.join("dep-build-script-build-script-build"),
2454            [0u8; 8],
2455        )?;
2456        let compile_anchor = root.join("debug/build/x-1122334455667788");
2457        fs::create_dir_all(&compile_anchor)?;
2458        fs::write(
2459            compile_anchor.join("build_script_build-1122334455667788"),
2460            [3u8; 32],
2461        )?;
2462        fs::write(
2463            compile_anchor.join("build_script_build-1122334455667788.d"),
2464            b"dep",
2465        )?;
2466        fs::write(
2467            compile_anchor.join("build_script_build-1122334455667788.kache-real"),
2468            b"wrapper",
2469        )?;
2470        fs::write(compile_anchor.join("invoked.timestamp"), "")?;
2471
2472        // The run unit has no dep-info at all (real Cargo layout).
2473        let run = root.join("debug/.fingerprint/x-aabbccddeeff0011");
2474        fs::create_dir_all(&run)?;
2475        fs::write(
2476            run.join("run-build-script-build-script-build"),
2477            "eeeeffff00001111",
2478        )?;
2479        fs::write(run.join("run-build-script-build-script-build.json"), jsons)?;
2480        let run_anchor = root.join("debug/build/x-aabbccddeeff0011");
2481        fs::create_dir_all(&run_anchor)?;
2482        fs::write(
2483            run_anchor.join("output"),
2484            b"cargo:rerun-if-changed=build.rs",
2485        )?;
2486        fs::write(run_anchor.join("stderr"), b"")?;
2487        fs::write(run_anchor.join("invoked.timestamp"), "")?;
2488        Ok(())
2489    }
2490
2491    /// A build dir with one incomplete unit (memo file, but no artifacts).
2492    fn synthetic_husk(root: &Path) -> Result<()> {
2493        let unit = root.join("debug/.fingerprint/husk-aaaabbbbccccdddd");
2494        fs::create_dir_all(&unit)?;
2495        fs::write(unit.join("lib-husk"), "0000111122223333")?;
2496        Ok(())
2497    }
2498
2499    #[test]
2500    fn parity_key_is_stable_and_shaped() -> Result<()> {
2501        let first = parity_key()?;
2502        let second = parity_key()?;
2503        assert_eq!(first, second);
2504        let tc = toolchain()?;
2505        assert!(first.starts_with(&format!("{}-{}", tc.release, tc.commit)));
2506        assert!(first.ends_with(&flags_digest()));
2507        Ok(())
2508    }
2509
2510    #[test]
2511    fn harvest_stores_groups_with_one_canonical_stamp() -> Result<()> {
2512        let build = tempfile::tempdir()?;
2513        let store = tempfile::tempdir()?;
2514        synthetic_build_dir(build.path())?;
2515
2516        let report = harvest(build.path(), store.path(), None, false)?;
2517        assert_eq!(report.stored, 3, "lib + build-script compile + run");
2518        assert_eq!(report.skipped_incomplete, 0);
2519        assert!(report.files >= 10, "got {}", report.files);
2520        assert!(report.bytes > 0);
2521
2522        let parity = parity_key()?;
2523        let manifest = load_manifest(store.path(), &parity)?.expect("manifest written");
2524        assert_eq!(manifest.units.len(), 3);
2525        let lib_entry = manifest
2526            .units
2527            .iter()
2528            .find(|unit| unit.name == "serde-102a33f055a8d229")
2529            .expect("lib unit recorded");
2530        assert_eq!(lib_entry.hash, "5d82713a7f2d05e2");
2531        assert_eq!(lib_entry.profile, "debug");
2532
2533        // Every stored file of the group carries the same stamp.
2534        let group = units_dir(store.path(), &parity).join(&lib_entry.name);
2535        for file in &lib_entry.files {
2536            let meta = fs::metadata(group.join(&file.path))?;
2537            assert_eq!(
2538                ceil_secs(meta.modified()?),
2539                lib_entry.stamp,
2540                "{}",
2541                file.path
2542            );
2543            assert_eq!(meta.len(), file.size);
2544        }
2545
2546        // Volatile files and wrapper internals never enter the store.
2547        assert!(!group.join("invoked.timestamp").exists());
2548        for file in &lib_entry.files {
2549            assert!(!file.path.contains("kache"), "{}", file.path);
2550            assert!(!file.path.ends_with(".d"), "{}", file.path);
2551        }
2552
2553        #[cfg(unix)]
2554        {
2555            use std::os::unix::fs::PermissionsExt;
2556            let meta = fs::metadata(group.join(&lib_entry.files[0].path))?;
2557            assert_eq!(
2558                meta.permissions().mode() & 0o777,
2559                0o444,
2560                "store is read-only"
2561            );
2562        }
2563        Ok(())
2564    }
2565
2566    #[test]
2567    fn harvest_is_idempotent_and_skips_incomplete_units() -> Result<()> {
2568        let build = tempfile::tempdir()?;
2569        let store = tempfile::tempdir()?;
2570        synthetic_build_dir(build.path())?;
2571        synthetic_husk(build.path())?;
2572
2573        let first = harvest(build.path(), store.path(), None, false)?;
2574        assert_eq!(first.stored, 3);
2575        assert_eq!(first.skipped_incomplete, 1, "husk without artifacts");
2576
2577        let second = harvest(build.path(), store.path(), None, false)?;
2578        assert_eq!(second.stored, 0);
2579        assert_eq!(second.unchanged, 3);
2580        assert_eq!(second.skipped_incomplete, 1);
2581        Ok(())
2582    }
2583
2584    #[test]
2585    fn harvest_refreshes_when_the_file_set_grows() -> Result<()> {
2586        let build = tempfile::tempdir()?;
2587        let store = tempfile::tempdir()?;
2588        synthetic_build_dir(build.path())?;
2589        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2590
2591        fs::write(
2592            build.path().join("debug/deps/libserde-102a33f055a8d229.so"),
2593            [1u8; 16],
2594        )?;
2595        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2596        assert_eq!(report.refreshed, 1);
2597        assert_eq!(report.stored, 0);
2598
2599        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2600        let paths: Vec<&str> = manifest.units[0]
2601            .files
2602            .iter()
2603            .map(|file| file.path.as_str())
2604            .collect();
2605        assert!(
2606            paths.contains(&"deps/libserde-102a33f055a8d229.so"),
2607            "{paths:?}"
2608        );
2609        Ok(())
2610    }
2611
2612    #[test]
2613    fn harvest_stores_all_memos_of_shared_dirs() -> Result<()> {
2614        let build = tempfile::tempdir()?;
2615        let store = tempfile::tempdir()?;
2616        synthetic_build_dir(build.path())?;
2617        let lib = build
2618            .path()
2619            .join("debug/.fingerprint/serde-102a33f055a8d229");
2620        fs::write(lib.join("bin-serde"), "1111222233334444")?;
2621        fs::write(lib.join("dep-bin-serde"), [0u8; 8])?;
2622
2623        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2624        assert_eq!(report.stored, 1);
2625        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2626        let entry = &manifest.units[0];
2627        let paths: Vec<&str> = entry.files.iter().map(|file| file.path.as_str()).collect();
2628        assert!(paths.contains(&"bin-serde"), "{paths:?}");
2629        assert!(paths.contains(&"dep-bin-serde"), "{paths:?}");
2630
2631        let reports = verify(store.path())?;
2632        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2633        Ok(())
2634    }
2635
2636    #[test]
2637    fn harvest_dry_run_writes_nothing() -> Result<()> {
2638        let build = tempfile::tempdir()?;
2639        let store = tempfile::tempdir()?;
2640        synthetic_build_dir(build.path())?;
2641
2642        let report = harvest(build.path(), store.path(), None, true)?;
2643        assert_eq!(report.stored, 3);
2644        assert_eq!(generation_names(store.path())?.len(), 0, "no generation");
2645        Ok(())
2646    }
2647
2648    #[test]
2649    fn harvest_unit_filter_selects_one_group() -> Result<()> {
2650        let build = tempfile::tempdir()?;
2651        let store = tempfile::tempdir()?;
2652        synthetic_build_dir(build.path())?;
2653
2654        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2655        assert_eq!(report.stored, 1);
2656        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
2657        assert_eq!(manifest.units.len(), 1);
2658        assert_eq!(manifest.units[0].name, "serde-102a33f055a8d229");
2659        Ok(())
2660    }
2661
2662    #[test]
2663    fn restore_links_hardlinks_and_never_overwrites() -> Result<()> {
2664        let build = tempfile::tempdir()?;
2665        let store = tempfile::tempdir()?;
2666        let view = tempfile::tempdir()?;
2667        synthetic_build_dir(build.path())?;
2668        harvest(build.path(), store.path(), None, false)?;
2669
2670        // A pre-existing file must survive a restore untouched.
2671        let occupied = view
2672            .path()
2673            .join("debug/deps/libserde-102a33f055a8d229.rlib");
2674        fs::create_dir_all(occupied.parent().unwrap())?;
2675        fs::write(&occupied, b"live artifact")?;
2676
2677        let report = restore(view.path(), store.path(), None, None, false)?;
2678        assert_eq!(report.units_linked, 3);
2679        assert_eq!(report.files_existing, 1, "the live artifact was skipped");
2680        assert_eq!(fs::read(&occupied)?, b"live artifact");
2681        // Only `deps/` artifacts are hard-linked; state and build trees are
2682        // private copies (cargo writes both in place). The single deps/
2683        // artifact of this fixture was the occupied one, so nothing linked.
2684        assert_eq!(report.files_linked, 0, "no other deps/ artifact");
2685        assert!(report.files_copied >= 12, "got {}", report.files_copied);
2686
2687        // Memo file content and hash landed in the right place.
2688        let memo = view
2689            .path()
2690            .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
2691        assert_eq!(fs::read_to_string(&memo)?.trim(), "5d82713a7f2d05e2");
2692        assert!(
2693            view.path()
2694                .join("debug/build/x-aabbccddeeff0011/output")
2695                .is_file()
2696        );
2697
2698        // Fingerprint state is a private writable copy: cargo rewrites it in
2699        // place after a rebuild, and a shared inode would EACCES on the
2700        // read-only store copy (or mutate the store).
2701        #[cfg(unix)]
2702        {
2703            use std::os::unix::fs::MetadataExt;
2704            use std::os::unix::fs::PermissionsExt;
2705            let parity = parity_key()?;
2706            let stored = units_dir(store.path(), &parity).join("serde-102a33f055a8d229/lib-serde");
2707            let view_meta = fs::metadata(&memo)?;
2708            assert_ne!(
2709                view_meta.ino(),
2710                fs::metadata(&stored)?.ino(),
2711                "state not shared"
2712            );
2713            assert_eq!(view_meta.nlink(), 1, "state owns its inode");
2714            assert_ne!(view_meta.permissions().mode() & 0o200, 0, "state writable");
2715        }
2716
2717        // Second restore: everything already fresh.
2718        let again = restore(view.path(), store.path(), None, None, false)?;
2719        assert_eq!(again.units_already, 3);
2720        assert_eq!(again.files_linked, 0);
2721        assert_eq!(again.files_copied, 0);
2722        Ok(())
2723    }
2724
2725    #[test]
2726    fn restore_into_missing_profile_dirs_creates_them() -> Result<()> {
2727        let build = tempfile::tempdir()?;
2728        let store = tempfile::tempdir()?;
2729        let view = tempfile::tempdir()?;
2730        synthetic_build_dir(build.path())?;
2731        harvest(build.path(), store.path(), None, false)?;
2732
2733        let report = restore(view.path(), store.path(), None, None, false)?;
2734        assert_eq!(report.errors, 0);
2735        assert!(view.path().join("debug/.fingerprint").is_dir());
2736        Ok(())
2737    }
2738
2739    #[test]
2740    fn restore_can_restrict_units_to_packages() -> Result<()> {
2741        let build = tempfile::tempdir()?;
2742        let store = tempfile::tempdir()?;
2743        let view = tempfile::tempdir()?;
2744        let now = now_secs();
2745        synthetic_edge_dir(build.path(), now - 3600, now)?;
2746        harvest(build.path(), store.path(), None, false)?;
2747
2748        let mut packages = std::collections::HashSet::new();
2749        packages.insert("serde_core".to_string());
2750        let report = restore(view.path(), store.path(), None, Some(&packages), false)?;
2751        assert_eq!(report.errors, 0);
2752        assert!(
2753            view.path()
2754                .join("debug/.fingerprint/serde_core-b468be6fa8c1f936")
2755                .is_dir(),
2756            "a unit of a selected package is restored"
2757        );
2758        assert!(
2759            !view
2760                .path()
2761                .join("debug/.fingerprint/serde-746d173581f1a314")
2762                .exists(),
2763            "a unit of an unselected package is not restored"
2764        );
2765        Ok(())
2766    }
2767
2768    #[test]
2769    fn harvest_and_restore_new_layout_units() -> Result<()> {
2770        let build = tempfile::tempdir()?;
2771        let store = tempfile::tempdir()?;
2772        let view = tempfile::tempdir()?;
2773        let unit = synthetic_new_layout_unit(build.path(), "anyhow", "9709d57b46b8d526")?;
2774
2775        let report = harvest_with(
2776            build.path(),
2777            store.path(),
2778            None,
2779            false,
2780            None,
2781            Some(fingerprint::UnitLayout::New),
2782        )?;
2783        assert_eq!(report.stored, 1, "{report:?}");
2784
2785        let parity = parity_key()?;
2786        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
2787        let entry = &manifest.units[0];
2788        assert_eq!(entry.layout(), fingerprint::UnitLayout::New);
2789        assert!(
2790            entry
2791                .files
2792                .iter()
2793                .any(|file| file.dest == "build/anyhow/9709d57b46b8d526/fingerprint/lib-anyhow")
2794        );
2795        assert!(
2796            entry.files.iter().any(|file| file.dest
2797                == "build/anyhow/9709d57b46b8d526/out/libanyhow-9709d57b46b8d526.rlib")
2798        );
2799        assert!(
2800            !entry.files.iter().any(|file| file.dest.ends_with(".d")),
2801            "rustc .d side files stay out of the vault"
2802        );
2803
2804        let restored = restore_with(
2805            view.path(),
2806            store.path(),
2807            None,
2808            None,
2809            Some(fingerprint::UnitLayout::New),
2810            None,
2811            false,
2812        )?;
2813        assert_eq!(restored.errors, 0);
2814        let root = view.path().join("debug/build/anyhow/9709d57b46b8d526");
2815        assert!(root.join("fingerprint/lib-anyhow").is_file());
2816        assert!(root.join("out/libanyhow-9709d57b46b8d526.rlib").is_file());
2817        assert!(root.join("out/libanyhow-9709d57b46b8d526.rmeta").is_file());
2818        assert!(
2819            !view.path().join("debug/.fingerprint").exists(),
2820            "no classic paths for a new-layout unit"
2821        );
2822
2823        #[cfg(unix)]
2824        {
2825            use std::os::unix::fs::MetadataExt;
2826            let rlib = root.join("out/libanyhow-9709d57b46b8d526.rlib");
2827            let rmeta = root.join("out/libanyhow-9709d57b46b8d526.rmeta");
2828            assert!(fs::metadata(&rlib)?.nlink() >= 2, "rlib is a hard link");
2829            assert_eq!(fs::metadata(&rmeta)?.nlink(), 1, "rmeta is a copy");
2830        }
2831        // The fixture's source unit survives untouched.
2832        assert!(unit.join("out/libanyhow-9709d57b46b8d526.rlib").is_file());
2833        Ok(())
2834    }
2835
2836    #[test]
2837    fn harvest_refreshes_when_only_the_layout_changes() -> Result<()> {
2838        let build = tempfile::tempdir()?;
2839        let store = tempfile::tempdir()?;
2840        synthetic_build_dir(build.path())?;
2841        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
2842
2843        let parity = parity_key()?;
2844        let before = load_manifest(store.path(), &parity)?.expect("manifest");
2845        assert_eq!(before.units[0].layout(), fingerprint::UnitLayout::Classic);
2846        let stamp = before.units[0].stamp;
2847
2848        // Same package/hash/memo/json — only the destinations move to the
2849        // new layout. The "unchanged" check must notice the new dests.
2850        fs::remove_dir_all(build.path().join("debug/.fingerprint"))?;
2851        let unit = synthetic_new_layout_unit(build.path(), "serde", "102a33f055a8d229")?;
2852        fs::write(
2853            unit.join("fingerprint/lib-serde.json"),
2854            r#"{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","deps":[]}"#,
2855        )?;
2856        for file in [
2857            unit.join("fingerprint/lib-serde"),
2858            unit.join("fingerprint/lib-serde.json"),
2859            unit.join("fingerprint/dep-lib-serde"),
2860            unit.join("out/libserde-102a33f055a8d229.rlib"),
2861            unit.join("out/libserde-102a33f055a8d229.rmeta"),
2862        ] {
2863            set_stamp(&file, stamp)?;
2864        }
2865
2866        let report = harvest_with(
2867            build.path(),
2868            store.path(),
2869            Some("102a33f0"),
2870            false,
2871            None,
2872            Some(fingerprint::UnitLayout::New),
2873        )?;
2874        assert_eq!(report.refreshed, 1, "{report:?}");
2875        assert_eq!(report.stored, 0);
2876
2877        let after = load_manifest(store.path(), &parity)?.expect("manifest");
2878        assert_eq!(
2879            after.units[0].layout(),
2880            fingerprint::UnitLayout::New,
2881            "the recorded layout follows the dests"
2882        );
2883        assert!(
2884            after.units[0]
2885                .files
2886                .iter()
2887                .any(|file| file.dest.starts_with("build/serde/102a33f055a8d229/"))
2888        );
2889        Ok(())
2890    }
2891
2892    #[test]
2893    fn restore_can_filter_units_by_layout() -> Result<()> {
2894        let build = tempfile::tempdir()?;
2895        let store = tempfile::tempdir()?;
2896        let view = tempfile::tempdir()?;
2897        synthetic_build_dir(build.path())?;
2898        synthetic_new_layout_unit(build.path(), "anyhow", "9709d57b46b8d526")?;
2899
2900        harvest_with(
2901            build.path(),
2902            store.path(),
2903            None,
2904            false,
2905            None,
2906            Some(fingerprint::UnitLayout::Classic),
2907        )?;
2908        harvest_with(
2909            build.path(),
2910            store.path(),
2911            None,
2912            false,
2913            None,
2914            Some(fingerprint::UnitLayout::New),
2915        )?;
2916
2917        let report = restore_with(
2918            view.path(),
2919            store.path(),
2920            None,
2921            None,
2922            Some(fingerprint::UnitLayout::New),
2923            None,
2924            false,
2925        )?;
2926        assert_eq!(report.errors, 0);
2927        assert!(
2928            view.path()
2929                .join("debug/build/anyhow/9709d57b46b8d526/fingerprint/lib-anyhow")
2930                .is_file()
2931        );
2932        assert!(
2933            !view
2934                .path()
2935                .join("debug/.fingerprint/serde-102a33f055a8d229")
2936                .exists(),
2937            "a classic unit is not restored into a new-layout child"
2938        );
2939        Ok(())
2940    }
2941
2942    #[test]
2943    fn restore_without_a_generation_reports_fail_open() -> Result<()> {
2944        let view = tempfile::tempdir()?;
2945        let store = tempfile::tempdir()?;
2946        let report = restore(view.path(), store.path(), None, None, false)?;
2947        assert!(report.note.is_some());
2948        assert_eq!(report.files_linked, 0);
2949        Ok(())
2950    }
2951
2952    #[test]
2953    fn verify_passes_clean_and_flags_drift() -> Result<()> {
2954        let build = tempfile::tempdir()?;
2955        let store = tempfile::tempdir()?;
2956        synthetic_build_dir(build.path())?;
2957        harvest(build.path(), store.path(), None, false)?;
2958
2959        let reports = verify(store.path())?;
2960        assert_eq!(reports.len(), 1);
2961        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
2962        assert_eq!(reports[0].units, 3);
2963
2964        // Damage one stored file: size + stamp drift must surface.
2965        let parity = parity_key()?;
2966        let victim = units_dir(store.path(), &parity).join("serde-102a33f055a8d229/dep-lib-serde");
2967        #[cfg(unix)]
2968        {
2969            use std::os::unix::fs::PermissionsExt;
2970            fs::set_permissions(&victim, fs::Permissions::from_mode(0o644))?;
2971        }
2972        fs::write(&victim, [9u8; 32])?;
2973
2974        let reports = verify(store.path())?;
2975        assert!(!reports[0].is_clean());
2976        assert!(
2977            reports[0]
2978                .problems
2979                .iter()
2980                .any(|line| line.contains("size drift")),
2981            "{:?}",
2982            reports[0].problems
2983        );
2984        Ok(())
2985    }
2986
2987    #[test]
2988    fn verify_reports_unrecorded_unit_groups() -> Result<()> {
2989        let build = tempfile::tempdir()?;
2990        let store = tempfile::tempdir()?;
2991        synthetic_build_dir(build.path())?;
2992        harvest(build.path(), store.path(), None, false)?;
2993
2994        let stray = units_dir(store.path(), &parity_key()?).join("stray-0000000000000000");
2995        fs::create_dir_all(stray.join("deps"))?;
2996        fs::write(stray.join("deps/x.rlib"), [0u8; 4])?;
2997
2998        let reports = verify(store.path())?;
2999        assert!(
3000            reports[0]
3001                .problems
3002                .iter()
3003                .any(|line| line.contains("stray-0000000000000000")),
3004            "{:?}",
3005            reports[0].problems
3006        );
3007        assert!(!reports[0].is_clean());
3008        Ok(())
3009    }
3010
3011    #[test]
3012    fn generations_summary_counts_store_files() -> Result<()> {
3013        let build = tempfile::tempdir()?;
3014        let store = tempfile::tempdir()?;
3015        synthetic_build_dir(build.path())?;
3016        harvest(build.path(), store.path(), None, false)?;
3017
3018        let generations = generations(store.path())?;
3019        assert_eq!(generations.len(), 1);
3020        assert!(generations[0].active, "current toolchain generation");
3021        assert_eq!(generations[0].units, 3);
3022        assert!(generations[0].files >= 10);
3023        assert!(generations[0].linked <= generations[0].files);
3024        Ok(())
3025    }
3026
3027    #[test]
3028    fn cached_diagnostics_are_stored_as_extra_and_verify_clean() -> Result<()> {
3029        let build = tempfile::tempdir()?;
3030        let store = tempfile::tempdir()?;
3031        synthetic_build_dir(build.path())?;
3032        // Cargo's message cache lives in the fingerprint dir, next to the memo.
3033        fs::write(
3034            build
3035                .path()
3036                .join("debug/.fingerprint/serde-102a33f055a8d229/output-lib-serde"),
3037            r#"{"$message_type":"diagnostic","message":"warning: unused"}"#,
3038        )?;
3039
3040        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3041        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3042        let output = manifest.units[0]
3043            .files
3044            .iter()
3045            .find(|file| file.path == "output-lib-serde")
3046            .expect("cached diagnostics are stored");
3047        assert_eq!(output.role, "extra");
3048
3049        let reports = verify(store.path())?;
3050        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3051        Ok(())
3052    }
3053
3054    #[test]
3055    fn harvest_refreshes_when_a_role_drifted() -> Result<()> {
3056        let build = tempfile::tempdir()?;
3057        let store = tempfile::tempdir()?;
3058        synthetic_build_dir(build.path())?;
3059        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3060
3061        // A manifest from before roles were content-based: the memo is
3062        // recorded as something else.
3063        let parity = parity_key()?;
3064        let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3065        let memo_index = manifest.units[0]
3066            .files
3067            .iter()
3068            .position(|file| file.path == manifest.units[0].memo)
3069            .expect("memo recorded");
3070        manifest.units[0].files[memo_index].role = "extra".to_string();
3071        save_manifest(store.path(), &parity, &manifest)?;
3072
3073        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3074        assert_eq!(report.refreshed, 1, "role drift forces a refresh");
3075        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3076        let role = &manifest.units[0].files[memo_index].role;
3077        assert_eq!(role, "memo", "roles are rewritten");
3078        Ok(())
3079    }
3080
3081    #[test]
3082    fn restore_shares_artifacts_but_copies_state_writable() -> Result<()> {
3083        let build = tempfile::tempdir()?;
3084        let store = tempfile::tempdir()?;
3085        synthetic_build_dir(build.path())?;
3086        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3087
3088        let view = tempfile::tempdir()?;
3089        restore(view.path(), store.path(), Some("102a33f0"), None, false)?;
3090
3091        let state = view
3092            .path()
3093            .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
3094        let artifact = view
3095            .path()
3096            .join("debug/deps/libserde-102a33f055a8d229.rlib");
3097        let metadata = view
3098            .path()
3099            .join("debug/deps/libserde-102a33f055a8d229.rmeta");
3100        assert!(state.is_file() && artifact.is_file() && metadata.is_file());
3101
3102        let parity = parity_key()?;
3103        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3104        let entry = manifest
3105            .units
3106            .iter()
3107            .find(|entry| entry.name == "serde-102a33f055a8d229")
3108            .expect("stored unit");
3109        assert_eq!(ceil_secs(fs::metadata(&state)?.modified()?), entry.stamp);
3110
3111        // Rewriting restored state must not reach the store (the exact
3112        // failure cargo hits when it stores a rebuilt fingerprint).
3113        fs::write(&state, "ffffffffffffffff")?;
3114        let stored = units_dir(store.path(), &parity)
3115            .join("serde-102a33f055a8d229")
3116            .join("lib-serde");
3117        assert_eq!(fs::read_to_string(&stored)?, "5d82713a7f2d05e2");
3118
3119        #[cfg(unix)]
3120        {
3121            use std::os::unix::fs::MetadataExt;
3122            assert!(
3123                fs::metadata(&artifact)?.nlink() >= 2,
3124                "artifact shares the store inode"
3125            );
3126            assert_eq!(fs::metadata(&state)?.nlink(), 1, "state is a private copy");
3127            assert_eq!(
3128                fs::metadata(&metadata)?.nlink(),
3129                1,
3130                ".rmeta is a private copy (rustc opens it for write)"
3131            );
3132        }
3133        Ok(())
3134    }
3135
3136    /// Two units with a resolved dependency edge: `serde` → `serde_core`.
3137    /// `dep_at`/`unit_at` are the stamps the files carry, so harvest stores
3138    /// exactly that ordering.
3139    /// A minimal new-layout unit: `debug/build/<pkg>/<hash>/{fingerprint,out}`.
3140    fn synthetic_new_layout_unit(root: &Path, package: &str, hash: &str) -> Result<PathBuf> {
3141        let unit = root.join("debug/build").join(package).join(hash);
3142        fs::create_dir_all(unit.join("fingerprint"))?;
3143        fs::create_dir_all(unit.join("out"))?;
3144        fs::write(
3145            unit.join("fingerprint").join(format!("lib-{package}")),
3146            "5d82713a7f2d05e2",
3147        )?;
3148        fs::write(
3149            unit.join("fingerprint").join(format!("dep-lib-{package}")),
3150            [0u8; 8],
3151        )?;
3152        fs::write(
3153            unit.join("out").join(format!("lib{package}-{hash}.rlib")),
3154            [7u8; 64],
3155        )?;
3156        fs::write(
3157            unit.join("out").join(format!("lib{package}-{hash}.rmeta")),
3158            [6u8; 48],
3159        )?;
3160        Ok(unit)
3161    }
3162
3163    fn synthetic_edge_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
3164        fs::create_dir_all(root.join("debug/deps"))?;
3165        let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
3166        fs::create_dir_all(&core)?;
3167        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
3168        fs::write(core.join("dep-lib-serde_core"), [0u8; 8])?;
3169        let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
3170        fs::write(&core_rlib, [1u8; 32])?;
3171
3172        let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
3173        fs::create_dir_all(&unit)?;
3174        fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
3175        fs::write(
3176            unit.join("lib-serde.json"),
3177            r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
3178        )?;
3179        fs::write(unit.join("dep-lib-serde"), [0u8; 8])?;
3180        let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
3181        fs::write(&unit_rlib, [1u8; 32])?;
3182
3183        set_stamp(&core.join("lib-serde_core"), dep_at)?;
3184        set_stamp(&core.join("dep-lib-serde_core"), dep_at)?;
3185        set_stamp(&core_rlib, dep_at)?;
3186        set_stamp(&unit.join("lib-serde"), unit_at)?;
3187        set_stamp(&unit.join("lib-serde.json"), unit_at)?;
3188        set_stamp(&unit.join("dep-lib-serde"), unit_at)?;
3189        set_stamp(&unit_rlib, unit_at)?;
3190        Ok(())
3191    }
3192
3193    #[test]
3194    fn harvest_clamps_a_dependent_to_its_dependency() -> Result<()> {
3195        let build = tempfile::tempdir()?;
3196        let store = tempfile::tempdir()?;
3197        let now = now_secs();
3198        // The view itself is inverted (a fragmented cache: the dependency was
3199        // rebuilt after its dependent) — harvest must clamp the dependent up.
3200        synthetic_edge_dir(build.path(), now, now - 3600)?;
3201        harvest(build.path(), store.path(), None, false)?;
3202
3203        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3204        let dependent = manifest
3205            .units
3206            .iter()
3207            .find(|entry| entry.name == "serde-746d173581f1a314")
3208            .expect("dependent stored");
3209        let dependency = manifest
3210            .units
3211            .iter()
3212            .find(|entry| entry.name == "serde_core-b468be6fa8c1f936")
3213            .expect("dependency stored");
3214        assert!(
3215            dependent.stamp >= dependency.stamp,
3216            "clamped: {} >= {}",
3217            dependent.stamp,
3218            dependency.stamp
3219        );
3220
3221        let reports = verify(store.path())?;
3222        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3223        assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
3224        Ok(())
3225    }
3226
3227    #[test]
3228    fn harvest_stamps_groups_by_the_metadata_output() -> Result<()> {
3229        let build = tempfile::tempdir()?;
3230        let store = tempfile::tempdir()?;
3231        synthetic_build_dir(build.path())?;
3232        let now = now_secs();
3233        // Rustc writes `.rmeta` first and `.rlib` last: the consumable
3234        // moment — and therefore the stamp — is the metadata.
3235        set_stamp(
3236            &build
3237                .path()
3238                .join("debug/deps/libserde-102a33f055a8d229.rmeta"),
3239            now - 30,
3240        )?;
3241        set_stamp(
3242            &build
3243                .path()
3244                .join("debug/deps/libserde-102a33f055a8d229.rlib"),
3245            now,
3246        )?;
3247        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3248
3249        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3250        assert_eq!(manifest.units[0].stamp, now - 30);
3251        Ok(())
3252    }
3253
3254    #[test]
3255    fn harvest_stores_read_only_sources() -> Result<()> {
3256        let build = tempfile::tempdir()?;
3257        let store = tempfile::tempdir()?;
3258        synthetic_build_dir(build.path())?;
3259        // Wrappers (kache) and backup tools leave read-only files in the build
3260        // dir; `fs::copy` carries that attribute along, and stamping the copy
3261        // must not depend on the source's permissions (Windows: access denied).
3262        let source = build
3263            .path()
3264            .join("debug/.fingerprint/serde-102a33f055a8d229/dep-lib-serde");
3265        let mut perms = fs::metadata(&source)?.permissions();
3266        perms.set_readonly(true);
3267        fs::set_permissions(&source, perms)?;
3268
3269        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3270        assert_eq!(report.stored, 1);
3271
3272        let parity = parity_key()?;
3273        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3274        let stored = units_dir(store.path(), &parity)
3275            .join(&manifest.units[0].name)
3276            .join("dep-lib-serde");
3277        assert!(stored.is_file(), "the state file is stored");
3278        assert_eq!(
3279            ceil_secs(fs::metadata(&stored)?.modified()?),
3280            manifest.units[0].stamp,
3281            "the stored copy carries the canonical stamp"
3282        );
3283        Ok(())
3284    }
3285
3286    /// Windows-only regression: `File::open` yields a GENERIC_READ handle and
3287    /// `SetFileTime` requires FILE_WRITE_ATTRIBUTES, so stamping failed with
3288    /// "Accesso negato" (os error 5) even when the copy itself was writable.
3289    #[cfg(windows)]
3290    #[test]
3291    fn set_stamp_sets_times_on_windows() -> Result<()> {
3292        let dir = tempfile::tempdir()?;
3293        let file = dir.path().join("dep-lib-x");
3294        fs::write(&file, b"x")?;
3295
3296        set_stamp(&file, 1_700_000_000)?;
3297        assert_eq!(
3298            ceil_secs(fs::metadata(&file)?.modified()?),
3299            1_700_000_000,
3300            "a writable copy must be stampable"
3301        );
3302
3303        // The exact `stage_group` order for a copy that inherited a read-only
3304        // source (kache hardlinks): make it writable, then stamp it.
3305        let mut perms = fs::metadata(&file)?.permissions();
3306        perms.set_readonly(true);
3307        fs::set_permissions(&file, perms)?;
3308        make_writable(&file)?;
3309        set_stamp(&file, 1_700_000_100)?;
3310        assert_eq!(
3311            ceil_secs(fs::metadata(&file)?.modified()?),
3312            1_700_000_100,
3313            "a copy of a read-only source must be stampable"
3314        );
3315        Ok(())
3316    }
3317
3318    #[cfg(unix)]
3319    #[test]
3320    fn a_failed_store_drops_its_staging_dir() -> Result<()> {
3321        use std::os::unix::fs::MetadataExt;
3322
3323        let build = tempfile::tempdir()?;
3324        let store = tempfile::tempdir()?;
3325        synthetic_build_dir(build.path())?;
3326        if fs::metadata(build.path())?.uid() == 0 {
3327            return Ok(()); // root reads a 000 file anyway — nothing to inject
3328        }
3329        let artifact = build
3330            .path()
3331            .join("debug/deps/libserde-102a33f055a8d229.rlib");
3332        {
3333            use std::os::unix::fs::PermissionsExt;
3334            fs::set_permissions(&artifact, fs::Permissions::from_mode(0o000))?;
3335        }
3336
3337        let result = harvest(build.path(), store.path(), Some("102a33f0"), false);
3338        assert!(result.is_err(), "an unreadable artifact aborts the store");
3339
3340        let units = units_dir(store.path(), &parity_key()?);
3341        if units.is_dir() {
3342            for entry in fs::read_dir(&units)? {
3343                let name = entry?.file_name().to_string_lossy().into_owned();
3344                assert!(!name.contains(".tmp-"), "staging leftover: {name}");
3345            }
3346        }
3347        Ok(())
3348    }
3349
3350    #[cfg(unix)]
3351    #[test]
3352    fn harvest_sweeps_stale_staging_dirs() -> Result<()> {
3353        use std::os::unix::fs::PermissionsExt;
3354
3355        let build = tempfile::tempdir()?;
3356        let store = tempfile::tempdir()?;
3357        synthetic_build_dir(build.path())?;
3358        let units = units_dir(store.path(), &parity_key()?);
3359
3360        // A crashed store run from yesterday: dot-dir, read-only leftovers.
3361        let stale = units.join(".serde-102a33f055a8d229.tmp-99999");
3362        fs::create_dir_all(&stale)?;
3363        let leftover = stale.join("dep-lib-serde");
3364        fs::write(&leftover, b"x")?;
3365        fs::set_permissions(&leftover, fs::Permissions::from_mode(0o444))?;
3366        set_stamp(&stale, now_secs() - 7200)?;
3367
3368        // A concurrent harvest's staging dir must survive the sweep.
3369        let fresh = units.join(".serde-102a33f055a8d229.tmp-11111");
3370        fs::create_dir_all(&fresh)?;
3371
3372        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
3373        assert!(!stale.exists(), "stale staging dir swept");
3374        assert!(fresh.exists(), "fresh staging dir left alone");
3375        Ok(())
3376    }
3377
3378    #[test]
3379    fn verify_flags_an_inverted_store_stamp() -> Result<()> {
3380        let build = tempfile::tempdir()?;
3381        let store = tempfile::tempdir()?;
3382        let now = now_secs();
3383        synthetic_edge_dir(build.path(), now - 3600, now)?;
3384        harvest(build.path(), store.path(), None, false)?;
3385
3386        // Simulate a store written before the clamp existed: re-stamp the
3387        // dependent (files and manifest) older than its dependency.
3388        let parity = parity_key()?;
3389        let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3390        let units = units_dir(store.path(), &parity);
3391        let entry = manifest
3392            .units
3393            .iter_mut()
3394            .find(|entry| entry.name == "serde-746d173581f1a314")
3395            .expect("dependent stored");
3396        entry.stamp = now - 7200;
3397        for file in &entry.files {
3398            set_stamp(&units.join(&entry.name).join(&file.path), now - 7200)?;
3399        }
3400        save_manifest(store.path(), &parity, &manifest)?;
3401
3402        let reports = verify(store.path())?;
3403        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3404        assert_eq!(reports[0].ordering.len(), 1, "{:?}", reports[0].ordering);
3405        assert!(reports[0].ordering[0].contains("serde-746d173581f1a314"));
3406        assert!(reports[0].ordering[0].contains("serde_core-b468be6fa8c1f936"));
3407        Ok(())
3408    }
3409
3410    #[test]
3411    fn manifest_validation_rejects_escapes() {
3412        let unit = |name: &str, profile: &str, path: &str, dest: &str| UnitEntry {
3413            name: name.into(),
3414            profile: profile.into(),
3415            memo: "lib-x".into(),
3416            hash: "0123456789abcdef".into(),
3417            stamp: 1,
3418            layout: None,
3419            files: vec![FileEntry {
3420                path: path.into(),
3421                dest: dest.into(),
3422                size: 1,
3423                sha256: String::new(),
3424                role: "artifact".into(),
3425            }],
3426        };
3427        let base = Manifest {
3428            parity_key: "p".into(),
3429            toolchain: "t".into(),
3430            created: 1,
3431            updated: 1,
3432            units: vec![],
3433            mac: String::new(),
3434        };
3435        for unit in [
3436            unit("../escape", "debug", "deps/x", "deps/x"),
3437            unit("ok", "/etc", "deps/x", "deps/x"),
3438            unit("ok", "debug", "/etc/passwd", "deps/x"),
3439            unit("ok", "debug", "deps/x", "/root/.ssh/authorized_keys"),
3440            unit("ok", "debug", "deps/../../x", "deps/x"),
3441            unit(".. ", "debug", "deps/x", "deps/x"),
3442            unit("...", "debug", "deps/x", "deps/x"),
3443            unit("CON.txt", "debug", "deps/x", "deps/x"),
3444        ] {
3445            let manifest = Manifest {
3446                units: vec![unit],
3447                ..base.clone()
3448            };
3449            assert!(manifest.validate().is_err(), "must reject {manifest:?}");
3450        }
3451    }
3452
3453    #[test]
3454    fn harvest_records_sha256_and_verify_flags_content_drift() -> Result<()> {
3455        let build = tempfile::tempdir()?;
3456        let store = tempfile::tempdir()?;
3457        synthetic_build_dir(build.path())?;
3458        harvest(build.path(), store.path(), None, false)?;
3459
3460        let parity = parity_key()?;
3461        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
3462        assert!(
3463            manifest
3464                .units
3465                .iter()
3466                .flat_map(|unit| &unit.files)
3467                .all(|file| !file.sha256.is_empty()),
3468            "every stored file must carry a hash"
3469        );
3470
3471        let entry = manifest
3472            .units
3473            .iter()
3474            .find(|entry| entry.name == "serde-102a33f055a8d229")
3475            .expect("unit");
3476        let file = entry
3477            .files
3478            .iter()
3479            .find(|file| file.path == "deps/libserde-102a33f055a8d229.rlib")
3480            .expect("rlib");
3481        let path = units_dir(store.path(), &parity)
3482            .join(&entry.name)
3483            .join(&file.path);
3484        let mut bytes = fs::read(&path)?;
3485        let last = bytes.len() - 1;
3486        bytes[last] ^= 0x01; // same size, different content
3487        #[cfg(unix)]
3488        {
3489            use std::os::unix::fs::PermissionsExt;
3490            fs::set_permissions(&path, fs::Permissions::from_mode(0o644))?;
3491        }
3492        fs::write(&path, &bytes)?;
3493        let stamp = UNIX_EPOCH + Duration::from_secs(entry.stamp);
3494        fs::OpenOptions::new()
3495            .write(true)
3496            .open(&path)?
3497            .set_modified(stamp)?;
3498        #[cfg(unix)]
3499        {
3500            use std::os::unix::fs::PermissionsExt;
3501            fs::set_permissions(&path, fs::Permissions::from_mode(0o444))?;
3502        }
3503
3504        let reports = verify(store.path())?;
3505        assert!(
3506            reports[0]
3507                .problems
3508                .iter()
3509                .any(|problem| problem.contains("content drift")),
3510            "{:?}",
3511            reports[0].problems
3512        );
3513
3514        let view = tempfile::tempdir()?;
3515        let report = restore(view.path(), store.path(), None, None, false)?;
3516        assert!(report.errors > 0, "tampered artifact must not install");
3517        assert!(
3518            !view.path().join(&file.dest).exists(),
3519            "tampered artifact must be absent from the view"
3520        );
3521        Ok(())
3522    }
3523
3524    #[test]
3525    fn concurrent_harvest_keeps_every_unit() -> Result<()> {
3526        let store = tempfile::tempdir()?;
3527        let mut builds = Vec::new();
3528        for index in 0..6u32 {
3529            let build = tempfile::tempdir()?;
3530            let hex = format!("{:016x}", index + 1);
3531            let unit = build
3532                .path()
3533                .join("debug/.fingerprint")
3534                .join(format!("u{index}-{hex}"));
3535            fs::create_dir_all(&unit)?;
3536            fs::write(unit.join(format!("lib-u{index}")), "0123456789abcdef")?;
3537            fs::write(
3538                unit.join(format!("lib-u{index}.json")),
3539                r#"{"rustc":1,"features":"[]","declared_features":"[]","deps":[]}"#,
3540            )?;
3541            fs::create_dir_all(build.path().join("debug/deps"))?;
3542            fs::write(
3543                build
3544                    .path()
3545                    .join("debug/deps")
3546                    .join(format!("libu{index}-{hex}.rlib")),
3547                [index as u8; 8],
3548            )?;
3549            builds.push(build);
3550        }
3551        std::thread::scope(|scope| {
3552            for build in &builds {
3553                scope.spawn(|| harvest(build.path(), store.path(), None, false));
3554            }
3555        });
3556        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
3557        assert_eq!(manifest.units.len(), builds.len(), "{:?}", manifest.units);
3558        Ok(())
3559    }
3560
3561    #[cfg(unix)]
3562    #[test]
3563    fn store_directories_are_private() -> Result<()> {
3564        use std::os::unix::fs::MetadataExt;
3565        let build = tempfile::tempdir()?;
3566        let store = tempfile::tempdir()?;
3567        synthetic_build_dir(build.path())?;
3568        harvest(build.path(), store.path(), None, false)?;
3569        assert_eq!(
3570            fs::metadata(store.path())?.mode() & 0o077,
3571            0,
3572            "store root 0700"
3573        );
3574        let generation = store.path().join(parity_key()?);
3575        assert_eq!(
3576            fs::metadata(&generation)?.mode() & 0o077,
3577            0,
3578            "generation 0700"
3579        );
3580        Ok(())
3581    }
3582
3583    #[test]
3584    fn verify_ordering_is_quiet_when_units_are_current() -> Result<()> {
3585        let build = tempfile::tempdir()?;
3586        let store = tempfile::tempdir()?;
3587        let now = now_secs();
3588        synthetic_edge_dir(build.path(), now - 3600, now)?;
3589        harvest(build.path(), store.path(), None, false)?;
3590
3591        let reports = verify(store.path())?;
3592        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
3593        assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
3594        Ok(())
3595    }
3596}