1use 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
76pub struct HostInputs {
77 pub records: Vec<HostInputRecord>,
79 pub mutated: Vec<String>,
82 pub complete: bool,
87}
88
89pub 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 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#[derive(Default)]
131struct Pattern<'a> {
132 seen: HashMap<&'a str, (Vec<HostFileIdentity>, HostFileIdentity)>,
133}
134
135impl<'a> Pattern<'a> {
136 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#[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 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
184pub 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 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 let before = previous?;
219 if (before.dev, before.ino) == (record.identity.dev, record.identity.ino) {
220 return None;
221 }
222 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
243pub 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
272pub 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
293fn 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
311pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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(¬_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 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 #[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 #[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 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 #[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 #[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}