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