Skip to main content

detcore_model/
host_input.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! The host identity of a file a guest opened.
10//!
11//! Hermit maps each host inode it sees to a deterministic one in the order it
12//! first sees them, so a host file that is REPLACED while a run is going (a
13//! package manager rewriting `/etc/ld.so.cache` between the opens of two
14//! guest processes, for example) gives that run one more inode than the other
15//! run, and every later inode number differs. `hermit run --verify` then
16//! reports a divergence that the guest did not cause. Each verification run
17//! therefore records one [`HostInputRecord`] per host file the guest opens.
18//! Detcore reports each open to its global state
19//! ([`crate::config::Config::record_host_inputs`]), which writes the run's
20//! records to a side file ([`crate::config::Config::host_input_log`]) when the
21//! run ends, never part of the compared log. On a divergence the two runs'
22//! records are compared to name such a change.
23//!
24//! A run that writes a record meant to be reproduced later (a
25//! `--record-preemptions-to` schedule, or a `--summary-json` summary) also
26//! keeps a [`HostInputSet`]: each host file the guest opened, with the
27//! identity its first open found, and a digest of the set. A run that replays
28//! such a schedule compares the files it opens against the set and reports
29//! each one that changed ([`HostInputChange`]): Hermit does not make a
30//! changing filesystem deterministic, so a replay that reads different host
31//! files can diverge, and it then says why.
32
33use std::path::Path;
34
35use serde::Deserialize;
36use serde::Serialize;
37
38/// The host's identity for a file at the moment it was opened or captured.
39#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
40pub struct HostFileIdentity {
41    /// The host `st_dev`.
42    pub dev: u64,
43    /// The host `st_ino`.
44    pub ino: u64,
45    /// The host `st_size`.
46    pub size: i64,
47    /// The host `st_mtime`, seconds.
48    pub mtime_sec: i64,
49    /// The host `st_mtime`, nanoseconds.
50    pub mtime_nsec: i64,
51}
52
53impl HostFileIdentity {
54    /// The identity in a host `struct stat`.
55    pub fn from_stat(stat: &libc::stat) -> Self {
56        Self {
57            dev: stat.st_dev,
58            ino: stat.st_ino,
59            size: stat.st_size,
60            mtime_sec: stat.st_mtime,
61            mtime_nsec: stat.st_mtime_nsec,
62        }
63    }
64}
65
66/// A private copy of a captured host input. The launcher supplies this only
67/// for a copy it owns: `physical` authenticates the object the guest opened,
68/// while `origin` names the object whose bytes were captured. Aliases of the
69/// copy have the same provenance; an unrelated object at its usual guest path
70/// does not. This is host-only state, never a guest or record-format payload.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct HostInputSnapshot {
73    pub physical: HostFileIdentity,
74    pub origin: HostFileIdentity,
75    /// SHA-256 of the captured bytes, checked before origin attribution is
76    /// retained at shutdown, even if a rewrite preserved size and mtime.
77    pub content: String,
78}
79
80impl std::fmt::Display for HostFileIdentity {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(
83            f,
84            "dev:ino {}:{}:{} size {} mtime {}.{:09}",
85            libc::major(self.dev),
86            libc::minor(self.dev),
87            self.ino,
88            self.size,
89            self.mtime_sec,
90            self.mtime_nsec
91        )
92    }
93}
94
95/// One host file a guest opened: the path it named, made absolute against its
96/// working directory or directory descriptor, the host identity of the file
97/// the open reached (or the captured origin of an authenticated launcher-owned
98/// snapshot), and the open itself, as the thread (`dtid`) and that
99/// thread's syscall number (`syscall`, the `finish syscall #N` of the run's
100/// log) that made it. A path that is not UTF-8 is recorded lossily.
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct HostInputRecord {
103    pub path: String,
104    pub dtid: u64,
105    pub syscall: u64,
106    #[serde(flatten)]
107    pub identity: HostFileIdentity,
108    /// The content of the file the open found, as [`HostInput::content`],
109    /// read when the run had ended; absent where Detcore could not read it.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub content: Option<String>,
112    /// Whether the kernel opened the file for an exec, as the new image or
113    /// its program interpreter, rather than the guest by an open call. Such a
114    /// record has no open in the log, so the verification pattern walk
115    /// leaves it out; a [`HostInputSet`] keeps it.
116    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
117    pub exec: bool,
118    /// Whether the open reached a kernel object: by the descriptor's own
119    /// path ([`is_kernel_object`]), whatever the guest spelled (`/etc/mtab`
120    /// names `/proc/self/mounts`), or by the file's type, a FIFO, a socket or
121    /// a device wherever it is, which holds no bytes to compare and whose
122    /// times move with every write to it. A [`HostInputSet`] leaves it out,
123    /// as it leaves out a path spelled under `/proc`, `/sys` or `/dev`.
124    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
125    pub kernel_object: bool,
126}
127
128/// A path the guest itself rebound in its file namespace during the run:
129/// renamed to or from, linked, unlinked, made as a symlink, node or directory,
130/// removed as a directory, or opened to create (see
131/// [`crate::config::Config::record_host_inputs`]). Made absolute as an open's
132/// path is. A host file whose path, or a directory above it, the guest
133/// rebound may have been replaced by the guest, so such a change is never
134/// named as a host input change.
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
136#[serde(deny_unknown_fields)]
137pub struct HostMutationRecord {
138    pub mutated: String,
139}
140
141/// The last line of a complete host-input log: how many records, of both
142/// kinds, precede it. A log without it, or with another count, is incomplete.
143#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
144#[serde(deny_unknown_fields)]
145pub struct HostInputLogEnd {
146    pub records: u64,
147}
148
149/// One host file in a [`HostInputSet`]: its path and the identity the run's
150/// first open of that path found.
151#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
152#[serde(deny_unknown_fields)]
153pub struct HostInput {
154    pub path: String,
155    #[serde(flatten)]
156    pub identity: HostFileIdentity,
157    /// The SHA-256 (lowercase hex) of what the guest can read of the file:
158    /// a regular file's bytes, or the sorted names in a directory, without
159    /// those the guest itself made or removed there ([`guest_rebound`]).
160    /// Read when the run has ended, after every guest process exited, never
161    /// while the guest runs (Linux raises an inotify event the guest could
162    /// see for every open and read of a file), and only where the file still
163    /// has the identity that open found (a directory, the same device and
164    /// inode); present for a regular file of at most
165    /// [`HOST_INPUT_CONTENT_LIMIT`] bytes or a directory on a backend whose
166    /// tool can name the guest's descriptor. The guest sees a host file's
167    /// bytes and size, and a directory's names, not their host inodes or
168    /// times (Hermit virtualizes those, and makes some directories, such as
169    /// its private `/tmp`, afresh for each run), so where both runs have it,
170    /// the content decides whether the file changed ([`host_input_changed`]).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub content: Option<String>,
173}
174
175/// The largest file whose bytes Detcore hashes for a [`HostInput`]: a larger
176/// one is compared by its identity alone, so that a guest that opens big data
177/// files does not pay to read them twice.
178pub const HOST_INPUT_CONTENT_LIMIT: u64 = 32 << 20;
179
180/// Whether `now` shows a different file than `recorded`, for the same path:
181/// other bytes, where both runs hashed them, and otherwise another identity.
182/// A file rewritten with the same bytes (as a package manager's `ldconfig`
183/// does) is therefore not a change where its content was hashed.
184pub fn host_input_changed(recorded: &HostInput, now: &HostInput) -> bool {
185    match (&recorded.content, &now.content) {
186        (Some(recorded), Some(now)) => recorded != now,
187        _ => recorded.identity != now.identity,
188    }
189}
190
191/// The host files one run's guest opened, for a later run that reproduces it
192/// to compare against: one [`HostInput`] per path, sorted by path, and a
193/// digest of the set, so that two runs can be compared by one string. The
194/// digest is the SHA-256 (lowercase hex) of one line per file: its path and
195/// the SHA-256 of its bytes where they were hashed, otherwise its identity.
196/// So it agrees with [`host_input_changed`]: two runs that read the same
197/// bytes at the same paths get the same digest whatever the files' inodes
198/// and times. A path is left out when it is not absolute, names a kernel
199/// object ([`is_kernel_object`], also by the descriptor's own path,
200/// [`HostInputRecord::kernel_object`]), or was rebound by the guest itself
201/// ([`guest_rebound`]): those can change between runs without the host's
202/// files changing.
203#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
204#[serde(deny_unknown_fields)]
205pub struct HostInputSet {
206    pub digest: String,
207    pub files: Vec<HostInput>,
208}
209
210impl HostInputSet {
211    /// The entry this set holds for `path`. A set read from a file is
212    /// searched as its files lie; [`Self::normalized`] sorts it first.
213    pub fn file(&self, path: &str) -> Option<&HostInput> {
214        self.files
215            .binary_search_by(|file| file.path.as_str().cmp(path))
216            .ok()
217            .map(|index| &self.files[index])
218    }
219
220    /// This set with its files sorted by path and one entry per path (the
221    /// first), as a run writes it, whatever a file it was read from held.
222    pub fn normalized(mut self) -> Self {
223        self.files.sort_by(|a, b| a.path.cmp(&b.path));
224        self.files
225            .dedup_by(|later, earlier| later.path == earlier.path);
226        self
227    }
228}
229
230/// A host file that differs between a recorded run and the run that
231/// reproduces it ([`host_input_changed`]): other bytes, or, where either run
232/// could not hash them, another identity (replaced, or modified in size or
233/// time). The reproduction may read other bytes than the recorded run did,
234/// and diverge where it does.
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236#[serde(deny_unknown_fields)]
237pub struct HostInputChange {
238    pub path: String,
239    pub recorded: HostFileIdentity,
240    pub now: HostFileIdentity,
241}
242
243impl std::fmt::Display for HostInputChange {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        write!(
246            f,
247            "host input {} changed since the recording ({} then, {} now); the run may diverge where the guest reads it",
248            self.path, self.recorded, self.now
249        )
250    }
251}
252
253/// The marker that begins the stderr line a reproducing run prints for each
254/// [`HostInputChange`]. A harness greps for it, as for
255/// `HERMIT_SKID_OVERSHOOT`; keep it stable.
256pub const HOST_INPUT_CHANGED_SINCE_RECORDING_MARKER: &str =
257    "HERMIT_HOST_INPUT_CHANGED_SINCE_RECORDING";
258
259/// Whether `path` names a kernel object under `/proc`, `/sys` or `/dev`
260/// rather than a host input file: its metadata is not file content, and
261/// Hermit virtualizes those trees separately.
262pub fn is_kernel_object(path: &str) -> bool {
263    ["/proc", "/sys", "/dev"].iter().any(|root| {
264        path == *root
265            || path
266                .strip_prefix(root)
267                .is_some_and(|rest| rest.starts_with('/'))
268    })
269}
270
271/// Whether the guest may have rebound `path`, or a directory above it, in its
272/// file namespace: one of `mutated` ([`HostMutationRecord`] paths) is `path`
273/// or an ancestor of it. Detcore reports `/`, the ancestor of every absolute
274/// path, for a change whose path it could not establish. A path that is not
275/// absolute cannot be matched against those changes at all, so it counts as
276/// rebound too.
277pub fn guest_rebound<'a>(mutated: impl IntoIterator<Item = &'a String>, path: &str) -> bool {
278    let path = Path::new(path);
279    !path.is_absolute()
280        || mutated
281            .into_iter()
282            .any(|rebound| path.starts_with(Path::new(rebound)))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn identity(ino: u64, size: i64) -> HostFileIdentity {
290        HostFileIdentity {
291            dev: 33,
292            ino,
293            size,
294            mtime_sec: 1_700_000_000,
295            mtime_nsec: 5,
296        }
297    }
298
299    fn set(files: &[(&str, HostFileIdentity)]) -> HostInputSet {
300        HostInputSet {
301            digest: String::new(),
302            files: files
303                .iter()
304                .map(|(path, identity)| HostInput {
305                    path: (*path).to_owned(),
306                    identity: *identity,
307                    content: None,
308                })
309                .collect(),
310        }
311    }
312
313    #[test]
314    fn a_rebound_ancestor_or_a_relative_path_counts_as_rebound() {
315        let mutated = vec!["/work".to_owned()];
316        assert!(guest_rebound(&mutated, "/work/out.txt"));
317        assert!(guest_rebound(&mutated, "/work"));
318        assert!(!guest_rebound(&mutated, "/workspace/in.txt"));
319        assert!(guest_rebound(&mutated, "relative.txt"));
320        assert!(guest_rebound(&["/".to_owned()], "/etc/ld.so.cache"));
321    }
322
323    #[test]
324    fn a_set_finds_each_file_by_its_path() {
325        let recorded = set(&[
326            ("/etc/ld.so.cache", identity(9, 36887)),
327            ("/lib64/libc.so.6", identity(10, 2_000_000)),
328        ]);
329        assert_eq!(
330            recorded.file("/etc/ld.so.cache").map(|file| file.identity),
331            Some(identity(9, 36887))
332        );
333        assert_eq!(
334            recorded.file("/lib64/libc.so.6").map(|file| file.identity),
335            Some(identity(10, 2_000_000))
336        );
337        assert_eq!(recorded.file("/etc"), None);
338    }
339
340    #[test]
341    fn hashed_content_decides_over_identity() {
342        let file = |ino: u64, content: Option<&str>| HostInput {
343            path: "/etc/ld.so.cache".to_owned(),
344            identity: identity(ino, 36887),
345            content: content.map(str::to_owned),
346        };
347        // Rewritten with the same bytes: a new inode, no change.
348        assert!(!host_input_changed(
349            &file(9, Some("aa")),
350            &file(11, Some("aa"))
351        ));
352        // Same inode and size, other bytes: a change.
353        assert!(host_input_changed(
354            &file(9, Some("aa")),
355            &file(9, Some("bb"))
356        ));
357        // Either side unhashed: the identity decides.
358        assert!(host_input_changed(&file(9, None), &file(11, Some("aa"))));
359        assert!(!host_input_changed(&file(9, Some("aa")), &file(9, None)));
360    }
361
362    #[test]
363    fn the_change_line_names_the_path_and_both_identities() {
364        let change = HostInputChange {
365            path: "/etc/ld.so.cache".to_owned(),
366            recorded: identity(9, 36887),
367            now: identity(11, 36839),
368        };
369        let line = change.to_string();
370        assert!(line.starts_with("host input /etc/ld.so.cache changed since the recording"));
371        assert!(line.contains("size 36887"), "{line}");
372        assert!(line.contains("size 36839"), "{line}");
373    }
374}