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