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}