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