Skip to main content

capsule_runtime/
host_facts.rs

1//! Facts about the host's `capabilities.shell.allow` binaries, kept between stagings so a warm
2//! launch checks them with `stat` instead of working them out again.
3//!
4//! What [`crate::interpreter_runtime`] works out for an allowlisted binary is read from host files
5//! alone: whether the binary is a recognised interpreter, the import path it prints, the entries
6//! of each directory on that path, and the `PT_INTERP`, `DT_NEEDED` and `DT_RUNPATH` of every file
7//! its extension modules' library closure reaches. The first staging that meets a binary records
8//! those facts in one entry under `~/.murmur/host_facts/`, each beside the [`FileStamp`] of the
9//! file or directory it was read from. A later staging takes the entry only when every recorded
10//! stamp equals a live `stat`, and then starts no interpreter, lists no directory and parses no
11//! extension module or library the walk reaches. Anything that differs, or that the entry never
12//! recorded, has the binary's facts worked out again from scratch, and that result is what the
13//! staging uses and stores.
14//!
15//! What depends on the staging is never kept. Every launch drops the reported entries under the
16//! capsule's writable roots and walks the closure live, skipping paths under those roots, so a
17//! kept entry grants exactly what a fresh derivation grants.
18//!
19//! The directory is read and written on the terms `~/.murmur/compiled` is: a real directory of
20//! this uid at [`MURMUR_HOME_DIR_MODE`], each entry a regular file of this uid at
21//! [`PRIVATE_FILE_MODE`] opened without following a symlink, and nothing at all when the murmur
22//! home or the directory is reachable from one of the capsule's writable roots. An entry that
23//! fails any of those checks, or does not parse as this build's format, is worked out again and
24//! never trusted. A stamp says which file a fact was read from, not who wrote the entry: code
25//! running as this uid outside containment, such as an `advisory` capsule's shell, can write a
26//! well-formed entry, as it can a compiled form or `~/.murmur/config.yaml`.
27//!
28//! CPython and Perl find their prefix from files beside the binary and in the directory above it.
29//! The stamps of those two directories change when a file there is added, removed or renamed, not
30//! when one is edited in place. [`FileStamp`] states the ctime resolution caveat.
31//!
32//! [`MURMUR_HOME_DIR_MODE`]: crate::murmur_home::MURMUR_HOME_DIR_MODE
33//! [`PRIVATE_FILE_MODE`]: crate::murmur_home::PRIVATE_FILE_MODE
34
35#[cfg(target_os = "linux")]
36use std::path::Path;
37use std::path::PathBuf;
38
39#[cfg(target_os = "linux")]
40use crate::interpreter_runtime::{DerivedInterpreterRuntime, Facts, InterpreterKind};
41#[cfg(target_os = "linux")]
42use crate::murmur_home::FileStamp;
43#[cfg(target_os = "linux")]
44use crate::sandbox::ElfDependencies;
45
46/// The format of an entry. Bump it whenever what is derived about a binary, or how, changes: an
47/// entry of any other format is worked out again.
48#[cfg(target_os = "linux")]
49pub(crate) const HOST_FACTS_FORMAT: u32 = 1;
50
51/// The largest entry read. An entry for an interpreter with a thousand extension modules is a few
52/// hundred KiB; a larger file is worked out again rather than parsed.
53#[cfg(target_os = "linux")]
54const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024;
55
56/// Where stagings keep host facts: `~/.murmur/host_facts`, or a directory a test names.
57#[derive(Debug, Clone)]
58#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
59pub struct HostFactsCache {
60    /// `None` for the murmur home's [`crate::murmur_home::HomeEntryKind::HostFacts`].
61    dir: Option<PathBuf>,
62}
63
64impl HostFactsCache {
65    /// The cache every launch and `mur run --explain-scope` uses.
66    pub fn in_murmur_home() -> Self {
67        Self { dir: None }
68    }
69
70    /// A cache at `dir`, whose parent stands in for the murmur home. For tests, which must never
71    /// touch the developer's own home.
72    pub fn at(dir: impl Into<PathBuf>) -> Self {
73        Self {
74            dir: Some(dir.into()),
75        }
76    }
77
78    /// The directory this staging reads and writes entries in, created owner-only if missing, or
79    /// `None` when the cache is unusable for it: no murmur home, a home or directory reachable
80    /// from one of the canonical `roots`, or a directory that is a symlink, another uid's, or
81    /// cannot be held at `0700`.
82    ///
83    /// A directory found wider than owner-only is narrowed and emptied of its files, the way
84    /// `~/.murmur/compiled` is, and this staging keeps nothing it held.
85    #[cfg(target_os = "linux")]
86    pub(crate) fn open(&self, roots: &[PathBuf]) -> Option<Shelf> {
87        let reachable = |dir: &Path| {
88            roots
89                .iter()
90                .any(|root| crate::murmur_home::reachable_from(dir, root))
91        };
92        let (home, name) = match &self.dir {
93            None => (
94                crate::murmur_home::ensure_murmur_home().ok()?,
95                crate::murmur_home::HomeEntryKind::HostFacts
96                    .file_name()?
97                    .to_string(),
98            ),
99            Some(dir) => (
100                dir.parent()?.to_path_buf(),
101                dir.file_name()?.to_str()?.to_string(),
102            ),
103        };
104        let (dir, emptied) = crate::murmur_home::private_subdir(&home, &name, true, reachable)?;
105        Some(Shelf { dir, emptied })
106    }
107}
108
109/// One examined binary, and where this staging's facts about it came from.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct HostFactsUse {
112    /// The binary, canonical.
113    pub binary: PathBuf,
114    pub source: HostFactsSource,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum HostFactsSource {
119    /// A kept entry, every recorded stamp matching.
120    Kept,
121    /// Worked out from the binary and its files. `changed_path` is the first recorded path whose
122    /// stamp differed, or that the entry never recorded, and is set only for
123    /// [`HostFactsReason::Changed`].
124    Derived {
125        reason: HostFactsReason,
126        changed_path: Option<PathBuf>,
127    },
128}
129
130/// Why a binary's facts were worked out instead of kept.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum HostFactsReason {
133    /// No entry for the binary.
134    Absent,
135    /// A path the entry recorded has a different stamp now, or this staging reached one it never
136    /// recorded.
137    Changed,
138    /// The entry is not an owner-only regular file of this uid, does not parse, or is of another
139    /// format, runtime version, interpreter table, library search list or binary.
140    Unreadable,
141    /// The cache cannot be used for this staging.
142    NotKept,
143}
144
145impl HostFactsReason {
146    /// The reason as `session_start.host_facts` names it.
147    pub fn wire_name(self) -> &'static str {
148        match self {
149            Self::Absent => "absent",
150            Self::Changed => "changed",
151            Self::Unreadable => "unreadable",
152            Self::NotKept => "not-kept",
153        }
154    }
155}
156
157/// The cache directory one staging opened.
158#[cfg(target_os = "linux")]
159pub(crate) struct Shelf {
160    dir: PathBuf,
161    /// Whether opening it narrowed a wide directory and removed what it held.
162    emptied: bool,
163}
164
165/// What [`runtime_of`] found for one binary.
166#[cfg(target_os = "linux")]
167pub(crate) struct RuntimeOf {
168    /// `None` when the binary is not a recognised interpreter or its runtime was not derived.
169    pub(crate) runtime: Option<DerivedInterpreterRuntime>,
170    pub(crate) source: HostFactsSource,
171    /// Whether the shelf holds an entry for the binary when the call returns: the kept one, or
172    /// the one just stored.
173    pub(crate) held: bool,
174}
175
176/// `binary`'s runtime, from its kept entry in `shelf` when every recorded stamp matches, and
177/// otherwise worked out and stored. With no `shelf` the facts are worked out and nothing is
178/// stored. Each line saying an interpreter's runtime was not derived goes to `report`, kept or
179/// worked out alike.
180#[cfg(target_os = "linux")]
181pub(crate) fn runtime_of(
182    shelf: Option<&Shelf>,
183    binary: &Path,
184    kinds: &[InterpreterKind<'_>],
185    roots: &[PathBuf],
186    timeout: std::time::Duration,
187    report: &mut dyn FnMut(&str),
188) -> RuntimeOf {
189    let derived = |reason, changed_path| HostFactsSource::Derived {
190        reason,
191        changed_path,
192    };
193    let Some(shelf) = shelf.filter(|shelf| !shelf.emptied) else {
194        let fresh = derive(binary, kinds, roots, timeout, report);
195        let held = match (shelf, fresh.entry) {
196            (Some(shelf), Some(entry)) => store(&shelf.dir.join(entry_name(binary)), &entry),
197            _ => false,
198        };
199        return RuntimeOf {
200            runtime: fresh.runtime,
201            source: derived(HostFactsReason::NotKept, None),
202            held,
203        };
204    };
205    let path = shelf.dir.join(entry_name(binary));
206    let source = match read_entry(&path) {
207        Read::Absent => derived(HostFactsReason::Absent, None),
208        Read::Unreadable => derived(HostFactsReason::Unreadable, None),
209        Read::Entry(entry) => match reuse(&entry, binary, kinds, roots, report) {
210            Ok(runtime) => {
211                return RuntimeOf {
212                    runtime,
213                    source: HostFactsSource::Kept,
214                    held: true,
215                }
216            }
217            Err(Stale::Unreadable) => derived(HostFactsReason::Unreadable, None),
218            Err(Stale::Changed(changed)) => derived(HostFactsReason::Changed, Some(changed)),
219        },
220    };
221    let fresh = derive(binary, kinds, roots, timeout, report);
222    let held = fresh.entry.is_some_and(|entry| store(&path, &entry));
223    RuntimeOf {
224        runtime: fresh.runtime,
225        source,
226        held,
227    }
228}
229
230/// `<lowercase hex sha256 of the canonical binary path's bytes>.json`.
231#[cfg(target_os = "linux")]
232fn entry_name(binary: &Path) -> String {
233    use std::os::unix::ffi::OsStrExt;
234    format!(
235        "{}.json",
236        murmur_artifact::sha256_hex(binary.as_os_str().as_bytes())
237    )
238}
239
240/// One entry: what was worked out for one binary, and the stamp of everything it was read from.
241#[cfg(target_os = "linux")]
242#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
243#[serde(deny_unknown_fields)]
244struct Entry {
245    /// [`HOST_FACTS_FORMAT`].
246    format: u32,
247    /// The deriving capsule-runtime's version.
248    runtime_version: String,
249    /// The interpreter table the binary was recognised and queried with, verbatim.
250    kinds: Vec<KindRecord>,
251    /// [`crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS`], verbatim.
252    search_dirs: Vec<String>,
253    /// The binary, canonical, which the file name was computed for.
254    binary: Stamped,
255    /// The binary's directory, then the one above it when there is one.
256    context: Vec<Stamped>,
257    /// The recognised kind's name, or `None` for a binary that is not an interpreter, whose entry
258    /// records nothing below.
259    kind: Option<String>,
260    /// What the completed query printed, in order.
261    reported: Vec<String>,
262    /// One per [`Self::reported`] string.
263    entries: Vec<ReportedEntry>,
264    /// Each kept entry of the import path the derivation listed for extension modules.
265    dirs: Vec<ListedDir>,
266    /// Each file the closure walk opened.
267    elf: Vec<ElfFacts>,
268}
269
270#[cfg(target_os = "linux")]
271#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
272#[serde(deny_unknown_fields)]
273struct KindRecord {
274    name: String,
275    marker_symbol: String,
276    args: Vec<String>,
277}
278
279#[cfg(target_os = "linux")]
280impl KindRecord {
281    fn table(kinds: &[InterpreterKind<'_>]) -> Vec<Self> {
282        kinds
283            .iter()
284            .map(|kind| Self {
285                name: kind.name.to_string(),
286                marker_symbol: kind.marker_symbol.to_string(),
287                args: kind.args.iter().map(|arg| arg.to_string()).collect(),
288            })
289            .collect()
290    }
291}
292
293/// A path that existed, with its [`FileStamp`] in its `Display` form.
294#[cfg(target_os = "linux")]
295#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
296#[serde(deny_unknown_fields)]
297struct Stamped {
298    path: PathBuf,
299    stamp: String,
300}
301
302/// A path with its stamp, `None` when `stat` failed on it.
303#[cfg(target_os = "linux")]
304#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
305#[serde(deny_unknown_fields)]
306struct MaybeStamped {
307    path: PathBuf,
308    stamp: Option<String>,
309}
310
311/// One reported string, and the existing file or directory it resolved to, if any.
312#[cfg(target_os = "linux")]
313#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
314#[serde(deny_unknown_fields)]
315struct ReportedEntry {
316    reported: String,
317    canonical: Option<PathBuf>,
318    stamp: Option<String>,
319}
320
321/// One listed import-path entry: its stamp, the ELF shared objects directly inside it, and every
322/// other entry of its listing that is not itself a directory, each stamped through any symlink.
323/// Both lists are empty for an entry that could not be listed, such as a zip archive.
324#[cfg(target_os = "linux")]
325#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
326#[serde(deny_unknown_fields)]
327struct ListedDir {
328    path: PathBuf,
329    stamp: Option<String>,
330    seeds: Vec<Stamped>,
331    others: Vec<MaybeStamped>,
332}
333
334/// One file the closure walk opened: its stamp and its dependencies, `None` when it could not be
335/// opened or did not parse as ELF64.
336#[cfg(target_os = "linux")]
337#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
338#[serde(deny_unknown_fields)]
339struct ElfFacts {
340    path: PathBuf,
341    stamp: Option<String>,
342    dependencies: Option<ElfDependencies>,
343}
344
345/// The live [`FileStamp`] of `path` through any symlink, as every recorded stamp but a seed's
346/// handle is taken.
347#[cfg(target_os = "linux")]
348fn stamp_of(path: &Path) -> Option<String> {
349    std::fs::metadata(path)
350        .ok()
351        .map(|metadata| FileStamp::of(&metadata).to_string())
352}
353
354/// The binary's directory and the one above it, stamped. `None` when either cannot be.
355#[cfg(target_os = "linux")]
356fn context_of(binary: &Path) -> Option<Vec<Stamped>> {
357    binary
358        .ancestors()
359        .skip(1)
360        .take(2)
361        .map(|dir| {
362            Some(Stamped {
363                path: dir.to_path_buf(),
364                stamp: stamp_of(dir)?,
365            })
366        })
367        .collect()
368}
369
370/// `reported` resolved the way [`crate::interpreter_runtime::keep_entries`] resolves it before
371/// filtering: absolute, canonical, and an existing directory or regular file.
372#[cfg(target_os = "linux")]
373fn resolve_reported(reported: &str) -> ReportedEntry {
374    let resolved = Some(Path::new(reported))
375        .filter(|path| path.is_absolute())
376        .and_then(|path| std::fs::canonicalize(path).ok())
377        .and_then(|canonical| {
378            let metadata = std::fs::metadata(&canonical).ok()?;
379            (metadata.is_dir() || metadata.is_file())
380                .then(|| (canonical, FileStamp::of(&metadata).to_string()))
381        });
382    let (canonical, stamp) = resolved.unzip();
383    ReportedEntry {
384        reported: reported.to_string(),
385        canonical,
386        stamp,
387    }
388}
389
390/// What one derivation produced: the runtime, and the entry to store when everything it read
391/// could be stamped and the query completed.
392#[cfg(target_os = "linux")]
393struct Fresh {
394    runtime: Option<DerivedInterpreterRuntime>,
395    entry: Option<Entry>,
396}
397
398/// `binary`'s facts worked out from scratch: recognised from its `.dynsym`, queried, filtered
399/// against `roots`, and its extension modules' closure walked, every read recorded with its stamp.
400/// A query that fails, or leaves nothing to grant, is reported to `report`.
401#[cfg(target_os = "linux")]
402fn derive(
403    binary: &Path,
404    kinds: &[InterpreterKind<'_>],
405    roots: &[PathBuf],
406    timeout: std::time::Duration,
407    report: &mut dyn FnMut(&str),
408) -> Fresh {
409    use crate::interpreter_runtime::{query, recognise, report_not_derived, runtime_from_report};
410
411    // Stamped before anything is read, so a change made while it is read leaves a stamp the next
412    // staging does not match.
413    let binary_stamp = stamp_of(binary);
414    let context = context_of(binary);
415    let header = |kind: Option<&str>| {
416        Some(Entry {
417            format: HOST_FACTS_FORMAT,
418            runtime_version: env!("CARGO_PKG_VERSION").to_string(),
419            kinds: KindRecord::table(kinds),
420            search_dirs: search_dirs(),
421            binary: Stamped {
422                path: binary.to_path_buf(),
423                stamp: binary_stamp.clone()?,
424            },
425            context: context.clone()?,
426            kind: kind.map(str::to_string),
427            reported: Vec::new(),
428            entries: Vec::new(),
429            dirs: Vec::new(),
430            elf: Vec::new(),
431        })
432    };
433    let Some(kind) = recognise(binary, kinds) else {
434        return Fresh {
435            runtime: None,
436            entry: header(None),
437        };
438    };
439    let reported = match query(binary, kind, timeout) {
440        Ok(reported) => reported,
441        Err(reason) => {
442            report_not_derived(binary, kind, &reason, report);
443            return Fresh {
444                runtime: None,
445                entry: None,
446            };
447        }
448    };
449    let entries = reported
450        .iter()
451        .map(|entry| resolve_reported(entry))
452        .collect();
453    let mut recorder = Recorder::default();
454    let Ok(runtime) = runtime_from_report(binary, kind, &reported, roots, &mut recorder, report);
455    // A binary or prefix replaced while the query ran leaves the entry describing neither.
456    let unmoved = stamp_of(binary) == binary_stamp && context_of(binary) == context;
457    let entry = header(Some(kind.name))
458        .filter(|_| unmoved)
459        .map(|entry| Entry {
460            reported,
461            entries,
462            dirs: recorder.dirs,
463            elf: recorder.elf,
464            ..entry
465        });
466    Fresh { runtime, entry }
467}
468
469#[cfg(target_os = "linux")]
470fn search_dirs() -> Vec<String> {
471    crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
472        .iter()
473        .map(|dir| dir.to_string())
474        .collect()
475}
476
477/// The facts a derivation reads, read from the host and recorded with their stamps.
478#[cfg(target_os = "linux")]
479#[derive(Default)]
480struct Recorder {
481    dirs: Vec<ListedDir>,
482    elf: Vec<ElfFacts>,
483}
484
485#[cfg(target_os = "linux")]
486impl Facts for Recorder {
487    type Changed = std::convert::Infallible;
488
489    fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, Self::Changed> {
490        use crate::sandbox::{elf_is_shared_object, ElfFile};
491
492        let stamp = stamp_of(dir);
493        let mut seeds = Vec::new();
494        let mut others = Vec::new();
495        for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
496            let path = entry.path();
497            // A directory is never a seed, and replacing it changes `dir`'s own stamp.
498            if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
499                continue;
500            }
501            let metadata = std::fs::metadata(&path).ok();
502            if metadata.as_ref().is_some_and(std::fs::Metadata::is_file) {
503                if let Ok(file) = ElfFile::open_host(&path) {
504                    if elf_is_shared_object(&file) {
505                        seeds.push(Stamped {
506                            stamp: FileStamp::of(file.metadata()).to_string(),
507                            path,
508                        });
509                        continue;
510                    }
511                }
512            }
513            others.push(MaybeStamped {
514                stamp: metadata.map(|metadata| FileStamp::of(&metadata).to_string()),
515                path,
516            });
517        }
518        let found = seeds.iter().map(|seed| seed.path.clone()).collect();
519        self.dirs.push(ListedDir {
520            path: dir.to_path_buf(),
521            stamp,
522            seeds,
523            others,
524        });
525        Ok(found)
526    }
527
528    fn dependencies(&mut self, path: &Path) -> Result<Option<ElfDependencies>, Self::Changed> {
529        let (stamp, dependencies) = match crate::sandbox::ElfFile::open_host(path) {
530            Ok(file) => (
531                Some(FileStamp::of(file.metadata()).to_string()),
532                crate::sandbox::parse_elf_dependencies(&file),
533            ),
534            Err(_) => (stamp_of(path), None),
535        };
536        self.elf.push(ElfFacts {
537            path: path.to_path_buf(),
538            stamp,
539            dependencies: dependencies.clone(),
540        });
541        Ok(dependencies)
542    }
543}
544
545/// The facts a kept entry recorded, each handed out only while its live stamp still matches.
546#[cfg(target_os = "linux")]
547struct Recorded<'a> {
548    dirs: std::collections::HashMap<&'a Path, &'a ListedDir>,
549    elf: std::collections::HashMap<&'a Path, &'a ElfFacts>,
550}
551
552#[cfg(target_os = "linux")]
553impl<'a> Recorded<'a> {
554    fn of(entry: &'a Entry) -> Self {
555        Self {
556            dirs: entry
557                .dirs
558                .iter()
559                .map(|dir| (dir.path.as_path(), dir))
560                .collect(),
561            elf: entry
562                .elf
563                .iter()
564                .map(|file| (file.path.as_path(), file))
565                .collect(),
566        }
567    }
568}
569
570#[cfg(target_os = "linux")]
571impl Facts for Recorded<'_> {
572    type Changed = PathBuf;
573
574    fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, PathBuf> {
575        let changed = |path: &Path| path.to_path_buf();
576        let listed = self.dirs.get(dir).ok_or_else(|| changed(dir))?;
577        if stamp_of(dir) != listed.stamp {
578            return Err(changed(dir));
579        }
580        for seed in &listed.seeds {
581            if stamp_of(&seed.path).as_ref() != Some(&seed.stamp) {
582                return Err(changed(&seed.path));
583            }
584        }
585        for other in &listed.others {
586            if stamp_of(&other.path) != other.stamp {
587                return Err(changed(&other.path));
588            }
589        }
590        Ok(listed.seeds.iter().map(|seed| seed.path.clone()).collect())
591    }
592
593    fn dependencies(&mut self, path: &Path) -> Result<Option<ElfDependencies>, PathBuf> {
594        match self.elf.get(path) {
595            Some(file) if stamp_of(path) == file.stamp => Ok(file.dependencies.clone()),
596            _ => Err(path.to_path_buf()),
597        }
598    }
599}
600
601#[cfg(target_os = "linux")]
602enum Read {
603    Absent,
604    Unreadable,
605    Entry(Box<Entry>),
606}
607
608/// The entry at `path`, when it is an owner-only regular file of this uid, reached without a
609/// symlink, that parses as this build's entry format.
610#[cfg(target_os = "linux")]
611fn read_entry(path: &Path) -> Read {
612    use std::io::Read as _;
613
614    if std::fs::symlink_metadata(path).is_err_and(|err| err.kind() == std::io::ErrorKind::NotFound)
615    {
616        return Read::Absent;
617    }
618    let Some((file, metadata)) = crate::murmur_home::open_private(path) else {
619        return Read::Unreadable;
620    };
621    if metadata.len() > MAX_ENTRY_BYTES {
622        return Read::Unreadable;
623    }
624    let mut bytes = Vec::new();
625    if file.take(MAX_ENTRY_BYTES).read_to_end(&mut bytes).is_err() {
626        return Read::Unreadable;
627    }
628    match serde_json::from_slice(&bytes) {
629        Ok(entry) => Read::Entry(Box::new(entry)),
630        Err(_) => Read::Unreadable,
631    }
632}
633
634/// Why a kept entry was not taken.
635#[cfg(target_os = "linux")]
636enum Stale {
637    Unreadable,
638    Changed(PathBuf),
639}
640
641/// `binary`'s runtime from `entry`, every recorded stamp checked against a live `stat` and the
642/// per-staging part run live against `roots`.
643#[cfg(target_os = "linux")]
644fn reuse(
645    entry: &Entry,
646    binary: &Path,
647    kinds: &[InterpreterKind<'_>],
648    roots: &[PathBuf],
649    report: &mut dyn FnMut(&str),
650) -> Result<Option<DerivedInterpreterRuntime>, Stale> {
651    let foreign = entry.format != HOST_FACTS_FORMAT
652        || entry.runtime_version != env!("CARGO_PKG_VERSION")
653        || entry.kinds != KindRecord::table(kinds)
654        || entry.search_dirs != search_dirs()
655        || entry.binary.path != binary
656        || entry.entries.len() != entry.reported.len()
657        || entry
658            .entries
659            .iter()
660            .zip(&entry.reported)
661            .any(|(resolved, reported)| resolved.reported != *reported);
662    let context: Vec<&Path> = binary.ancestors().skip(1).take(2).collect();
663    let context_paths: Vec<&Path> = entry.context.iter().map(|dir| dir.path.as_path()).collect();
664    if foreign || context_paths != context {
665        return Err(Stale::Unreadable);
666    }
667    if stamp_of(binary).as_ref() != Some(&entry.binary.stamp) {
668        return Err(Stale::Changed(binary.to_path_buf()));
669    }
670    for dir in &entry.context {
671        if stamp_of(&dir.path).as_ref() != Some(&dir.stamp) {
672            return Err(Stale::Changed(dir.path.clone()));
673        }
674    }
675    let Some(name) = &entry.kind else {
676        let empty = entry.reported.is_empty() && entry.dirs.is_empty() && entry.elf.is_empty();
677        return if empty {
678            Ok(None)
679        } else {
680            Err(Stale::Unreadable)
681        };
682    };
683    let kind = *kinds
684        .iter()
685        .find(|kind| kind.name == name)
686        .ok_or(Stale::Unreadable)?;
687    for recorded in &entry.entries {
688        let live = resolve_reported(&recorded.reported);
689        if live != *recorded {
690            let changed = recorded
691                .canonical
692                .clone()
693                .or(live.canonical)
694                .unwrap_or_else(|| PathBuf::from(&recorded.reported));
695            return Err(Stale::Changed(changed));
696        }
697    }
698    crate::interpreter_runtime::runtime_from_report(
699        binary,
700        kind,
701        &entry.reported,
702        roots,
703        &mut Recorded::of(entry),
704        report,
705    )
706    .map_err(Stale::Changed)
707}
708
709/// Writes `entry` to `path`, owner-only, by rename, and says whether it was written. A failed
710/// write leaves the next staging to work the facts out again.
711#[cfg(target_os = "linux")]
712fn store(path: &Path, entry: &Entry) -> bool {
713    serde_json::to_vec(entry)
714        .is_ok_and(|bytes| crate::murmur_home::write_private_file(path, &bytes).is_ok())
715}
716
717#[cfg(all(test, target_os = "linux"))]
718#[allow(clippy::print_stdout)]
719mod tests {
720    use super::*;
721    use crate::interpreter_runtime::tests::{
722        cache_in, canonical_tempdir, dash_copy, dash_kind, pad_with_zeros, thread_rchar,
723    };
724    use crate::interpreter_runtime::{derive_in, InterpreterRuntimes, QUERY_TIMEOUT};
725    use std::os::unix::fs::{MetadataExt, PermissionsExt};
726
727    const EIGHT_MIB: u64 = 8 * 1024 * 1024;
728
729    /// A stand-in interpreter at `<root>/prefix/bin/standin` whose query records that it ran in
730    /// `<root>/ran` and reports each of `reported`, under a cache at `<root>/host_facts`. The
731    /// marker, the cache and every reported directory sit outside the two directories whose
732    /// stamps the entry records beside the binary, so writing them never changes the entry's
733    /// context.
734    struct StandIn {
735        _temp: tempfile::TempDir,
736        root: PathBuf,
737        binary: PathBuf,
738        marker: PathBuf,
739        script: String,
740    }
741
742    impl StandIn {
743        fn new(reported: &[&Path]) -> Option<Self> {
744            let (temp, root) = canonical_tempdir();
745            let binary = dash_copy(&root.join("prefix"), "standin")?;
746            let marker = root.join("ran");
747            let printed: Vec<String> = reported
748                .iter()
749                .map(|path| path.display().to_string())
750                .collect();
751            let script = format!(
752                ": > {}; printf '{}'",
753                marker.display(),
754                printed.join("\\000")
755            );
756            Some(Self {
757                _temp: temp,
758                root,
759                binary,
760                marker,
761                script,
762            })
763        }
764
765        /// `derive_in` for the stand-in alone, with `roots` writable, through `cache`.
766        fn derive_with(&self, roots: &[&Path], cache: &HostFactsCache) -> InterpreterRuntimes {
767            let args = ["-c", self.script.as_str()];
768            let kinds = [dash_kind(&args)];
769            derive_in(
770                &["standin".to_string()],
771                &[],
772                roots,
773                &[self.binary.parent().unwrap().to_path_buf()],
774                &kinds,
775                QUERY_TIMEOUT,
776                cache,
777            )
778        }
779
780        fn derive(&self, roots: &[&Path]) -> InterpreterRuntimes {
781            self.derive_with(roots, &cache_in(&self.root))
782        }
783
784        /// The same derivation through a cache that can never be used.
785        fn derive_uncached(&self, roots: &[&Path]) -> InterpreterRuntimes {
786            let not_a_dir = self.root.join("not-a-cache");
787            std::fs::write(&not_a_dir, b"").unwrap();
788            let derived = self.derive_with(roots, &HostFactsCache::at(&not_a_dir));
789            assert_eq!(self.source(&derived), not_kept());
790            derived
791        }
792
793        fn entry(&self) -> PathBuf {
794            self.root.join("host_facts").join(entry_name(&self.binary))
795        }
796
797        fn source(&self, derived: &InterpreterRuntimes) -> HostFactsSource {
798            assert_eq!(derived.host_facts.len(), 1, "{derived:?}");
799            assert_eq!(derived.host_facts[0].binary, self.binary);
800            derived.host_facts[0].source.clone()
801        }
802
803        /// Removes the marker, so whether the next derivation ran the stand-in can be read.
804        fn forget_run(&self) {
805            let _ = std::fs::remove_file(&self.marker);
806        }
807
808        fn ran(&self) -> bool {
809            self.marker.exists()
810        }
811    }
812
813    fn derived(reason: HostFactsReason, changed_path: Option<PathBuf>) -> HostFactsSource {
814        HostFactsSource::Derived {
815            reason,
816            changed_path,
817        }
818    }
819
820    fn absent() -> HostFactsSource {
821        derived(HostFactsReason::Absent, None)
822    }
823
824    fn not_kept() -> HostFactsSource {
825        derived(HostFactsReason::NotKept, None)
826    }
827
828    /// A synthetic ELF64 shared object at `path` needing `needed`, found through `runpath`.
829    fn shared_object(path: &Path, needed: &[&str], runpath: Option<&str>) {
830        std::fs::write(path, crate::sandbox::build_elf64(None, needed, runpath)).unwrap();
831    }
832
833    /// `<root>/lib` holding one extension module that needs only the C library, which the
834    /// stand-in's own closure already grants.
835    fn lib_with_one_module(root: &Path) -> PathBuf {
836        let lib = root.join("lib");
837        std::fs::create_dir_all(&lib).unwrap();
838        shared_object(&lib.join("ext.so"), &["libc.so.6"], None);
839        lib
840    }
841
842    /// Whether `path` is an owner-only regular file holding an entry this build reads.
843    fn holds_a_valid_entry(path: &Path) -> bool {
844        let metadata = std::fs::symlink_metadata(path).unwrap();
845        metadata.is_file()
846            && metadata.mode() & 0o777 == 0o600
847            && matches!(read_entry(path), Read::Entry(_))
848    }
849
850    /// A cold derivation stores an entry; the next staging keeps it, runs nothing and reads a
851    /// few KiB, though the binary is padded to 64 MiB.
852    #[test]
853    fn a_kept_entry_runs_nothing_and_reads_no_binary_in_full() {
854        let (_guard, root) = canonical_tempdir();
855        let lib = lib_with_one_module(&root);
856        let Some(stand_in) = StandIn::new(&[&lib]) else {
857            return;
858        };
859        pad_with_zeros(&stand_in.binary, 64 << 20);
860
861        let first = stand_in.derive(&[]);
862        assert_eq!(stand_in.source(&first), absent());
863        assert!(stand_in.ran());
864        assert!(holds_a_valid_entry(&stand_in.entry()));
865        assert_eq!(first.runtimes.len(), 1, "{first:?}");
866        assert_eq!(first.runtimes[0].dirs, vec![lib.clone()]);
867
868        stand_in.forget_run();
869        let before = thread_rchar();
870        let second = stand_in.derive(&[]);
871        let read = thread_rchar() - before;
872        assert_eq!(stand_in.source(&second), HostFactsSource::Kept);
873        assert_eq!(second.runtimes, first.runtimes);
874        assert!(!stand_in.ran(), "a kept entry ran the interpreter");
875        assert!(read < EIGHT_MIB, "the kept staging read {read} bytes");
876        println!("a kept staging read {read} bytes");
877    }
878
879    /// A binary replaced by rename is worked out again, and its entry rewritten.
880    #[test]
881    fn a_replaced_binary_is_derived_again() {
882        let (_guard, root) = canonical_tempdir();
883        let lib = lib_with_one_module(&root);
884        let Some(stand_in) = StandIn::new(&[&lib]) else {
885            return;
886        };
887        let first = stand_in.derive(&[]);
888        assert_eq!(
889            stand_in.source(&stand_in.derive(&[])),
890            HostFactsSource::Kept
891        );
892        let stored = std::fs::metadata(stand_in.entry()).unwrap().ino();
893
894        let replacement = stand_in.binary.with_file_name(".standin.new");
895        std::fs::copy(&stand_in.binary, &replacement).unwrap();
896        std::fs::rename(&replacement, &stand_in.binary).unwrap();
897        stand_in.forget_run();
898        let again = stand_in.derive(&[]);
899
900        assert_eq!(
901            stand_in.source(&again),
902            derived(HostFactsReason::Changed, Some(stand_in.binary.clone()))
903        );
904        assert!(stand_in.ran());
905        assert_eq!(again.runtimes, first.runtimes);
906        assert_ne!(std::fs::metadata(stand_in.entry()).unwrap().ino(), stored);
907        assert!(holds_a_valid_entry(&stand_in.entry()));
908        assert_eq!(
909            stand_in.source(&stand_in.derive(&[])),
910            HostFactsSource::Kept
911        );
912    }
913
914    /// A module dropped into a kept directory is worked out again, and its closure is granted.
915    #[test]
916    fn a_module_added_to_a_kept_directory_is_derived_again() {
917        let (_guard, root) = canonical_tempdir();
918        let lib = lib_with_one_module(&root);
919        let deps = root.join("deps");
920        std::fs::create_dir(&deps).unwrap();
921        std::fs::write(deps.join("libextra.so.1"), b"not elf").unwrap();
922        let Some(stand_in) = StandIn::new(&[&lib]) else {
923            return;
924        };
925        let first = stand_in.derive(&[]);
926        assert_eq!(first.runtimes[0].libraries, Vec::<PathBuf>::new());
927        assert_eq!(
928            stand_in.source(&stand_in.derive(&[])),
929            HostFactsSource::Kept
930        );
931
932        shared_object(
933            &lib.join("_extra.so"),
934            &["libextra.so.1"],
935            Some("$ORIGIN/../deps"),
936        );
937        stand_in.forget_run();
938        let again = stand_in.derive(&[]);
939
940        assert_eq!(
941            stand_in.source(&again),
942            derived(HostFactsReason::Changed, Some(lib.clone()))
943        );
944        assert!(stand_in.ran());
945        assert_eq!(
946            again.runtimes[0].libraries,
947            vec![deps.join("libextra.so.1")]
948        );
949        assert_eq!(again.runtimes, stand_in.derive_uncached(&[]).runtimes);
950        assert_eq!(
951            stand_in.source(&stand_in.derive(&[])),
952            HostFactsSource::Kept
953        );
954    }
955
956    /// Stores a valid entry, damages it with `damage`, and expects the next staging to work
957    /// the facts out again, equal to a derivation with no cache, and to leave a valid entry.
958    fn an_entry_damaged_by(damage: impl FnOnce(&Path)) {
959        let (_guard, root) = canonical_tempdir();
960        let lib = lib_with_one_module(&root);
961        let Some(stand_in) = StandIn::new(&[&lib]) else {
962            return;
963        };
964        let first = stand_in.derive(&[]);
965        assert!(holds_a_valid_entry(&stand_in.entry()));
966
967        damage(&stand_in.entry());
968        stand_in.forget_run();
969        let again = stand_in.derive(&[]);
970
971        assert_eq!(
972            stand_in.source(&again),
973            derived(HostFactsReason::Unreadable, None)
974        );
975        assert!(stand_in.ran(), "a damaged entry was trusted");
976        assert_eq!(again.runtimes, first.runtimes);
977        assert_eq!(again.runtimes, stand_in.derive_uncached(&[]).runtimes);
978        assert!(holds_a_valid_entry(&stand_in.entry()));
979        assert_eq!(
980            stand_in.source(&stand_in.derive(&[])),
981            HostFactsSource::Kept
982        );
983    }
984
985    /// Rewrites the JSON entry at `path` through `edit`, owner-only.
986    fn edit_entry(path: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
987        let mut value: serde_json::Value =
988            serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
989        edit(&mut value);
990        crate::murmur_home::write_private_file(path, value.to_string().as_bytes()).unwrap();
991    }
992
993    #[test]
994    fn an_entry_of_random_bytes_is_derived_again() {
995        an_entry_damaged_by(|path| {
996            let mut state: u64 = 0x9e37_79b9_7f4a_7c15;
997            let bytes: Vec<u8> = (0..4096)
998                .map(|_| {
999                    state ^= state << 13;
1000                    state ^= state >> 7;
1001                    state ^= state << 17;
1002                    state as u8
1003                })
1004                .collect();
1005            crate::murmur_home::write_private_file(path, &bytes).unwrap();
1006        });
1007    }
1008
1009    #[test]
1010    fn a_truncated_entry_is_derived_again() {
1011        an_entry_damaged_by(|path| {
1012            let bytes = std::fs::read(path).unwrap();
1013            crate::murmur_home::write_private_file(path, &bytes[..bytes.len() / 2]).unwrap();
1014        });
1015    }
1016
1017    #[test]
1018    fn an_entry_of_another_format_is_derived_again() {
1019        an_entry_damaged_by(|path| {
1020            edit_entry(path, |entry| {
1021                entry["format"] = serde_json::json!(HOST_FACTS_FORMAT + 1);
1022            });
1023        });
1024    }
1025
1026    #[test]
1027    fn an_entry_from_another_runtime_version_is_derived_again() {
1028        an_entry_damaged_by(|path| {
1029            edit_entry(path, |entry| entry["runtime_version"] = "0.0.0".into());
1030        });
1031    }
1032
1033    #[test]
1034    fn an_entry_from_another_interpreter_table_is_derived_again() {
1035        an_entry_damaged_by(|path| {
1036            edit_entry(path, |entry| {
1037                entry["kinds"][0]["args"] = serde_json::json!(["-c", "exit 0"]);
1038            });
1039        });
1040    }
1041
1042    #[test]
1043    fn a_group_readable_entry_is_derived_again() {
1044        an_entry_damaged_by(|path| {
1045            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
1046        });
1047    }
1048
1049    #[test]
1050    fn an_entry_that_is_a_symlink_to_a_valid_entry_is_derived_again() {
1051        an_entry_damaged_by(|path| {
1052            let target = path.with_file_name("elsewhere.json");
1053            std::fs::rename(path, &target).unwrap();
1054            std::os::unix::fs::symlink(&target, path).unwrap();
1055            assert!(holds_a_valid_entry(&target));
1056        });
1057    }
1058
1059    #[test]
1060    fn an_entry_for_another_binary_is_derived_again() {
1061        an_entry_damaged_by(|path| {
1062            edit_entry(path, |entry| {
1063                entry["binary"]["path"] = "/usr/bin/another".into();
1064            });
1065        });
1066    }
1067
1068    /// The cache never writes to standard error: a damaged, foreign or unusable entry is worked
1069    /// out again and prints exactly what a fresh derivation prints.
1070    #[test]
1071    fn the_cache_prints_nothing_of_its_own() {
1072        let source = include_str!("host_facts.rs");
1073        let production = &source[..source
1074            .find("#[cfg(all(test, target_os = \"linux\"))]")
1075            .unwrap()];
1076        for writer in ["runtime_err!", "eprintln!", "eprint!", "println!", "stderr"] {
1077            assert!(!production.contains(writer), "host_facts.rs uses {writer}");
1078        }
1079    }
1080
1081    /// The per-staging filtering runs live. An entry derived reporting A and B with `deps`
1082    /// writable is kept for a staging that can write B, granting A alone, and for one that can
1083    /// write neither, granting both; a staging whose closure walk reaches the file under `deps`
1084    /// the entry never read works the facts out again.
1085    #[test]
1086    fn a_kept_entry_is_filtered_against_each_stagings_roots() {
1087        let (_guard, root) = canonical_tempdir();
1088        let a = root.join("a");
1089        let b = root.join("b");
1090        let deps = root.join("deps");
1091        for dir in [&a, &b, &deps] {
1092            std::fs::create_dir(dir).unwrap();
1093        }
1094        shared_object(
1095            &a.join("ext.so"),
1096            &["libextra.so.1"],
1097            Some("$ORIGIN/../deps"),
1098        );
1099        std::fs::write(deps.join("libextra.so.1"), b"not elf").unwrap();
1100        let Some(stand_in) = StandIn::new(&[&a, &b]) else {
1101            return;
1102        };
1103
1104        let first = stand_in.derive(&[&deps]);
1105        assert_eq!(stand_in.source(&first), absent());
1106        assert_eq!(first.runtimes[0].dirs, vec![a.clone(), b.clone()]);
1107        assert_eq!(first.runtimes[0].libraries, Vec::<PathBuf>::new());
1108
1109        stand_in.forget_run();
1110        let without_b = stand_in.derive(&[&deps, &b]);
1111        assert_eq!(stand_in.source(&without_b), HostFactsSource::Kept);
1112        assert_eq!(without_b.runtimes[0].dirs, vec![a.clone()]);
1113        assert_eq!(
1114            without_b.runtimes,
1115            stand_in.derive_uncached(&[&deps, &b]).runtimes
1116        );
1117        stand_in.forget_run();
1118        let both = stand_in.derive(&[&deps]);
1119        assert_eq!(stand_in.source(&both), HostFactsSource::Kept);
1120        assert_eq!(both.runtimes, first.runtimes);
1121        assert!(!stand_in.ran());
1122
1123        let reached = stand_in.derive(&[]);
1124        assert_eq!(
1125            stand_in.source(&reached),
1126            derived(HostFactsReason::Changed, Some(deps.join("libextra.so.1")))
1127        );
1128        assert!(stand_in.ran());
1129        assert_eq!(
1130            reached.runtimes[0].libraries,
1131            vec![deps.join("libextra.so.1")]
1132        );
1133        assert_eq!(reached.runtimes, stand_in.derive_uncached(&[]).runtimes);
1134    }
1135
1136    /// A cache under one of the capsule's writable roots is neither read nor written.
1137    #[test]
1138    fn a_cache_under_a_writable_root_is_never_read_or_written() {
1139        let (_guard, root) = canonical_tempdir();
1140        let lib = lib_with_one_module(&root);
1141        let Some(stand_in) = StandIn::new(&[&lib]) else {
1142            return;
1143        };
1144        let first = stand_in.derive(&[]);
1145        let work = root.join("work");
1146        std::fs::create_dir(&work).unwrap();
1147        crate::murmur_home::hold_private_dir(&work.join("host_facts")).unwrap();
1148        let planted = work.join("host_facts").join(entry_name(&stand_in.binary));
1149        std::fs::copy(stand_in.entry(), &planted).unwrap();
1150        std::fs::set_permissions(&planted, std::fs::Permissions::from_mode(0o600)).unwrap();
1151        let planted_stamp = stamp_of(&planted);
1152
1153        stand_in.forget_run();
1154        let under = stand_in.derive_with(&[&work], &HostFactsCache::at(work.join("host_facts")));
1155        assert_eq!(stand_in.source(&under), not_kept());
1156        assert!(stand_in.ran(), "the entry under the writable root was read");
1157        assert_eq!(under.runtimes, first.runtimes);
1158        assert_eq!(stamp_of(&planted), planted_stamp, "the entry was rewritten");
1159        assert_eq!(
1160            std::fs::read_dir(work.join("host_facts")).unwrap().count(),
1161            1
1162        );
1163
1164        // A cache that does not exist yet under a writable root is not created there either.
1165        let fresh = work.join("fresh");
1166        let uncreated =
1167            stand_in.derive_with(&[&work], &HostFactsCache::at(fresh.join("host_facts")));
1168        assert_eq!(stand_in.source(&uncreated), not_kept());
1169        assert!(!fresh.join("host_facts").exists());
1170    }
1171
1172    /// A cache directory reached through a symlink is never used, and one wider than
1173    /// owner-only is narrowed and emptied before anything is kept in it.
1174    #[test]
1175    fn a_symlinked_or_wide_cache_directory_keeps_nothing() {
1176        let (_guard, root) = canonical_tempdir();
1177        let lib = lib_with_one_module(&root);
1178        let Some(stand_in) = StandIn::new(&[&lib]) else {
1179            return;
1180        };
1181        let first = stand_in.derive(&[]);
1182        let entry_file = entry_name(&stand_in.binary);
1183
1184        let real = root.join("real");
1185        crate::murmur_home::hold_private_dir(&real).unwrap();
1186        std::fs::copy(stand_in.entry(), real.join(&entry_file)).unwrap();
1187        std::fs::set_permissions(
1188            real.join(&entry_file),
1189            std::fs::Permissions::from_mode(0o600),
1190        )
1191        .unwrap();
1192        let real_stamp = stamp_of(&real.join(&entry_file));
1193        let link_home = root.join("linked");
1194        std::fs::create_dir(&link_home).unwrap();
1195        std::os::unix::fs::symlink(&real, link_home.join("host_facts")).unwrap();
1196        stand_in.forget_run();
1197        let linked = stand_in.derive_with(&[], &HostFactsCache::at(link_home.join("host_facts")));
1198        assert_eq!(stand_in.source(&linked), not_kept());
1199        assert!(stand_in.ran());
1200        assert_eq!(linked.runtimes, first.runtimes);
1201        assert_eq!(stamp_of(&real.join(&entry_file)), real_stamp);
1202
1203        let wide_home = root.join("wide");
1204        let wide = wide_home.join("host_facts");
1205        std::fs::create_dir_all(&wide).unwrap();
1206        std::fs::set_permissions(&wide, std::fs::Permissions::from_mode(0o755)).unwrap();
1207        std::fs::copy(stand_in.entry(), wide.join(&entry_file)).unwrap();
1208        std::fs::set_permissions(
1209            wide.join(&entry_file),
1210            std::fs::Permissions::from_mode(0o600),
1211        )
1212        .unwrap();
1213        // A second link outside the cache keeps the planted inode allocated, so the entry written
1214        // after the narrowing cannot be handed its number, as ext4 does with a just-freed inode.
1215        let planted_link = root.join("planted-entry");
1216        std::fs::hard_link(wide.join(&entry_file), &planted_link).unwrap();
1217        let planted_ino = std::fs::metadata(&planted_link).unwrap().ino();
1218        std::fs::write(wide.join("planted.json"), b"{}").unwrap();
1219        std::fs::create_dir(wide.join("subdir")).unwrap();
1220        stand_in.forget_run();
1221        let narrowed = stand_in.derive_with(&[], &HostFactsCache::at(&wide));
1222        assert_eq!(stand_in.source(&narrowed), not_kept());
1223        assert!(stand_in.ran(), "an entry in a wide directory was trusted");
1224        assert_eq!(narrowed.runtimes, first.runtimes);
1225        assert_eq!(
1226            std::fs::symlink_metadata(&wide).unwrap().mode() & 0o777,
1227            0o700
1228        );
1229        assert!(!wide.join("planted.json").exists());
1230        assert!(wide.join("subdir").is_dir());
1231        assert_ne!(
1232            std::fs::metadata(wide.join(&entry_file)).unwrap().ino(),
1233            planted_ino,
1234            "the entry found in the wide directory was kept"
1235        );
1236        assert_eq!(std::fs::metadata(&planted_link).unwrap().nlink(), 1);
1237        assert!(holds_a_valid_entry(&wide.join(&entry_file)));
1238        let kept = stand_in.derive_with(&[], &HostFactsCache::at(&wide));
1239        assert_eq!(stand_in.source(&kept), HostFactsSource::Kept);
1240    }
1241}