Skip to main content

epicenter_cli/
fingerprint.rs

1//! Read-only parser for cargo's `.fingerprint/` state: parses each unit
2//! directory into a [`Unit`] (kind, hashes, fingerprint JSON, dep-info) and
3//! attaches the artifacts that belong to it.
4//!
5//! Directory layout as written by cargo:
6//! ```text
7//! <build>/<profile>/.fingerprint/<package>-<16hex>/
8//!   <memo-file>            ← TEXT, 16 hex chars = the authoritative memo hash
9//!   <memo-file>.json       ← serialized Fingerprint (schema below)
10//!   dep-<memo-file>        ← binary EncodedDepInfo (not parsed here)
11//!   invoked.timestamp      ← optional, ignorable
12//! ```
13//! `<memo-file>` names the target kind: `lib-serde` (lib),
14//! `build-script-build` (compile build script), `run-build-script-build`
15//! (run build script), or the bin name.
16//!
17//! Cargo's new build-dir layout (nightly 1.99+ with `-Zbuild-dir-new-layout`,
18//! on by default there; stable 1.100+) writes one directory per unit instead:
19//! ```text
20//! <build>/<profile>/build/<package>/<16hex>/
21//!   fingerprint/   ← the same files as the classic `.fingerprint/<pkg>-<hex>/`
22//!   out/           ← the artifacts the classic layout kept in `<profile>/deps/`
23//!   run/           ← run-build-script state (classic `build/<anchor>/`)
24//! ```
25//! Both layouts can coexist in one build dir (a stable and a nightly build
26//! sharing the cache), so [`read_units`] discovers units by shape. The
27//! [`layout_for_cargo`] helper decides which layout a child build will write,
28//! which `exec` uses to seed the matching units.
29//!
30//! JSON schema — mostly opaque HASH NUMBERS plus two human facts:
31//! `{rustc, features, declared_features, target, profile, path, deps,
32//!  local, rustflags, config, compile_kind}` where `features` /
33//! `declared_features` are STRINGS containing serialized JSON arrays.
34
35use std::collections::HashMap;
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::time::SystemTime;
39
40use anyhow::{Context, Result};
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43use walkdir::WalkDir;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum UnitKind {
47    Lib,
48    BuildScriptCompile,
49    BuildScriptRun,
50    Other,
51}
52
53impl UnitKind {
54    pub fn label(self) -> &'static str {
55        match self {
56            UnitKind::Lib => "lib",
57            UnitKind::BuildScriptCompile => "build.rs",
58            UnitKind::BuildScriptRun => "build-run",
59            UnitKind::Other => "other",
60        }
61    }
62}
63
64/// Which build-dir layout produced a unit.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum UnitLayout {
68    /// Cargo 1.91–1.99 stable: `<profile>/.fingerprint/<pkg>-<hash>/` +
69    /// `<profile>/deps/`.
70    Classic,
71    /// Cargo nightly 1.99+ / stable 1.100+:
72    /// `<profile>/build/<pkg>/<hash>/{fingerprint,out}/`.
73    New,
74}
75
76impl UnitLayout {
77    pub fn label(self) -> &'static str {
78        match self {
79            UnitLayout::Classic => "classic",
80            UnitLayout::New => "new",
81        }
82    }
83}
84
85/// The per-unit directory of the new build-dir layout:
86/// ```text
87/// <build-dir>/<profile>/build/<package>/<hash>/
88///    fingerprint/   ↔ the classic `.fingerprint/<package>-<hash>/` contents
89///    out/           ↔ the artifacts classic kept in `deps/`
90/// ```
91/// `<hash>` is the unit hash — the classic fingerprint-dir suffix.
92#[derive(Debug, Clone)]
93pub struct NewUnitLayout {
94    /// `debug` / `release` …
95    profile: String,
96    /// `anyhow`, `serde_core`, …
97    package: String,
98    /// `9709d57b46b8d526`
99    hash: String,
100}
101
102impl NewUnitLayout {
103    pub fn new(profile: &str, package: &str, hash: &str) -> Self {
104        Self {
105            profile: profile.to_string(),
106            package: package.to_string(),
107            hash: hash.to_string(),
108        }
109    }
110
111    pub fn profile(&self) -> &str {
112        &self.profile
113    }
114
115    pub fn package(&self) -> &str {
116        &self.package
117    }
118
119    pub fn hash(&self) -> &str {
120        &self.hash
121    }
122
123    /// `<build>/<profile>/build/<package>/<hash>`
124    pub fn unit_dir(&self, build_root: &Path) -> PathBuf {
125        build_root
126            .join(&self.profile)
127            .join("build")
128            .join(&self.package)
129            .join(&self.hash)
130    }
131
132    /// `<build>/<profile>/build/<package>/<hash>/fingerprint`
133    pub fn fingerprint_dir(&self, build_root: &Path) -> PathBuf {
134        self.unit_dir(build_root).join("fingerprint")
135    }
136
137    /// `<build>/<profile>/build/<package>/<hash>/out`
138    pub fn out_dir(&self, build_root: &Path) -> PathBuf {
139        self.unit_dir(build_root).join("out")
140    }
141}
142
143/// Which layout a cargo of this version writes. `version` is the output of
144/// `cargo --version` (e.g. `cargo 1.99.0-nightly (7c83d4cc0 …)`); `args` is
145/// the child argv, scanned for `-Zbuild-dir-new-layout`.
146///
147/// Rules (the feature is nightly-first):
148/// - stable/beta ≥ 1.100 → new (the new layout ships in 1.100);
149/// - nightly ≥ 1.99 → new (nightly 1.99 enables the flag by default);
150/// - nightly < 1.99 with `-Zbuild-dir-new-layout` → new;
151/// - everything else → classic (covers 1.91 … 1.99 stable).
152pub fn layout_for_cargo(version: &str, args: &[String]) -> UnitLayout {
153    let Some((major, minor)) = parse_cargo_version(version) else {
154        return UnitLayout::Classic;
155    };
156    let nightly = version.contains("-nightly");
157    if major > 1 || (major == 1 && minor >= 100) {
158        return UnitLayout::New;
159    }
160    if nightly && minor >= 99 {
161        return UnitLayout::New;
162    }
163    if nightly && has_new_layout_flag(args) {
164        return UnitLayout::New;
165    }
166    UnitLayout::Classic
167}
168
169/// `cargo 1.99.0-nightly (7c83d4cc0 …)` → `(1, 99)`.
170pub fn parse_cargo_version(text: &str) -> Option<(u64, u64)> {
171    let token = text
172        .split_whitespace()
173        .find(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
174    let mut parts = token.split('.');
175    let major = parts.next()?.parse().ok()?;
176    let minor = parts.next()?.parse().ok()?;
177    Some((major, minor))
178}
179
180/// `-Zbuild-dir-new-layout` or `-Z build-dir-new-layout` in an argv.
181pub fn has_new_layout_flag(args: &[String]) -> bool {
182    let mut iter = args.iter();
183    while let Some(arg) = iter.next() {
184        if arg == "--" {
185            break;
186        }
187        if arg == "-Z" {
188            if let Some(next) = iter.next() {
189                if next == "build-dir-new-layout" || next.starts_with("build-dir-new-layout=") {
190                    return true;
191                }
192            }
193        } else if let Some(rest) = arg.strip_prefix("-Z") {
194            if rest == "build-dir-new-layout" || rest.starts_with("build-dir-new-layout=") {
195                return true;
196            }
197        }
198    }
199    false
200}
201
202/// One parsed unit directory, in either build-dir layout.
203#[derive(Debug, Clone)]
204pub struct Unit {
205    /// `serde-102a33f055a8d229`
206    pub dir_name: String,
207    /// `serde` (dir name minus the 16-hex suffix)
208    pub package: String,
209    /// `102a33f055a8d229` — == the deps/ filename suffix
210    pub dir_suffix: String,
211    /// `debug` / `release` …
212    pub profile: String,
213    pub kind: UnitKind,
214    /// Which build-dir layout this unit came from.
215    pub layout: UnitLayout,
216    /// Directory holding the memo/json/dep files:
217    /// classic `.fingerprint/<pkg>-<hash>`, new `build/<pkg>/<hash>/fingerprint`.
218    pub fingerprint_dir: PathBuf,
219    /// New-layout unit root (`<profile>/build/<pkg>/<hash>`); equal to
220    /// `fingerprint_dir` in the classic layout.
221    pub unit_dir: PathBuf,
222    /// Name of the memo file (`lib-serde`, `build-script-build`, …)
223    pub memo_file: String,
224    /// Content of the memo file — the authoritative hash
225    pub memo_hash: Option<String>,
226    pub json: Option<FingerprintJson>,
227    pub dep_info: Option<PathBuf>,
228    /// Files inside the unit dir (for forensics completeness).
229    pub extra_files: Vec<String>,
230}
231
232impl Unit {
233    /// View-relative directory that holds this unit's fingerprint state.
234    pub fn fingerprint_rel(&self) -> String {
235        match self.layout {
236            UnitLayout::Classic => format!(".fingerprint/{}", self.dir_name),
237            UnitLayout::New => {
238                format!("build/{}/{}/fingerprint", self.package, self.dir_suffix)
239            }
240        }
241    }
242}
243
244/// Typed view of the serialized Fingerprint schema.
245#[derive(Debug, Clone, Default)]
246pub struct FingerprintJson {
247    /// Toolchain identity — the #1 cache-key invalidator.
248    pub rustc: String,
249    pub features: Vec<String>,
250    pub declared_features: Vec<String>,
251    /// `[dep-id, dep-name, flag, unit-hash]` triples, simplified.
252    pub deps: Vec<DepRef>,
253    /// Opaque hashes (reported as-is).
254    pub target: String,
255    pub profile: String,
256    pub path: String,
257    pub config: String,
258    pub compile_kind: String,
259    pub rustflags: Vec<String>,
260    /// `local[].CheckDepInfo.dep_info` paths (relative to the build dir).
261    pub dep_info_paths: Vec<String>,
262}
263
264#[derive(Debug, Clone)]
265pub struct DepRef {
266    pub name: String,
267    pub unit_hash: String,
268}
269
270impl DepRef {
271    /// The dependency's **memo hash** — the join key between a dependency
272    /// edge and the unit that owns it.
273    ///
274    /// Cargo stores a dependency's fingerprint hash as a u64 in the
275    /// fingerprint JSON, and writes the same value into the dependency's
276    /// memo file as hex of its **little-endian** bytes (cargo's `to_hex`).
277    /// Example: serde's `serde_core` entry `9416646317397223793`
278    /// resolves to `serde_core-…/lib-serde_core` = `71a9f323efa5ae82`
279    /// (the same number, in opposite byte order).
280    pub fn memo_hash(&self) -> Option<String> {
281        let value: u64 = self.unit_hash.parse().ok()?;
282        let mut out = String::with_capacity(16);
283        for byte in value.to_le_bytes() {
284            out.push_str(&format!("{byte:02x}"));
285        }
286        Some(out)
287    }
288}
289
290/// Parse `<package>-<16hex>` into (package, hex).
291fn split_dir_name(dir_name: &str) -> Option<(String, String)> {
292    let (pkg, hex) = dir_name.rsplit_once('-')?;
293    Some((pkg.to_string(), hex.to_string()))
294}
295
296/// The target kind a memo file name implies (`lib-serde`, `build-script-build`, …).
297pub fn classify_kind(memo_file: &str) -> UnitKind {
298    if memo_file.starts_with("lib") {
299        UnitKind::Lib
300    } else if memo_file.starts_with("run") {
301        UnitKind::BuildScriptRun
302    } else if memo_file.contains("build-script") || memo_file.starts_with("build") {
303        UnitKind::BuildScriptCompile
304    } else {
305        UnitKind::Other
306    }
307}
308
309/// `features`/`declared_features` are strings holding a JSON array — unwrap.
310fn parse_string_array(value: &Value) -> Vec<String> {
311    match value {
312        Value::Array(items) => items
313            .iter()
314            .filter_map(|v| v.as_str().map(str::to_string))
315            .collect(),
316        Value::String(text) => serde_json::from_str::<Vec<String>>(text)
317            .unwrap_or_else(|_| text.split_whitespace().map(str::to_string).collect()),
318        _ => Vec::new(),
319    }
320}
321
322fn parse_u64_field(json: &Value, key: &str) -> String {
323    match json.get(key) {
324        Some(Value::Number(n)) => n.to_string(),
325        Some(Value::String(s)) => s.clone(),
326        Some(other) if !other.is_null() => other.to_string(),
327        _ => String::new(),
328    }
329}
330
331fn parse_fingerprint_json(json: &Value) -> FingerprintJson {
332    let deps = json
333        .get("deps")
334        .and_then(Value::as_array)
335        .map(|items| {
336            items
337                .iter()
338                .filter_map(|item| {
339                    let arr = item.as_array()?;
340                    let unit_hash = match arr.get(3)? {
341                        Value::Number(number) => number.to_string(),
342                        Value::String(text) => text.clone(),
343                        _ => return None,
344                    };
345                    Some(DepRef {
346                        name: arr.get(1)?.as_str()?.to_string(),
347                        unit_hash,
348                    })
349                })
350                .collect()
351        })
352        .unwrap_or_default();
353
354    let dep_info_paths = json
355        .get("local")
356        .and_then(Value::as_array)
357        .map(|items| {
358            items
359                .iter()
360                .filter_map(|item| {
361                    item.get("CheckDepInfo")?
362                        .get("dep_info")?
363                        .as_str()
364                        .map(str::to_string)
365                })
366                .collect()
367        })
368        .unwrap_or_default();
369
370    FingerprintJson {
371        rustc: parse_u64_field(json, "rustc"),
372        features: json
373            .get("features")
374            .map(parse_string_array)
375            .unwrap_or_default(),
376        declared_features: json
377            .get("declared_features")
378            .map(parse_string_array)
379            .unwrap_or_default(),
380        deps,
381        target: parse_u64_field(json, "target"),
382        profile: parse_u64_field(json, "profile"),
383        path: parse_u64_field(json, "path"),
384        config: parse_u64_field(json, "config"),
385        compile_kind: parse_u64_field(json, "compile_kind"),
386        rustflags: json
387            .get("rustflags")
388            .map(parse_string_array)
389            .unwrap_or_default(),
390        dep_info_paths,
391    }
392}
393
394/// Parse a serialized `<memo>.json` fingerprint — from a build dir or from
395/// the vault's stored copy; both hold the same cargo-written bytes.
396pub fn parse_fingerprint(text: &str) -> Option<FingerprintJson> {
397    serde_json::from_str::<Value>(text)
398        .ok()
399        .map(|value| parse_fingerprint_json(&value))
400}
401
402/// Read every unit directory in both build-dir layouts:
403/// `<profile>/.fingerprint/<pkg>-<hash>/` (classic) and
404/// `<profile>/build/<pkg>/<hash>/fingerprint/` (new). Both may coexist in
405/// one build dir, so this is shape-based rather than a single global mode.
406pub fn read_units(build_root: &Path) -> Result<Vec<Unit>> {
407    let mut units = Vec::new();
408    for profile_entry in fs::read_dir(build_root)
409        .with_context(|| format!("reading build dir {}", build_root.display()))?
410    {
411        let profile_entry = profile_entry?;
412        if !profile_entry.file_type()?.is_dir() {
413            continue;
414        }
415        let profile = profile_entry.file_name().to_string_lossy().into_owned();
416        let profile_dir = profile_entry.path();
417
418        // Classic layout: <profile>/.fingerprint/<pkg>-<hash>/
419        let fp_root = profile_dir.join(".fingerprint");
420        if is_real_dir(&fp_root) {
421            for unit_entry in
422                fs::read_dir(&fp_root).with_context(|| format!("reading {}", fp_root.display()))?
423            {
424                let unit_entry = unit_entry?;
425                if !unit_entry.file_type()?.is_dir() {
426                    continue;
427                }
428                if let Some(unit) = read_classic_unit(&profile, &unit_entry.path()) {
429                    units.push(unit);
430                }
431            }
432        }
433
434        // New layout: <profile>/build/<pkg>/<hash>/fingerprint/
435        let build_dir = profile_dir.join("build");
436        if is_real_dir(&build_dir) {
437            for package_entry in fs::read_dir(&build_dir)
438                .with_context(|| format!("reading {}", build_dir.display()))?
439            {
440                let package_entry = package_entry?;
441                if !package_entry.file_type()?.is_dir() {
442                    continue;
443                }
444                let package = package_entry.file_name().to_string_lossy().into_owned();
445                for hash_entry in fs::read_dir(package_entry.path())
446                    .with_context(|| format!("reading {}", package_entry.path().display()))?
447                {
448                    let hash_entry = hash_entry?;
449                    if !hash_entry.file_type()?.is_dir() {
450                        continue;
451                    }
452                    let hash = hash_entry.file_name().to_string_lossy().into_owned();
453                    if !looks_like_hash(&hash) {
454                        continue;
455                    }
456                    if !is_real_dir(&hash_entry.path().join("fingerprint")) {
457                        continue;
458                    }
459                    if let Some(unit) = read_new_unit(build_root, &profile, &package, &hash) {
460                        units.push(unit);
461                    }
462                }
463            }
464        }
465    }
466    Ok(units)
467}
468
469/// Memo hash → index of the unit that owns it. First wins on duplicates.
470pub fn memo_index(units: &[Unit]) -> HashMap<String, usize> {
471    let mut index = HashMap::new();
472    for (position, unit) in units.iter().enumerate() {
473        if let Some(hash) = unit.memo_hash.as_deref() {
474            index.entry(hash.to_string()).or_insert(position);
475        }
476    }
477    index
478}
479
480/// Resolve one dependency edge against a memo index.
481pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
482    index.get(dep.memo_hash()?.as_str()).copied()
483}
484
485/// Every unit's dependency edges, resolved to indices in `units`.
486///
487/// Unresolved edges — a dependency absent from this view, or one recorded
488/// against a secondary memo file of a shared dir — are dropped: a partial
489/// graph is useful, a wrong edge would be worse than a missing one.
490pub fn dep_edges(units: &[Unit]) -> Vec<Vec<usize>> {
491    let index = memo_index(units);
492    units
493        .iter()
494        .map(|unit| {
495            unit.json
496                .as_ref()
497                .map(|json| {
498                    json.deps
499                        .iter()
500                        .filter_map(|dep| resolve_dep(&index, dep))
501                        .collect()
502                })
503                .unwrap_or_default()
504        })
505        .collect()
506}
507
508/// A directory we may read: real, not a symlink or a Windows reparse point.
509fn is_real_dir(path: &Path) -> bool {
510    fs::symlink_metadata(path)
511        .map(|meta| {
512            meta.is_dir() && !meta.file_type().is_symlink() && !crate::util::is_reparse_point(&meta)
513        })
514        .unwrap_or(false)
515}
516
517fn read_classic_unit(profile: &str, unit_dir: &Path) -> Option<Unit> {
518    let dir_name = unit_dir.file_name()?.to_string_lossy().into_owned();
519    let (package, dir_suffix) = split_dir_name(&dir_name)?;
520    let parsed = parse_unit_files(unit_dir)?;
521    Some(Unit {
522        dir_name,
523        package,
524        dir_suffix,
525        profile: profile.to_string(),
526        kind: parsed.kind,
527        layout: UnitLayout::Classic,
528        fingerprint_dir: unit_dir.to_path_buf(),
529        unit_dir: unit_dir.to_path_buf(),
530        memo_file: parsed.memo_file,
531        memo_hash: Some(parsed.memo_hash),
532        json: parsed.json,
533        dep_info: parsed.dep_info,
534        extra_files: parsed.extra_files,
535    })
536}
537
538fn read_new_unit(build_root: &Path, profile: &str, package: &str, hash: &str) -> Option<Unit> {
539    let layout = NewUnitLayout::new(profile, package, hash);
540    let fingerprint_dir = layout.fingerprint_dir(build_root);
541    let parsed = parse_unit_files(&fingerprint_dir)?;
542    Some(Unit {
543        dir_name: format!("{package}-{hash}"),
544        package: package.to_string(),
545        dir_suffix: hash.to_string(),
546        profile: profile.to_string(),
547        kind: parsed.kind,
548        layout: UnitLayout::New,
549        unit_dir: layout.unit_dir(build_root),
550        fingerprint_dir,
551        memo_file: parsed.memo_file,
552        memo_hash: Some(parsed.memo_hash),
553        json: parsed.json,
554        dep_info: parsed.dep_info,
555        extra_files: parsed.extra_files,
556    })
557}
558
559struct ParsedUnit {
560    kind: UnitKind,
561    memo_file: String,
562    memo_hash: String,
563    json: Option<FingerprintJson>,
564    dep_info: Option<PathBuf>,
565    extra_files: Vec<String>,
566}
567
568/// Parse the contents of a fingerprint directory — identical in both layouts.
569fn parse_unit_files(unit_dir: &Path) -> Option<ParsedUnit> {
570    let mut candidates: Vec<(String, String)> = Vec::new(); // (name, content)
571    let mut dep_info: Option<PathBuf> = None;
572    let mut json_path: Option<PathBuf> = None;
573
574    for entry in fs::read_dir(unit_dir).ok()? {
575        let entry = entry.ok()?;
576        // Only regular files: never read a FIFO/device/symlink planted by a
577        // build script (harvest would otherwise hang or copy a secret).
578        let entry_meta = fs::symlink_metadata(entry.path()).ok()?;
579        if !entry_meta.is_file()
580            || entry_meta.file_type().is_symlink()
581            || crate::util::is_reparse_point(&entry_meta)
582        {
583            continue;
584        }
585        let name = entry.file_name().to_string_lossy().into_owned();
586        if name.starts_with("dep-") {
587            dep_info = Some(entry.path());
588            continue;
589        }
590        if name.ends_with(".json") {
591            json_path = Some(entry.path());
592            continue;
593        }
594        if name == "invoked.timestamp" {
595            continue;
596        }
597        if entry
598            .metadata()
599            .map(|meta| meta.len() > 4 * 1024 * 1024)
600            .unwrap_or(true)
601        {
602            continue;
603        }
604        let content = crate::util::read_small_text(&entry.path(), 4 * 1024 * 1024)
605            .ok()?
606            .trim()
607            .to_string();
608        candidates.push((name, content));
609    }
610
611    let ((memo_file, memo_hash), extra_files) = pick_memo(candidates)?;
612    let kind = classify_kind(&memo_file);
613    let json = json_path
614        .and_then(|path| crate::util::read_small_text(&path, 4 * 1024 * 1024).ok())
615        .and_then(|text| parse_fingerprint(&text));
616    Some(ParsedUnit {
617        kind,
618        memo_file,
619        memo_hash,
620        json,
621        dep_info,
622        extra_files,
623    })
624}
625
626/// Choose the memo file among a unit dir's remaining files: the one whose
627/// content is a 16-hex hash. Cargo also keeps cached diagnostics in the same
628/// directory (`output-*`) — those are state, not fingerprints. When nothing
629/// looks like a hash the first candidate is used anyway, so a corrupted unit
630/// still shows up in forensics instead of disappearing.
631fn pick_memo(mut candidates: Vec<(String, String)>) -> Option<((String, String), Vec<String>)> {
632    if candidates.is_empty() {
633        return None;
634    }
635    let index = candidates
636        .iter()
637        .position(|(_, content)| looks_like_hash(content))
638        .unwrap_or(0);
639    let (name, hash) = candidates.remove(index);
640    let extras = candidates.into_iter().map(|(name, _)| name).collect();
641    Some(((name, hash), extras))
642}
643
644/// A fingerprint memo file holds exactly 16 hex characters.
645pub(crate) fn looks_like_hash(text: &str) -> bool {
646    text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
647}
648
649/// Directories that back a unit's artifacts (shared by the reader and by
650/// `evict`): classic `build/<package>-<hex>/` anchors (dash/underscore and
651/// bare-hash candidates), or the new per-unit root `build/<pkg>/<hash>/`.
652pub(crate) fn build_anchor_dirs(build_root: &Path, unit: &Unit) -> Vec<PathBuf> {
653    if unit.layout == UnitLayout::New {
654        return vec![unit.unit_dir.clone()];
655    }
656    let build_dir = build_root.join(&unit.profile).join("build");
657    let pkg_plain = &unit.package;
658    let pkg_underscored = unit.package.replace('-', "_");
659    let mut candidates = Vec::new();
660    candidates.push(build_dir.join(format!("{pkg_underscored}-{}", unit.dir_suffix)));
661    if pkg_plain != &pkg_underscored {
662        candidates.push(build_dir.join(format!("{pkg_plain}-{}", unit.dir_suffix)));
663    }
664    candidates.push(build_dir.join(&unit.dir_suffix));
665    candidates
666}
667
668/// Artifacts that belong to a unit, found by suffix (fingerprint dir hash
669/// == deps filename suffix) — `deps/*-{hex}.rlib|rmeta|so…` and, for build
670/// script units, the whole `build/{hex}/` subtree. Registry and workspace
671/// units look identical here.
672pub struct ArtifactInfo {
673    pub paths: Vec<(PathBuf, u64, Option<SystemTime>)>,
674    pub bytes: u64,
675    pub newest: Option<SystemTime>,
676}
677
678pub fn artifacts_for(build_root: &Path, unit: &Unit) -> ArtifactInfo {
679    let mut paths = Vec::new();
680    match unit.layout {
681        UnitLayout::Classic => collect_classic_artifacts(build_root, unit, &mut paths),
682        UnitLayout::New => collect_new_artifacts(unit, &mut paths),
683    }
684
685    let mut bytes = 0;
686    let mut newest: Option<SystemTime> = None;
687    for (_, size, mtime) in &paths {
688        bytes += size;
689        if let Some(m) = mtime {
690            if newest.map_or(true, |n| *m > n) {
691                newest = Some(*m);
692            }
693        }
694    }
695    ArtifactInfo {
696        paths,
697        bytes,
698        newest,
699    }
700}
701
702fn collect_classic_artifacts(
703    build_root: &Path,
704    unit: &Unit,
705    paths: &mut Vec<(PathBuf, u64, Option<SystemTime>)>,
706) {
707    let profile_dir = build_root.join(&unit.profile);
708    let deps_dir = profile_dir.join("deps");
709    if is_real_dir(&deps_dir) {
710        let suffix = format!("-{}", unit.dir_suffix);
711        if let Ok(entries) = fs::read_dir(&deps_dir) {
712            for entry in entries.flatten() {
713                // Regular files only; a symlink/FIFO here must not be copied.
714                if !entry
715                    .file_type()
716                    .map(|kind| kind.is_file())
717                    .unwrap_or(false)
718                {
719                    continue;
720                }
721                let name = entry.file_name().to_string_lossy().into_owned();
722                // Compare on the STEM so extensions don't hide the match:
723                // `libserde-102a….rlib` → stem `libserde-102a…` ends with `-102a…`.
724                let stem = Path::new(&name)
725                    .file_stem()
726                    .map(|s| s.to_string_lossy().into_owned())
727                    .unwrap_or(name.clone());
728                if !stem.ends_with(&suffix) {
729                    continue;
730                }
731                if let Ok(meta) = entry.metadata() {
732                    paths.push((entry.path(), meta.len(), meta.modified().ok()));
733                }
734            }
735        }
736    }
737
738    if unit.kind != UnitKind::Lib {
739        for candidate in build_anchor_dirs(build_root, unit) {
740            if !is_real_dir(&candidate) {
741                continue;
742            }
743            for entry in WalkDir::new(&candidate).min_depth(1) {
744                let Ok(entry) = entry else { continue };
745                if !entry.file_type().is_file() {
746                    continue;
747                }
748                if let Ok(meta) = entry.metadata() {
749                    paths.push((entry.path().to_path_buf(), meta.len(), meta.modified().ok()));
750                }
751            }
752        }
753    }
754}
755
756/// New layout: artifacts live in the unit's `out/` (rustc link outputs,
757/// build-script binaries), `run/` (run-build-script state) and at the unit
758/// root (`output`, `stderr`, `stdout`, `root-output`, …). `fingerprint/` is
759/// handled separately by the vault.
760fn collect_new_artifacts(unit: &Unit, paths: &mut Vec<(PathBuf, u64, Option<SystemTime>)>) {
761    for sub in ["out", "run"] {
762        let dir = unit.unit_dir.join(sub);
763        if !is_real_dir(&dir) {
764            continue;
765        }
766        for entry in WalkDir::new(&dir).min_depth(1) {
767            let Ok(entry) = entry else { continue };
768            if !entry.file_type().is_file() {
769                continue;
770            }
771            if let Ok(meta) = entry.metadata() {
772                paths.push((entry.path().to_path_buf(), meta.len(), meta.modified().ok()));
773            }
774        }
775    }
776    let Ok(entries) = fs::read_dir(&unit.unit_dir) else {
777        return;
778    };
779    for entry in entries.flatten() {
780        if !entry
781            .file_type()
782            .map(|kind| kind.is_file())
783            .unwrap_or(false)
784        {
785            continue;
786        }
787        if let Ok(meta) = entry.metadata() {
788            paths.push((entry.path(), meta.len(), meta.modified().ok()));
789        }
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    /// Verbatim sample from the live shared cache (1.97.1 generation).
798    const SAMPLE_JSON: &str = r#"{"rustc":12019306335353385202,"features":"[\"default\", \"derive\", \"serde_derive\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"derive\", \"rc\", \"serde_derive\", \"std\", \"unstable\"]","target":11327258112168116673,"profile":15657897354478470176,"path":14081935628356765094,"deps":[[6557439603276904804,"build_script_build",false,14091436140065671243],[11029742160753049355,"serde_core",false,8824494307375987869],[13312204359551525516,"serde_derive",false,16721730783038555939]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\serde-102a33f055a8d229\\dep-lib-serde","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}"#;
799
800    #[test]
801    fn reads_and_parses_synthetic_unit() -> Result<()> {
802        let root = tempfile::tempdir()?;
803        let unit_dir = root
804            .path()
805            .join("debug/.fingerprint/serde-102a33f055a8d229");
806        fs::create_dir_all(&unit_dir)?;
807        // memo file: 16 hex chars, no trailing newline (verbatim format)
808        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
809        fs::write(unit_dir.join("lib-serde.json"), SAMPLE_JSON)?;
810        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 69])?;
811        fs::write(unit_dir.join("invoked.timestamp"), "")?;
812        fs::create_dir_all(root.path().join("debug/deps"))?;
813        fs::write(
814            root.path()
815                .join("debug/deps/libserde-102a33f055a8d229.rlib"),
816            [0u8; 10],
817        )?;
818
819        let units = read_units(root.path())?;
820        assert_eq!(units.len(), 1);
821
822        let unit = &units[0];
823        assert_eq!(unit.package, "serde");
824        assert_eq!(unit.dir_suffix, "102a33f055a8d229");
825        assert_eq!(unit.profile, "debug");
826        assert_eq!(unit.kind, UnitKind::Lib);
827        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
828        assert!(unit.dep_info.is_some());
829
830        let json = unit.json.as_ref().expect("sample json must parse");
831        assert_eq!(json.rustc, "12019306335353385202");
832        assert!(json.features.contains(&"derive".to_string()));
833        assert!(json.declared_features.contains(&"unstable".to_string()));
834        assert_eq!(json.deps.len(), 3);
835        assert_eq!(json.deps[1].name, "serde_core");
836        assert_eq!(json.dep_info_paths.len(), 1);
837
838        // Suffix-matching artifact attach.
839        let arts = artifacts_for(root.path(), unit);
840        assert_eq!(arts.paths.len(), 1);
841        assert_eq!(arts.bytes, 10);
842        assert!(arts.newest.is_some());
843        Ok(())
844    }
845
846    #[test]
847    fn reads_new_layout_units() -> Result<()> {
848        let root = tempfile::tempdir()?;
849        let unit_dir = root.path().join("debug/build/anyhow/9709d57b46b8d526");
850        fs::create_dir_all(unit_dir.join("fingerprint"))?;
851        fs::create_dir_all(unit_dir.join("out"))?;
852        fs::write(unit_dir.join("fingerprint/lib-anyhow"), "5d82713a7f2d05e2")?;
853        fs::write(unit_dir.join("fingerprint/lib-anyhow.json"), SAMPLE_JSON)?;
854        fs::write(unit_dir.join("fingerprint/dep-lib-anyhow"), [0u8; 69])?;
855        fs::write(unit_dir.join("fingerprint/invoked.timestamp"), "")?;
856        fs::write(
857            unit_dir.join("out/libanyhow-9709d57b46b8d526.rlib"),
858            [0u8; 10],
859        )?;
860        fs::write(
861            unit_dir.join("out/libanyhow-9709d57b46b8d526.rmeta"),
862            [0u8; 5],
863        )?;
864        fs::write(unit_dir.join("out/anyhow-9709d57b46b8d526.d"), [0u8; 3])?;
865
866        let units = read_units(root.path())?;
867        assert_eq!(units.len(), 1);
868        let unit = &units[0];
869        assert_eq!(unit.layout, UnitLayout::New);
870        assert_eq!(unit.package, "anyhow");
871        assert_eq!(unit.dir_suffix, "9709d57b46b8d526");
872        assert_eq!(unit.dir_name, "anyhow-9709d57b46b8d526");
873        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
874        assert_eq!(unit.fingerprint_dir, unit_dir.join("fingerprint"));
875        assert_eq!(unit.unit_dir, unit_dir);
876        assert_eq!(
877            unit.fingerprint_rel(),
878            "build/anyhow/9709d57b46b8d526/fingerprint"
879        );
880
881        let arts = artifacts_for(root.path(), unit);
882        assert_eq!(arts.paths.len(), 3, "rlib + rmeta + d");
883        assert_eq!(arts.bytes, 18);
884        Ok(())
885    }
886
887    #[test]
888    fn reads_mixed_layouts_in_one_build_dir() -> Result<()> {
889        let root = tempfile::tempdir()?;
890        let classic = root
891            .path()
892            .join("debug/.fingerprint/serde-102a33f055a8d229");
893        fs::create_dir_all(&classic)?;
894        fs::write(classic.join("lib-serde"), "5d82713a7f2d05e2")?;
895        fs::create_dir_all(root.path().join("debug/deps"))?;
896        fs::write(
897            root.path()
898                .join("debug/deps/libserde-102a33f055a8d229.rlib"),
899            [0u8; 4],
900        )?;
901
902        let new = root.path().join("release/build/anyhow/9709d57b46b8d526");
903        fs::create_dir_all(new.join("fingerprint"))?;
904        fs::create_dir_all(new.join("out"))?;
905        fs::write(new.join("fingerprint/lib-anyhow"), "aaaaaaaaaaaaaaaa")?;
906        fs::write(new.join("out/libanyhow-9709d57b46b8d526.rlib"), [0u8; 4])?;
907
908        let units = read_units(root.path())?;
909        assert_eq!(units.len(), 2);
910        assert!(
911            units
912                .iter()
913                .any(|unit| unit.layout == UnitLayout::Classic && unit.profile == "debug")
914        );
915        assert!(
916            units
917                .iter()
918                .any(|unit| unit.layout == UnitLayout::New && unit.profile == "release")
919        );
920        Ok(())
921    }
922
923    #[test]
924    fn layout_decision_follows_cargo_version_and_flags() {
925        let none: Vec<String> = Vec::new();
926        let new_flag = vec!["-Zbuild-dir-new-layout".to_string()];
927        let split_flag = vec!["-Z".to_string(), "build-dir-new-layout".to_string()];
928
929        assert_eq!(
930            layout_for_cargo("cargo 1.91.0 (000000000 2026-01-01)", &none),
931            UnitLayout::Classic
932        );
933        assert_eq!(
934            layout_for_cargo("cargo 1.99.0 (000000000 2026-01-01)", &none),
935            UnitLayout::Classic
936        );
937        assert_eq!(
938            layout_for_cargo("cargo 1.99.0-nightly (7c83d4cc0 2026-07-29)", &none),
939            UnitLayout::New
940        );
941        assert_eq!(
942            layout_for_cargo("cargo 1.98.0-nightly (000000000 2026-01-01)", &none),
943            UnitLayout::Classic
944        );
945        assert_eq!(
946            layout_for_cargo("cargo 1.98.0-nightly (000000000 2026-01-01)", &new_flag),
947            UnitLayout::New
948        );
949        assert_eq!(
950            layout_for_cargo("cargo 1.98.0-nightly (000000000 2026-01-01)", &split_flag),
951            UnitLayout::New
952        );
953        assert_eq!(
954            layout_for_cargo("cargo 1.100.0 (000000000 2026-01-01)", &none),
955            UnitLayout::New
956        );
957        assert_eq!(layout_for_cargo("garbage", &none), UnitLayout::Classic);
958    }
959
960    #[test]
961    fn run_units_attach_their_build_tree() -> Result<()> {
962        let root = tempfile::tempdir()?;
963        let unit_dir = root.path().join("debug/.fingerprint/x-1122334455667788");
964        fs::create_dir_all(&unit_dir)?;
965        fs::write(unit_dir.join("build-script-build"), "aaaabbbbccccdddd")?;
966        fs::create_dir_all(root.path().join("debug/build/1122334455667788"))?;
967        fs::write(
968            root.path().join("debug/build/1122334455667788/output"),
969            [0u8; 4],
970        )?;
971        fs::write(
972            root.path().join("debug/build/1122334455667788/stderr"),
973            [0u8; 6],
974        )?;
975
976        let units = read_units(root.path())?;
977        assert_eq!(units.len(), 1);
978        let unit = &units[0];
979        assert_eq!(unit.kind, UnitKind::BuildScriptCompile);
980        let arts = artifacts_for(root.path(), unit);
981        assert_eq!(arts.paths.len(), 2, "output + stderr attached");
982        assert_eq!(arts.bytes, 10);
983        assert!(arts.newest.is_some());
984        Ok(())
985    }
986
987    #[test]
988    fn cached_diagnostics_are_not_mistaken_for_the_memo() -> Result<()> {
989        let root = tempfile::tempdir()?;
990        let unit_dir = root
991            .path()
992            .join("debug/.fingerprint/serde-102a33f055a8d229");
993        fs::create_dir_all(&unit_dir)?;
994        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
995        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 8])?;
996        // Cargo's message cache: rendered diagnostics live in the same dir.
997        fs::write(
998            unit_dir.join("output-lib-serde"),
999            r#"{"$message_type":"diagnostic","message":"unused import"}"#,
1000        )?;
1001
1002        let units = read_units(root.path())?;
1003        assert_eq!(units.len(), 1);
1004        let unit = &units[0];
1005        assert_eq!(unit.memo_file, "lib-serde", "the hash file wins");
1006        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
1007        assert_eq!(unit.extra_files, vec!["output-lib-serde".to_string()]);
1008        Ok(())
1009    }
1010
1011    #[test]
1012    fn dep_hashes_resolve_to_memo_hashes_little_endian() {
1013        // Example: serde's fingerprint lists serde_core as 9416646317397223793,
1014        // and serde_core-b468be6fa8c1f936/lib-serde_core contains 71a9f323efa5ae82.
1015        let dep = DepRef {
1016            name: "serde_core".into(),
1017            unit_hash: "9416646317397223793".into(),
1018        };
1019        assert_eq!(dep.memo_hash().as_deref(), Some("71a9f323efa5ae82"));
1020
1021        let junk = DepRef {
1022            name: "x".into(),
1023            unit_hash: "not-a-number".into(),
1024        };
1025        assert!(junk.memo_hash().is_none());
1026    }
1027
1028    #[test]
1029    fn dep_edges_join_the_graph_by_memo_hash() -> Result<()> {
1030        let root = tempfile::tempdir()?;
1031        let core = root
1032            .path()
1033            .join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
1034        fs::create_dir_all(&core)?;
1035        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
1036        fs::create_dir_all(root.path().join("debug/deps"))?;
1037        fs::write(
1038            root.path()
1039                .join("debug/deps/libserde_core-b468be6fa8c1f936.rlib"),
1040            [0u8; 4],
1041        )?;
1042
1043        let serde = root
1044            .path()
1045            .join("debug/.fingerprint/serde-746d173581f1a314");
1046        fs::create_dir_all(&serde)?;
1047        fs::write(serde.join("lib-serde"), "5d82713a7f2d05e2")?;
1048        fs::write(
1049            serde.join("lib-serde.json"),
1050            r#"{"deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
1051        )?;
1052        fs::write(
1053            root.path()
1054                .join("debug/deps/libserde-746d173581f1a314.rlib"),
1055            [0u8; 4],
1056        )?;
1057
1058        let units = read_units(root.path())?;
1059        assert_eq!(units.len(), 2);
1060        let edges = dep_edges(&units);
1061        let serde_at = units.iter().position(|u| u.package == "serde").unwrap();
1062        let core_at = units
1063            .iter()
1064            .position(|u| u.package == "serde_core")
1065            .unwrap();
1066        assert_eq!(edges[serde_at], vec![core_at]);
1067        assert!(edges[core_at].is_empty());
1068        Ok(())
1069    }
1070
1071    #[test]
1072    fn pick_memo_prefers_hash_content_and_falls_back() {
1073        let candidates = vec![
1074            (
1075                "output-lib-x".to_string(),
1076                r#"{"message":"warning"}"#.to_string(),
1077            ),
1078            ("lib-x".to_string(), "5d82713a7f2d05e2".to_string()),
1079        ];
1080        let ((name, hash), extras) = pick_memo(candidates).expect("a memo exists");
1081        assert_eq!(name, "lib-x");
1082        assert_eq!(hash, "5d82713a7f2d05e2");
1083        assert_eq!(extras, vec!["output-lib-x".to_string()]);
1084
1085        // Nothing hash-shaped: the first candidate is the fallback, so a
1086        // corrupted unit still shows up in forensics instead of vanishing.
1087        let candidates = vec![("weird".to_string(), "not a hash".to_string())];
1088        let ((name, _), extras) = pick_memo(candidates).expect("fallback");
1089        assert_eq!(name, "weird");
1090        assert!(extras.is_empty());
1091
1092        assert!(pick_memo(Vec::new()).is_none());
1093    }
1094}