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::path::{Path, PathBuf};
33use std::process::Command;
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use anyhow::{Context, Result};
37use serde::{Deserialize, Serialize};
38use walkdir::WalkDir;
39
40use crate::fingerprint::{self, ArtifactInfo, Unit};
41
42/// Seed for the flag digest, versioned: bumping it retires old generations.
43const PARITY_SEED: &str = "epicenter-parity-v1";
44
45// ── toolchain identity ──────────────────────────────────────────────────────
46
47/// The `rustc -vV` facts that name a vault generation.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Toolchain {
50    /// `1.99.0`
51    pub release: String,
52    /// Short commit hash (`b940084d7`) as rustc prints it.
53    pub commit: String,
54    /// `x86_64-unknown-linux-gnu`
55    pub host: String,
56}
57
58impl Toolchain {
59    /// `rustc 1.99.0 (b940084d7)` — for humans, not for keys.
60    pub fn version_line(&self) -> String {
61        format!("rustc {} ({})", self.release, self.commit)
62    }
63}
64
65/// Parse `rustc -vV` (first line + `host:`).
66pub fn toolchain() -> Result<Toolchain> {
67    let output = Command::new("rustc")
68        .arg("-vV")
69        .output()
70        .context("running `rustc -vV`")?;
71    if !output.status.success() {
72        anyhow::bail!("`rustc -vV` failed — is a toolchain installed?");
73    }
74    let text = String::from_utf8_lossy(&output.stdout);
75
76    let mut release = "unknown".to_string();
77    let mut commit = "unknown".to_string();
78    let mut host = "unknown".to_string();
79    for line in text.lines() {
80        if let Some(rest) = line.strip_prefix("rustc ") {
81            let mut parts = rest.split_whitespace();
82            if let Some(version) = parts.next() {
83                release = version.to_string();
84            }
85            // `rustc 1.99.0 (b940084d7 2026-09-28)` — the token starts `(`.
86            if let Some(commit_part) = parts.next() {
87                let value = commit_part.trim_start_matches('(');
88                if !value.is_empty() {
89                    commit = value.to_string();
90                }
91            }
92        } else if let Some(value) = line.strip_prefix("host: ") {
93            host = value.trim().to_string();
94        }
95    }
96    Ok(Toolchain {
97        release,
98        commit,
99        host,
100    })
101}
102
103/// Install time of the active toolchain: the real `rustc` binary in the
104/// sysroot (falling back to the sysroot directory). This is the reference
105/// for "was this unit built by the installed toolchain?".
106pub fn toolchain_install_time() -> Option<SystemTime> {
107    let output = Command::new("rustc")
108        .args(["--print", "sysroot"])
109        .output()
110        .ok()?;
111    if !output.status.success() {
112        return None;
113    }
114    let sysroot = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
115    let rustc = sysroot
116        .join("bin")
117        .join(if cfg!(windows) { "rustc.exe" } else { "rustc" });
118    fs::metadata(&rustc)
119        .and_then(|meta| meta.modified())
120        .ok()
121        .or_else(|| fs::metadata(&sysroot).and_then(|meta| meta.modified()).ok())
122}
123
124// ── parity key ──────────────────────────────────────────────────────────────
125
126/// One vault generation = toolchain + host + flag environment:
127/// `1.99.0-b940084d7-x86_64-unknown-linux-gnu-3f9a2c1b`.
128pub fn parity_key() -> Result<String> {
129    let tc = toolchain()?;
130    Ok(format!(
131        "{}-{}-{}-{}",
132        tc.release,
133        tc.commit,
134        tc.host,
135        flags_digest()
136    ))
137}
138
139/// Digest of the flags that live outside the fingerprint: the flag
140/// environment plus the flag-relevant keys of Cargo's global config.
141fn flags_digest() -> String {
142    let mut input = String::from(PARITY_SEED);
143    for name in [
144        "RUSTFLAGS",
145        "RUSTDOCFLAGS",
146        "CARGO_BUILD_RUSTFLAGS",
147        "CARGO_BUILD_RUSTDOCFLAGS",
148        "CARGO_BUILD_TARGET",
149    ] {
150        input.push('\0');
151        input.push_str(name);
152        input.push('=');
153        input.push_str(&std::env::var(name).unwrap_or_default());
154    }
155    if let Ok(path) = crate::config::cargo_config_path() {
156        if let Ok(text) = fs::read_to_string(path) {
157            for key in ["rustflags", "rustdocflags", "target"] {
158                if let Some(value) = crate::config::read_table_key(&text, "build", key) {
159                    input.push('\0');
160                    input.push_str(key);
161                    input.push('=');
162                    input.push_str(&value);
163                }
164            }
165        }
166    }
167    format!("{:08x}", (fnv1a64(input.as_bytes()) >> 32) as u32)
168}
169
170fn fnv1a64(bytes: &[u8]) -> u64 {
171    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
172    for byte in bytes {
173        hash ^= *byte as u64;
174        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
175    }
176    hash
177}
178
179// ── layout ──────────────────────────────────────────────────────────────────
180
181/// `<build>/../store` — the vault lives beside the shared build dir, so
182/// `cargo clean` can never touch it.
183pub fn store_root(build_root: &Path) -> PathBuf {
184    build_root
185        .parent()
186        .unwrap_or(build_root)
187        .join("store")
188}
189
190/// `store/<parity-key>/units`.
191pub fn units_dir(store_root: &Path, parity: &str) -> PathBuf {
192    store_root.join(parity).join("units")
193}
194
195fn manifest_path(store_root: &Path, parity: &str) -> PathBuf {
196    store_root.join(parity).join("manifest.json")
197}
198
199// ── manifest ────────────────────────────────────────────────────────────────
200
201/// `manifest.json` of one generation.
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct Manifest {
204    pub parity_key: String,
205    pub toolchain: String,
206    /// Unix seconds of the first harvest of this generation.
207    pub created: u64,
208    /// Unix seconds of the last harvest.
209    pub updated: u64,
210    pub units: Vec<UnitEntry>,
211}
212
213/// One stored unit group.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct UnitEntry {
216    /// Fingerprint dir name: `<pkg>-<16hex>`.
217    pub name: String,
218    pub profile: String,
219    /// Memo file name, e.g. `lib-serde`.
220    pub memo: String,
221    /// Memo file content — the authoritative 16-hex hash.
222    pub hash: String,
223    /// Canonical mtime of the whole group (whole seconds).
224    pub stamp: u64,
225    pub files: Vec<FileEntry>,
226}
227
228/// One stored file.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct FileEntry {
231    /// Path inside the unit group.
232    pub path: String,
233    /// Path relative to the profile dir in a build dir.
234    pub dest: String,
235    pub size: u64,
236    /// `memo` | `fingerprint` | `dep-info` | `artifact` | `build-tree`.
237    pub role: String,
238}
239
240fn load_manifest(store_root: &Path, parity: &str) -> Result<Option<Manifest>> {
241    let path = manifest_path(store_root, parity);
242    if !path.is_file() {
243        return Ok(None);
244    }
245    let text = fs::read_to_string(&path)
246        .with_context(|| format!("reading {}", path.display()))?;
247    let manifest = serde_json::from_str(&text)
248        .with_context(|| format!("parsing {}", path.display()))?;
249    Ok(Some(manifest))
250}
251
252fn save_manifest(store_root: &Path, parity: &str, manifest: &Manifest) -> Result<()> {
253    let dir = store_root.join(parity);
254    fs::create_dir_all(&dir)?;
255    let what = serde_json::to_string_pretty(manifest)?;
256    let tmp = dir.join(format!("manifest.json.tmp-{}", std::process::id()));
257    fs::write(&tmp, what + "\n")?;
258    fs::rename(&tmp, manifest_path(store_root, parity))?;
259    Ok(())
260}
261
262/// Names of the store generations (directories under `store/`).
263pub fn generation_names(store_root: &Path) -> Result<Vec<String>> {
264    if !store_root.is_dir() {
265        return Ok(Vec::new());
266    }
267    let mut names = Vec::new();
268    for entry in fs::read_dir(store_root)
269        .with_context(|| format!("reading {}", store_root.display()))?
270    {
271        let entry = entry?;
272        if !entry.file_type()?.is_dir() {
273            continue;
274        }
275        let name = entry.file_name().to_string_lossy().into_owned();
276        if !name.starts_with('.') {
277            names.push(name);
278        }
279    }
280    names.sort();
281    Ok(names)
282}
283
284// ── stored units (read / remove) ─────────────────────────────────────────────
285
286/// One unit as the vault holds it.
287#[derive(Debug, Clone)]
288pub struct StoredUnit {
289    /// The generation directory the unit lives in (the parity key).
290    pub generation: String,
291    /// The manifest entry: name, profile, hash, stamp, files.
292    pub entry: UnitEntry,
293    /// `store/<generation>/units/<name>`.
294    pub dir: PathBuf,
295}
296
297/// Every stored unit across all generations, manifest-driven.
298pub fn stored_units(store_root: &Path) -> Result<Vec<StoredUnit>> {
299    let mut out = Vec::new();
300    for generation in generation_names(store_root)? {
301        let Some(manifest) = load_manifest(store_root, &generation)? else {
302            continue;
303        };
304        let units = units_dir(store_root, &generation);
305        for entry in manifest.units {
306            let dir = units.join(&entry.name);
307            out.push(StoredUnit {
308                generation: generation.clone(),
309                entry,
310                dir,
311            });
312        }
313    }
314    Ok(out)
315}
316
317/// The `rustc` identity recorded in a stored unit's Fingerprint JSON.
318pub fn stored_unit_rustc(unit: &StoredUnit) -> Option<String> {
319    let text = fs::read_to_string(unit.dir.join(format!("{}.json", unit.entry.memo))).ok()?;
320    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
321    match value.get("rustc") {
322        Some(serde_json::Value::Number(number)) => Some(number.to_string()),
323        Some(serde_json::Value::String(text)) => Some(text.clone()),
324        _ => None,
325    }
326}
327
328/// Remove stored unit groups: their directories go, the manifest is pruned,
329/// and a generation left without units disappears entirely. Returns how many
330/// groups were dropped; groups that could not be removed stay recorded.
331pub fn drop_units(store_root: &Path, generation: &str, names: &[String]) -> Result<usize> {
332    let Some(mut manifest) = load_manifest(store_root, generation)? else {
333        return Ok(0);
334    };
335    let units = units_dir(store_root, generation);
336    let mut dropped = 0;
337    manifest.units.retain(|entry| {
338        if !names.contains(&entry.name) {
339            return true;
340        }
341        match fs::remove_dir_all(units.join(&entry.name)) {
342            Ok(()) => {
343                dropped += 1;
344                false
345            }
346            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
347                dropped += 1;
348                false
349            }
350            Err(err) => {
351                eprintln!("could not remove {}: {err}", units.join(&entry.name).display());
352                true
353            }
354        }
355    });
356    if manifest.units.is_empty() {
357        let _ = fs::remove_dir_all(store_root.join(generation));
358    } else if dropped > 0 {
359        manifest.updated = now_secs();
360        save_manifest(store_root, generation, &manifest)?;
361    }
362    Ok(dropped)
363}
364
365// ── harvest ─────────────────────────────────────────────────────────────────
366
367/// What one harvest did.
368#[derive(Debug, Default)]
369pub struct HarvestReport {
370    pub parity: String,
371    pub toolchain: String,
372    pub stored: usize,
373    pub refreshed: usize,
374    pub unchanged: usize,
375    pub skipped_stale: usize,
376    pub skipped_incomplete: usize,
377    pub files: usize,
378    pub bytes: u64,
379    pub note: Option<String>,
380}
381
382/// Store unit groups from a build dir into the vault.
383///
384/// Only units built by the installed toolchain are stored: the newest unit's
385/// rustc cohort marks the active generation, and a cache that as a whole
386/// predates the installed toolchain produces a note instead of stale copies
387/// filed under the current parity key.
388pub fn harvest(
389    build_root: &Path,
390    store_root: &Path,
391    unit_filter: Option<&str>,
392    dry_run: bool,
393) -> Result<HarvestReport> {
394    let tc = toolchain()?;
395    let parity = parity_key()?;
396    let mut report = HarvestReport {
397        parity: parity.clone(),
398        toolchain: tc.version_line(),
399        ..Default::default()
400    };
401
402    let rows: Vec<(Unit, ArtifactInfo)> = fingerprint::read_units(build_root)?
403        .into_iter()
404        .map(|unit| {
405            let arts = fingerprint::artifacts_for(build_root, &unit);
406            (unit, arts)
407        })
408        .collect();
409
410    // Freshness gate for the whole cache: nothing built since the toolchain
411    // was installed means the cache belongs to an older generation.
412    let newest_activity = rows.iter().filter_map(|(_, arts)| arts.newest).max();
413    if let (Some(newest), Some(installed)) = (newest_activity, toolchain_install_time()) {
414        if newest < installed {
415            report.note = Some(format!(
416                "the shared cache predates the installed toolchain ({}) — build once, then harvest",
417                tc.version_line()
418            ));
419            return Ok(report);
420        }
421    }
422
423    // Active generation = the rustc cohort of the most recently built unit
424    // that carries a fingerprint JSON. Units without one can't be placed and
425    // are harvested as-is (fail-open) rather than mislabeled.
426    let active_gen = rows
427        .iter()
428        .filter(|(unit, _)| crate::who::rustc_of(unit) != "unknown")
429        .max_by_key(|(_, arts)| arts.newest)
430        .map(|(unit, _)| crate::who::rustc_of(unit))
431        .unwrap_or_else(|| "unknown".into());
432
433    let mut manifest = load_manifest(store_root, &parity)?.unwrap_or_else(|| Manifest {
434        parity_key: parity.clone(),
435        toolchain: tc.version_line(),
436        created: now_secs(),
437        updated: 0,
438        units: Vec::new(),
439    });
440
441    // ── pass 1: collect the groups to store and their base stamps ──────────
442    struct GroupPlan {
443        unit: Unit,
444        files: Vec<(PathBuf, String, String, String)>,
445        memo: String,
446        hash: String,
447        base: u64,
448    }
449    let mut plans: Vec<GroupPlan> = Vec::new();
450
451    for (unit, arts) in &rows {
452        if let Some(query) = unit_filter {
453            if !unit_name_matches(&unit.dir_name, query) {
454                continue;
455            }
456        }
457        let cohort = crate::who::rustc_of(unit);
458        if cohort != "unknown" && active_gen != "unknown" && cohort != active_gen {
459            report.skipped_stale += 1;
460            continue;
461        }
462
463        let unit_dir = build_root
464            .join(&unit.profile)
465            .join(".fingerprint")
466            .join(&unit.dir_name);
467        let profile_dir = build_root.join(&unit.profile);
468        let memo = unit.memo_file.clone();
469        let mut files: Vec<(PathBuf, String, String, String)> = Vec::new();
470        // (source, store path, destination relative to the profile dir, role)
471
472        // The whole fingerprint dir except the volatile timestamp: hash
473        // files, their serialized fingerprints, and dep-info. Shared dirs
474        // (several memo files) are stored completely, so every mode of the
475        // unit stays recognizable to Cargo.
476        for entry in fs::read_dir(&unit_dir)
477            .with_context(|| format!("reading {}", unit_dir.display()))?
478        {
479            let entry = entry?;
480            if !entry.file_type()?.is_file() {
481                continue;
482            }
483            let name = entry.file_name().to_string_lossy().into_owned();
484            if is_volatile(&name) {
485                continue;
486            }
487            let role = if name.starts_with("dep-") {
488                "dep-info"
489            } else if name.ends_with(".json") {
490                "fingerprint"
491            } else if fs::read_to_string(entry.path())
492                .map(|text| crate::fingerprint::looks_like_hash(text.trim()))
493                .unwrap_or(false)
494            {
495                "memo"
496            } else {
497                // Cached diagnostics (`output-*`) and other cargo state.
498                "extra"
499            };
500            files.push((
501                entry.path(),
502                name.clone(),
503                format!(".fingerprint/{}/{name}", unit.dir_name),
504                role.into(),
505            ));
506        }
507
508        // Artifacts: `deps/…` and the `build/<anchor>/…` tree of build
509        // scripts. Wrapper internals and rustc's `.d` side files stay out.
510        let mut artifact_count = 0;
511        for (path, _, _) in &arts.paths {
512            let name = path
513                .file_name()
514                .map(|name| name.to_string_lossy().into_owned())
515                .unwrap_or_default();
516            if is_volatile(&name) {
517                continue;
518            }
519            let Ok(rel) = path.strip_prefix(&profile_dir) else {
520                continue;
521            };
522            let rel = rel.to_string_lossy().replace('\\', "/");
523            if rel.starts_with("deps/") && rel.ends_with(".d") {
524                continue;
525            }
526            let role = if rel.starts_with("build/") {
527                "build-tree"
528            } else {
529                "artifact"
530            };
531            files.push((path.clone(), rel.clone(), rel, role.into()));
532            artifact_count += 1;
533        }
534        if artifact_count == 0 {
535            report.skipped_incomplete += 1;
536            continue;
537        }
538
539        plans.push(GroupPlan {
540            base: group_stamp(&files)?,
541            memo,
542            hash: unit.memo_hash.clone().unwrap_or_default(),
543            files,
544            unit: unit.clone(),
545        });
546    }
547
548    // ── pass 2: enforce `T(unit) ≥ every dependency's T` (§11) ─────────────
549    //
550    // Base stamps restore the natural order inside one build view; a store
551    // fed from several views can still invert edges (a dependency harvested
552    // later than its dependent — e.g. a fragmented multi-project cache).
553    // Resolve the edges (a dependency's memo hash → the unit that owns it)
554    // and raise dependents until nothing is older than what it consumed.
555    // `verify`'s ordering audit stays the guard for anything left over.
556    let stored_stamps: std::collections::HashMap<String, u64> = manifest
557        .units
558        .iter()
559        .map(|entry| (entry.hash.clone(), entry.stamp))
560        .collect();
561    let index: std::collections::HashMap<&str, usize> = plans
562        .iter()
563        .enumerate()
564        .map(|(position, plan)| (plan.hash.as_str(), position))
565        .collect();
566    let mut plan_deps: Vec<Vec<usize>> = Vec::with_capacity(plans.len());
567    let mut stamps: Vec<u64> = Vec::with_capacity(plans.len());
568    for plan in &plans {
569        let mut deps = Vec::new();
570        let mut stamp = plan.base;
571        for dep in plan
572            .unit
573            .json
574            .as_ref()
575            .map(|json| json.deps.as_slice())
576            .unwrap_or(&[])
577        {
578            let Some(hash) = dep.memo_hash() else { continue };
579            if let Some(&at) = index.get(hash.as_str()) {
580                deps.push(at);
581            } else if let Some(known) = stored_stamps.get(hash.as_str()) {
582                stamp = stamp.max(*known);
583            }
584        }
585        plan_deps.push(deps);
586        stamps.push(stamp);
587    }
588    // Dependencies before dependents; a cycle (never observed) keeps its base
589    // order and the ordering audit reports whatever is left.
590    let mut order: Vec<usize> = Vec::with_capacity(plans.len());
591    let mut settled = vec![false; plans.len()];
592    loop {
593        let mut progressed = false;
594        for at in 0..plans.len() {
595            if settled[at] || !plan_deps[at].iter().all(|&dep| settled[dep]) {
596                continue;
597            }
598            settled[at] = true;
599            order.push(at);
600            progressed = true;
601        }
602        if !progressed {
603            break;
604        }
605    }
606    for at in 0..plans.len() {
607        if !settled[at] {
608            order.push(at);
609        }
610    }
611    for &at in &order {
612        for dep in &plan_deps[at] {
613            stamps[at] = stamps[at].max(stamps[*dep]);
614        }
615    }
616
617    // ── pass 3: store (or refresh) one group at a time ─────────────────────
618    for (at, plan) in plans.iter().enumerate() {
619        let unit = &plan.unit;
620        let files = &plan.files;
621        let stamp = stamps[at];
622        let group_dir = units_dir(store_root, &parity).join(&unit.dir_name);
623
624        // Unchanged = same hash, profile and stamp, same (path, role) file
625        // set, all present. Anything else is refreshed as a whole group.
626        let mut new_entries: Vec<(&str, &str)> = files
627            .iter()
628            .map(|(_, path, _, role)| (path.as_str(), role.as_str()))
629            .collect();
630        new_entries.sort_unstable();
631        let already = manifest
632            .units
633            .iter()
634            .find(|entry| entry.name == unit.dir_name)
635            .map(|entry| {
636                let mut stored: Vec<(&str, &str)> = entry
637                    .files
638                    .iter()
639                    .map(|file| (file.path.as_str(), file.role.as_str()))
640                    .collect();
641                stored.sort_unstable();
642                entry.hash == plan.hash
643                    && entry.profile == unit.profile
644                    && entry.stamp == stamp
645                    && stored == new_entries
646                    && entry
647                        .files
648                        .iter()
649                        .all(|file| group_dir.join(&file.path).is_file())
650            })
651            .unwrap_or(false);
652        if already {
653            report.unchanged += 1;
654            continue;
655        }
656
657        let bytes: u64 = files
658            .iter()
659            .filter_map(|(src, _, _, _)| fs::metadata(src).ok())
660            .map(|meta| meta.len())
661            .sum();
662        if dry_run {
663            report.stored += 1;
664            report.files += files.len();
665            report.bytes += bytes;
666            continue;
667        }
668
669        let mut entries = Vec::with_capacity(files.len());
670        store_group(store_root, &parity, &unit.dir_name, stamp, files, &mut entries)
671            .with_context(|| format!("storing unit {}", unit.dir_name))?;
672
673        let entry = UnitEntry {
674            name: unit.dir_name.clone(),
675            profile: unit.profile.clone(),
676            memo: plan.memo.clone(),
677            hash: plan.hash.clone(),
678            stamp,
679            files: entries,
680        };
681        match manifest
682            .units
683            .iter_mut()
684            .find(|existing| existing.name == entry.name)
685        {
686            Some(slot) => {
687                *slot = entry;
688                report.refreshed += 1;
689            }
690            None => {
691                manifest.units.push(entry);
692                report.stored += 1;
693            }
694        }
695        report.files += files.len();
696        report.bytes += bytes;
697    }
698
699    if !dry_run {
700        manifest.units.sort_by(|a, b| {
701            (a.profile.as_str(), a.name.as_str()).cmp(&(b.profile.as_str(), b.name.as_str()))
702        });
703        manifest.updated = now_secs();
704        save_manifest(store_root, &parity, &manifest)?;
705    }
706    Ok(report)
707}
708
709/// Copy one unit group into its final place: a temporary directory next to
710/// the target, stamped read-only files, then a swap that never leaves the
711/// group half-written under its real name.
712fn store_group(
713    store_root: &Path,
714    parity: &str,
715    name: &str,
716    stamp: u64,
717    files: &[(PathBuf, String, String, String)],
718    entries: &mut Vec<FileEntry>,
719) -> Result<()> {
720    let units = units_dir(store_root, parity);
721    let final_dir = units.join(name);
722    let tmp = units.join(format!(".{name}.tmp-{}", std::process::id()));
723    if tmp.is_dir() {
724        fs::remove_dir_all(&tmp)?;
725    }
726    fs::create_dir_all(&tmp)?;
727
728    for (src, path, dest, role) in files {
729        let dst = tmp.join(path);
730        if let Some(parent) = dst.parent() {
731            fs::create_dir_all(parent)?;
732        }
733        fs::copy(src, &dst)
734            .with_context(|| format!("copying {} -> {}", src.display(), dst.display()))?;
735        set_stamp(&dst, stamp)?;
736        make_read_only(&dst)?;
737        let size = fs::metadata(&dst).map(|meta| meta.len()).unwrap_or(0);
738        entries.push(FileEntry {
739            path: path.clone(),
740            dest: dest.clone(),
741            size,
742            role: role.clone(),
743        });
744    }
745
746    let old = units.join(format!(".{name}.old-{}", std::process::id()));
747    if final_dir.is_dir() {
748        fs::rename(&final_dir, &old)?;
749    }
750    fs::rename(&tmp, &final_dir)?;
751    if old.is_dir() {
752        let _ = fs::remove_dir_all(&old);
753    }
754    Ok(())
755}
756
757/// The canonical stamp of a group: the ceiling (whole seconds) of its
758/// newest file, so every stored file is stamped at or after its true mtime
759/// and cross-filesystem second-resolution round-trips cannot make an output
760/// look newer than its own group.
761fn group_stamp(files: &[(PathBuf, String, String, String)]) -> Result<u64> {
762    let mut newest: Option<SystemTime> = None;
763    let mut metadata: Option<SystemTime> = None;
764    for (src, path, _, _) in files {
765        let modified = fs::metadata(src)
766            .and_then(|meta| meta.modified())
767            .with_context(|| format!("reading mtime of {}", src.display()))?;
768        if newest.map_or(true, |current| modified > current) {
769            newest = Some(modified);
770        }
771        if path.ends_with(".rmeta") && metadata.map_or(true, |current| modified > current) {
772            metadata = Some(modified);
773        }
774    }
775    // The metadata output, when present, is the moment dependents can compile
776    // against this unit: rustc writes `.rmeta` first and `.rlib` last, and a
777    // dependent routinely finishes before its dependency's `.rlib` exists.
778    // Stamping with the `.rlib` would make the group look newer than its own
779    // dependents and force spurious rebuilds after a restore.
780    Ok(match metadata.or(newest) {
781        Some(time) => ceil_secs(time),
782        None => now_secs(),
783    })
784}
785
786/// Round a timestamp up to whole seconds.
787fn ceil_secs(time: SystemTime) -> u64 {
788    match time.duration_since(UNIX_EPOCH) {
789        Ok(duration) => {
790            let secs = duration.as_secs();
791            if duration.subsec_nanos() > 0 { secs + 1 } else { secs }
792        }
793        Err(_) => 0,
794    }
795}
796
797fn now_secs() -> u64 {
798    SystemTime::now()
799        .duration_since(UNIX_EPOCH)
800        .map(|duration| duration.as_secs())
801        .unwrap_or(0)
802}
803
804fn set_stamp(path: &Path, stamp: u64) -> Result<()> {
805    let time = UNIX_EPOCH + Duration::from_secs(stamp);
806    let file = fs::File::open(path)
807        .with_context(|| format!("opening {} to stamp it", path.display()))?;
808    file.set_modified(time)
809        .with_context(|| format!("stamping {}", path.display()))?;
810    Ok(())
811}
812
813/// Copy a file into a view as a **private** inode: writable, and stamped
814/// with the canonical group time. Required for everything cargo writes in
815/// place (`O_TRUNC`) — a shared inode would either hit the read-only store
816/// file (EACCES) or mutate the store.
817fn copy_private(src: &Path, dst: &Path, stamp: u64) -> std::io::Result<()> {
818    fs::copy(src, dst)?;
819    make_writable(dst)?;
820    let _ = set_stamp(dst, stamp);
821    Ok(())
822}
823
824/// The store is read-only by contract; restored state must not be.
825#[cfg(unix)]
826fn make_writable(path: &Path) -> std::io::Result<()> {
827    use std::os::unix::fs::PermissionsExt;
828    let mut perms = fs::metadata(path)?.permissions();
829    perms.set_mode(0o644);
830    fs::set_permissions(path, perms)
831}
832
833#[cfg(not(unix))]
834fn make_writable(path: &Path) -> std::io::Result<()> {
835    let mut perms = fs::metadata(path)?.permissions();
836    perms.set_readonly(false);
837    fs::set_permissions(path, perms)
838}
839
840/// On Unix the store is read-only (owner can still stamp and delete; only
841/// content edits are refused). On Windows a read-only file blocks rename
842/// and delete, so the store keeps normal permissions there.
843fn make_read_only(path: &Path) -> Result<()> {
844    #[cfg(unix)]
845    {
846        use std::os::unix::fs::PermissionsExt;
847        fs::set_permissions(path, fs::Permissions::from_mode(0o444))?;
848    }
849    #[cfg(not(unix))]
850    {
851        let _ = path;
852    }
853    Ok(())
854}
855
856/// Files that never enter the store: the volatile invocation timestamp,
857/// and wrapper bookkeeping (`*.kache-*`, `.kache*`) that lives in the build
858/// dir but is no part of Cargo's state.
859fn is_volatile(name: &str) -> bool {
860    name == "invoked.timestamp" || name.contains(".kache")
861}
862
863// ── restore ─────────────────────────────────────────────────────────────────
864
865/// What one restore did.
866#[derive(Debug, Default)]
867pub struct RestoreReport {
868    pub parity: String,
869    pub target: PathBuf,
870    pub units_linked: usize,
871    pub units_already: usize,
872    pub files_linked: usize,
873    pub files_copied: usize,
874    pub files_existing: usize,
875    pub bytes: u64,
876    pub errors: usize,
877    pub note: Option<String>,
878}
879
880/// Link matching unit groups from the vault into a working dir.
881///
882/// `deps/` link outputs (`.rlib`, proc-macro `.so`) are hard-linked — rustc
883/// only ever replaces those atomically. Fingerprint state, build-script
884/// trees and `deps/*.rmeta` become private writable copies: rustc writes
885/// `.rmeta` in place, so a read-only link turns any rebuild of that unit
886/// into `output file … .rmeta is not writeable`. Hard links share the
887/// store's canonical stamp; private copies are stamped after copying. Never
888/// overwrites a file that already exists, never touches sources, and a
889/// partial vault is fine: Cargo recompiles whatever is missing.
890pub fn restore(
891    view_root: &Path,
892    store_root: &Path,
893    unit_filter: Option<&str>,
894    dry_run: bool,
895) -> Result<RestoreReport> {
896    let parity = parity_key()?;
897    let mut report = RestoreReport {
898        parity: parity.clone(),
899        target: view_root.to_path_buf(),
900        ..Default::default()
901    };
902    let Some(manifest) = load_manifest(store_root, &parity)? else {
903        report.note = Some(
904            "the vault holds no units for this generation yet — run `epicenter harvest` after a build"
905                .into(),
906        );
907        return Ok(report);
908    };
909
910    let units = units_dir(store_root, &parity);
911    for entry in &manifest.units {
912        if let Some(query) = unit_filter {
913            if !unit_name_matches(&entry.name, query) {
914                continue;
915            }
916        }
917        let fp_dir = view_root
918            .join(&entry.profile)
919            .join(".fingerprint")
920            .join(&entry.name);
921        if fs::read_to_string(fp_dir.join(&entry.memo))
922            .map(|text| text.trim() == entry.hash)
923            .unwrap_or(false)
924        {
925            report.units_already += 1;
926            continue;
927        }
928
929        let mut unit_touched = false;
930        for file in &entry.files {
931            let dst = view_root.join(&entry.profile).join(&file.dest);
932            if dst.exists() {
933                report.files_existing += 1;
934                continue;
935            }
936            // Only `deps/` link outputs (`.rlib`, `.so`) may share the
937            // store inode — rustc replaces those atomically. `.rmeta` and
938            // everything else are written in place by rustc, cargo or build
939            // scripts: private copies only.
940            let shared = file.path.starts_with("deps/") && !file.path.ends_with(".rmeta");
941            if dry_run {
942                unit_touched = true;
943                if shared {
944                    report.files_linked += 1;
945                } else {
946                    report.files_copied += 1;
947                }
948                report.bytes += file.size;
949                continue;
950            }
951            let src = units.join(&entry.name).join(&file.path);
952            if let Some(parent) = dst.parent() {
953                if let Err(err) = fs::create_dir_all(parent) {
954                    report.errors += 1;
955                    eprintln!("restore: creating {}: {err}", parent.display());
956                    continue;
957                }
958            }
959            let linked = shared && fs::hard_link(&src, &dst).is_ok();
960            let result = if linked {
961                Ok(())
962            } else {
963                copy_private(&src, &dst, entry.stamp)
964            };
965            match result {
966                Ok(()) => {
967                    if linked {
968                        report.files_linked += 1;
969                    } else {
970                        report.files_copied += 1;
971                    }
972                    unit_touched = true;
973                }
974                Err(err) => {
975                    report.errors += 1;
976                    eprintln!("restore: writing {}: {err}", dst.display());
977                    continue;
978                }
979            }
980            report.bytes += file.size;
981        }
982        if unit_touched {
983            report.units_linked += 1;
984        }
985    }
986    Ok(report)
987}
988
989// ── verify ──────────────────────────────────────────────────────────────────
990
991/// Result of auditing one generation.
992#[derive(Debug, Default)]
993pub struct VerifyReport {
994    pub parity: String,
995    pub units: usize,
996    pub files: usize,
997    pub bytes: u64,
998    /// Human lines: missing files, size/hash/stamp drift, orphan groups.
999    pub problems: Vec<String>,
1000    /// Not a failure, but worth knowing (e.g. unknown files lying around).
1001    pub warnings: Vec<String>,
1002    /// Units older than one of their dependencies. Cargo recompiles exactly
1003    /// these after a restore ("the dependency `X` was rebuilt") — a quality
1004    /// fact, not an integrity failure.
1005    pub ordering: Vec<String>,
1006}
1007
1008impl VerifyReport {
1009    pub fn is_clean(&self) -> bool {
1010        self.problems.is_empty()
1011    }
1012}
1013
1014/// Audit every generation in the vault: every manifest file present with the
1015/// recorded size, the canonical stamp, and — for memo files — the recorded
1016/// hash content; plus a count of unit groups the manifest doesn't know.
1017pub fn verify(store_root: &Path) -> Result<Vec<VerifyReport>> {
1018    let mut reports = Vec::new();
1019    for parity in generation_names(store_root)? {
1020        let mut report = VerifyReport {
1021            parity: parity.clone(),
1022            ..Default::default()
1023        };
1024        let Some(manifest) = load_manifest(store_root, &parity)? else {
1025            report.problems.push(format!(
1026                "generation {parity} has no readable manifest.json — it cannot be audited"
1027            ));
1028            reports.push(report);
1029            continue;
1030        };
1031        let units = units_dir(store_root, &parity);
1032        for entry in &manifest.units {
1033            for file in &entry.files {
1034                let path = units.join(&entry.name).join(&file.path);
1035                let meta = match fs::metadata(&path) {
1036                    Ok(meta) => meta,
1037                    Err(_) => {
1038                        report
1039                            .problems
1040                            .push(format!("missing: {}", display_join(&path, units.as_path())));
1041                        continue;
1042                    }
1043                };
1044                report.files += 1;
1045                report.bytes += meta.len();
1046                if meta.len() != file.size {
1047                    report.problems.push(format!(
1048                        "size drift: {} (recorded {}, found {})",
1049                        display_join(&path, &units),
1050                        file.size,
1051                        meta.len()
1052                    ));
1053                }
1054                let found_stamp = meta.modified().map(ceil_secs).unwrap_or(0);
1055                if found_stamp != entry.stamp {
1056                    report.problems.push(format!(
1057                        "stamp drift: {} (recorded {}, found {})",
1058                        display_join(&path, &units),
1059                        entry.stamp,
1060                        found_stamp
1061                    ));
1062                }
1063                if file.role == "memo" {
1064                    match fs::read_to_string(&path) {
1065                        Ok(text) if file.path == entry.memo && text.trim() != entry.hash => {
1066                            report.problems.push(format!(
1067                                "hash drift: {} (recorded {}, found {})",
1068                                display_join(&path, &units),
1069                                entry.hash,
1070                                text.trim()
1071                            ));
1072                        }
1073                        Ok(text)
1074                            if file.path != entry.memo
1075                                && !crate::fingerprint::looks_like_hash(text.trim()) =>
1076                        {
1077                            report.problems.push(format!(
1078                                "malformed memo file: {} ({:?})",
1079                                display_join(&path, &units),
1080                                text.trim()
1081                            ));
1082                        }
1083                        Ok(_) => {}
1084                        Err(err) => report.problems.push(format!(
1085                            "unreadable memo file {}: {err}",
1086                            display_join(&path, &units)
1087                        )),
1088                    }
1089                }
1090            }
1091        }
1092        // Ordering audit: a unit must be at least as new as its
1093        // dependencies, or cargo recompiles it after a restore ("the
1094        // dependency `X` was rebuilt"). Edges come from the stored
1095        // fingerprints; the join key is the dependency's memo hash — the
1096        // same value serde's `serde_core` edge (9416646317397223793) and
1097        // serde_core's memo file (71a9f323efa5ae82) share.
1098        let by_hash: std::collections::HashMap<&str, usize> = manifest
1099            .units
1100            .iter()
1101            .enumerate()
1102            .map(|(index, entry)| (entry.hash.as_str(), index))
1103            .collect();
1104        for entry in &manifest.units {
1105            let json = entry
1106                .files
1107                .iter()
1108                .find(|file| file.path.ends_with(".json"))
1109                .and_then(|file| {
1110                    fs::read_to_string(units.join(&entry.name).join(&file.path)).ok()
1111                })
1112                .and_then(|text| crate::fingerprint::parse_fingerprint(&text));
1113            let Some(json) = json else { continue };
1114            for dep in &json.deps {
1115                let Some(dep_index) = dep
1116                    .memo_hash()
1117                    .and_then(|hash| by_hash.get(hash.as_str()))
1118                    .copied()
1119                else {
1120                    continue;
1121                };
1122                let dep_entry = &manifest.units[dep_index];
1123                if dep_entry.stamp > entry.stamp {
1124                    report.ordering.push(format!(
1125                        "{} is older than its dependency {} ({} older) — cargo recompiles it after a restore",
1126                        entry.name,
1127                        dep_entry.name,
1128                        crate::util::human_duration(Duration::from_secs(dep_entry.stamp - entry.stamp))
1129                    ));
1130                }
1131            }
1132        }
1133        report.units = manifest.units.len();
1134
1135        // Unit groups on disk that the manifest doesn't record.
1136        if units.is_dir() {
1137            let known: std::collections::BTreeSet<&str> =
1138                manifest.units.iter().map(|unit| unit.name.as_str()).collect();
1139            for entry in fs::read_dir(&units)? {
1140                let entry = entry?;
1141                if !entry.file_type()?.is_dir() {
1142                    continue;
1143                }
1144                let name = entry.file_name().to_string_lossy().into_owned();
1145                if name.starts_with('.') {
1146                    continue; // in-flight temp dirs of a crashed harvest
1147                }
1148                if !known.contains(name.as_str()) {
1149                    report
1150                        .warnings
1151                        .push(format!("unrecorded unit group: units/{name}"));
1152                }
1153            }
1154        }
1155        reports.push(report);
1156    }
1157    Ok(reports)
1158}
1159
1160/// Path for humans: relative to `base` when possible.
1161fn display_join(path: &Path, base: &Path) -> String {
1162    path.strip_prefix(base)
1163        .unwrap_or(path)
1164        .to_string_lossy()
1165        .replace('\\', "/")
1166}
1167
1168// ── status ──────────────────────────────────────────────────────────────────
1169
1170/// One generation as the `vault` command shows it.
1171#[derive(Debug, Default)]
1172pub struct GenerationInfo {
1173    pub parity: String,
1174    /// Matches the installed toolchain + flag environment.
1175    pub active: bool,
1176    pub units: usize,
1177    pub files: usize,
1178    /// Bytes as the store holds them; a file linked into views counts once.
1179    pub bytes: u64,
1180    /// Store files that are also linked elsewhere (`nlink > 1`).
1181    pub linked: usize,
1182    pub created: Option<u64>,
1183    pub updated: Option<u64>,
1184}
1185
1186/// Summarize every generation of the vault.
1187pub fn generations(store_root: &Path) -> Result<Vec<GenerationInfo>> {
1188    let active = parity_key().ok();
1189    let mut out = Vec::new();
1190    for parity in generation_names(store_root)? {
1191        let mut info = GenerationInfo {
1192            parity: parity.clone(),
1193            active: active.as_deref() == Some(parity.as_str()),
1194            ..Default::default()
1195        };
1196        if let Some(manifest) = load_manifest(store_root, &parity)? {
1197            info.units = manifest.units.len();
1198            info.created = Some(manifest.created);
1199            info.updated = Some(manifest.updated);
1200        }
1201        let units = units_dir(store_root, &parity);
1202        if units.is_dir() {
1203            for entry in WalkDir::new(&units).min_depth(1) {
1204                let entry = entry?;
1205                if !entry.file_type().is_file() {
1206                    continue;
1207                }
1208                let meta = entry.metadata()?;
1209                info.files += 1;
1210                info.bytes += meta.len();
1211                if link_count(&meta) > 1 {
1212                    info.linked += 1;
1213                }
1214            }
1215        }
1216        out.push(info);
1217    }
1218    Ok(out)
1219}
1220
1221/// Hard links of one inode as the file system knows it (1 where unsupported).
1222pub(crate) fn link_count(meta: &fs::Metadata) -> u64 {
1223    #[cfg(unix)]
1224    {
1225        use std::os::unix::fs::MetadataExt;
1226        meta.nlink()
1227    }
1228    #[cfg(not(unix))]
1229    {
1230        let _ = meta;
1231        1
1232    }
1233}
1234
1235/// The vault's own matcher: package prefix or hash fragment of a group name.
1236fn unit_name_matches(name: &str, query: &str) -> bool {
1237    name.contains(query)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243
1244    /// A build dir in the shapes real Cargo writes: a lib unit, a
1245    /// build-script compile unit, and a build-script run unit without
1246    /// dep-info — plus volatile files and wrapper internals that must be
1247    /// filtered out.
1248    fn synthetic_build_dir(root: &Path) -> Result<()> {
1249        let jsons = r#"{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","deps":[]}"#;
1250
1251        let lib = root.join("debug/.fingerprint/serde-102a33f055a8d229");
1252        fs::create_dir_all(&lib)?;
1253        fs::write(lib.join("lib-serde"), "5d82713a7f2d05e2")?;
1254        fs::write(lib.join("lib-serde.json"), jsons)?;
1255        fs::write(lib.join("dep-lib-serde"), [0u8; 24])?;
1256        fs::write(lib.join("invoked.timestamp"), "")?;
1257        fs::create_dir_all(root.join("debug/deps"))?;
1258        fs::write(
1259            root.join("debug/deps/libserde-102a33f055a8d229.rlib"),
1260            [7u8; 64],
1261        )?;
1262        fs::write(
1263            root.join("debug/deps/libserde-102a33f055a8d229.rmeta"),
1264            [6u8; 48],
1265        )?;
1266        fs::write(
1267            root.join("debug/deps/serde-102a33f055a8d229.d"),
1268            b"side file",
1269        )?;
1270        fs::write(
1271            root.join("debug/deps/libserde-102a33f055a8d229.kache-real"),
1272            b"wrapper",
1273        )?;
1274
1275        let compile = root.join("debug/.fingerprint/x-1122334455667788");
1276        fs::create_dir_all(&compile)?;
1277        fs::write(
1278            compile.join("build-script-build-script-build"),
1279            "aaaabbbbccccdddd",
1280        )?;
1281        fs::write(
1282            compile.join("build-script-build-script-build.json"),
1283            jsons,
1284        )?;
1285        fs::write(
1286            compile.join("dep-build-script-build-script-build"),
1287            [0u8; 8],
1288        )?;
1289        let compile_anchor = root.join("debug/build/x-1122334455667788");
1290        fs::create_dir_all(&compile_anchor)?;
1291        fs::write(
1292            compile_anchor.join("build_script_build-1122334455667788"),
1293            [3u8; 32],
1294        )?;
1295        fs::write(
1296            compile_anchor.join("build_script_build-1122334455667788.d"),
1297            b"dep",
1298        )?;
1299        fs::write(
1300            compile_anchor.join("build_script_build-1122334455667788.kache-real"),
1301            b"wrapper",
1302        )?;
1303        fs::write(compile_anchor.join("invoked.timestamp"), "")?;
1304
1305        // The run unit has no dep-info at all (real Cargo layout).
1306        let run = root.join("debug/.fingerprint/x-aabbccddeeff0011");
1307        fs::create_dir_all(&run)?;
1308        fs::write(
1309            run.join("run-build-script-build-script-build"),
1310            "eeeeffff00001111",
1311        )?;
1312        fs::write(
1313            run.join("run-build-script-build-script-build.json"),
1314            jsons,
1315        )?;
1316        let run_anchor = root.join("debug/build/x-aabbccddeeff0011");
1317        fs::create_dir_all(&run_anchor)?;
1318        fs::write(
1319            run_anchor.join("output"),
1320            b"cargo:rerun-if-changed=build.rs",
1321        )?;
1322        fs::write(run_anchor.join("stderr"), b"")?;
1323        fs::write(run_anchor.join("invoked.timestamp"), "")?;
1324        Ok(())
1325    }
1326
1327    /// A build dir with one incomplete unit (memo file, but no artifacts).
1328    fn synthetic_husk(root: &Path) -> Result<()> {
1329        let unit = root.join("debug/.fingerprint/husk-aaaabbbbccccdddd");
1330        fs::create_dir_all(&unit)?;
1331        fs::write(unit.join("lib-husk"), "0000111122223333")?;
1332        Ok(())
1333    }
1334
1335    #[test]
1336    fn parity_key_is_stable_and_shaped() -> Result<()> {
1337        let first = parity_key()?;
1338        let second = parity_key()?;
1339        assert_eq!(first, second);
1340        let tc = toolchain()?;
1341        assert!(first.starts_with(&format!("{}-{}", tc.release, tc.commit)));
1342        assert!(first.ends_with(&flags_digest()));
1343        Ok(())
1344    }
1345
1346    #[test]
1347    fn harvest_stores_groups_with_one_canonical_stamp() -> Result<()> {
1348        let build = tempfile::tempdir()?;
1349        let store = tempfile::tempdir()?;
1350        synthetic_build_dir(build.path())?;
1351
1352        let report = harvest(build.path(), store.path(), None, false)?;
1353        assert_eq!(report.stored, 3, "lib + build-script compile + run");
1354        assert_eq!(report.skipped_incomplete, 0);
1355        assert!(report.files >= 10, "got {}", report.files);
1356        assert!(report.bytes > 0);
1357
1358        let parity = parity_key()?;
1359        let manifest = load_manifest(store.path(), &parity)?.expect("manifest written");
1360        assert_eq!(manifest.units.len(), 3);
1361        let lib_entry = manifest
1362            .units
1363            .iter()
1364            .find(|unit| unit.name == "serde-102a33f055a8d229")
1365            .expect("lib unit recorded");
1366        assert_eq!(lib_entry.hash, "5d82713a7f2d05e2");
1367        assert_eq!(lib_entry.profile, "debug");
1368
1369        // Every stored file of the group carries the same stamp.
1370        let group = units_dir(store.path(), &parity).join(&lib_entry.name);
1371        for file in &lib_entry.files {
1372            let meta = fs::metadata(group.join(&file.path))?;
1373            assert_eq!(ceil_secs(meta.modified()?), lib_entry.stamp, "{}", file.path);
1374            assert_eq!(meta.len(), file.size);
1375        }
1376
1377        // Volatile files and wrapper internals never enter the store.
1378        assert!(!group.join("invoked.timestamp").exists());
1379        for file in &lib_entry.files {
1380            assert!(!file.path.contains("kache"), "{}", file.path);
1381            assert!(!file.path.ends_with(".d"), "{}", file.path);
1382        }
1383
1384        #[cfg(unix)]
1385        {
1386            use std::os::unix::fs::PermissionsExt;
1387            let meta = fs::metadata(group.join(&lib_entry.files[0].path))?;
1388            assert_eq!(meta.permissions().mode() & 0o777, 0o444, "store is read-only");
1389        }
1390        Ok(())
1391    }
1392
1393    #[test]
1394    fn harvest_is_idempotent_and_skips_incomplete_units() -> Result<()> {
1395        let build = tempfile::tempdir()?;
1396        let store = tempfile::tempdir()?;
1397        synthetic_build_dir(build.path())?;
1398        synthetic_husk(build.path())?;
1399
1400        let first = harvest(build.path(), store.path(), None, false)?;
1401        assert_eq!(first.stored, 3);
1402        assert_eq!(first.skipped_incomplete, 1, "husk without artifacts");
1403
1404        let second = harvest(build.path(), store.path(), None, false)?;
1405        assert_eq!(second.stored, 0);
1406        assert_eq!(second.unchanged, 3);
1407        assert_eq!(second.skipped_incomplete, 1);
1408        Ok(())
1409    }
1410
1411    #[test]
1412    fn harvest_refreshes_when_the_file_set_grows() -> Result<()> {
1413        let build = tempfile::tempdir()?;
1414        let store = tempfile::tempdir()?;
1415        synthetic_build_dir(build.path())?;
1416        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1417
1418        fs::write(
1419            build
1420                .path()
1421                .join("debug/deps/libserde-102a33f055a8d229.so"),
1422            [1u8; 16],
1423        )?;
1424        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1425        assert_eq!(report.refreshed, 1);
1426        assert_eq!(report.stored, 0);
1427
1428        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1429        let paths: Vec<&str> = manifest.units[0]
1430            .files
1431            .iter()
1432            .map(|file| file.path.as_str())
1433            .collect();
1434        assert!(
1435            paths.contains(&"deps/libserde-102a33f055a8d229.so"),
1436            "{paths:?}"
1437        );
1438        Ok(())
1439    }
1440
1441    #[test]
1442    fn harvest_stores_all_memos_of_shared_dirs() -> Result<()> {
1443        let build = tempfile::tempdir()?;
1444        let store = tempfile::tempdir()?;
1445        synthetic_build_dir(build.path())?;
1446        let lib = build.path().join("debug/.fingerprint/serde-102a33f055a8d229");
1447        fs::write(lib.join("bin-serde"), "1111222233334444")?;
1448        fs::write(lib.join("dep-bin-serde"), [0u8; 8])?;
1449
1450        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1451        assert_eq!(report.stored, 1);
1452        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1453        let entry = &manifest.units[0];
1454        let paths: Vec<&str> = entry.files.iter().map(|file| file.path.as_str()).collect();
1455        assert!(paths.contains(&"bin-serde"), "{paths:?}");
1456        assert!(paths.contains(&"dep-bin-serde"), "{paths:?}");
1457
1458        let reports = verify(store.path())?;
1459        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1460        Ok(())
1461    }
1462
1463    #[test]
1464    fn harvest_dry_run_writes_nothing() -> Result<()> {
1465        let build = tempfile::tempdir()?;
1466        let store = tempfile::tempdir()?;
1467        synthetic_build_dir(build.path())?;
1468
1469        let report = harvest(build.path(), store.path(), None, true)?;
1470        assert_eq!(report.stored, 3);
1471        assert_eq!(generation_names(store.path())?.len(), 0, "no generation");
1472        Ok(())
1473    }
1474
1475    #[test]
1476    fn harvest_unit_filter_selects_one_group() -> Result<()> {
1477        let build = tempfile::tempdir()?;
1478        let store = tempfile::tempdir()?;
1479        synthetic_build_dir(build.path())?;
1480
1481        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1482        assert_eq!(report.stored, 1);
1483        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1484        assert_eq!(manifest.units.len(), 1);
1485        assert_eq!(manifest.units[0].name, "serde-102a33f055a8d229");
1486        Ok(())
1487    }
1488
1489    #[test]
1490    fn restore_links_hardlinks_and_never_overwrites() -> Result<()> {
1491        let build = tempfile::tempdir()?;
1492        let store = tempfile::tempdir()?;
1493        let view = tempfile::tempdir()?;
1494        synthetic_build_dir(build.path())?;
1495        harvest(build.path(), store.path(), None, false)?;
1496
1497        // A pre-existing file must survive a restore untouched.
1498        let occupied = view.path().join("debug/deps/libserde-102a33f055a8d229.rlib");
1499        fs::create_dir_all(occupied.parent().unwrap())?;
1500        fs::write(&occupied, b"live artifact")?;
1501
1502        let report = restore(view.path(), store.path(), None, false)?;
1503        assert_eq!(report.units_linked, 3);
1504        assert_eq!(report.files_existing, 1, "the live artifact was skipped");
1505        assert_eq!(fs::read(&occupied)?, b"live artifact");
1506        // Only `deps/` artifacts are hard-linked; state and build trees are
1507        // private copies (cargo writes both in place). The single deps/
1508        // artifact of this fixture was the occupied one, so nothing linked.
1509        assert_eq!(report.files_linked, 0, "no other deps/ artifact");
1510        assert!(report.files_copied >= 12, "got {}", report.files_copied);
1511
1512        // Memo file content and hash landed in the right place.
1513        let memo = view
1514            .path()
1515            .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
1516        assert_eq!(fs::read_to_string(&memo)?.trim(), "5d82713a7f2d05e2");
1517        assert!(
1518            view.path()
1519                .join("debug/build/x-aabbccddeeff0011/output")
1520                .is_file()
1521        );
1522
1523        // Fingerprint state is a private writable copy: cargo rewrites it in
1524        // place after a rebuild, and a shared inode would EACCES on the
1525        // read-only store copy (or mutate the store).
1526        #[cfg(unix)]
1527        {
1528            use std::os::unix::fs::MetadataExt;
1529            use std::os::unix::fs::PermissionsExt;
1530            let parity = parity_key()?;
1531            let stored =
1532                units_dir(store.path(), &parity).join("serde-102a33f055a8d229/lib-serde");
1533            let view_meta = fs::metadata(&memo)?;
1534            assert_ne!(view_meta.ino(), fs::metadata(&stored)?.ino(), "state not shared");
1535            assert_eq!(view_meta.nlink(), 1, "state owns its inode");
1536            assert_ne!(view_meta.permissions().mode() & 0o200, 0, "state writable");
1537        }
1538
1539        // Second restore: everything already fresh.
1540        let again = restore(view.path(), store.path(), None, false)?;
1541        assert_eq!(again.units_already, 3);
1542        assert_eq!(again.files_linked, 0);
1543        assert_eq!(again.files_copied, 0);
1544        Ok(())
1545    }
1546
1547    #[test]
1548    fn restore_into_missing_profile_dirs_creates_them() -> Result<()> {
1549        let build = tempfile::tempdir()?;
1550        let store = tempfile::tempdir()?;
1551        let view = tempfile::tempdir()?;
1552        synthetic_build_dir(build.path())?;
1553        harvest(build.path(), store.path(), None, false)?;
1554
1555        let report = restore(view.path(), store.path(), None, false)?;
1556        assert_eq!(report.errors, 0);
1557        assert!(view.path().join("debug/.fingerprint").is_dir());
1558        Ok(())
1559    }
1560
1561    #[test]
1562    fn restore_without_a_generation_reports_fail_open() -> Result<()> {
1563        let view = tempfile::tempdir()?;
1564        let store = tempfile::tempdir()?;
1565        let report = restore(view.path(), store.path(), None, false)?;
1566        assert!(report.note.is_some());
1567        assert_eq!(report.files_linked, 0);
1568        Ok(())
1569    }
1570
1571    #[test]
1572    fn verify_passes_clean_and_flags_drift() -> Result<()> {
1573        let build = tempfile::tempdir()?;
1574        let store = tempfile::tempdir()?;
1575        synthetic_build_dir(build.path())?;
1576        harvest(build.path(), store.path(), None, false)?;
1577
1578        let reports = verify(store.path())?;
1579        assert_eq!(reports.len(), 1);
1580        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1581        assert_eq!(reports[0].units, 3);
1582
1583        // Damage one stored file: size + stamp drift must surface.
1584        let parity = parity_key()?;
1585        let victim =
1586            units_dir(store.path(), &parity).join("serde-102a33f055a8d229/dep-lib-serde");
1587        #[cfg(unix)]
1588        {
1589            use std::os::unix::fs::PermissionsExt;
1590            fs::set_permissions(&victim, fs::Permissions::from_mode(0o644))?;
1591        }
1592        fs::write(&victim, [9u8; 32])?;
1593
1594        let reports = verify(store.path())?;
1595        assert!(!reports[0].is_clean());
1596        assert!(
1597            reports[0]
1598                .problems
1599                .iter()
1600                .any(|line| line.contains("size drift")),
1601            "{:?}",
1602            reports[0].problems
1603        );
1604        Ok(())
1605    }
1606
1607    #[test]
1608    fn verify_reports_unrecorded_unit_groups() -> Result<()> {
1609        let build = tempfile::tempdir()?;
1610        let store = tempfile::tempdir()?;
1611        synthetic_build_dir(build.path())?;
1612        harvest(build.path(), store.path(), None, false)?;
1613
1614        let stray = units_dir(store.path(), &parity_key()?).join("stray-0000000000000000");
1615        fs::create_dir_all(stray.join("deps"))?;
1616        fs::write(stray.join("deps/x.rlib"), [0u8; 4])?;
1617
1618        let reports = verify(store.path())?;
1619        assert!(
1620            reports[0]
1621                .warnings
1622                .iter()
1623                .any(|line| line.contains("stray-0000000000000000")),
1624            "{:?}",
1625            reports[0].warnings
1626        );
1627        Ok(())
1628    }
1629
1630    #[test]
1631    fn generations_summary_counts_store_files() -> Result<()> {
1632        let build = tempfile::tempdir()?;
1633        let store = tempfile::tempdir()?;
1634        synthetic_build_dir(build.path())?;
1635        harvest(build.path(), store.path(), None, false)?;
1636
1637        let generations = generations(store.path())?;
1638        assert_eq!(generations.len(), 1);
1639        assert!(generations[0].active, "current toolchain generation");
1640        assert_eq!(generations[0].units, 3);
1641        assert!(generations[0].files >= 10);
1642        assert!(generations[0].linked <= generations[0].files);
1643        Ok(())
1644    }
1645
1646    #[test]
1647    fn cached_diagnostics_are_stored_as_extra_and_verify_clean() -> Result<()> {
1648        let build = tempfile::tempdir()?;
1649        let store = tempfile::tempdir()?;
1650        synthetic_build_dir(build.path())?;
1651        // Cargo's message cache lives in the fingerprint dir, next to the memo.
1652        fs::write(
1653            build
1654                .path()
1655                .join("debug/.fingerprint/serde-102a33f055a8d229/output-lib-serde"),
1656            r#"{"$message_type":"diagnostic","message":"warning: unused"}"#,
1657        )?;
1658
1659        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1660        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1661        let output = manifest.units[0]
1662            .files
1663            .iter()
1664            .find(|file| file.path == "output-lib-serde")
1665            .expect("cached diagnostics are stored");
1666        assert_eq!(output.role, "extra");
1667
1668        let reports = verify(store.path())?;
1669        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1670        Ok(())
1671    }
1672
1673    #[test]
1674    fn harvest_refreshes_when_a_role_drifted() -> Result<()> {
1675        let build = tempfile::tempdir()?;
1676        let store = tempfile::tempdir()?;
1677        synthetic_build_dir(build.path())?;
1678        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1679
1680        // A manifest from before roles were content-based: the memo is
1681        // recorded as something else.
1682        let parity = parity_key()?;
1683        let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
1684        let memo_index = manifest.units[0]
1685            .files
1686            .iter()
1687            .position(|file| file.path == manifest.units[0].memo)
1688            .expect("memo recorded");
1689        manifest.units[0].files[memo_index].role = "extra".to_string();
1690        save_manifest(store.path(), &parity, &manifest)?;
1691
1692        let report = harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1693        assert_eq!(report.refreshed, 1, "role drift forces a refresh");
1694        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
1695        let role = &manifest.units[0].files[memo_index].role;
1696        assert_eq!(role, "memo", "roles are rewritten");
1697        Ok(())
1698    }
1699
1700    #[test]
1701    fn restore_shares_artifacts_but_copies_state_writable() -> Result<()> {
1702        let build = tempfile::tempdir()?;
1703        let store = tempfile::tempdir()?;
1704        synthetic_build_dir(build.path())?;
1705        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1706
1707        let view = tempfile::tempdir()?;
1708        restore(view.path(), store.path(), Some("102a33f0"), false)?;
1709
1710        let state = view
1711            .path()
1712            .join("debug/.fingerprint/serde-102a33f055a8d229/lib-serde");
1713        let artifact = view
1714            .path()
1715            .join("debug/deps/libserde-102a33f055a8d229.rlib");
1716        let metadata = view
1717            .path()
1718            .join("debug/deps/libserde-102a33f055a8d229.rmeta");
1719        assert!(state.is_file() && artifact.is_file() && metadata.is_file());
1720
1721        let parity = parity_key()?;
1722        let manifest = load_manifest(store.path(), &parity)?.expect("manifest");
1723        let entry = manifest
1724            .units
1725            .iter()
1726            .find(|entry| entry.name == "serde-102a33f055a8d229")
1727            .expect("stored unit");
1728        assert_eq!(ceil_secs(fs::metadata(&state)?.modified()?), entry.stamp);
1729
1730        // Rewriting restored state must not reach the store (the exact
1731        // failure cargo hits when it stores a rebuilt fingerprint).
1732        fs::write(&state, "ffffffffffffffff")?;
1733        let stored = units_dir(store.path(), &parity)
1734            .join("serde-102a33f055a8d229")
1735            .join("lib-serde");
1736        assert_eq!(fs::read_to_string(&stored)?, "5d82713a7f2d05e2");
1737
1738        #[cfg(unix)]
1739        {
1740            use std::os::unix::fs::MetadataExt;
1741            assert!(fs::metadata(&artifact)?.nlink() >= 2, "artifact shares the store inode");
1742            assert_eq!(fs::metadata(&state)?.nlink(), 1, "state is a private copy");
1743            assert_eq!(
1744                fs::metadata(&metadata)?.nlink(),
1745                1,
1746                ".rmeta is a private copy (rustc opens it for write)"
1747            );
1748        }
1749        Ok(())
1750    }
1751
1752    /// Two units with a resolved dependency edge: `serde` → `serde_core`.
1753    /// `dep_at`/`unit_at` are the stamps the files carry, so harvest stores
1754    /// exactly that ordering.
1755    fn synthetic_edge_dir(root: &Path, dep_at: u64, unit_at: u64) -> Result<()> {
1756        fs::create_dir_all(root.join("debug/deps"))?;
1757        let core = root.join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
1758        fs::create_dir_all(&core)?;
1759        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
1760        fs::write(core.join("dep-lib-serde_core"), [0u8; 8])?;
1761        let core_rlib = root.join("debug/deps/libserde_core-b468be6fa8c1f936.rlib");
1762        fs::write(&core_rlib, [1u8; 32])?;
1763
1764        let unit = root.join("debug/.fingerprint/serde-746d173581f1a314");
1765        fs::create_dir_all(&unit)?;
1766        fs::write(unit.join("lib-serde"), "5d82713a7f2d05e2")?;
1767        fs::write(
1768            unit.join("lib-serde.json"),
1769            r#"{"rustc":5244445300240561903,"features":"[]","declared_features":"[]","deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
1770        )?;
1771        fs::write(unit.join("dep-lib-serde"), [0u8; 8])?;
1772        let unit_rlib = root.join("debug/deps/libserde-746d173581f1a314.rlib");
1773        fs::write(&unit_rlib, [1u8; 32])?;
1774
1775        set_stamp(&core.join("lib-serde_core"), dep_at)?;
1776        set_stamp(&core.join("dep-lib-serde_core"), dep_at)?;
1777        set_stamp(&core_rlib, dep_at)?;
1778        set_stamp(&unit.join("lib-serde"), unit_at)?;
1779        set_stamp(&unit.join("lib-serde.json"), unit_at)?;
1780        set_stamp(&unit.join("dep-lib-serde"), unit_at)?;
1781        set_stamp(&unit_rlib, unit_at)?;
1782        Ok(())
1783    }
1784
1785    #[test]
1786    fn harvest_clamps_a_dependent_to_its_dependency() -> Result<()> {
1787        let build = tempfile::tempdir()?;
1788        let store = tempfile::tempdir()?;
1789        let now = now_secs();
1790        // The view itself is inverted (a fragmented cache: the dependency was
1791        // rebuilt after its dependent) — harvest must clamp the dependent up.
1792        synthetic_edge_dir(build.path(), now, now - 3600)?;
1793        harvest(build.path(), store.path(), None, false)?;
1794
1795        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1796        let dependent = manifest
1797            .units
1798            .iter()
1799            .find(|entry| entry.name == "serde-746d173581f1a314")
1800            .expect("dependent stored");
1801        let dependency = manifest
1802            .units
1803            .iter()
1804            .find(|entry| entry.name == "serde_core-b468be6fa8c1f936")
1805            .expect("dependency stored");
1806        assert!(
1807            dependent.stamp >= dependency.stamp,
1808            "clamped: {} >= {}",
1809            dependent.stamp,
1810            dependency.stamp
1811        );
1812
1813        let reports = verify(store.path())?;
1814        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1815        assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
1816        Ok(())
1817    }
1818
1819    #[test]
1820    fn harvest_stamps_groups_by_the_metadata_output() -> Result<()> {
1821        let build = tempfile::tempdir()?;
1822        let store = tempfile::tempdir()?;
1823        synthetic_build_dir(build.path())?;
1824        let now = now_secs();
1825        // Rustc writes `.rmeta` first and `.rlib` last: the consumable
1826        // moment — and therefore the stamp — is the metadata.
1827        set_stamp(
1828            &build.path().join("debug/deps/libserde-102a33f055a8d229.rmeta"),
1829            now - 30,
1830        )?;
1831        set_stamp(
1832            &build.path().join("debug/deps/libserde-102a33f055a8d229.rlib"),
1833            now,
1834        )?;
1835        harvest(build.path(), store.path(), Some("102a33f0"), false)?;
1836
1837        let manifest = load_manifest(store.path(), &parity_key()?)?.expect("manifest");
1838        assert_eq!(manifest.units[0].stamp, now - 30);
1839        Ok(())
1840    }
1841
1842    #[test]
1843    fn verify_flags_an_inverted_store_stamp() -> Result<()> {
1844        let build = tempfile::tempdir()?;
1845        let store = tempfile::tempdir()?;
1846        let now = now_secs();
1847        synthetic_edge_dir(build.path(), now - 3600, now)?;
1848        harvest(build.path(), store.path(), None, false)?;
1849
1850        // Simulate a store written before the clamp existed: re-stamp the
1851        // dependent (files and manifest) older than its dependency.
1852        let parity = parity_key()?;
1853        let mut manifest = load_manifest(store.path(), &parity)?.expect("manifest");
1854        let units = units_dir(store.path(), &parity);
1855        let entry = manifest
1856            .units
1857            .iter_mut()
1858            .find(|entry| entry.name == "serde-746d173581f1a314")
1859            .expect("dependent stored");
1860        entry.stamp = now - 7200;
1861        for file in &entry.files {
1862            set_stamp(&units.join(&entry.name).join(&file.path), now - 7200)?;
1863        }
1864        save_manifest(store.path(), &parity, &manifest)?;
1865
1866        let reports = verify(store.path())?;
1867        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1868        assert_eq!(reports[0].ordering.len(), 1, "{:?}", reports[0].ordering);
1869        assert!(reports[0].ordering[0].contains("serde-746d173581f1a314"));
1870        assert!(reports[0].ordering[0].contains("serde_core-b468be6fa8c1f936"));
1871        Ok(())
1872    }
1873
1874    #[test]
1875    fn verify_ordering_is_quiet_when_units_are_current() -> Result<()> {
1876        let build = tempfile::tempdir()?;
1877        let store = tempfile::tempdir()?;
1878        let now = now_secs();
1879        synthetic_edge_dir(build.path(), now - 3600, now)?;
1880        harvest(build.path(), store.path(), None, false)?;
1881
1882        let reports = verify(store.path())?;
1883        assert!(reports[0].is_clean(), "{:?}", reports[0].problems);
1884        assert!(reports[0].ordering.is_empty(), "{:?}", reports[0].ordering);
1885        Ok(())
1886    }
1887}