Skip to main content

hermit/
host_input_change.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//! Name a host file that changed during one verification run.
10//!
11//! Each run of `hermit run --verify` records the host identity of every file
12//! the guest opens, in order, with the open that found it (see
13//! `detcore_model::host_input`). A file that a run opens several times shows a
14//! PATTERN of identities: each open finds an identity it has found at that
15//! path before, or a new one. A file the guest itself creates, rewrites or
16//! replaces changes at the same opens in both runs, even though its host inode
17//! numbers and times differ between them. So the two runs' patterns agree
18//! unless something outside the guest changed a file underneath one run: that
19//! run finds a new identity at an open where the other run found a known one.
20//!
21//! Only one kind of change is named: the file was REPLACED, so the open found
22//! a different host inode (`dev`, `ino`). Hermit mints deterministic inodes in
23//! the order it first sees host inodes, so a replacement gives that run one
24//! more inode and shifts every later deterministic inode number, whatever the
25//! guest does with the file. A change of size or time alone is not named.
26//!
27//! A pattern difference is evidence of a host change only if the runs had not
28//! diverged yet. A guest that behaves differently in the two runs can, for
29//! example, replace a file in one run only, and then the patterns differ too,
30//! but that behaviour shows in the compared log before the open. So the
31//! difference names the cause of a divergence only when the comparison
32//! compared everything (one strict enough for bitwise parity) and the open
33//! that found it is recorded, in BOTH runs' logs, before the first divergent
34//! record ([`explains_divergence`]): up to that record both runs did the same
35//! things, so the guest did not cause the difference.
36//!
37//! Positions are records in the log, not times. On a backend whose log is not
38//! one stream in time order, such as SaBRe, which appends the records its
39//! guest forwards after the coordinator's own, each part is still in time
40//! order, and the comparator reports the first difference in the earliest
41//! part. An open in a later part than that difference is therefore refused,
42//! and an open before it in the same part did happen before it.
43//!
44//! Nor is a change named when the guest itself rebound that path, or a
45//! directory above it, in either run: renamed to or from it, linked or
46//! unlinked it, or created something there. Those operations can replace the
47//! file without the compared log showing any difference (a rename onto a name
48//! that already names the same file succeeds and changes nothing), so a
49//! replacement there may be the guest's own.
50//!
51//! Nothing else is concluded. The walk stops at the first open whose path,
52//! thread or syscall differs between the runs and at the first difference that
53//! is not a replacement, an incomplete log names nothing, an open whose finish
54//! record a log does not show exactly once is not placed, and paths below
55//! `/proc`, `/sys` and `/dev` are skipped: those are kernel objects whose
56//! metadata is not file content and which Hermit virtualizes separately. So
57//! are the records of files the kernel opened for an exec
58//! (`HostInputRecord::exec`), which have no open in the log to place.
59
60use std::collections::HashMap;
61use std::path::Path;
62
63use detcore::detlog::DetLogEvent;
64use detcore_model::host_input::HostFileIdentity;
65use detcore_model::host_input::HostInputLogEnd;
66use detcore_model::host_input::HostInputRecord;
67use detcore_model::host_input::HostMutationRecord;
68use detcore_model::host_input::guest_rebound;
69use detcore_model::host_input::is_kernel_object;
70
71use crate::canonical_verdict::InfrastructureError;
72use crate::canonical_verdict::VerificationRun;
73
74/// One run's host-input log.
75#[derive(Clone, Debug, Default, Eq, PartialEq)]
76pub struct HostInputs {
77    /// The records, in the order the run made the opens.
78    pub records: Vec<HostInputRecord>,
79    /// The paths the guest itself rebound in its file namespace during the
80    /// run ([`HostMutationRecord`]).
81    pub mutated: Vec<String>,
82    /// Whether every line parsed and the log ends with the
83    /// [`HostInputLogEnd`] line counting exactly these lines. Detcore writes
84    /// the log once, when the run ends, so a run that did not end normally, a
85    /// failed write or a cut file leaves it incomplete.
86    pub complete: bool,
87}
88
89/// Read one run's log. A missing or empty file is an incomplete log.
90pub fn read_host_inputs(path: &Path) -> std::io::Result<HostInputs> {
91    match std::fs::read_to_string(path) {
92        Ok(text) => Ok(parse_host_inputs(&text)),
93        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(HostInputs::default()),
94        Err(error) => Err(error),
95    }
96}
97
98fn parse_host_inputs(text: &str) -> HostInputs {
99    let mut lines: Vec<&str> = text.lines().collect();
100    let end = lines
101        .pop()
102        .and_then(|line| serde_json::from_str::<HostInputLogEnd>(line).ok());
103    let mut records = Vec::new();
104    let mut mutated = Vec::new();
105    let mut parsed = true;
106    for line in &lines {
107        if let Ok(record) = serde_json::from_str::<HostInputRecord>(line) {
108            // A file the kernel opened for an exec has no open in the log;
109            // the walk below sees exactly the opens it saw before such
110            // records existed.
111            if !record.exec {
112                records.push(record);
113            }
114        } else if let Ok(mutation) = serde_json::from_str::<HostMutationRecord>(line) {
115            mutated.push(mutation.mutated);
116        } else {
117            parsed = false;
118        }
119    }
120    let complete = parsed && end.is_some_and(|end| end.records == lines.len() as u64);
121    HostInputs {
122        records,
123        mutated,
124        complete,
125    }
126}
127
128/// The opens one run has made so far: for each path, the distinct
129/// identities found there and the identity found at its latest open.
130#[derive(Default)]
131struct Pattern<'a> {
132    seen: HashMap<&'a str, (Vec<HostFileIdentity>, HostFileIdentity)>,
133}
134
135impl<'a> Pattern<'a> {
136    /// Record an open. Returns whether its identity is one this run has not
137    /// found at the path before, and the identity of the path's previous open.
138    fn open(&mut self, record: &'a HostInputRecord) -> (bool, Option<HostFileIdentity>) {
139        match self.seen.get_mut(record.path.as_str()) {
140            None => {
141                self.seen.insert(
142                    record.path.as_str(),
143                    (vec![record.identity], record.identity),
144                );
145                (true, None)
146            }
147            Some((distinct, latest)) => {
148                let previous = std::mem::replace(latest, record.identity);
149                let new = !distinct.contains(&record.identity);
150                if new {
151                    distinct.push(record.identity);
152                }
153                (new, Some(previous))
154            }
155        }
156    }
157}
158
159/// A pattern difference between the runs: in `run`, the open that thread
160/// `dtid` made as its syscall number `syscall` found `path` changed from
161/// `before` to `after`, while the other run found a known identity there.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct PatternDifference {
164    pub run: VerificationRun,
165    pub path: String,
166    pub dtid: u64,
167    pub syscall: u64,
168    pub before: HostFileIdentity,
169    pub after: HostFileIdentity,
170}
171
172impl PatternDifference {
173    /// The typed outcome, for a difference that [`explains_divergence`].
174    pub fn into_infrastructure_error(self) -> InfrastructureError {
175        InfrastructureError::HostInputChanged {
176            run: self.run,
177            path: self.path,
178            before: self.before,
179            after: self.after,
180        }
181    }
182}
183
184/// The first open, in order, at which exactly one run found an identity it
185/// had not found at that path before while the other run found one it had,
186/// when that identity is a different host inode.
187///
188/// Returns `None` when either log is incomplete, when the patterns agree up to
189/// the first open whose path, thread or syscall number differs, when both
190/// runs found an unfamiliar identity at the same open, which no single run
191/// explains, or when the first such difference keeps the host inode.
192pub fn find_pattern_difference(run1: &HostInputs, run2: &HostInputs) -> Option<PatternDifference> {
193    if !run1.complete || !run2.complete {
194        return None;
195    }
196    let mut pattern1 = Pattern::default();
197    let mut pattern2 = Pattern::default();
198    for (left, right) in run1.records.iter().zip(&run2.records) {
199        if (&left.path, left.dtid, left.syscall) != (&right.path, right.dtid, right.syscall) {
200            return None;
201        }
202        // A kernel object by its path, or by what the open reached (a FIFO,
203        // a socket or a device, whose times move with every write, so an
204        // open finding its same inode at another time would end the walk
205        // below before a real change at a later open).
206        if is_kernel_object(&left.path) || left.kernel_object || right.kernel_object {
207            continue;
208        }
209        let (new1, previous1) = pattern1.open(left);
210        let (new2, previous2) = pattern2.open(right);
211        let (run, previous, record) = match (new1, new2) {
212            (true, false) => (VerificationRun::Run1, previous1, left),
213            (false, true) => (VerificationRun::Run2, previous2, right),
214            _ => continue,
215        };
216        // Both runs find a new identity at a path's first open, so only a
217        // path seen before reaches this point.
218        let before = previous?;
219        if (before.dev, before.ino) == (record.identity.dev, record.identity.ino) {
220            return None;
221        }
222        // The guest may have replaced it itself, through operations whose
223        // effects the compared log does not show (a rename onto a name that
224        // already names the same file succeeds and changes nothing).
225        if [run1, run2]
226            .iter()
227            .any(|run| guest_rebound(&run.mutated, &record.path))
228        {
229            return None;
230        }
231        return Some(PatternDifference {
232            run,
233            path: record.path.clone(),
234            dtid: record.dtid,
235            syscall: record.syscall,
236            before,
237            after: record.identity,
238        });
239    }
240    None
241}
242
243/// Where an open sits in a run's log (`contents`): the position, as the
244/// comparator counts records (`detcore::logdiff::log_record_events`), of the
245/// record on which thread `dtid` finished its syscall number `syscall`, an
246/// open. `None` when the log does not show that record exactly once: a log
247/// without it says nothing, and a thread number and syscall count can repeat
248/// (a thread that execs from a non-leader thread takes the leader's number and
249/// keeps its own count), so a repeated one is ambiguous; also `None` for a log
250/// the comparator could not read either.
251///
252/// The open's record is a `syscall_result` whose Hermit-written prefix reads
253/// `[syscall][detcore, dtid D] finish syscall #N: ` followed by `openat(`,
254/// `open(` or `creat(`, the calls Detcore's openat handler serves. The prefix
255/// is read where it first occurs in the record, before any guest-chosen text
256/// such as a path.
257pub fn open_position(contents: &str, dtid: u64, syscall: u64) -> Option<usize> {
258    let mut found = None;
259    for (position, human, event) in detcore::logdiff::log_record_events(contents).ok()? {
260        if matches!(event, Some(DetLogEvent::SyscallResult { .. }))
261            && finished_open(human) == Some((dtid, syscall))
262        {
263            if found.is_some() {
264                return None;
265            }
266            found = Some(position);
267        }
268    }
269    found
270}
271
272/// Fallback placement by path and syscall number, for backends whose
273/// per-thread syscall numbering restarts across process images (DBT), so
274/// `(dtid, syscall)` names more than one finished open in the log. The
275/// open that found a new identity is the last finished open of `path`
276/// with that syscall number; without a match nothing is placed.
277pub fn open_position_for_path(contents: &str, syscall: u64, path: &str) -> Option<usize> {
278    let mut found = None;
279    for (position, human, event) in detcore::logdiff::log_record_events(contents).ok()? {
280        if !matches!(event, Some(DetLogEvent::SyscallResult { .. })) {
281            continue;
282        }
283        let Some((_, number)) = finished_open(human) else {
284            continue;
285        };
286        if number == syscall && human.contains(&format!("\"{path}\"")) {
287            found = Some(position);
288        }
289    }
290    found
291}
292
293/// The thread and that thread's syscall number of a finished open's record,
294/// read from the Hermit-written prefix where it first occurs.
295fn finished_open(human: &str) -> Option<(u64, u64)> {
296    const PREFIX: &str = "[syscall][detcore, dtid ";
297    let rest = &human[human.find(PREFIX)? + PREFIX.len()..];
298    let (dtid, rest) = rest.split_once("] finish syscall #")?;
299    let (syscall, call) = rest.split_once(": ")?;
300    let numeric = |digits: &str| {
301        (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
302            .then(|| digits.parse().ok())
303            .flatten()
304    };
305    ["openat(", "open(", "creat("]
306        .iter()
307        .any(|name| call.starts_with(name))
308        .then_some((numeric(dtid)?, numeric(syscall)?))
309}
310
311/// Whether a pattern difference explains a divergence: the comparison was one
312/// strict enough for bitwise parity (`complete_comparison`), and the open that
313/// found the difference (placed by [`open_position`] in each run's log) is
314/// recorded before the first divergent record in both logs
315/// (`first_divergent_positions`, the comparator's positions of that record),
316/// so the two runs had done the same things up to that open. Without every
317/// position nothing is attributed.
318pub fn explains_divergence(
319    complete_comparison: bool,
320    open_positions: [Option<usize>; 2],
321    first_divergent_positions: Option<(Option<usize>, Option<usize>)>,
322) -> bool {
323    let Some((Some(left), Some(right))) = first_divergent_positions else {
324        return false;
325    };
326    complete_comparison
327        && matches!(open_positions, [Some(open1), Some(open2)] if open1 < left && open2 < right)
328}
329
330#[cfg(test)]
331mod tests {
332    use detcore::detlog::record_suffix;
333
334    use super::*;
335
336    fn identity(ino: u64) -> HostFileIdentity {
337        HostFileIdentity {
338            dev: 33,
339            ino,
340            size: 100,
341            mtime_sec: 1_700_000_000,
342            mtime_nsec: 0,
343        }
344    }
345
346    /// A complete log of opens by thread 3, as its syscalls 10, 11, ...
347    fn opens(records: &[(&str, u64)]) -> HostInputs {
348        HostInputs {
349            records: records
350                .iter()
351                .enumerate()
352                .map(|(index, (path, ino))| HostInputRecord {
353                    path: (*path).into(),
354                    dtid: 3,
355                    syscall: index as u64 + 10,
356                    identity: identity(*ino),
357                    content: None,
358                    exec: false,
359                    kernel_object: false,
360                })
361                .collect(),
362            mutated: Vec::new(),
363            complete: true,
364        }
365    }
366
367    /// The sar divergence: two processes open /etc/ld.so.cache, and a package
368    /// update replaces it between them in run 1 only.
369    #[test]
370    fn a_file_replaced_during_run_1_is_named() {
371        let run1 = opens(&[
372            ("/etc/ld.so.cache", 7),
373            ("/lib64/libc.so.6", 9),
374            ("/etc/ld.so.cache", 8),
375        ]);
376        let run2 = opens(&[
377            ("/etc/ld.so.cache", 8),
378            ("/lib64/libc.so.6", 9),
379            ("/etc/ld.so.cache", 8),
380        ]);
381        assert_eq!(
382            find_pattern_difference(&run1, &run2),
383            Some(PatternDifference {
384                run: VerificationRun::Run1,
385                path: "/etc/ld.so.cache".into(),
386                dtid: 3,
387                syscall: 12,
388                before: identity(7),
389                after: identity(8),
390            })
391        );
392        let named = find_pattern_difference(&run2, &run1).unwrap();
393        assert_eq!(named.run, VerificationRun::Run2);
394        assert_eq!(
395            named.into_infrastructure_error().to_string(),
396            "host input changed during run 2: /etc/ld.so.cache (dev:ino 0:33:7 size 100 mtime 1700000000.000000000 -> dev:ino 0:33:8 size 100 mtime 1700000000.000000000)"
397        );
398    }
399
400    /// A file the guest creates and replaces itself has new host inodes in
401    /// every run, but changes at the same opens in both.
402    #[test]
403    fn a_file_the_guest_creates_and_replaces_is_not_a_host_change() {
404        let run1 = opens(&[("/test/out", 100), ("/test/out", 100), ("/test/out", 101)]);
405        let run2 = opens(&[("/test/out", 200), ("/test/out", 200), ("/test/out", 201)]);
406        assert_eq!(find_pattern_difference(&run1, &run2), None);
407    }
408
409    /// A file rewritten in place by the guest changes size and time at the
410    /// same opens in both runs, with different times.
411    #[test]
412    fn a_file_the_guest_rewrites_is_not_a_host_change() {
413        let mut run1 = opens(&[("/test/log", 5), ("/test/log", 5)]);
414        let mut run2 = opens(&[("/test/log", 6), ("/test/log", 6)]);
415        run1.records[1].identity.size = 200;
416        run1.records[1].identity.mtime_sec = 1;
417        run2.records[1].identity.size = 200;
418        run2.records[1].identity.mtime_sec = 2;
419        assert_eq!(find_pattern_difference(&run1, &run2), None);
420    }
421
422    /// Identical opens, as in a divergence with another cause.
423    #[test]
424    fn matching_patterns_name_nothing() {
425        let run = opens(&[("/etc/a", 1), ("/etc/b", 2), ("/etc/a", 1)]);
426        assert_eq!(find_pattern_difference(&run, &run), None);
427    }
428
429    /// Past the first open whose path, thread or syscall number differs the
430    /// runs no longer make the same opens, so a later difference is not
431    /// evidence.
432    #[test]
433    fn nothing_is_concluded_after_the_runs_open_differently() {
434        let run1 = opens(&[("/etc/a", 1), ("/etc/b", 2), ("/etc/a", 3)]);
435        let run2 = opens(&[("/etc/a", 1), ("/etc/c", 2), ("/etc/a", 1)]);
436        assert_eq!(find_pattern_difference(&run1, &run2), None);
437
438        let changed = opens(&[("/etc/a", 1), ("/etc/a", 2)]);
439        let mut later = opens(&[("/etc/a", 1), ("/etc/a", 1)]);
440        assert!(find_pattern_difference(&changed, &later).is_some());
441        later.records[1].syscall += 1;
442        assert_eq!(find_pattern_difference(&changed, &later), None);
443        later.records[1].syscall -= 1;
444        later.records[1].dtid += 1;
445        assert_eq!(find_pattern_difference(&changed, &later), None);
446    }
447
448    /// A record missing from one run would shift its pattern against the
449    /// other's, so an incomplete log names nothing.
450    #[test]
451    fn an_incomplete_log_names_nothing() {
452        let run1 = opens(&[("/etc/x", 1), ("/etc/x", 2)]);
453        let mut run2 = opens(&[("/etc/x", 1), ("/etc/x", 1)]);
454        assert!(find_pattern_difference(&run1, &run2).is_some());
455        run2.complete = false;
456        assert_eq!(find_pattern_difference(&run1, &run2), None);
457        assert_eq!(find_pattern_difference(&run2, &run1), None);
458    }
459
460    /// A FIFO the guest writes is recorded as a kernel object
461    /// ([`HostInputRecord::kernel_object`]), and passed over: its second open
462    /// finds the same inode at another time in one run, which would end the
463    /// walk, so a real change at a later open is still named.
464    #[test]
465    fn a_fifo_whose_times_moved_does_not_hide_a_later_change() {
466        let mut run1 = opens(&[("/tmp/f", 4), ("/tmp/f", 4), ("/etc/a", 1), ("/etc/a", 2)]);
467        let mut run2 = opens(&[("/tmp/f", 4), ("/tmp/f", 4), ("/etc/a", 1), ("/etc/a", 1)]);
468        run1.records[1].identity.mtime_sec += 1;
469        let flag = |runs: [&mut HostInputs; 2], kernel_object: bool| {
470            for run in runs {
471                run.records[0].kernel_object = kernel_object;
472                run.records[1].kernel_object = kernel_object;
473            }
474        };
475        flag([&mut run1, &mut run2], true);
476        let named = find_pattern_difference(&run1, &run2).expect("the change to /etc/a is named");
477        assert_eq!(
478            (named.run, named.path.as_str(), named.syscall),
479            (VerificationRun::Run1, "/etc/a", 13)
480        );
481        // Unflagged, the FIFO's moved time ends the walk first.
482        flag([&mut run1, &mut run2], false);
483        assert_eq!(find_pattern_difference(&run1, &run2), None);
484    }
485
486    #[test]
487    fn kernel_objects_are_not_host_inputs() {
488        let run1 = opens(&[("/proc/stat", 1), ("/proc/stat", 2)]);
489        let run2 = opens(&[("/proc/stat", 1), ("/proc/stat", 1)]);
490        assert_eq!(find_pattern_difference(&run1, &run2), None);
491        assert!(is_kernel_object("/sys/bus/i2c/devices"));
492        assert!(is_kernel_object("/dev"));
493        assert!(!is_kernel_object("/device/x"));
494        assert!(!is_kernel_object("/etc/ld.so.cache"));
495    }
496
497    /// A file that changes back: run 1 finds A, B, A and run 2 finds A, A, A.
498    /// The change is at the second open.
499    #[test]
500    fn a_change_and_its_reversal_is_named_at_the_change() {
501        let run1 = opens(&[("/etc/x", 1), ("/etc/x", 2), ("/etc/x", 1)]);
502        let run2 = opens(&[("/etc/x", 1), ("/etc/x", 1), ("/etc/x", 1)]);
503        let difference = find_pattern_difference(&run1, &run2).unwrap();
504        assert_eq!(
505            (difference.syscall, difference.before, difference.after),
506            (11, identity(1), identity(2))
507        );
508    }
509
510    /// Only a log whose lines all parse and that ends with the count of
511    /// exactly its records is complete.
512    #[test]
513    fn only_a_log_with_its_end_line_is_complete() {
514        let line = |ino| serde_json::to_string(&opens(&[("/etc/a", ino)]).records[0]).unwrap();
515        let parsed = parse_host_inputs(&format!("{}\n{}\n{{\"records\":2}}\n", line(1), line(2)));
516        assert!(parsed.complete);
517        assert_eq!(parsed.records.len(), 2);
518        let empty_run = parse_host_inputs("{\"records\":0}\n");
519        assert!(empty_run.complete);
520        assert!(empty_run.records.is_empty());
521        for incomplete in [
522            format!("{}\n{}\n", line(1), line(2)),
523            format!("{}\n{}\n{{\"records\":3}}\n", line(1), line(2)),
524            format!("{}\n{{\"records\":2}}\n", line(1)),
525            format!(
526                "{}\n{{\"path\":\"/etc/b\",\"dtid\"\n{{\"records\":2}}\n",
527                line(1)
528            ),
529            String::new(),
530        ] {
531            assert!(!parse_host_inputs(&incomplete).complete, "{incomplete}");
532        }
533        let directory = tempfile::tempdir().unwrap();
534        let absent = read_host_inputs(&directory.path().join("absent")).unwrap();
535        assert!(!absent.complete);
536    }
537
538    /// A log record as Hermit writes it: a wall-clock prefix, then the
539    /// record, which is how the comparator separates records.
540    fn record(text: &str) -> String {
541        format!("2026-10-05T19:00:00.000000Z {text}")
542    }
543
544    fn commit(turn: u64) -> String {
545        record(&format!(
546            "INFO detcore::scheduler: COMMIT turn {turn}{}",
547            record_suffix(DetLogEvent::SchedulerCommit {
548                scheduler_turn: turn,
549                virtual_nanoseconds: turn * 10,
550                internal_io_poll: false,
551                runtime_maps_read: false,
552            })
553        ))
554    }
555
556    fn finished(dtid: u64, syscall: u64, call: &str) -> String {
557        record(&format!(
558            "INFO detcore: DETLOG [syscall][detcore, dtid {dtid}] finish syscall #{syscall}: {call} = Ok(3){}",
559            record_suffix(DetLogEvent::SyscallResult {
560                finished_syscall_number: syscall,
561            })
562        ))
563    }
564
565    fn finish(dtid: u64, syscall: u64) -> String {
566        finished(dtid, syscall, "openat(-100, \"/etc/a\", O_RDONLY)")
567    }
568
569    /// An open is placed at its own finish record's position, as the
570    /// comparator counts records (from 1: the first record follows the empty
571    /// text before the first wall-clock prefix).
572    #[test]
573    fn an_open_is_placed_at_its_finish_record() {
574        let log = [commit(4), finish(3, 12), commit(5), finish(5, 12)].join("\n");
575        assert_eq!(open_position(&log, 3, 12), Some(2));
576        assert_eq!(open_position(&log, 5, 12), Some(4));
577        assert_eq!(open_position(&log, 3, 13), None);
578        for call in ["open(\"/etc/a\", O_RDONLY)", "creat(\"/etc/a\", 0644)"] {
579            let log = [commit(4), finished(3, 12, call)].join("\n");
580            assert_eq!(open_position(&log, 3, 12), Some(2), "{call}");
581        }
582        assert_eq!(open_position("not a Hermit log", 3, 12), None);
583    }
584
585    #[test]
586    fn path_fallback_places_the_last_open_of_that_path_and_syscall() {
587        let log = [
588            commit(4),
589            finish(3, 12),
590            commit(5),
591            finished(9, 12, "openat(-100, 0x1 -> \"/etc/a\", O_RDONLY)"),
592        ]
593        .join("\n");
594        // (dtid, syscall) is ambiguous here; the fallback names the last
595        // finished open of the changed path with that syscall number.
596        assert_eq!(open_position_for_path(&log, 12, "/etc/a"), Some(4));
597        assert_eq!(open_position_for_path(&log, 12, "/etc/b"), None);
598        assert_eq!(open_position_for_path(&log, 13, "/etc/a"), None);
599    }
600
601    /// Only a structured record of a finished open, read at Hermit's own
602    /// prefix and found exactly once, places an open. A thread number and
603    /// syscall count that repeat (a non-leader exec takes the leader's number
604    /// and keeps its own count) are ambiguous, and a guest path that spells
605    /// another syscall's prefix is not that syscall.
606    #[test]
607    fn only_one_anchored_open_record_places_an_open() {
608        let repeated = [commit(4), finish(3, 12), commit(12), finish(3, 12)].join("\n");
609        assert_eq!(open_position(&repeated, 3, 12), None);
610        let not_an_open = [commit(4), finished(3, 12, "stat(\"/etc/a\")")].join("\n");
611        assert_eq!(open_position(&not_an_open, 3, 12), None);
612        let prose_only = record(
613            "INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #12: openat(..) = Ok(3)",
614        );
615        assert_eq!(open_position(&prose_only, 3, 12), None);
616        // dtid 3's syscall 7 opens a file whose name spells dtid 3's syscall 12.
617        let spoofed = finished(
618            3,
619            7,
620            "openat(-100, \"[syscall][detcore, dtid 3] finish syscall #12: openat(\")",
621        );
622        let log = [commit(2), spoofed, commit(9), finish(3, 12)].join("\n");
623        assert_eq!(open_position(&log, 3, 12), Some(4));
624        assert_eq!(open_position(&log, 3, 7), Some(2));
625    }
626
627    /// A pattern difference explains a divergence only when the comparison
628    /// compared everything and its open is recorded, in both runs, before
629    /// the first divergent record. A guest that behaved differently first,
630    /// such as one that replaced the file in one run only, has diverged by
631    /// the open, and is not explained.
632    #[test]
633    fn only_an_open_before_the_divergence_in_both_runs_explains_it() {
634        let divergent = Some((Some(40), Some(42)));
635        assert!(explains_divergence(true, [Some(10), Some(10)], divergent));
636        assert!(explains_divergence(true, [Some(39), Some(41)], divergent));
637        assert!(!explains_divergence(false, [Some(10), Some(10)], divergent));
638        assert!(!explains_divergence(true, [Some(40), Some(10)], divergent));
639        assert!(!explains_divergence(true, [Some(10), Some(42)], divergent));
640        assert!(!explains_divergence(true, [None, Some(10)], divergent));
641        assert!(!explains_divergence(true, [Some(10), None], divergent));
642        assert!(!explains_divergence(true, [Some(10), Some(10)], None));
643        assert!(!explains_divergence(
644            true,
645            [Some(10), Some(10)],
646            Some((None, Some(42)))
647        ));
648        assert!(!explains_divergence(
649            true,
650            [Some(10), Some(10)],
651            Some((Some(40), None))
652        ));
653    }
654
655    /// A replacement of a file whose path, or a directory above it, the
656    /// guest rebound in either run is not named: the guest may have made it
657    /// with operations whose effects the compared log does not show.
658    #[test]
659    fn a_replacement_where_the_guest_rebound_the_path_is_not_named() {
660        let run1 = opens(&[("/data/F", 1), ("/data/F", 2)]);
661        let run2 = opens(&[("/data/F", 2), ("/data/F", 2)]);
662        assert!(find_pattern_difference(&run1, &run2).is_some());
663        for (in_run1, rebound) in [(true, "/data/F"), (false, "/data/F"), (false, "/data")] {
664            let (mut run1, mut run2) = (run1.clone(), run2.clone());
665            let run = if in_run1 { &mut run1 } else { &mut run2 };
666            run.mutated.push(rebound.into());
667            assert_eq!(find_pattern_difference(&run1, &run2), None, "{rebound}");
668        }
669        let mut unrelated = run1.clone();
670        unrelated.mutated = vec!["/data/F.tmp".into(), "/tmp".into(), "/data/Fx".into()];
671        assert!(find_pattern_difference(&unrelated, &run2).is_some());
672        assert!(guest_rebound(&["/".into()], "/etc/ld.so.cache"));
673        assert!(!guest_rebound(&["/etc/ld.so".into()], "/etc/ld.so.cache"));
674        // An open recorded under a path that could not be made absolute
675        // (through a directory descriptor with no known path) cannot be
676        // matched against the guest's changes, so it is never named, with or
677        // without them.
678        assert!(guest_rebound(&[], "F"));
679        assert!(guest_rebound(&["/".into()], "F"));
680        let relative1 = opens(&[("F", 1), ("F", 2)]);
681        let relative2 = opens(&[("F", 2), ("F", 2)]);
682        assert_eq!(find_pattern_difference(&relative1, &relative2), None);
683    }
684
685    /// A log's mutation lines count toward its end line like its records.
686    #[test]
687    fn a_log_with_mutation_lines_is_complete_when_its_end_counts_them() {
688        let record = serde_json::to_string(&opens(&[("/etc/a", 1)]).records[0]).unwrap();
689        let mutation = serde_json::to_string(&HostMutationRecord {
690            mutated: "/data/F".into(),
691        })
692        .unwrap();
693        let parsed = parse_host_inputs(&format!("{record}\n{mutation}\n{{\"records\":2}}\n"));
694        assert!(parsed.complete);
695        assert_eq!(parsed.records.len(), 1);
696        assert_eq!(parsed.mutated, vec!["/data/F".to_string()]);
697        assert!(!parse_host_inputs(&format!("{record}\n{mutation}\n{{\"records\":1}}\n")).complete);
698    }
699
700    /// A change of size or time that keeps the host inode is not the
701    /// replacement whose new inode shifts Hermit's numbering, so it is not
702    /// named, and the walk concludes nothing past it.
703    #[test]
704    fn a_change_that_keeps_the_host_inode_is_not_named() {
705        let mut touched = opens(&[("/etc/x", 1), ("/etc/x", 1), ("/etc/y", 5), ("/etc/y", 6)]);
706        touched.records[1].identity.mtime_sec += 1;
707        let steady = opens(&[("/etc/x", 1), ("/etc/x", 1), ("/etc/y", 5), ("/etc/y", 5)]);
708        assert_eq!(find_pattern_difference(&touched, &steady), None);
709        touched.records[1].identity.mtime_sec -= 1;
710        assert_eq!(
711            find_pattern_difference(&touched, &steady).unwrap().path,
712            "/etc/y"
713        );
714    }
715}