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//! JSON schema — mostly opaque HASH NUMBERS plus two human facts:
18//! `{rustc, features, declared_features, target, profile, path, deps,
19//!  local, rustflags, config, compile_kind}` where `features` /
20//! `declared_features` are STRINGS containing serialized JSON arrays.
21
22use std::collections::HashMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::time::SystemTime;
26
27use anyhow::{Context, Result};
28use serde_json::Value;
29use walkdir::WalkDir;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum UnitKind {
33    Lib,
34    BuildScriptCompile,
35    BuildScriptRun,
36    Other,
37}
38
39impl UnitKind {
40    pub fn label(self) -> &'static str {
41        match self {
42            UnitKind::Lib => "lib",
43            UnitKind::BuildScriptCompile => "build.rs",
44            UnitKind::BuildScriptRun => "build-run",
45            UnitKind::Other => "other",
46        }
47    }
48}
49
50/// One parsed `.fingerprint/<package>-<16hex>/` directory.
51#[derive(Debug, Clone)]
52pub struct Unit {
53    /// `serde-102a33f055a8d229`
54    pub dir_name: String,
55    /// `serde` (dir name minus the 16-hex suffix)
56    pub package: String,
57    /// `102a33f055a8d229` — == the deps/ filename suffix
58    pub dir_suffix: String,
59    /// `debug` / `release` …
60    pub profile: String,
61    pub kind: UnitKind,
62    /// Name of the memo file (`lib-serde`, `build-script-build`, …)
63    pub memo_file: String,
64    /// Content of the memo file — the authoritative hash
65    pub memo_hash: Option<String>,
66    pub json: Option<FingerprintJson>,
67    pub dep_info: Option<PathBuf>,
68    /// Files inside the unit dir (for forensics completeness).
69    pub extra_files: Vec<String>,
70}
71
72/// Typed view of the serialized Fingerprint schema.
73#[derive(Debug, Clone, Default)]
74pub struct FingerprintJson {
75    /// Toolchain identity — the #1 cache-key invalidator.
76    pub rustc: String,
77    pub features: Vec<String>,
78    pub declared_features: Vec<String>,
79    /// `[dep-id, dep-name, flag, unit-hash]` triples, simplified.
80    pub deps: Vec<DepRef>,
81    /// Opaque hashes (reported as-is).
82    pub target: String,
83    pub profile: String,
84    pub path: String,
85    pub config: String,
86    pub compile_kind: String,
87    pub rustflags: Vec<String>,
88    /// `local[].CheckDepInfo.dep_info` paths (relative to the build dir).
89    pub dep_info_paths: Vec<String>,
90}
91
92#[derive(Debug, Clone)]
93pub struct DepRef {
94    pub name: String,
95    pub unit_hash: String,
96}
97
98impl DepRef {
99    /// The dependency's **memo hash** — the join key between a dependency
100    /// edge and the unit that owns it.
101    ///
102    /// Cargo stores a dependency's fingerprint hash as a u64 in the
103    /// fingerprint JSON, and writes the same value into the dependency's
104    /// memo file as hex of its **little-endian** bytes (cargo's `to_hex`).
105    /// Example: serde's `serde_core` entry `9416646317397223793`
106    /// resolves to `serde_core-…/lib-serde_core` = `71a9f323efa5ae82`
107    /// (the same number, in opposite byte order).
108    pub fn memo_hash(&self) -> Option<String> {
109        let value: u64 = self.unit_hash.parse().ok()?;
110        let mut out = String::with_capacity(16);
111        for byte in value.to_le_bytes() {
112            out.push_str(&format!("{byte:02x}"));
113        }
114        Some(out)
115    }
116}
117
118/// Parse `<package>-<16hex>` into (package, hex).
119fn split_dir_name(dir_name: &str) -> Option<(String, String)> {
120    let (pkg, hex) = dir_name.rsplit_once('-')?;
121    Some((pkg.to_string(), hex.to_string()))
122}
123
124/// The target kind a memo file name implies (`lib-serde`, `build-script-build`, …).
125pub fn classify_kind(memo_file: &str) -> UnitKind {
126    if memo_file.starts_with("lib") {
127        UnitKind::Lib
128    } else if memo_file.starts_with("run") {
129        UnitKind::BuildScriptRun
130    } else if memo_file.contains("build-script") || memo_file.starts_with("build") {
131        UnitKind::BuildScriptCompile
132    } else {
133        UnitKind::Other
134    }
135}
136
137/// `features`/`declared_features` are strings holding a JSON array — unwrap.
138fn parse_string_array(value: &Value) -> Vec<String> {
139    match value {
140        Value::Array(items) => items
141            .iter()
142            .filter_map(|v| v.as_str().map(str::to_string))
143            .collect(),
144        Value::String(text) => serde_json::from_str::<Vec<String>>(text)
145            .unwrap_or_else(|_| text.split_whitespace().map(str::to_string).collect()),
146        _ => Vec::new(),
147    }
148}
149
150fn parse_u64_field(json: &Value, key: &str) -> String {
151    match json.get(key) {
152        Some(Value::Number(n)) => n.to_string(),
153        Some(Value::String(s)) => s.clone(),
154        Some(other) if !other.is_null() => other.to_string(),
155        _ => String::new(),
156    }
157}
158
159fn parse_fingerprint_json(json: &Value) -> FingerprintJson {
160    let deps = json
161        .get("deps")
162        .and_then(Value::as_array)
163        .map(|items| {
164            items
165                .iter()
166                .filter_map(|item| {
167                    let arr = item.as_array()?;
168                    let unit_hash = match arr.get(3)? {
169                        Value::Number(number) => number.to_string(),
170                        Value::String(text) => text.clone(),
171                        _ => return None,
172                    };
173                    Some(DepRef {
174                        name: arr.get(1)?.as_str()?.to_string(),
175                        unit_hash,
176                    })
177                })
178                .collect()
179        })
180        .unwrap_or_default();
181
182    let dep_info_paths = json
183        .get("local")
184        .and_then(Value::as_array)
185        .map(|items| {
186            items
187                .iter()
188                .filter_map(|item| {
189                    item.get("CheckDepInfo")?
190                        .get("dep_info")?
191                        .as_str()
192                        .map(str::to_string)
193                })
194                .collect()
195        })
196        .unwrap_or_default();
197
198    FingerprintJson {
199        rustc: parse_u64_field(json, "rustc"),
200        features: json.get("features").map(parse_string_array).unwrap_or_default(),
201        declared_features: json
202            .get("declared_features")
203            .map(parse_string_array)
204            .unwrap_or_default(),
205        deps,
206        target: parse_u64_field(json, "target"),
207        profile: parse_u64_field(json, "profile"),
208        path: parse_u64_field(json, "path"),
209        config: parse_u64_field(json, "config"),
210        compile_kind: parse_u64_field(json, "compile_kind"),
211        rustflags: json
212            .get("rustflags")
213            .map(parse_string_array)
214            .unwrap_or_default(),
215        dep_info_paths,
216    }
217}
218
219/// Parse a serialized `<memo>.json` fingerprint — from a build dir or from
220/// the vault's stored copy; both hold the same cargo-written bytes.
221pub fn parse_fingerprint(text: &str) -> Option<FingerprintJson> {
222    serde_json::from_str::<Value>(text)
223        .ok()
224        .map(|value| parse_fingerprint_json(&value))
225}
226
227/// Read every unit directory under all `.fingerprint/` roots inside the build dir.
228pub fn read_units(build_root: &Path) -> Result<Vec<Unit>> {
229    let mut units = Vec::new();
230    for profile_entry in fs::read_dir(build_root).with_context(|| {
231        format!("reading build dir {}", build_root.display())
232    })? {
233        let profile_entry = profile_entry?;
234        if !profile_entry.file_type()?.is_dir() {
235            continue;
236        }
237        let profile = profile_entry.file_name().to_string_lossy().into_owned();
238        let fp_root = build_root.join(&profile).join(".fingerprint");
239        if !fp_root.is_dir() {
240            continue; // finals (CACHEDIR.TAG …), locks — no fingerprints here
241        }
242        for unit_entry in fs::read_dir(&fp_root).with_context(|| {
243            format!("reading {}", fp_root.display())
244        })? {
245            let unit_dir = unit_entry?.path();
246            if !unit_dir.is_dir() {
247                continue;
248            }
249            if let Some(unit) = read_unit_dir(&profile, &unit_dir) {
250                units.push(unit);
251            }
252        }
253    }
254    Ok(units)
255}
256
257/// Memo hash → index of the unit that owns it. First wins on duplicates.
258pub fn memo_index(units: &[Unit]) -> HashMap<String, usize> {
259    let mut index = HashMap::new();
260    for (position, unit) in units.iter().enumerate() {
261        if let Some(hash) = unit.memo_hash.as_deref() {
262            index.entry(hash.to_string()).or_insert(position);
263        }
264    }
265    index
266}
267
268/// Resolve one dependency edge against a memo index.
269pub fn resolve_dep(index: &HashMap<String, usize>, dep: &DepRef) -> Option<usize> {
270    index.get(dep.memo_hash()?.as_str()).copied()
271}
272
273/// Every unit's dependency edges, resolved to indices in `units`.
274///
275/// Unresolved edges — a dependency absent from this view, or one recorded
276/// against a secondary memo file of a shared dir — are dropped: a partial
277/// graph is useful, a wrong edge would be worse than a missing one.
278pub fn dep_edges(units: &[Unit]) -> Vec<Vec<usize>> {
279    let index = memo_index(units);
280    units
281        .iter()
282        .map(|unit| {
283            unit.json
284                .as_ref()
285                .map(|json| {
286                    json.deps
287                        .iter()
288                        .filter_map(|dep| resolve_dep(&index, dep))
289                        .collect()
290                })
291                .unwrap_or_default()
292        })
293        .collect()
294}
295
296fn read_unit_dir(profile: &str, unit_dir: &Path) -> Option<Unit> {
297    let dir_name = unit_dir.file_name()?.to_string_lossy().into_owned();
298    let (package, dir_suffix) = split_dir_name(&dir_name)?;
299
300    let mut candidates: Vec<(String, String)> = Vec::new(); // (name, content)
301    let mut dep_info: Option<PathBuf> = None;
302    let mut json_path: Option<PathBuf> = None;
303
304    for entry in fs::read_dir(unit_dir).ok()? {
305        let entry = entry.ok()?;
306        let name = entry.file_name().to_string_lossy().into_owned();
307        if name.starts_with("dep-") {
308            dep_info = Some(entry.path());
309            continue;
310        }
311        if name.ends_with(".json") {
312            json_path = Some(entry.path());
313            continue;
314        }
315        if name == "invoked.timestamp" {
316            continue;
317        }
318        let content = fs::read_to_string(entry.path()).ok()?.trim().to_string();
319        candidates.push((name, content));
320    }
321
322    let ((memo_file_name, memo_hash), extra_files) = pick_memo(candidates)?;
323    let kind = classify_kind(&memo_file_name);
324
325    let json = json_path
326        .and_then(|path| fs::read_to_string(path).ok())
327        .and_then(|text| parse_fingerprint(&text));
328
329    Some(Unit {
330        dir_name,
331        package,
332        dir_suffix,
333        profile: profile.to_string(),
334        kind,
335        memo_file: memo_file_name,
336        memo_hash: Some(memo_hash),
337        json,
338        dep_info,
339        extra_files,
340    })
341}
342
343/// Choose the memo file among a unit dir's remaining files: the one whose
344/// content is a 16-hex hash. Cargo also keeps cached diagnostics in the same
345/// directory (`output-*`) — those are state, not fingerprints. When nothing
346/// looks like a hash the first candidate is used anyway, so a corrupted unit
347/// still shows up in forensics instead of disappearing.
348fn pick_memo(
349    mut candidates: Vec<(String, String)>,
350) -> Option<((String, String), Vec<String>)> {
351    if candidates.is_empty() {
352        return None;
353    }
354    let index = candidates
355        .iter()
356        .position(|(_, content)| looks_like_hash(content))
357        .unwrap_or(0);
358    let (name, hash) = candidates.remove(index);
359    let extras = candidates.into_iter().map(|(name, _)| name).collect();
360    Some(((name, hash), extras))
361}
362
363/// A fingerprint memo file holds exactly 16 hex characters.
364pub(crate) fn looks_like_hash(text: &str) -> bool {
365    text.len() == 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit())
366}
367
368/// Candidate `build/<package>-<hex>` anchor dirs for a build-script unit
369/// (dashes may be underscore-normalized in artifact paths — check both
370/// plus the bare-hash fallback). Shared by the reader and by `evict`.
371pub(crate) fn build_anchor_dirs(build_root: &Path, unit: &Unit) -> Vec<PathBuf> {
372    let build_dir = build_root.join(&unit.profile).join("build");
373    let pkg_plain = &unit.package;
374    let pkg_underscored = unit.package.replace('-', "_");
375    let mut candidates = Vec::new();
376    candidates.push(build_dir.join(format!("{pkg_underscored}-{}", unit.dir_suffix)));
377    if pkg_plain != &pkg_underscored {
378        candidates.push(build_dir.join(format!("{pkg_plain}-{}", unit.dir_suffix)));
379    }
380    candidates.push(build_dir.join(&unit.dir_suffix));
381    candidates
382}
383
384/// Artifacts that belong to a unit, found by suffix (fingerprint dir hash
385/// == deps filename suffix) — `deps/*-{hex}.rlib|rmeta|so…` and, for build
386/// script units, the whole `build/{hex}/` subtree. Registry and workspace
387/// units look identical here.
388pub struct ArtifactInfo {
389    pub paths: Vec<(PathBuf, u64, Option<SystemTime>)>,
390    pub bytes: u64,
391    pub newest: Option<SystemTime>,
392}
393
394pub fn artifacts_for(build_root: &Path, unit: &Unit) -> ArtifactInfo {
395    let profile_dir = build_root.join(&unit.profile);
396    let mut paths = Vec::new();
397
398    let deps_dir = profile_dir.join("deps");
399    if let Ok(entries) = fs::read_dir(&deps_dir) {
400        let suffix = format!("-{}", unit.dir_suffix);
401        for entry in entries.flatten() {
402            let name = entry.file_name().to_string_lossy().into_owned();
403            // Compare on the STEM so extensions don't hide the match:
404            // `libserde-102a….rlib` → stem `libserde-102a…` ends with `-102a…`.
405            let stem = Path::new(&name)
406                .file_stem()
407                .map(|s| s.to_string_lossy().into_owned())
408                .unwrap_or(name.clone());
409            if !stem.ends_with(&suffix) {
410                continue;
411            }
412            if let Ok(meta) = entry.metadata() {
413                paths.push((
414                    entry.path(),
415                    meta.len(),
416                    meta.modified().ok(),
417                ));
418            }
419        }
420    }
421
422    if unit.kind != UnitKind::Lib {
423        for candidate in build_anchor_dirs(build_root, unit) {
424            if !candidate.is_dir() {
425                continue;
426            }
427            for entry in WalkDir::new(&candidate).min_depth(1) {
428                let Ok(entry) = entry else { continue };
429                if !entry.file_type().is_file() {
430                    continue;
431                }
432                if let Ok(meta) = entry.metadata() {
433                    paths.push((entry.path().into(), meta.len(), meta.modified().ok()));
434                }
435            }
436        }
437    }
438
439    let mut bytes = 0;
440    let mut newest: Option<SystemTime> = None;
441    for (_, size, mtime) in &paths {
442        bytes += size;
443        if let Some(m) = mtime {
444            if newest.map_or(true, |n| *m > n) {
445                newest = Some(*m);
446            }
447        }
448    }
449    ArtifactInfo { paths, bytes, newest }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    /// Verbatim sample from the live shared cache (1.97.1 generation).
457    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}"#;
458
459    #[test]
460    fn reads_and_parses_synthetic_unit() -> Result<()> {
461        let root = tempfile::tempdir()?;
462        let unit_dir = root.path().join("debug/.fingerprint/serde-102a33f055a8d229");
463        fs::create_dir_all(&unit_dir)?;
464        // memo file: 16 hex chars, no trailing newline (verbatim format)
465        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
466        fs::write(unit_dir.join("lib-serde.json"), SAMPLE_JSON)?;
467        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 69])?;
468        fs::write(unit_dir.join("invoked.timestamp"), "")?;
469        fs::create_dir_all(root.path().join("debug/deps"))?;
470        fs::write(
471            root.path().join("debug/deps/libserde-102a33f055a8d229.rlib"),
472            [0u8; 10],
473        )?;
474
475        let units = read_units(root.path())?;
476        assert_eq!(units.len(), 1);
477
478        let unit = &units[0];
479        assert_eq!(unit.package, "serde");
480        assert_eq!(unit.dir_suffix, "102a33f055a8d229");
481        assert_eq!(unit.profile, "debug");
482        assert_eq!(unit.kind, UnitKind::Lib);
483        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
484        assert!(unit.dep_info.is_some());
485
486        let json = unit.json.as_ref().expect("sample json must parse");
487        assert_eq!(json.rustc, "12019306335353385202");
488        assert!(json.features.contains(&"derive".to_string()));
489        assert!(json.declared_features.contains(&"unstable".to_string()));
490        assert_eq!(json.deps.len(), 3);
491        assert_eq!(json.deps[1].name, "serde_core");
492        assert_eq!(json.dep_info_paths.len(), 1);
493
494        // Suffix-matching artifact attach.
495        let arts = artifacts_for(root.path(), unit);
496        assert_eq!(arts.paths.len(), 1);
497        assert_eq!(arts.bytes, 10);
498        assert!(arts.newest.is_some());
499        Ok(())
500    }
501
502    #[test]
503    fn run_units_attach_their_build_tree() -> Result<()> {
504        let root = tempfile::tempdir()?;
505        let unit_dir = root.path().join("debug/.fingerprint/x-1122334455667788");
506        fs::create_dir_all(&unit_dir)?;
507        fs::write(unit_dir.join("build-script-build"), "aaaabbbbccccdddd")?;
508        fs::create_dir_all(root.path().join("debug/build/1122334455667788"))?;
509        fs::write(
510            root.path().join("debug/build/1122334455667788/output"),
511            [0u8; 4],
512        )?;
513        fs::write(
514            root.path().join("debug/build/1122334455667788/stderr"),
515            [0u8; 6],
516        )?;
517
518        let units = read_units(root.path())?;
519        assert_eq!(units.len(), 1);
520        let unit = &units[0];
521        assert_eq!(unit.kind, UnitKind::BuildScriptCompile);
522        let arts = artifacts_for(root.path(), unit);
523        assert_eq!(arts.paths.len(), 2, "output + stderr attached");
524        assert_eq!(arts.bytes, 10);
525        assert!(arts.newest.is_some());
526        Ok(())
527    }
528
529    #[test]
530    fn cached_diagnostics_are_not_mistaken_for_the_memo() -> Result<()> {
531        let root = tempfile::tempdir()?;
532        let unit_dir = root.path().join("debug/.fingerprint/serde-102a33f055a8d229");
533        fs::create_dir_all(&unit_dir)?;
534        fs::write(unit_dir.join("lib-serde"), "5d82713a7f2d05e2")?;
535        fs::write(unit_dir.join("dep-lib-serde"), [0u8; 8])?;
536        // Cargo's message cache: rendered diagnostics live in the same dir.
537        fs::write(
538            unit_dir.join("output-lib-serde"),
539            r#"{"$message_type":"diagnostic","message":"unused import"}"#,
540        )?;
541
542        let units = read_units(root.path())?;
543        assert_eq!(units.len(), 1);
544        let unit = &units[0];
545        assert_eq!(unit.memo_file, "lib-serde", "the hash file wins");
546        assert_eq!(unit.memo_hash.as_deref(), Some("5d82713a7f2d05e2"));
547        assert_eq!(unit.extra_files, vec!["output-lib-serde".to_string()]);
548        Ok(())
549    }
550
551    #[test]
552    fn dep_hashes_resolve_to_memo_hashes_little_endian() {
553        // Example: serde's fingerprint lists serde_core as 9416646317397223793,
554        // and serde_core-b468be6fa8c1f936/lib-serde_core contains 71a9f323efa5ae82.
555        let dep = DepRef {
556            name: "serde_core".into(),
557            unit_hash: "9416646317397223793".into(),
558        };
559        assert_eq!(dep.memo_hash().as_deref(), Some("71a9f323efa5ae82"));
560
561        let junk = DepRef {
562            name: "x".into(),
563            unit_hash: "not-a-number".into(),
564        };
565        assert!(junk.memo_hash().is_none());
566    }
567
568    #[test]
569    fn dep_edges_join_the_graph_by_memo_hash() -> Result<()> {
570        let root = tempfile::tempdir()?;
571        let core = root.path().join("debug/.fingerprint/serde_core-b468be6fa8c1f936");
572        fs::create_dir_all(&core)?;
573        fs::write(core.join("lib-serde_core"), "71a9f323efa5ae82")?;
574        fs::create_dir_all(root.path().join("debug/deps"))?;
575        fs::write(
576            root.path().join("debug/deps/libserde_core-b468be6fa8c1f936.rlib"),
577            [0u8; 4],
578        )?;
579
580        let serde = root.path().join("debug/.fingerprint/serde-746d173581f1a314");
581        fs::create_dir_all(&serde)?;
582        fs::write(serde.join("lib-serde"), "5d82713a7f2d05e2")?;
583        fs::write(
584            serde.join("lib-serde.json"),
585            r#"{"deps":[[11029742160753049355,"serde_core",false,9416646317397223793]]}"#,
586        )?;
587        fs::write(
588            root.path().join("debug/deps/libserde-746d173581f1a314.rlib"),
589            [0u8; 4],
590        )?;
591
592        let units = read_units(root.path())?;
593        assert_eq!(units.len(), 2);
594        let edges = dep_edges(&units);
595        let serde_at = units.iter().position(|u| u.package == "serde").unwrap();
596        let core_at = units.iter().position(|u| u.package == "serde_core").unwrap();
597        assert_eq!(edges[serde_at], vec![core_at]);
598        assert!(edges[core_at].is_empty());
599        Ok(())
600    }
601
602    #[test]
603    fn pick_memo_prefers_hash_content_and_falls_back() {
604        let candidates = vec![
605            (
606                "output-lib-x".to_string(),
607                r#"{"message":"warning"}"#.to_string(),
608            ),
609            ("lib-x".to_string(), "5d82713a7f2d05e2".to_string()),
610        ];
611        let ((name, hash), extras) = pick_memo(candidates).expect("a memo exists");
612        assert_eq!(name, "lib-x");
613        assert_eq!(hash, "5d82713a7f2d05e2");
614        assert_eq!(extras, vec!["output-lib-x".to_string()]);
615
616        // Nothing hash-shaped: the first candidate is the fallback, so a
617        // corrupted unit still shows up in forensics instead of vanishing.
618        let candidates = vec![("weird".to_string(), "not a hash".to_string())];
619        let ((name, _), extras) = pick_memo(candidates).expect("fallback");
620        assert_eq!(name, "weird");
621        assert!(extras.is_empty());
622
623        assert!(pick_memo(Vec::new()).is_none());
624    }
625}