Skip to main content

detcore/
logdiff.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//! Everything to do with post-processing hermit/detcore logs.
10
11use core::fmt::Display;
12use core::fmt::Formatter;
13use core::fmt::Result;
14use std::cmp::Ordering;
15use std::collections::HashMap;
16use std::io::Write;
17use std::path::Path;
18use std::process::Command;
19use std::str::FromStr;
20use std::sync::LazyLock;
21
22use clap;
23use clap::Parser;
24use regex::Regex;
25use tempfile::NamedTempFile;
26
27use crate::detlog::DetLogEvent;
28use crate::detlog::DetLogRecord;
29
30/// The in-band line a bounded log writer emits when a run's log file reaches
31/// its configured size bound.
32///
33/// This lives beside the comparator, not only beside the writer that emits it,
34/// because the comparator is the consumer that must not ignore it. The
35/// comparison below walks the two message lists in lockstep, so it can only
36/// ever speak for the retained prefix; a pair of logs that were both cut at the
37/// bound would agree on that prefix while the discarded tails were never looked
38/// at. Recognizing the marker is what keeps that from being reported as a
39/// match. `hermit-cli`'s writer emits this exact line and a unit test there
40/// binds the two by running the real writer's output through
41/// [`log_was_truncated`].
42///
43/// This is the COMPLETE sentence, not a prefix of it. An earlier version
44/// matched only the leading `=== HERMIT LOG TRUNCATED:` fragment anywhere in
45/// the text, which made the refusal fire on guest-controlled content: DETLOG
46/// records syscall path arguments verbatim, so a guest that merely touched a
47/// path containing that fragment poisoned its own `--verify` -- including with
48/// the bound already disabled, so the refusal's own remedy was unavailable.
49pub const TRUNCATION_MARKER: &str = "=== HERMIT LOG TRUNCATED: reached the configured size bound \
50     (HERMIT_LOG_MAX_BYTES). Output beyond this point was DISCARDED. The run itself continued and \
51     was NOT affected. ===";
52
53/// Versioned policy token for the only prefix removed by `BitwiseInfoV1`.
54pub const STRIP_WALL_CLOCK_PREFIX_V1: &str = "real-wall-clock-prefix/v1";
55
56/// Versioned policy token for lossless host-address ordinalization.
57pub const CANON_ADDRESS_ORDINAL_V1: &str = "host-address-to-first-appearance-ordinal/v1";
58
59/// Whether `log_text` is the output of a writer that hit its size bound.
60///
61/// The question this answers is "was THIS LOG truncated", which is not the same
62/// question as "does this text mention the marker". The distinction is the
63/// whole point: log text contains guest-controlled bytes, so a predicate that
64/// merely searches for the marker is a predicate a guest can satisfy.
65///
66/// Two anchors make it discriminate, and both are properties of how the marker
67/// is produced rather than of what it says:
68///
69/// 1. **End of file.** `BoundedWriter::announce_truncation` writes the marker
70///    at the moment the bound is crossed and every later write is discarded, so
71///    on a truncated log the marker is the final bytes. Trailing newlines are
72///    ignored; nothing else may follow.
73/// 2. **A whole line.** The marker is emitted preceded by its own newline, so
74///    it occupies a line by itself. A DETLOG line always carries a
75///    `<timestamp> LEVEL target:` prefix and therefore can never equal it. Nor
76///    can a guest forge the line break: DETLOG renders path arguments with
77///    `Debug`, which escapes a newline to a literal backslash-`n`, so guest
78///    bytes cannot start a line at all.
79///
80/// A genuinely truncated log still satisfies both, so this narrows the
81/// predicate to the real condition without weakening it.
82pub fn log_was_truncated(log_text: &str) -> bool {
83    let trimmed = log_text.trim_end_matches(['\n', '\r']);
84    if !trimmed.ends_with(TRUNCATION_MARKER) {
85        return false;
86    }
87    // `ends_with` matched, so this offset is on a character boundary.
88    let marker_start = trimmed.len() - TRUNCATION_MARKER.len();
89    marker_start == 0 || trimmed.as_bytes()[marker_start - 1] == b'\n'
90}
91
92/// Selects the set of log messages compared for determinism.
93#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
94pub enum LogComparisonMode {
95    /// Compare deterministic Detcore and scheduler messages.
96    #[default]
97    Deterministic,
98    /// Compare every INFO message exactly, while leaving any captured DEBUG or
99    /// TRACE messages available for diagnostics. This is the observation
100    /// envelope used by the `BitwiseInfoV1` verification policy.
101    Info,
102    /// Compare every captured log message without filtering.
103    FullTrace,
104}
105
106/// Reader-facing names for the two inputs to a log comparison.
107///
108/// Standalone `hermit log-diff` keeps the historical `run 1` / `run 2`
109/// vocabulary through [`Default`]. Verification callers override these names
110/// when the inputs are a recording and its replay.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ComparisonSideLabels {
113    /// Name of the left comparison input.
114    pub left: String,
115    /// Name of the right comparison input.
116    pub right: String,
117}
118
119impl ComparisonSideLabels {
120    /// Construct labels for a caller that knows what each input represents.
121    pub fn new(left: impl Into<String>, right: impl Into<String>) -> Self {
122        Self {
123            left: left.into(),
124            right: right.into(),
125        }
126    }
127}
128
129impl Default for ComparisonSideLabels {
130    fn default() -> Self {
131        Self::new("run 1", "run 2")
132    }
133}
134
135/// Options for calling `log_diff`.
136#[derive(Debug, Parser, Clone)]
137pub struct LogDiffOpts {
138    /// UNSAFE: strips numbers and temporary paths before comparison.
139    ///
140    /// This erases timestamps and syscall values that bitwise parity exists to
141    /// compare. Never use this option to make a failing parity diff pass; doing
142    /// so is cheating. It is only for non-parity diagnostic localization.
143    #[clap(long = "unsafe-strip-lines")]
144    pub strip_lines: bool,
145
146    /// Canonicalize host memory addresses before comparison WITHOUT erasing them.
147    ///
148    /// Only addresses a producer has explicitly marked with the
149    /// `<hostaddr 0x...>` wrapper (see [`host_addr`]) are canonicalized; each
150    /// distinct marked address is rewritten to an ordinal placeholder
151    /// `<addr{N}>` assigned by order of first appearance within a single run
152    /// (see `canonicalize_addresses_in_line`). Unlike [`Self::strip_lines`],
153    /// this discards ONLY the host-specific raw pointer value: it preserves
154    /// identity (same address -> same ordinal), ordering (introduction
155    /// sequence), and aliasing (two names for one address collapse to one
156    /// ordinal), and it leaves every other byte -- virtual-time timestamps,
157    /// syscall argument/result values, counts, sizes, flags -- untouched for an
158    /// exact comparison. In canonical parity, this preserves the ability to DETECT a
159    /// difference (allocation-order or aliasing changes still diverge), which
160    /// wholesale stripping throws away.
161    ///
162    /// The marker is REQUIRED (a bare `0x...` literal is left exact) because
163    /// nothing in the compared DETLOG stream can otherwise distinguish a varying
164    /// host pointer from a reproducible hex value -- syscall arguments printed
165    /// `{:#x}` (e.g. `flock` `operation=0x2` vs `0x6`), guest memory ranges,
166    /// content digests, cpuid leaves. A blanket `0x` canonicalization would
167    /// collapse those too, silently erasing real syscall-argument divergence:
168    /// a "softer strip" and exactly the fake-green this policy exists to prevent.
169    ///
170    /// `--canonical-info` and `--json` always apply it. This flag applies it
171    /// to the other comparisons too (DETLOG/COMMIT subsets, `--ignore-lines`),
172    /// which is what separate-process comparisons such as hermit-verify need:
173    /// ASLR moves the marked launcher pointers between the two processes
174    /// (<https://github.com/rrnewton/hermit/issues/3412>).
175    #[clap(long = "canonicalize-host-addresses")]
176    pub canonicalize_addresses: bool,
177
178    /// The internal message set to compare.
179    #[clap(skip)]
180    pub comparison: LogComparisonMode,
181
182    /// Reader-facing names for the left and right inputs. Standalone callers
183    /// retain the historical defaults; verification paths bind their own.
184    #[clap(skip)]
185    pub side_labels: ComparisonSideLabels,
186
187    /// Require current producer-owned DETLOG records instead of using the
188    /// historical text compatibility path.
189    #[clap(skip)]
190    pub require_structured_events: bool,
191
192    /// Print both selected logs exactly as they are passed to the comparator.
193    ///
194    /// The output names the active comparison policy and reflects every
195    /// selection, wall-clock-prefix removal, and normalization step.
196    #[clap(long)]
197    pub print_logs: bool,
198
199    /// Limit the number of differences printed. Set to 0 for no limit.
200    #[clap(long, default_value = "20")]
201    pub limit: u64,
202
203    /// Before comparison, filter out lines which contain this substring.
204    #[clap(long)]
205    pub ignore_lines: Vec<String>,
206
207    /// Show this many completed syscalls before each side-specific divergence point.
208    /// Set to 0 to omit history.
209    #[clap(long, default_value = "0")]
210    pub syscall_history: u64,
211    /// Disable colored console output for line diffs.
212    #[clap(long)]
213    pub no_color: bool,
214
215    /// Do not consider "COMMIT" messages for deterministic checks.
216    #[clap(long)]
217    pub skip_commit: bool,
218
219    /// Do not consider "DETLOG" messages for deterministic checks.
220    #[clap(long)]
221    pub skip_detlog: bool,
222
223    /// Use git diff instead of the internal, basic log comparison.
224    #[clap(long)]
225    pub git_diff: bool,
226
227    /// In case --skip-detlog=false this parameter further filters which
228    /// "DETLOG" messages will be included for deterministic checks
229    #[clap(long, default_values = &["syscall", "syscallresult", "other"])]
230    pub include_detlogs: Vec<DetLogFilter>,
231}
232
233impl LogDiffOpts {
234    fn is_skip(&self, filter: DetLogFilter) -> bool {
235        !self.include_detlogs.contains(&filter)
236    }
237
238    fn skip_detlog(&self, entry: &LogMessage<'_>) -> bool {
239        if self.skip_detlog {
240            return true;
241        }
242
243        if is_detlog_syscall(entry) && self.is_skip(DetLogFilter::Syscall) {
244            return true;
245        }
246        if is_detlog_syscall_result(entry) && self.is_skip(DetLogFilter::SyscallResult) {
247            return true;
248        }
249
250        if !is_detlog_syscall(entry)
251            && !is_detlog_syscall_result(entry)
252            && self.is_skip(DetLogFilter::Other)
253        {
254            return true;
255        }
256
257        false
258    }
259
260    fn filter_deterministic<'a>(&self, v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
261        v.iter()
262            .filter_map(|message| {
263                if (is_detlog(message)
264                    && !self.skip_detlog(message)
265                    && !is_scheduler_committed_time(message))
266                    || (is_commit(message)
267                        && !self.skip_commit
268                        && !is_internal_io_poll_commit(message))
269                {
270                    Some(*message)
271                } else {
272                    None
273                }
274            })
275            .collect()
276    }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum LogNormalization {
281    Exact,
282    Stripped,
283    Canonical,
284}
285
286/// The scope and normalization that jointly determine a log comparison.
287///
288/// Construct this once from [`LogDiffOpts`], then use it for selection,
289/// transformation, and the displayed policy name so those three facts cannot
290/// drift apart.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292struct LogComparisonPolicy {
293    comparison: LogComparisonMode,
294    normalization: LogNormalization,
295}
296
297impl LogComparisonPolicy {
298    fn from_options(options: &LogDiffOpts) -> Self {
299        let normalization = if options.strip_lines {
300            LogNormalization::Stripped
301        } else if options.canonicalize_addresses {
302            LogNormalization::Canonical
303        } else {
304            LogNormalization::Exact
305        };
306        Self {
307            comparison: options.comparison,
308            normalization,
309        }
310    }
311
312    fn name(self) -> &'static str {
313        match (self.comparison, self.normalization) {
314            (LogComparisonMode::Deterministic, LogNormalization::Exact) => "Deterministic",
315            (LogComparisonMode::Deterministic, LogNormalization::Stripped) => "Stripped",
316            (LogComparisonMode::Deterministic, LogNormalization::Canonical) => {
317                "Deterministic with Canonical host-address normalization"
318            }
319            (LogComparisonMode::Info, LogNormalization::Exact) => "Info",
320            (LogComparisonMode::Info, LogNormalization::Stripped) => {
321                "Info with Stripped normalization"
322            }
323            (LogComparisonMode::Info, LogNormalization::Canonical) => "Canonical",
324            (LogComparisonMode::FullTrace, LogNormalization::Exact) => "FullTrace",
325            (LogComparisonMode::FullTrace, LogNormalization::Stripped) => {
326                "FullTrace with Stripped normalization"
327            }
328            (LogComparisonMode::FullTrace, LogNormalization::Canonical) => {
329                "FullTrace with Canonical host-address normalization"
330            }
331        }
332    }
333}
334
335/// Indicates which DETLOG entries to be used for log-diff comparison
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum DetLogFilter {
338    ///the start of syscall will be used for logdiff
339    Syscall,
340    ///the syscall result  will be used for logdiff
341    SyscallResult,
342    ///all other unspecified DETLOG entries will be used for logdiff
343    Other,
344}
345
346impl FromStr for DetLogFilter {
347    type Err = anyhow::Error;
348
349    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
350        match s.to_lowercase().as_str() {
351            "syscall" => Ok(DetLogFilter::Syscall),
352            "syscallresult" => Ok(DetLogFilter::SyscallResult),
353            "other" => Ok(DetLogFilter::Other),
354            _ => Err(anyhow::Error::msg(format!(
355                "unknown value {} for DetLogFilter",
356                s
357            ))),
358        }
359    }
360}
361
362/// N.B. we don't want to specify two different notions of "default", so we use the
363/// `Clap` instance above.
364impl Default for LogDiffOpts {
365    fn default() -> Self {
366        let v: Vec<String> = vec![];
367        LogDiffOpts::parse_from(v.iter())
368    }
369}
370
371/// In fully-deterministic modes, many log lines should be fully determinstic across runs.
372/// But as that is a work-in-progress, this utility strips known-nondeterministic
373/// information from logs.
374///
375/// This erasure is deliberately lossy and is NOT a parity claim: it backs the
376/// `Stripped` comparator only (`bitwise_parity: false`). `BitwiseInfoV1`
377/// canonicalizes rather than erases -- see `canonicalize_addresses_in_line`.
378/// Lossy as it is, each pattern must still erase only what it names: erasing a
379/// neighbouring field turns a real divergence into a reported match.
380///
381/// Example input/output:
382///   `Input:  COMMIT turn 3, dettid 231635 using resources Resources { tid: DetPid { inner: 231635 }, resources: {Path("/proc/231635/fd/1"): W} }`
383///   `Output: COMMIT turn <NUM>, dettid <NUM> using resources Resources { tid: DetPid { inner: <NUM> }, resources: {Path("/proc/<pid>/fd/<num>"): W} }`
384///
385/// As you can see this is overkill and smarter strategies would be possible. For example,
386/// ones that remember and post-facto-determinize certain identifiers.
387pub fn strip_log_entry(log: &str) -> String {
388    // Memory addresses, like 0x7fcfb7e7d450
389    //
390    // TODO: use a debug allocator that increases only, never reusing. Also, consider
391    // post-facto processing all of these into new virtual addresses based on the order they're seen.
392    static RE0: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b0[xX][A-Fa-f0-9]+\b").unwrap());
393
394    // Every number, plus common duration suffixes so fractional timing jitter is
395    // not left behind. This one is terrible overkill: for `hermit run` itself and
396    // for all command tests the full contents of a COMMIT line should already be
397    // deterministic, so nothing here should need erasing. It is retained for
398    // `spawn_fn_*` variants, which fork from another process and so exercise only
399    // a *partial* detcore setup without a true process tree of their own.
400    //
401    // N.B. RE4 must run BEFORE this pattern, or `800.709_180s` is consumed here as
402    // a bare number and never reaches the duration rule.
403    static RE1: LazyLock<Regex> =
404        LazyLock::new(|| Regex::new(r"\b[\d][\d_]*(?:\.[\d][\d_]*)?(?:ns|us|µs|ms)?\b").unwrap());
405
406    // A quoted /tmp path. `[^"]*` stops at the path's OWN closing quote: a greedy
407    // `.*` here ran to the last quote on the line and erased every field after the
408    // path, so two entries differing only downstream of a /tmp path compared equal.
409    //
410    // TODO: only strip this information if the config specified to the host /tmp through.
411    // Otherwise we can determinize /tmp access fully.
412    static RE2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"/tmp/[^"]*""#).unwrap());
413
414    // TODO: only strip this one if we're allowing through the host /proc or failing to determinize tids/pids:
415    static RE3: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/proc/[\d]+/").unwrap());
416
417    // TODO: only strip this if we're running a library-based test where we can't
418    // guarantee the starting state of the allocator/etc.
419    static RE4: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[\d][\d_.]*s\b").unwrap());
420
421    let log = RE4.replace_all(log, "<NANOSECONDS>");
422    let log = RE3.replace_all(&log, "/proc/<PID>/");
423    let log = RE0.replace_all(&log, "<ADDR>");
424    let log = RE1.replace_all(&log, "<NUM>");
425    let log = RE2.replace_all(&log, "/tmp/<somewhere>\"");
426    String::from(log)
427}
428
429/// Wrap a host memory address so `canonicalize_addresses_in_line` will
430/// canonicalize it. Producers that print a genuinely host-specific pointer
431/// (one that varies run-to-run, e.g. a supervisor-side allocation) should emit
432/// it via this helper -- `<hostaddr 0x7fcfb7e7d450>` -- instead of a bare
433/// `0x...` literal. Only marked addresses are canonicalized, so reproducible hex
434/// (syscall arguments, guest memory ranges, digests) is compared exactly.
435///
436/// Command bootstrap logs use this marker for proven launcher-image pointers
437/// before the first successful exec. Guest pointers, syscall scalars, and
438/// addresses without that provenance remain exact. Marking an address preserves
439/// its identity and aliasing through the per-run ordinal mapping below.
440pub fn host_addr(addr: usize) -> String {
441    format!("<hostaddr {addr:#x}>")
442}
443
444/// Rewrite each MARKED host memory address (`<hostaddr 0x...>`, see
445/// [`host_addr`]) in `line` to an ordinal placeholder `<addr{N}>`, numbered by
446/// order of first appearance within a single run. `map`/`next` carry the per-run
447/// assignment state threaded across all of that run's lines, so `next` should
448/// start at 1 and the same `map`/`next` must be reused for every line of one run
449/// (and a FRESH pair used for the other run).
450///
451/// Canonical parity strips the wall-clock prefix, canonicalizes these marked
452/// addresses, and compares everything else exactly. This step differs
453/// from [`strip_log_entry`]'s `<ADDR>` erasure in one decisive way: erasure maps
454/// every address to a single token, so two runs that allocate in a DIFFERENT
455/// ORDER, or that ALIAS differently (one address printed twice vs. two distinct
456/// addresses), compare EQUAL -- the exact defect parity exists to catch. An
457/// ordinal assigned by first appearance keeps identity, order, and aliasing, so
458/// those cases still diverge while a pure ASLR-shift (same structure, different
459/// raw values) compares equal.
460///
461/// Only the explicit `<hostaddr ...>` marker is canonicalized. A bare `0x...`
462/// literal is left byte-for-byte for exact comparison: a blanket regex cannot
463/// tell a varying host pointer from a reproducible syscall argument printed
464/// `{:#x}` (`flock` `operation=0x2` vs `0x6`, `membarrier` bitmasks), so
465/// canonicalizing every hex token would erase real syscall-argument divergence.
466/// Decimal values (virtual-time timestamps, counts, sizes) are likewise exact.
467fn canonicalize_addresses_in_line(
468    line: &str,
469    map: &mut HashMap<String, usize>,
470    next: &mut usize,
471) -> String {
472    // Match ONLY the explicit host-address marker emitted by `host_addr`; the
473    // captured group is the raw `0x...` value used as the ordinal key.
474    static RE_HOSTADDR: LazyLock<Regex> =
475        LazyLock::new(|| Regex::new(r"<hostaddr (0[xX][A-Fa-f0-9]+)>").unwrap());
476
477    RE_HOSTADDR
478        .replace_all(line, |caps: &regex::Captures| {
479            let addr = &caps[1];
480            let ord = match map.get(addr) {
481                Some(existing) => *existing,
482                None => {
483                    let assigned = *next;
484                    *next += 1;
485                    map.insert(addr.to_string(), assigned);
486                    assigned
487                }
488            };
489            format!("<addr{ord}>")
490        })
491        .into_owned()
492}
493
494fn messages_for_comparison(
495    messages: &[LogMessage<'_>],
496    policy: LogComparisonPolicy,
497) -> Vec<String> {
498    match policy.normalization {
499        LogNormalization::Stripped => messages
500            .iter()
501            .map(|message| strip_log_entry(message.text))
502            .collect(),
503        LogNormalization::Canonical => {
504            let mut addresses = HashMap::new();
505            let mut next_address = 1usize;
506            messages
507                .iter()
508                .map(|message| {
509                    canonicalize_addresses_in_line(message.text, &mut addresses, &mut next_address)
510                })
511                .collect()
512        }
513        LogNormalization::Exact => messages
514            .iter()
515            .map(|message| message.text.to_owned())
516            .collect(),
517    }
518}
519
520#[cfg(test)]
521fn canonical_info_from_str(contents: &str) -> std::io::Result<Vec<String>> {
522    canonical_info_from_str_with_filter(contents, |_| true)
523}
524
525fn canonical_info_from_str_with_filter(
526    contents: &str,
527    keep_record: impl Fn(&str) -> bool,
528) -> std::io::Result<Vec<String>> {
529    let info = filter_infos(
530        &extract_log_messages(contents)?
531            .into_iter()
532            .filter(|record| keep_record(record.text))
533            .collect::<Vec<_>>(),
534    );
535    let opts = LogDiffOpts {
536        canonicalize_addresses: true,
537        comparison: LogComparisonMode::Info,
538        ..Default::default()
539    };
540    Ok(messages_for_comparison(
541        &info,
542        LogComparisonPolicy::from_options(&opts),
543    ))
544}
545
546/// Print the canonical INFO messages that strict verification would compare for
547/// one captured log.
548///
549/// This removes the real wall-clock prefix and rewrites only explicitly marked
550/// host addresses to first-appearance ordinals. It does not run the lossy
551/// `--unsafe-strip-lines` transformation: scheduler turns, virtual time, syscall
552/// values, counts, flags, and every other substantive byte are preserved.
553pub fn write_canonical_info(file: &Path, writer: &mut impl Write) -> std::io::Result<usize> {
554    write_canonical_info_with_filter(file, writer, |_| true)
555}
556
557/// Render the canonical INFO messages that `BitwiseInfoV1` would compare from
558/// bytes already captured by the caller.
559///
560/// Unlike [`write_canonical_info`], this fixed-policy form requires UTF-8 and
561/// current structured DETLOG events and refuses a bounded-writer truncation.
562/// A harness can therefore render retained comparison records from the exact
563/// immutable bytes it compared without reopening a mutable pathname.
564pub fn write_bitwise_info_v1_bytes(
565    bytes: &[u8],
566    side_label: &str,
567    writer: &mut impl Write,
568) -> std::io::Result<usize> {
569    let contents = std::str::from_utf8(bytes).map_err(|error| {
570        std::io::Error::new(
571            std::io::ErrorKind::InvalidData,
572            format!("{side_label} is not UTF-8: {error}"),
573        )
574    })?;
575    if log_was_truncated(contents) {
576        return Err(std::io::Error::new(
577            std::io::ErrorKind::InvalidData,
578            format!("{side_label} was truncated at the configured size bound"),
579        ));
580    }
581    let records = extract_log_messages(contents)
582        .map_err(|error| std::io::Error::new(error.kind(), format!("{side_label} {error}")))?;
583    validate_structured_events(side_label, &records, true)?;
584    let info = filter_infos(&records);
585    let options = bitwise_info_v1_options(ComparisonSideLabels::new(side_label, side_label));
586    let messages = messages_for_comparison(&info, LogComparisonPolicy::from_options(&options));
587    for message in &messages {
588        writeln!(writer, "{message}")?;
589    }
590    Ok(messages.len())
591}
592
593/// Print canonical INFO messages after applying a caller-supplied record filter.
594///
595/// Every record is parsed and its level tag validated before `keep_record` is
596/// consulted. Existing callers should use [`write_canonical_info`]; backend
597/// adapters use this form to exclude transport-only records at their boundary.
598/// This low-level hook does not name the predicate, so any product verdict or
599/// JSON report using it must bind the function to a typed, serialized policy.
600pub fn write_canonical_info_with_filter(
601    file: &Path,
602    writer: &mut impl Write,
603    keep_record: impl Fn(&str) -> bool,
604) -> std::io::Result<usize> {
605    let bytes = std::fs::read(file)?;
606    let contents = std::str::from_utf8(&bytes).map_err(|error| {
607        std::io::Error::new(
608            std::io::ErrorKind::InvalidData,
609            format!("{} is not UTF-8: {error}", file.display()),
610        )
611    })?;
612    let messages = canonical_info_from_str_with_filter(contents, keep_record)?;
613    for message in &messages {
614        writeln!(writer, "{message}")?;
615    }
616    Ok(messages.len())
617}
618
619/// Separate a full, continuous log into discrete (possibly-multiline) log messages,
620/// stripping off the timestamps in the process.  Return lines tagged with their
621/// index number.
622/// The timestamp that begins every log record. A record runs from one match to
623/// the next, so records may span multiple lines -- which is why comparison is
624/// done on records and never on lines or bytes.
625static RECORD_START: LazyLock<Regex> = LazyLock::new(|| {
626    Regex::new(r"((Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d \d\d:\d\d:\d\d\.\d+|\d+-\d\d-\d\dT\d\d:\d\d:\d\d.\d+Z) +")
627        .unwrap()
628});
629
630/// Byte offsets at which each record starts.
631fn record_starts(contents: &str) -> Vec<usize> {
632    RECORD_START
633        .find_iter(contents)
634        .map(|m| m.start())
635        .collect()
636}
637
638/// How many records in `contents` are known COMPLETE.
639///
640/// A record is complete only once the *next* record has begun, because nothing
641/// else marks its end. In a log still being written the final record may be
642/// half-flushed, so it is never counted and never compared: a partial write must
643/// not read as a difference. A buffer with one record start has zero complete
644/// records, and that is a real answer, not a failure.
645pub fn complete_record_count(contents: &str) -> usize {
646    record_starts(contents).len().saturating_sub(1)
647}
648
649/// The prefix of `contents` holding exactly its first `n` complete records.
650///
651/// Returns `None` when fewer than `n` complete records are present, so a caller
652/// can tell "the runs agree over n records" apart from "n records have not been
653/// written yet". Those two must never collapse into one answer.
654pub fn take_complete_records(contents: &str, n: usize) -> Option<&str> {
655    let starts = record_starts(contents);
656    if n == 0 {
657        return Some(&contents[..0]);
658    }
659    // Record n-1 ends where record n begins, so n complete records require n+1 starts.
660    starts.get(n).map(|end| &contents[..*end])
661}
662
663/// Split a log into tagged records, REFUSING rather than panicking on a line
664/// that carries no level tag.
665///
666/// This used to `panic!`, and a panic is the wrong failure mode for a
667/// diagnostic tool -- people reach for `log-diff` when something is ALREADY
668/// broken, and unwinding at them is the least helpful thing it can do. Worse,
669/// the `--json` consumer could not tell a crash from a real verdict: the report
670/// came back `verdict: no_result` with null counts, which reads as "no
671/// comparison was reached" rather than "the tool died".
672///
673/// This is still FAIL-CLOSED -- an unrecognised line refuses the whole
674/// comparison rather than being skipped. Skipping would silently change the
675/// compared surface, which is exactly what `RecordEnvelopePolicy` exists to
676/// make explicit and versioned. Choosing what to exclude is a disclosed policy
677/// decision, not something a parser should do on its own initiative.
678///
679/// The refusal names the offending line, because in practice the cause is a
680/// backend emitting its own untagged diagnostics into the same stream --
681/// measured: DBT writes fourteen `detcore-dbt: ...` startup lines, which is
682/// what makes a ptrace-vs-DBT comparison impossible today.
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684struct LogMessage<'a> {
685    index: usize,
686    text: &'a str,
687    event: Option<DetLogEvent>,
688}
689
690fn extract_log_messages(contents: &str) -> std::io::Result<Vec<LogMessage<'_>>> {
691    let ts = &*RECORD_START;
692    let tag = Regex::new("^(ERROR|WARN|INFO|DEBUG|TRACE) ").unwrap();
693    ts.split(contents) // Not aware of a streaming version of this RE split operation.
694        .enumerate()
695        .map(|(i, s)| (i, s.trim()))
696        .filter(|(_, s)| !s.is_empty())
697        .map(|(i, s)| {
698            // Only let through lines that start with one of the expected tags:
699            if !tag.is_match(s) {
700                return Err(std::io::Error::new(
701                    std::io::ErrorKind::InvalidData,
702                    format!(
703                        "log line {i} has no ERROR/WARN/INFO/DEBUG/TRACE tag, so it cannot be \
704                         placed in the compared record stream: {s}"
705                    ),
706                ));
707            }
708            let (text, record) = DetLogRecord::split(s).map_err(|error| {
709                std::io::Error::new(
710                    std::io::ErrorKind::InvalidData,
711                    format!("log record {i} has an invalid structured DETLOG result: {error}"),
712                )
713            })?;
714            Ok(LogMessage {
715                index: i,
716                text,
717                event: record.map(|record| record.event),
718            })
719        })
720        .collect()
721}
722
723fn is_info(message: &LogMessage<'_>) -> bool {
724    message.text.starts_with("INFO ")
725}
726
727fn historical_is_commit(line: &str) -> bool {
728    line.contains(" COMMIT ")
729}
730
731fn historical_is_detlog(line: &str) -> bool {
732    line.contains(" DETLOG ")
733}
734
735fn is_commit(message: &LogMessage<'_>) -> bool {
736    match message.event {
737        Some(DetLogEvent::SchedulerCommit { .. }) => true,
738        Some(_) => false,
739        None => historical_is_commit(message.text),
740    }
741}
742
743fn is_detlog(message: &LogMessage<'_>) -> bool {
744    match message.event {
745        Some(
746            DetLogEvent::Other
747            | DetLogEvent::Syscall
748            | DetLogEvent::SyscallResult { .. }
749            | DetLogEvent::SchedulerCommittedTime,
750        ) => true,
751        Some(_) => false,
752        None => historical_is_detlog(message.text),
753    }
754}
755
756/// A scheduler COMMIT turn that only grants the `InternalIOPolling` resource, i.e. a
757/// granted retry of a nonblocking poll (poll/epoll_wait/wait4/futex/recv/send...). These
758/// grants are internal bookkeeping of Hermit's blocking-via-polling mechanism: how many
759/// times a thread is re-granted permission to re-attempt a nonblocking syscall before it
760/// stops returning would-block depends on when a concurrent external-IO action (e.g. a
761/// child linker process writing to a pipe) becomes ready on the host, which is wall-clock
762/// dependent and not tied to the (RCB-deterministic) logical schedule. The corresponding
763/// `NONCOMMIT ... polling resource` skips are already excluded from comparison (they are
764/// not tagged `COMMIT`); excluding the matching grant-COMMITs keeps the deterministic
765/// comparison consistent and focused on guest-observable events (the actual syscall
766/// results, still compared via their DETLOG entries).
767///
768/// SaBRe's inherited stdio pipes emit an outer device-resource turn before the inner polling
769/// turn. The scheduler tags that outer turn with `[sabre-internal-pipe-io]`; it is the same
770/// host-timing-sensitive operation and is normalized here as well. A SaBRe task with a loopback
771/// peer similarly tags the strong yield before each zero-timeout poll with
772/// `[sabre-loopback-poll-zero-timeout]`. The scheduler additionally suppresses the per-retry
773/// "advance global time for scheduler turn" DETLOG line for these turns at the source (see
774/// `Scheduler::bump_global_time`), so the two mechanisms together make the deterministic
775/// comparison insensitive to host-timing-dependent polling-loop counts.
776fn is_internal_io_poll_commit(message: &LogMessage<'_>) -> bool {
777    match message.event {
778        Some(DetLogEvent::SchedulerCommit {
779            internal_io_poll, ..
780        }) => internal_io_poll,
781        Some(_) => false,
782        None => {
783            historical_is_commit(message.text)
784                && (message.text.contains("{InternalIOPolling: ")
785                    || message.text.contains(" [sabre-internal-pipe-io]")
786                    || message.text.contains(" [sabre-loopback-poll-zero-timeout]"))
787        }
788    }
789}
790
791/// The scheduler's per-turn `committed_time` advance bookkeeping. `committed_time` tracks
792/// the global logical clock, which still moves forward when an `InternalIOPolling` retry
793/// (see `is_internal_io_poll_commit`) advances time -- and the number of those retries is
794/// host-timing nondeterministic. That makes the *presence* of this line on a given turn
795/// retry-count sensitive, so we exclude it from the deterministic comparison. No
796/// guest-observable signal is lost: the value is redundant with the (retained,
797/// retry-count-insensitive) "advance global time for scheduler turn" DETLOG line and with
798/// the per-turn committed time echoed on each COMMIT line.
799fn is_scheduler_committed_time(message: &LogMessage<'_>) -> bool {
800    match message.event {
801        Some(DetLogEvent::SchedulerCommittedTime) => true,
802        Some(_) => false,
803        None => message.text.contains("advancing committed_time from "),
804    }
805}
806
807fn is_detcore(message: &LogMessage<'_>) -> bool {
808    static PREFIX: LazyLock<Regex> =
809        LazyLock::new(|| Regex::new("^(ERROR|WARN|INFO|DEBUG|TRACE).* detcore:").unwrap());
810
811    PREFIX.is_match(message.text)
812}
813
814fn is_detlog_syscall(message: &LogMessage<'_>) -> bool {
815    match message.event {
816        Some(DetLogEvent::Syscall | DetLogEvent::SyscallResult { .. }) => true,
817        Some(_) => false,
818        None => historical_is_detlog(message.text) && message.text.contains("[syscall]"),
819    }
820}
821
822fn is_detlog_syscall_result(message: &LogMessage<'_>) -> bool {
823    match message.event {
824        Some(DetLogEvent::SyscallResult { .. }) => true,
825        Some(_) => false,
826        None => is_detlog_syscall(message) && message.text.contains("finish syscall"),
827    }
828}
829
830fn event_matches_human_record(event: DetLogEvent, text: &str) -> bool {
831    match event {
832        DetLogEvent::Other
833        | DetLogEvent::Syscall
834        | DetLogEvent::SyscallResult { .. }
835        | DetLogEvent::SchedulerCommittedTime => historical_is_detlog(text),
836        DetLogEvent::SchedulerCommit { .. } => historical_is_commit(text),
837        DetLogEvent::SchedulerEmptyQueueKick => text.contains(SCHEDULER_EMPTY_QUEUE_KICK),
838    }
839}
840
841fn validate_structured_events(
842    label: &str,
843    messages: &[LogMessage<'_>],
844    require: bool,
845) -> std::io::Result<()> {
846    for message in messages {
847        let is_semantic_record = historical_is_detlog(message.text)
848            || historical_is_commit(message.text)
849            || message.text.contains(SCHEDULER_EMPTY_QUEUE_KICK);
850        if require && is_semantic_record && message.event.is_none() {
851            return Err(std::io::Error::new(
852                std::io::ErrorKind::InvalidData,
853                format!(
854                    "{label} log record {} is missing its structured DETLOG result",
855                    message.index
856                ),
857            ));
858        }
859        if let Some(event) = message.event
860            && !event_matches_human_record(event, message.text)
861        {
862            return Err(std::io::Error::new(
863                std::io::ErrorKind::InvalidData,
864                format!(
865                    "{label} log record {} has structured DETLOG kind {:?} that disagrees with its human record",
866                    message.index, event
867                ),
868            ));
869        }
870    }
871    Ok(())
872}
873
874// TODO:
875// Append together a sequence of messages while truncating if there are too many.
876fn _truncate_messages(_v: &[&str]) -> String {
877    unimplemented!()
878}
879
880fn filter_infos<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
881    v.iter()
882        .filter(|message| is_info(message))
883        .copied()
884        .collect()
885}
886
887fn filter_detcore<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
888    v.iter()
889        .filter(|message| is_detcore(message))
890        .copied()
891        .collect()
892}
893
894/// Historical text of the INFO message the scheduler logs when it finds the
895/// run queue empty but is not finished yet. Current records carry
896/// [`DetLogEvent::SchedulerEmptyQueueKick`]; this spelling remains only so
897/// retained logs stay readable. `Scheduler::step2_process_blocked` in
898/// [`crate::scheduler`] emits it and returns `SkipTurn`, so the loop goes
899/// around again and still exits later through the ordinary
900/// "run queue empty, exiting sched_loop." message.
901///
902/// Two consequences make this the message worth recording. First, both paths
903/// end with the same final scheduler message, so the *last* scheduler line does
904/// not say which path a run took; only whether this kick appeared does. Second,
905/// whether it appears used to be decided by host timing -- the SaBRe ptrace
906/// supervisor might not yet have reaped a physical process exit when the check
907/// ran, and a dead thread's second exit hook could queue a removal after it --
908/// so a pair of runs of the same guest could differ here while agreeing on every
909/// committed scheduling decision. The scheduler now logs it once per logical
910/// empty state, before and regardless of that wait
911/// (https://github.com/rrnewton/hermit/issues/3360,
912/// https://github.com/rrnewton/hermit/issues/3223), so a pair whose counts
913/// differ points at a scheduler defect rather than at the host.
914///
915/// Kept as a constant so the string sits beside the code that reads it; grep
916/// for this text to find the producing `info!`.
917const SCHEDULER_EMPTY_QUEUE_KICK: &str = "zero threads left anywhere, fizzling.";
918
919/// How many of `v` record the scheduler's empty-run-queue kick.
920fn count_empty_queue_kicks(v: &[LogMessage<'_>]) -> usize {
921    v.iter()
922        .filter(|message| match message.event {
923            Some(DetLogEvent::SchedulerEmptyQueueKick) => true,
924            Some(_) => false,
925            None => message.text.contains(SCHEDULER_EMPTY_QUEUE_KICK),
926        })
927        .count()
928}
929
930/// Historical resource text of the COMMIT record produced when a guest's
931/// runtime reads the process's own memory map during bootstrap. Current records
932/// carry `runtime_maps_read`; this spelling remains only for retained logs.
933///
934/// This record carries the second known shape of backend self-nondeterminism,
935/// and it is unlike the empty-run-queue kick in a way that matters. Two runs of
936/// one guest can agree on every scheduling decision — same turn, same thread,
937/// same resource — and still commit that turn at *different virtual times*,
938/// because virtual time advances with retired conditional branches and the
939/// number of branches the scan executes depends on the map it reads. So the
940/// evidence is not the presence of a message but the value inside one.
941///
942/// A diverging pair prints both times in its diff. A matching pair kept
943/// nothing, which left the prior question — does this guest perform the read at
944/// all, and therefore can it exhibit the drift — answerable only by collecting
945/// divergences over many runs.
946const RUNTIME_MAPS_READ_RESOURCE: &str = r#"Path("/proc/self/maps")"#;
947
948/// One run's first memory-map read COMMIT, rendered for the retained line.
949/// `None` is spelled out rather than omitted, so a run that never performed the
950/// read is distinguishable from a run whose value was simply not reported.
951fn describe_maps_commit(first: Option<(u64, Option<u64>)>) -> String {
952    match first {
953        Some((turn, Some(nanoseconds))) => {
954            format!("first at turn {turn}, committed virtual time {nanoseconds}ns")
955        }
956        Some((turn, None)) => format!("first at turn {turn}, committed virtual time unrecorded"),
957        None => "no such record".to_string(),
958    }
959}
960
961/// COMMIT records in `v` that read the process's own memory map: how many there
962/// are, and the turn and committed virtual time of the first.
963fn maps_read_commits(v: &[LogMessage<'_>]) -> (usize, Option<(u64, Option<u64>)>) {
964    let mut count = 0;
965    let mut first = None;
966    for message in v {
967        let reads_runtime_maps = match message.event {
968            Some(DetLogEvent::SchedulerCommit {
969                runtime_maps_read, ..
970            }) => runtime_maps_read,
971            Some(_) => false,
972            None => message.text.contains(RUNTIME_MAPS_READ_RESOURCE),
973        };
974        if !reads_runtime_maps {
975            continue;
976        }
977        let Some(position) = commit_position(message) else {
978            continue;
979        };
980        count += 1;
981        if first.is_none() {
982            first = Some(position);
983        }
984    }
985    (count, first)
986}
987
988fn filter_ignored<'a>(lines: Vec<LogMessage<'a>>, omits: &Vec<String>) -> Vec<LogMessage<'a>> {
989    lines
990        .into_iter()
991        .filter(|message| {
992            let mut keep = true;
993            for omit in omits {
994                if message.text.contains(omit) {
995                    keep = false
996                }
997            }
998            keep
999        })
1000        .collect()
1001}
1002
1003fn collect_syscalls<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
1004    v.iter()
1005        .filter(|entry| is_detlog_syscall(entry))
1006        .copied()
1007        .collect()
1008}
1009
1010/// Number of leading compared messages that are equal on both sides.
1011///
1012/// This is the matched prefix in compared-stream units: the zero-based position
1013/// of the first differing compared message, or the shorter stream's length when
1014/// every shared position agrees. Two equal streams therefore match over their
1015/// full length, and a stream that is a strict prefix of the other matches over
1016/// the shorter length only -- the extra messages are the divergence.
1017fn matched_prefix_length(compared_left: &[String], compared_right: &[String]) -> usize {
1018    compared_left
1019        .iter()
1020        .zip(compared_right)
1021        .take_while(|(left, right)| left == right)
1022        .count()
1023}
1024
1025/// The matched prefix to report, or None when the exact prefix scan cannot
1026/// stand behind the comparator's verdict.
1027///
1028/// A match must have matched over the full length of both streams, and a
1029/// divergence must leave at least one message unmatched. The exact comparator
1030/// always satisfies this, because its verdict is the same message-by-message
1031/// scan. The external `git diff -w` comparator does not: it compares rendered
1032/// text, so one multi-line record on one side and the same lines as two records
1033/// on the other match with unequal counts, messages that differ only in
1034/// whitespace match although the exact scan stops at them, and a git error exit
1035/// on identical streams reads as a divergence with nothing unmatched. Reporting
1036/// any of these would claim a prefix nobody measured, so none is reported.
1037fn matched_prefix_for_verdict(
1038    diff_found: bool,
1039    prefix: usize,
1040    compared_left: usize,
1041    compared_right: usize,
1042) -> Option<usize> {
1043    let consistent = if diff_found {
1044        prefix < compared_left.max(compared_right)
1045    } else {
1046        prefix == compared_left && prefix == compared_right
1047    };
1048    consistent.then_some(prefix)
1049}
1050
1051fn first_different_message_indices(
1052    left: &[LogMessage<'_>],
1053    compared_left: &[String],
1054    right: &[LogMessage<'_>],
1055    compared_right: &[String],
1056) -> Option<(Option<usize>, Option<usize>)> {
1057    let common = compared_left.len().min(compared_right.len());
1058    let position = matched_prefix_length(compared_left, compared_right);
1059
1060    if position < common {
1061        return Some((Some(left[position].index), Some(right[position].index)));
1062    }
1063
1064    match compared_left.len().cmp(&compared_right.len()) {
1065        Ordering::Less => Some((None, Some(right[common].index))),
1066        Ordering::Greater => Some((Some(left[common].index), None)),
1067        Ordering::Equal => None,
1068    }
1069}
1070
1071/// Keep the content that identifies a differing event while removing only
1072/// values already carried in separate fields beside it.
1073///
1074/// This is deliberately narrower than [`strip_log_entry`]. Syscall arguments,
1075/// resource names, thread identities, payload bytes, and all other numbers stay
1076/// exact. Record position is not part of the message and remains a separate
1077/// observation.
1078fn first_divergent_message(message: &LogMessage<'_>) -> String {
1079    static FINISHED_SYSCALL: LazyLock<Regex> =
1080        LazyLock::new(|| Regex::new(r"(finish syscall #)[0-9][0-9_]*").unwrap());
1081    static COMMIT_TURN: LazyLock<Regex> =
1082        LazyLock::new(|| Regex::new(r"(\bCOMMIT turn )[0-9][0-9_]*\b").unwrap());
1083    static COMMITTED_TIME: LazyLock<Regex> = LazyLock::new(|| {
1084        Regex::new(r"(\b(?:at time|on previously committed) )[0-9][0-9_]*(?:\.[0-9_]+)?(?:ns|s)?\b")
1085            .unwrap()
1086    });
1087
1088    let (first_line, continuation) = match message.text.split_once('\n') {
1089        Some((first_line, continuation)) => (first_line, Some(continuation)),
1090        None => (message.text, None),
1091    };
1092    let first_line =
1093        if is_detlog_syscall_result(message) && finished_syscall_number(message).is_some() {
1094            FINISHED_SYSCALL
1095                .replace_all(first_line, "${1}<NUM>")
1096                .into_owned()
1097        } else {
1098            first_line.to_string()
1099        };
1100    let first_line = if is_commit(message) && commit_position(message).is_some() {
1101        let first_line = COMMIT_TURN.replace_all(&first_line, "${1}<NUM>");
1102        COMMITTED_TIME
1103            .replace_all(&first_line, "${1}<NANOSECONDS>")
1104            .into_owned()
1105    } else {
1106        first_line
1107    };
1108    match continuation {
1109        Some(continuation) => format!("{first_line}\n{continuation}"),
1110        None => first_line,
1111    }
1112}
1113
1114fn compared_message_at_record(
1115    records: &[LogMessage<'_>],
1116    compared: &[String],
1117    record: Option<usize>,
1118) -> Option<String> {
1119    let record = record?;
1120    let position = records.iter().position(|message| message.index == record)?;
1121    let original = records.get(position)?;
1122    let prepared = LogMessage {
1123        index: original.index,
1124        text: compared.get(position)?,
1125        event: original.event,
1126    };
1127    Some(first_divergent_message(&prepared))
1128}
1129
1130fn parse_underscored_u64(value: &str) -> Option<u64> {
1131    value.replace('_', "").parse().ok()
1132}
1133
1134fn parse_virtual_nanoseconds(value: &str, unit: Option<&str>) -> Option<u64> {
1135    match unit {
1136        None | Some("ns") if !value.contains('.') => parse_underscored_u64(value),
1137        Some("s") => {
1138            let value = value.replace('_', "");
1139            let (seconds, fraction) = value.split_once('.').unwrap_or((&value, ""));
1140            if fraction.len() > 9 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
1141                return None;
1142            }
1143            let seconds = seconds.parse::<u64>().ok()?;
1144            let fraction = if fraction.is_empty() {
1145                0
1146            } else {
1147                let digits = fraction.parse::<u64>().ok()?;
1148                digits.checked_mul(10_u64.pow((9 - fraction.len()) as u32))?
1149            };
1150            seconds.checked_mul(1_000_000_000)?.checked_add(fraction)
1151        }
1152        _ => None,
1153    }
1154}
1155
1156fn historical_commit_position(message: &str) -> Option<(u64, Option<u64>)> {
1157    static TURN: LazyLock<Regex> =
1158        LazyLock::new(|| Regex::new(r"\bCOMMIT turn ([0-9][0-9_]*)\b").unwrap());
1159    static TIME: LazyLock<Regex> = LazyLock::new(|| {
1160        Regex::new(r"\b(?:at time|on previously committed) ([0-9][0-9_]*(?:\.[0-9_]+)?)(ns|s)?\b")
1161            .unwrap()
1162    });
1163
1164    let turn = parse_underscored_u64(TURN.captures(message)?.get(1)?.as_str())?;
1165    let virtual_nanoseconds = TIME.captures(message).and_then(|captures| {
1166        parse_virtual_nanoseconds(
1167            captures.get(1)?.as_str(),
1168            captures.get(2).map(|unit| unit.as_str()),
1169        )
1170    });
1171    Some((turn, virtual_nanoseconds))
1172}
1173
1174fn commit_position(message: &LogMessage<'_>) -> Option<(u64, Option<u64>)> {
1175    match message.event {
1176        Some(DetLogEvent::SchedulerCommit {
1177            scheduler_turn,
1178            virtual_nanoseconds,
1179            ..
1180        }) => Some((scheduler_turn, Some(virtual_nanoseconds))),
1181        Some(_) => None,
1182        None => historical_commit_position(message.text),
1183    }
1184}
1185
1186fn commit_position_at_or_before(
1187    messages: &[LogMessage<'_>],
1188    message_index: usize,
1189) -> Option<(u64, Option<u64>)> {
1190    messages
1191        .iter()
1192        .rev()
1193        .filter(|message| message.index <= message_index)
1194        .find_map(commit_position)
1195}
1196
1197/// Detcore's own syscall counter from the producer-owned record.
1198///
1199/// Retained historical logs fall back to the old `finish syscall #N` spelling.
1200/// Current verification sets `require_structured_events`, so removing the
1201/// structured number refuses rather than silently restoring prose authority.
1202fn historical_finished_syscall_number(line: &str) -> Option<u64> {
1203    let rest = line.split("finish syscall #").nth(1)?;
1204    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1205    digits.parse().ok()
1206}
1207
1208fn finished_syscall_number(message: &LogMessage<'_>) -> Option<u64> {
1209    match message.event {
1210        Some(DetLogEvent::SyscallResult {
1211            finished_syscall_number,
1212        }) => Some(finished_syscall_number),
1213        Some(_) => None,
1214        None => historical_finished_syscall_number(message.text),
1215    }
1216}
1217
1218/// How many syscalls the guest had COMPLETED when the divergence appeared.
1219///
1220/// `inbound syscall` records carry no number, so this deliberately reads only
1221/// `finish syscall #N`: the answer is "the guest got this far", and a syscall
1222/// that was entered but never returned has not got anywhere yet.
1223///
1224/// ⚠️ THIS UNIT IS COMPARABLE ACROSS BACKENDS, AND A DIFFERENCE IS A FINDING
1225/// RATHER THAN AN ARTEFACT. The same `getpgrp` sitting at DETLOG event 40 under
1226/// ptrace and 39 under DBT is not a limitation of the measurement -- the guest
1227/// is not supposed to be able to tell which backend it is running on, so a
1228/// syscall-stream difference between two backends IS the parity divergence.
1229/// Do not restrict this to within-backend use or label it incomparable; that
1230/// would hide exactly what parity cells exist to detect.
1231fn finished_syscall_at_or_before(v: &[LogMessage<'_>], message_index: usize) -> Option<u64> {
1232    v.iter()
1233        .rev()
1234        .filter(|message| message.index <= message_index)
1235        .find_map(finished_syscall_number)
1236}
1237
1238fn syscall_at_or_before<'a>(
1239    syscalls: &'a [LogMessage<'a>],
1240    index: usize,
1241) -> Option<LogMessage<'a>> {
1242    syscalls
1243        .iter()
1244        .rev()
1245        .find(|message| message.index <= index)
1246        .copied()
1247}
1248
1249fn sentence_case_label(label: &str) -> String {
1250    let mut characters = label.chars();
1251    match characters.next() {
1252        Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
1253        None => String::new(),
1254    }
1255}
1256
1257fn write_syscall_context(
1258    w: &mut impl std::io::Write,
1259    left_index: usize,
1260    right_index: usize,
1261    left_syscalls: &[LogMessage<'_>],
1262    right_syscalls: &[LogMessage<'_>],
1263    labels: &ComparisonSideLabels,
1264    history_count: u64,
1265) -> std::io::Result<()> {
1266    if history_count == 0 {
1267        return Ok(());
1268    }
1269
1270    let left_current = syscall_at_or_before(left_syscalls, left_index);
1271    let right_current = syscall_at_or_before(right_syscalls, right_index);
1272    if left_current.is_none() && right_current.is_none() {
1273        return Ok(());
1274    }
1275
1276    writeln!(w, "Divergent syscall context:")?;
1277    for (label, current) in [
1278        (labels.left.as_str(), left_current),
1279        (labels.right.as_str(), right_current),
1280    ] {
1281        if let Some(syscall) = current {
1282            writeln!(
1283                w,
1284                "  {label}, log message {}: {}",
1285                syscall.index, syscall.text
1286            )?;
1287        } else {
1288            writeln!(w, "  {label}: <no syscall observed>")?;
1289        }
1290    }
1291
1292    let history_limit = usize::try_from(history_count).unwrap_or(usize::MAX);
1293    for (label, index, syscalls) in [
1294        (labels.left.as_str(), left_index, left_syscalls),
1295        (labels.right.as_str(), right_index, right_syscalls),
1296    ] {
1297        let history_boundary =
1298            syscall_at_or_before(syscalls, index).map_or(index, |current| current.index);
1299        let mut history = syscalls
1300            .iter()
1301            .rev()
1302            .filter(|entry| entry.index < history_boundary && is_detlog_syscall_result(entry))
1303            .take(history_limit)
1304            .copied()
1305            .collect::<Vec<_>>();
1306        history.reverse();
1307        if !history.is_empty() {
1308            writeln!(w, "  Prior completed syscalls for {label}:")?;
1309            for syscall in history {
1310                writeln!(w, "    {}", syscall.text)?;
1311            }
1312        }
1313    }
1314    writeln!(w)?;
1315
1316    Ok(())
1317}
1318
1319/// A comparison of two strings.
1320///
1321/// Displays comparison result without any formatting
1322pub struct Comparison<'a> {
1323    left: &'a str,
1324    right: &'a str,
1325    no_color: bool,
1326}
1327impl<'a> Comparison<'a> {
1328    /// Store two values to be compared in future.
1329    ///
1330    /// Expensive diffing is deferred until calling `Debug::fmt`.
1331    pub fn new(no_color: bool, left: &'a str, right: &'a str) -> Comparison<'a> {
1332        Comparison {
1333            left,
1334            right,
1335            no_color,
1336        }
1337    }
1338}
1339impl<'a> Display for Comparison<'a> {
1340    fn fmt(&self, f: &mut Formatter) -> Result {
1341        if self.no_color {
1342            writeln!(f, "Diff < left / right > :")?;
1343            writeln!(f, "<\"{}\"", self.left)?;
1344            writeln!(f, ">\"{}\"", self.right)
1345        } else {
1346            pretty_assertions::Comparison::new(&self.left, &self.right).fmt(f)
1347        }
1348    }
1349}
1350
1351/// Returns `true` if a difference is found.
1352///
1353/// We could use an existing diff library on the entire log, but this provides us more
1354/// control over how to present the (stripped/unstripped) differences, and to focus on the
1355/// per-line divergence(s), and potentially focus on the first point of divergence.
1356//
1357// Future TODO:
1358//  - report bulk differences, e.g. leftover lines, but with truncation
1359//  - report only the first K per-line differences
1360//  - detect reorderings and/or switch to larger differences for consecutive multi-line mismatches
1361fn diff_vecs(
1362    which: &str,
1363    left: (&[LogMessage<'_>], &[String]),
1364    right: (&[LogMessage<'_>], &[String]),
1365    opts: &LogDiffOpts,
1366    w: &mut impl std::io::Write,
1367    left_syscalls: &[LogMessage<'_>],
1368    right_syscalls: &[LogMessage<'_>],
1369) -> std::io::Result<bool> {
1370    let (v1, compared_left) = left;
1371    let (v2, compared_right) = right;
1372    writeln!(w, "  Comparing {which} messages...\n")?;
1373    if v1.is_empty() && v2.is_empty() {
1374        return Ok(false);
1375    }
1376
1377    let mut diff_count = 0;
1378    for (position, (left, right)) in v1.iter().zip(v2.iter()).enumerate() {
1379        let left_compared = &compared_left[position];
1380        let right_compared = &compared_right[position];
1381        if left_compared == right_compared {
1382            continue;
1383        }
1384
1385        if diff_count >= opts.limit && opts.limit != 0 {
1386            writeln!(
1387                w,
1388                "More than {} differences, eliding the rest...",
1389                opts.limit
1390            )?;
1391            break;
1392        }
1393
1394        write!(
1395            w,
1396            "({which}) Mismatch at log messages {} ({}) and {} ({}): {}",
1397            left.index,
1398            opts.side_labels.left,
1399            right.index,
1400            opts.side_labels.right,
1401            Comparison::new(opts.no_color, left_compared, right_compared)
1402        )?;
1403        if opts.strip_lines || opts.canonicalize_addresses {
1404            write!(
1405                w,
1406                "({which}) Original entries before normalization: {}",
1407                Comparison::new(opts.no_color, left.text, right.text)
1408            )?;
1409        }
1410        write_syscall_context(
1411            w,
1412            left.index,
1413            right.index,
1414            left_syscalls,
1415            right_syscalls,
1416            &opts.side_labels,
1417            opts.syscall_history,
1418        )?;
1419
1420        diff_count += 1;
1421    }
1422
1423    match v1.len().cmp(&v2.len()) {
1424        Ordering::Less => {
1425            writeln!(
1426                w,
1427                "{} contains {} extra messages not matched in {}. Displaying up to 10:",
1428                sentence_case_label(&opts.side_labels.right),
1429                v2.len() - v1.len(),
1430                opts.side_labels.left,
1431            )?;
1432            diff_count += 1;
1433            let start = v2.len() - std::cmp::min(10, v2.len() - v1.len());
1434            for message in &compared_right[start..] {
1435                writeln!(w, "{message}")?;
1436            }
1437        }
1438        Ordering::Greater => {
1439            writeln!(
1440                w,
1441                "{} contains {} extra messages not matched in {}. Displaying up to 10:",
1442                sentence_case_label(&opts.side_labels.left),
1443                v1.len() - v2.len(),
1444                opts.side_labels.right,
1445            )?;
1446            diff_count += 1;
1447            let start = v1.len() - std::cmp::min(10, v1.len() - v2.len());
1448            for message in &compared_left[start..] {
1449                writeln!(w, "{message}")?;
1450            }
1451        }
1452        Ordering::Equal => {}
1453    }
1454
1455    Ok(diff_count > 0)
1456}
1457
1458fn write_compared_messages(
1459    writer: &mut impl std::io::Write,
1460    messages: &[String],
1461) -> std::io::Result<()> {
1462    for message in messages {
1463        writeln!(writer, "{message}")?;
1464    }
1465    Ok(())
1466}
1467
1468fn write_compared_logs(
1469    writer: &mut impl std::io::Write,
1470    policy: LogComparisonPolicy,
1471    compared_left: &[String],
1472    compared_right: &[String],
1473    labels: &ComparisonSideLabels,
1474) -> std::io::Result<()> {
1475    writeln!(writer, "Comparison policy: {}", policy.name())?;
1476    writeln!(writer, "--- begin {} compared log ---", labels.left)?;
1477    write_compared_messages(writer, compared_left)?;
1478    writeln!(writer, "--- end {} compared log ---", labels.left)?;
1479    writeln!(writer, "--- begin {} compared log ---", labels.right)?;
1480    write_compared_messages(writer, compared_right)?;
1481    writeln!(writer, "--- end {} compared log ---", labels.right)?;
1482    Ok(())
1483}
1484
1485fn git_diff(
1486    which: &str,
1487    left: (&[LogMessage<'_>], &[String]),
1488    right: (&[LogMessage<'_>], &[String]),
1489    opts: &LogDiffOpts,
1490    w: &mut impl std::io::Write,
1491    left_syscalls: &[LogMessage<'_>],
1492    right_syscalls: &[LogMessage<'_>],
1493) -> std::io::Result<bool> {
1494    let (v1, compared_left) = left;
1495    let (v2, compared_right) = right;
1496    writeln!(w, "  Comparing {which} messages...\n")?;
1497
1498    let mut file1 = NamedTempFile::new()?;
1499    let mut file2 = NamedTempFile::new()?;
1500
1501    write_compared_messages(&mut file1, compared_left)?;
1502    write_compared_messages(&mut file2, compared_right)?;
1503
1504    match Command::new("git")
1505        .args(["diff", "--color", "--color-words", "-w"])
1506        .arg(file1.path())
1507        .arg(file2.path())
1508        .status()
1509    {
1510        Ok(code) => Ok(!code.success()),
1511        Err(error) => {
1512            eprintln!("Error launching git, falling back to basic diff: {error}");
1513            diff_vecs(
1514                which,
1515                (v1, compared_left),
1516                (v2, compared_right),
1517                opts,
1518                w,
1519                left_syscalls,
1520                right_syscalls,
1521            )
1522        }
1523    }
1524}
1525
1526/// What a log comparison actually compared, alongside whether it differed.
1527///
1528/// A bare "no difference found" boolean cannot distinguish *"the two message
1529/// streams were compared and matched"* from *"there were no messages to
1530/// compare"*. Both selected lists being empty is a NO-RESULT, not a match, so
1531/// the counts travel with the verdict and a parity consumer can require nonzero
1532/// execution before believing a green.
1533#[derive(Debug, Clone, PartialEq, Eq)]
1534pub struct LogDiffSummary {
1535    /// True if a substantive difference was found between the two runs.
1536    pub diff_found: bool,
1537    /// Number of messages actually selected for comparison from the first run.
1538    pub compared_left: usize,
1539    /// Number of messages actually selected for comparison from the second run.
1540    pub compared_right: usize,
1541    /// Scheduler turn at the first different selected message, when the first
1542    /// run has a preceding scheduler COMMIT message that identifies it.
1543    pub first_divergent_scheduler_turn: Option<u64>,
1544    /// Virtual nanoseconds at that same scheduler COMMIT, when its time is
1545    /// present and parseable.
1546    pub first_divergent_virtual_nanoseconds: Option<u64>,
1547    /// 1-based index of the first record that differs. "Diverged somewhere in
1548    /// the first N records" is a bound, not a location, and on a long run the
1549    /// two are far apart; this is the location.
1550    pub first_divergent_record: Option<usize>,
1551    /// Number of leading COMPARED messages that are equal on both sides, in
1552    /// the same units as [`Self::compared_left`] and [`Self::compared_right`].
1553    ///
1554    /// Equal to both compared counts when the streams match. When one stream is
1555    /// a strict prefix of the other it is the shorter length, because the extra
1556    /// messages are the divergence. None when the comparison was refused, and
1557    /// when the verdict came from the `git diff -w` comparator and the exact
1558    /// prefix scan disagrees with it (a match over unequal counts, or a
1559    /// divergence with nothing unmatched); see the private
1560    /// `matched_prefix_for_verdict` in this module.
1561    ///
1562    /// This is not [`Self::first_divergent_record`] minus one: that field is a
1563    /// raw log-record index, which also counts records outside the compared
1564    /// stream.
1565    pub matched_prefix_messages: Option<usize>,
1566    /// How many syscalls the guest had COMPLETED when the divergence appeared,
1567    /// as detcore's own `finish syscall #N` counter. The fourth unit: a
1568    /// divergence located at record 108 is easier to act on when you also know
1569    /// the guest was 37 syscalls in.
1570    pub first_divergent_syscall: Option<u64>,
1571    /// The first differing compared message from the left execution, after
1572    /// removing only the syscall number, scheduler turn, and committed virtual
1573    /// time that are already recorded in separate fields.
1574    pub first_divergent_left_message: Option<String>,
1575    /// The corresponding first differing compared message from the right
1576    /// execution, with the same narrowly scoped removals.
1577    pub first_divergent_right_message: Option<String>,
1578    /// The reason the comparison was REFUSED rather than performed, so there
1579    /// is no verdict about whether the runs agree. None means the comparison
1580    /// ran. Keeping the cause here, rather than only printing it, lets every
1581    /// caller carry the producer's diagnosis into its typed result.
1582    ///
1583    /// [`Self::diff_found`] is also set, because a refusal must never read as a
1584    /// match on any existing predicate. But the two are not the same fact: a
1585    /// difference is something observed, whereas a refusal is the absence of an
1586    /// observation. A caller that reports the outcome must be able to tell them
1587    /// apart -- otherwise a run that compared nothing is announced as a run
1588    /// that found the two executions differing, which is a claim nothing
1589    /// supports.
1590    ///
1591    /// Set today by exactly one condition: an input log that ends at the
1592    /// bounded writer's truncation marker.
1593    pub refusal_reason: Option<String>,
1594}
1595
1596impl LogDiffSummary {
1597    /// True only when the comparison both ran on a nonempty selection *and*
1598    /// found no difference. An empty-vs-empty comparison is never a match.
1599    pub fn matched_with_evidence(&self) -> bool {
1600        self.refusal_reason.is_none()
1601            && !self.diff_found
1602            && self.compared_left > 0
1603            && self.compared_right > 0
1604    }
1605}
1606
1607/// Process log messages from two files.  Log messages look like this:
1608///     "Apr 09 06:08:03.100  INFO detcore: [detcore, dtid 2]  finish syscall: close(2) = Ok(0)"
1609///
1610/// With some complexities:
1611///  * Some entries are multi-line (contain newlines).
1612///  * Some stripping of nondeterministic information is needed for direct comparability.
1613///  * Certain lines are intended to be deterministic/comparable, in their contents,
1614///    and others in their *presence* but not their details.
1615///
1616/// Reports only whether the two files differ. See [`log_diff_detailed`] when the
1617/// caller must also know how many messages were actually compared; a bare
1618/// `false` here cannot distinguish a match from an empty comparison.
1619//
1620// TODO: we should replace this with a diff algorithm that can handle insertions while maintaining
1621// alignment. There's also no reason we can't output the stripped relevant lines and use a separate
1622// diff tool.
1623pub fn log_diff(file_a: &Path, file_b: &Path, opts: &LogDiffOpts) -> bool {
1624    log_diff_detailed(file_a, file_b, opts).diff_found
1625}
1626
1627/// Like [`log_diff`], but returns the counted comparison evidence rather than a
1628/// bare boolean.
1629pub fn log_diff_detailed(file_a: &Path, file_b: &Path, opts: &LogDiffOpts) -> LogDiffSummary {
1630    try_log_diff_detailed(file_a, file_b, opts).expect("could not read or compare log inputs")
1631}
1632
1633/// Fallible form of [`log_diff_detailed`] for user-facing callers. Missing or
1634/// unreadable inputs are ordinary command errors, not process panics.
1635pub fn try_log_diff_detailed(
1636    file_a: &Path,
1637    file_b: &Path,
1638    opts: &LogDiffOpts,
1639) -> std::io::Result<LogDiffSummary> {
1640    try_log_diff_detailed_with_filter(file_a, file_b, opts, |_| true)
1641}
1642
1643/// Compare two current Hermit logs under the canonical `BitwiseInfoV1` policy.
1644///
1645/// Callers provide only the two inputs, their reader-facing labels, and
1646/// diagnostic-output settings. They cannot weaken record selection or
1647/// normalization. The fixed policy compares every INFO record, requires
1648/// current structured DETLOG events, canonicalizes only explicitly marked host
1649/// addresses to first-appearance ordinals, and otherwise compares the selected
1650/// messages exactly.
1651pub fn try_compare_bitwise_info_v1(
1652    file_a: &Path,
1653    file_b: &Path,
1654    side_labels: ComparisonSideLabels,
1655) -> std::io::Result<LogDiffSummary> {
1656    try_compare_bitwise_info_v1_with_records(file_a, file_b, side_labels)
1657        .map(|(summary, _, _)| summary)
1658}
1659
1660/// Canonical comparison plus total complete-record counts from both inputs.
1661pub fn try_compare_bitwise_info_v1_with_records(
1662    file_a: &Path,
1663    file_b: &Path,
1664    side_labels: ComparisonSideLabels,
1665) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1666    let bytes_a = std::fs::read(file_a)?;
1667    let bytes_b = std::fs::read(file_b)?;
1668    try_compare_bitwise_info_v1_bytes_with_records(&bytes_a, &bytes_b, side_labels)
1669}
1670
1671/// Canonical comparison over bytes already captured by the caller.
1672pub fn try_compare_bitwise_info_v1_bytes_with_records(
1673    bytes_a: &[u8],
1674    bytes_b: &[u8],
1675    side_labels: ComparisonSideLabels,
1676) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1677    try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1678        bytes_a,
1679        bytes_b,
1680        side_labels,
1681        BitwiseInfoV1Diagnostics::default(),
1682        &mut std::io::stderr(),
1683    )
1684}
1685
1686/// Output-only settings for a canonical `BitwiseInfoV1` comparison.
1687///
1688/// These settings control rendered diagnostics only. They cannot change which
1689/// records are selected, how records are normalized, or the returned verdict
1690/// and counts.
1691#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1692pub struct BitwiseInfoV1Diagnostics {
1693    /// Maximum number of differing messages to render. Zero means unlimited.
1694    pub difference_limit: u64,
1695    /// Number of completed syscalls to render before each divergent syscall.
1696    pub syscall_history: u64,
1697    /// Disable terminal color in rendered differences.
1698    pub no_color: bool,
1699    /// Print both selected canonical streams before reporting differences.
1700    pub print_logs: bool,
1701}
1702
1703impl Default for BitwiseInfoV1Diagnostics {
1704    fn default() -> Self {
1705        Self {
1706            difference_limit: 20,
1707            syscall_history: 5,
1708            no_color: false,
1709            print_logs: false,
1710        }
1711    }
1712}
1713
1714/// Canonical file comparison with caller-selected diagnostic output.
1715pub fn try_compare_bitwise_info_v1_with_records_and_diagnostics(
1716    file_a: &Path,
1717    file_b: &Path,
1718    side_labels: ComparisonSideLabels,
1719    diagnostics: BitwiseInfoV1Diagnostics,
1720    writer: &mut impl Write,
1721) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1722    let bytes_a = std::fs::read(file_a)?;
1723    let bytes_b = std::fs::read(file_b)?;
1724    try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1725        &bytes_a,
1726        &bytes_b,
1727        side_labels,
1728        diagnostics,
1729        writer,
1730    )
1731}
1732
1733/// Canonical file comparison with caller-selected diagnostic output.
1734pub fn try_compare_bitwise_info_v1_with_diagnostics(
1735    file_a: &Path,
1736    file_b: &Path,
1737    side_labels: ComparisonSideLabels,
1738    diagnostics: BitwiseInfoV1Diagnostics,
1739    writer: &mut impl Write,
1740) -> std::io::Result<LogDiffSummary> {
1741    try_compare_bitwise_info_v1_with_records_and_diagnostics(
1742        file_a,
1743        file_b,
1744        side_labels,
1745        diagnostics,
1746        writer,
1747    )
1748    .map(|(summary, _, _)| summary)
1749}
1750
1751/// Canonical comparison over captured bytes with output-only diagnostics.
1752pub fn try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1753    bytes_a: &[u8],
1754    bytes_b: &[u8],
1755    side_labels: ComparisonSideLabels,
1756    diagnostics: BitwiseInfoV1Diagnostics,
1757    writer: &mut impl Write,
1758) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1759    let str_a = std::str::from_utf8(bytes_a).map_err(|error| {
1760        std::io::Error::new(
1761            std::io::ErrorKind::InvalidData,
1762            format!("{} is not UTF-8: {error}", side_labels.left),
1763        )
1764    })?;
1765    let str_b = std::str::from_utf8(bytes_b).map_err(|error| {
1766        std::io::Error::new(
1767            std::io::ErrorKind::InvalidData,
1768            format!("{} is not UTF-8: {error}", side_labels.right),
1769        )
1770    })?;
1771    let mut options = bitwise_info_v1_options(side_labels);
1772    options.limit = diagnostics.difference_limit;
1773    options.syscall_history = diagnostics.syscall_history;
1774    options.no_color = diagnostics.no_color;
1775    options.print_logs = diagnostics.print_logs;
1776    let records_a = record_count(str_a);
1777    let records_b = record_count(str_b);
1778    let summary = log_diff_summary_from_strs_with_filter(str_a, str_b, &options, writer, |_| true)?;
1779    Ok((summary, records_a, records_b))
1780}
1781
1782/// Construct the complete fixed policy used by `BitwiseInfoV1` callers.
1783fn bitwise_info_v1_options(side_labels: ComparisonSideLabels) -> LogDiffOpts {
1784    LogDiffOpts {
1785        strip_lines: false,
1786        canonicalize_addresses: true,
1787        comparison: LogComparisonMode::Info,
1788        side_labels,
1789        require_structured_events: true,
1790        print_logs: false,
1791        limit: 20,
1792        ignore_lines: Vec::new(),
1793        syscall_history: 5,
1794        no_color: false,
1795        skip_commit: false,
1796        skip_detlog: false,
1797        git_diff: false,
1798        include_detlogs: vec![
1799            DetLogFilter::Syscall,
1800            DetLogFilter::SyscallResult,
1801            DetLogFilter::Other,
1802        ],
1803    }
1804}
1805
1806/// Fallible log comparison with a caller-supplied record filter.
1807///
1808/// Existing callers keep the unfiltered behavior through
1809/// [`try_log_diff_detailed`]. Backend adapters use this form to apply policy
1810/// without placing backend names or semantics in Detcore. This low-level hook
1811/// does not name the predicate, so any product verdict or JSON report using it
1812/// must bind the function to a typed, serialized policy.
1813pub fn try_log_diff_detailed_with_filter(
1814    file_a: &Path,
1815    file_b: &Path,
1816    opts: &LogDiffOpts,
1817    keep_record: impl Fn(&str) -> bool,
1818) -> std::io::Result<LogDiffSummary> {
1819    // For now the log-diff mode reads both logs fully into memory. This could be
1820    // modified in the future for a streaming solution, at least for scrolling through
1821    // the identical prefixes of very large logs.
1822    let vec_a = std::fs::read(file_a)?;
1823    let vec_b = std::fs::read(file_b)?;
1824    let str_a = String::from_utf8_lossy(&vec_a);
1825    let str_b = String::from_utf8_lossy(&vec_b);
1826    log_diff_summary_from_strs_with_filter(str_a, str_b, opts, &mut std::io::stderr(), keep_record)
1827}
1828
1829/// Total records in a log, including a final record that may still be being
1830/// written. Use [`complete_record_count`] when the log is still growing.
1831pub fn record_count(contents: &str) -> usize {
1832    record_starts(contents).len()
1833}
1834
1835/// Like [`try_log_diff_detailed`], but also reports how many records each log
1836/// contained. Every log comparison should be able to say what it read, so a
1837/// caller is never left to infer coverage from a bare verdict.
1838pub fn try_log_diff_with_records(
1839    file_a: &Path,
1840    file_b: &Path,
1841    opts: &LogDiffOpts,
1842) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1843    try_log_diff_with_records_and_filter(file_a, file_b, opts, |_| true)
1844}
1845
1846/// Compare two logs with a caller-supplied record filter and report source counts.
1847/// Product callers must bind the predicate to a typed, serialized policy; this
1848/// backend-neutral layer intentionally carries no backend policy identity.
1849pub fn try_log_diff_with_records_and_filter(
1850    file_a: &Path,
1851    file_b: &Path,
1852    opts: &LogDiffOpts,
1853    keep_record: impl Fn(&str) -> bool,
1854) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1855    let vec_a = std::fs::read(file_a)?;
1856    let vec_b = std::fs::read(file_b)?;
1857    let str_a = String::from_utf8_lossy(&vec_a);
1858    let str_b = String::from_utf8_lossy(&vec_b);
1859    let records_a = record_count(&str_a);
1860    let records_b = record_count(&str_b);
1861    let summary = log_diff_summary_from_strs_with_filter(
1862        &str_a,
1863        &str_b,
1864        opts,
1865        &mut std::io::stderr(),
1866        keep_record,
1867    )?;
1868    Ok((summary, records_a, records_b))
1869}
1870
1871/// A comparison of two logs that may still be growing.
1872///
1873/// The verdict alone is not reportable. "The runs agree" and "the runs agree so
1874/// far as anyone has looked" are different claims, and a caller who cannot tell
1875/// them apart will stop early and conclude the wrong thing. The record counts
1876/// are therefore part of the result, not optional detail alongside it.
1877#[derive(Debug, Clone, PartialEq, Eq)]
1878pub struct PrefixComparison {
1879    /// The verdict over the compared prefix only.
1880    pub summary: LogDiffSummary,
1881    /// Complete records present in the first log when it was read.
1882    pub records_available_left: usize,
1883    /// Complete records present in the second log when it was read.
1884    pub records_available_right: usize,
1885    /// Records actually compared: the shorter of the two available counts.
1886    pub records_compared: usize,
1887}
1888
1889impl PrefixComparison {
1890    /// True when one log has complete records the other has not reached yet, so
1891    /// the comparison is bounded by reading rather than by the runs agreeing.
1892    pub fn one_side_is_ahead(&self) -> bool {
1893        self.records_available_left != self.records_available_right
1894    }
1895}
1896
1897/// Compare only the records both logs have finished writing.
1898///
1899/// The tail of a log being written may be half-flushed, and a half-flushed
1900/// record is not a difference -- it is an absence. Truncating both inputs to
1901/// their common *complete* prefix is what makes this safe to run against a live
1902/// run: bytes that have not been written yet can never be mistaken for bytes
1903/// that disagree.
1904pub fn compare_complete_prefix(
1905    contents_a: &str,
1906    contents_b: &str,
1907    opts: &LogDiffOpts,
1908    w: &mut impl std::io::Write,
1909) -> std::io::Result<PrefixComparison> {
1910    compare_complete_prefix_with_filter(contents_a, contents_b, opts, w, |_| true)
1911}
1912
1913/// Compare the complete common prefix of two growing logs under the fixed
1914/// `BitwiseInfoV1` policy.
1915///
1916/// The whole byte buffers must be valid UTF-8, and a terminal bounded-writer
1917/// marker refuses immediately even though it is not a timestamped record.
1918/// Structured-record validation covers every complete record currently
1919/// available on each side, while the verdict compares only the common complete
1920/// prefix. The unfinished final record remains withheld until a later record
1921/// proves that it is complete.
1922pub fn compare_complete_bitwise_info_v1_prefix(
1923    bytes_a: &[u8],
1924    bytes_b: &[u8],
1925    side_labels: ComparisonSideLabels,
1926    diagnostics: BitwiseInfoV1Diagnostics,
1927    w: &mut impl std::io::Write,
1928) -> std::io::Result<PrefixComparison> {
1929    let contents_a = std::str::from_utf8(bytes_a).map_err(|error| {
1930        std::io::Error::new(
1931            std::io::ErrorKind::InvalidData,
1932            format!("{} is not UTF-8: {error}", side_labels.left),
1933        )
1934    })?;
1935    let contents_b = std::str::from_utf8(bytes_b).map_err(|error| {
1936        std::io::Error::new(
1937            std::io::ErrorKind::InvalidData,
1938            format!("{} is not UTF-8: {error}", side_labels.right),
1939        )
1940    })?;
1941
1942    let truncated_a = log_was_truncated(contents_a);
1943    let truncated_b = log_was_truncated(contents_b);
1944    if truncated_a || truncated_b {
1945        let which_side = match (truncated_a, truncated_b) {
1946            (true, true) => "both logs were",
1947            (true, false) => "the first log was",
1948            (false, true) => "the second log was",
1949            (false, false) => unreachable!("guarded by the condition above"),
1950        };
1951        return Err(std::io::Error::new(
1952            std::io::ErrorKind::InvalidData,
1953            format!(
1954                "{which_side} truncated at the configured size bound; the discarded tail was never written"
1955            ),
1956        ));
1957    }
1958
1959    let records_available_left = complete_record_count(contents_a);
1960    let records_available_right = complete_record_count(contents_b);
1961    for (label, contents, available) in [
1962        (
1963            side_labels.left.as_str(),
1964            contents_a,
1965            records_available_left,
1966        ),
1967        (
1968            side_labels.right.as_str(),
1969            contents_b,
1970            records_available_right,
1971        ),
1972    ] {
1973        let complete = take_complete_records(contents, available)
1974            .expect("the complete-record count always identifies its own prefix");
1975        let records = extract_log_messages(complete)
1976            .map_err(|error| std::io::Error::new(error.kind(), format!("{label} {error}")))?;
1977        validate_structured_events(label, &records, true)?;
1978    }
1979
1980    let records_compared = records_available_left.min(records_available_right);
1981    let prefix_a = take_complete_records(contents_a, records_compared)
1982        .expect("common prefix never exceeds either side's complete record count");
1983    let prefix_b = take_complete_records(contents_b, records_compared)
1984        .expect("common prefix never exceeds either side's complete record count");
1985    let mut options = bitwise_info_v1_options(side_labels);
1986    options.limit = diagnostics.difference_limit;
1987    options.syscall_history = diagnostics.syscall_history;
1988    options.no_color = diagnostics.no_color;
1989    options.print_logs = diagnostics.print_logs;
1990    let summary =
1991        log_diff_summary_from_strs_with_filter(prefix_a, prefix_b, &options, w, |_| true)?;
1992    Ok(PrefixComparison {
1993        summary,
1994        records_available_left,
1995        records_available_right,
1996        records_compared,
1997    })
1998}
1999
2000/// Compare the complete common prefix after applying a caller-supplied record filter.
2001/// Product callers must bind the predicate to a typed, serialized policy; this
2002/// backend-neutral layer intentionally carries no backend policy identity.
2003pub fn compare_complete_prefix_with_filter(
2004    contents_a: &str,
2005    contents_b: &str,
2006    opts: &LogDiffOpts,
2007    w: &mut impl std::io::Write,
2008    keep_record: impl Fn(&str) -> bool,
2009) -> std::io::Result<PrefixComparison> {
2010    let records_available_left = complete_record_count(contents_a);
2011    let records_available_right = complete_record_count(contents_b);
2012    let records_compared = records_available_left.min(records_available_right);
2013    // Both are Some: `records_compared` is at most each side's complete count.
2014    let prefix_a = take_complete_records(contents_a, records_compared)
2015        .expect("common prefix never exceeds either side's complete record count");
2016    let prefix_b = take_complete_records(contents_b, records_compared)
2017        .expect("common prefix never exceeds either side's complete record count");
2018    let summary = log_diff_summary_from_strs_with_filter(prefix_a, prefix_b, opts, w, keep_record)?;
2019    Ok(PrefixComparison {
2020        summary,
2021        records_available_left,
2022        records_available_right,
2023        records_compared,
2024    })
2025}
2026
2027/// Boolean-only wrapper retained for tests that only ask "did it differ?".
2028/// Prefer [`log_diff_summary_from_strs`] where the counts matter.
2029#[cfg(test)]
2030fn log_diff_from_strs(
2031    file_a_str: impl AsRef<str>,
2032    file_b_str: impl AsRef<str>,
2033    opts: &LogDiffOpts,
2034    w: &mut impl std::io::Write,
2035) -> std::io::Result<bool> {
2036    Ok(log_diff_summary_from_strs(file_a_str, file_b_str, opts, w)?.diff_found)
2037}
2038
2039#[cfg(test)]
2040fn log_diff_summary_from_strs(
2041    file_a_str: impl AsRef<str>,
2042    file_b_str: impl AsRef<str>,
2043    opts: &LogDiffOpts,
2044    w: &mut impl std::io::Write,
2045) -> std::io::Result<LogDiffSummary> {
2046    log_diff_summary_from_strs_with_filter(file_a_str, file_b_str, opts, w, |_| true)
2047}
2048
2049/// Compare two in-memory logs after applying a caller-supplied record filter.
2050///
2051/// Every record is parsed and its level tag validated before `keep_record` is
2052/// consulted, so filtering cannot turn malformed input into a successful
2053/// comparison. Backend-specific transport policy belongs in the backend adapter;
2054/// this function supplies only the abstract comparison hook. Product verdicts
2055/// using it must serialize the typed policy bound to this predicate.
2056pub fn log_diff_summary_from_strs_with_filter(
2057    file_a_str: impl AsRef<str>,
2058    file_b_str: impl AsRef<str>,
2059    opts: &LogDiffOpts,
2060    w: &mut impl std::io::Write,
2061    keep_record: impl Fn(&str) -> bool,
2062) -> std::io::Result<LogDiffSummary> {
2063    // A log that reached its size bound stops early and says so in-band. The
2064    // comparison below walks the two selected lists in lockstep, so it speaks
2065    // only for the retained prefix: two runs both cut at the bound with equal
2066    // retained message counts would agree on that prefix while the discarded
2067    // tails were never compared, and reporting that as "no differences found"
2068    // would assert determinism over a region nothing looked at. Refuse the
2069    // verdict instead, in both directions of the green predicate --
2070    // `diff_found` is set AND the compared counts stay zero, so neither
2071    // `log_diff() == false` nor `matched_with_evidence()` can read as a match.
2072    let truncated_a = log_was_truncated(file_a_str.as_ref());
2073    let truncated_b = log_was_truncated(file_b_str.as_ref());
2074    if truncated_a || truncated_b {
2075        let which_side = match (truncated_a, truncated_b) {
2076            (true, true) => "both logs were",
2077            (true, false) => "the first log was",
2078            (false, true) => "the second log was",
2079            (false, false) => unreachable!("guarded by the condition above"),
2080        };
2081        let refusal_reason = format!(
2082            "{which_side} truncated at the configured size bound (the log ends with the bounded writer's truncation marker). The discarded tail was never written, so no comparison of these files can establish that the runs agree past that point. Re-run with a larger HERMIT_LOG_MAX_BYTES, or 0 to disable the bound."
2083        );
2084        writeln!(
2085            w,
2086            "REFUSING to compare: {refusal_reason} This is a NO-RESULT, not a difference and not a match."
2087        )?;
2088        return Ok(LogDiffSummary {
2089            diff_found: true,
2090            compared_left: 0,
2091            compared_right: 0,
2092            first_divergent_scheduler_turn: None,
2093            first_divergent_virtual_nanoseconds: None,
2094            first_divergent_record: None,
2095            matched_prefix_messages: None,
2096            first_divergent_syscall: None,
2097            first_divergent_left_message: None,
2098            first_divergent_right_message: None,
2099            refusal_reason: Some(refusal_reason),
2100        });
2101    }
2102
2103    let extracted_a = extract_log_messages(file_a_str.as_ref())?;
2104    let extracted_b = extract_log_messages(file_b_str.as_ref())?;
2105    validate_structured_events(
2106        opts.side_labels.left.as_str(),
2107        &extracted_a,
2108        opts.require_structured_events,
2109    )?;
2110    validate_structured_events(
2111        opts.side_labels.right.as_str(),
2112        &extracted_b,
2113        opts.require_structured_events,
2114    )?;
2115    let all_a = filter_ignored(
2116        extracted_a
2117            .into_iter()
2118            .filter(|record| keep_record(record.text))
2119            .collect(),
2120        &opts.ignore_lines,
2121    );
2122    let all_b = filter_ignored(
2123        extracted_b
2124            .into_iter()
2125            .filter(|record| keep_record(record.text))
2126            .collect(),
2127        &opts.ignore_lines,
2128    );
2129
2130    writeln!(
2131        w,
2132        "Logs contain {} | {} messages total",
2133        all_a.len(),
2134        all_b.len(),
2135    )?;
2136
2137    let detcore_a = filter_detcore(&all_a);
2138    let detcore_b = filter_detcore(&all_b);
2139    let infos_a = filter_infos(&all_a);
2140    let infos_b = filter_infos(&all_b);
2141    let detlogs_a = opts.filter_deterministic(&detcore_a);
2142    let detlogs_b = opts.filter_deterministic(&detcore_b);
2143    let left_syscalls = collect_syscalls(&all_a);
2144    let right_syscalls = collect_syscalls(&all_b);
2145    writeln!(
2146        w,
2147        "Logs contain {} | {} detcore-specific messages",
2148        detcore_a.len(),
2149        detcore_b.len(),
2150    )?;
2151    writeln!(
2152        w,
2153        "Logs contain {} | {} INFO messages",
2154        infos_a.len(),
2155        infos_b.len(),
2156    )?;
2157    writeln!(
2158        w,
2159        "Logs contain {} | {} DETLOG & scheduler COMMIT messages",
2160        detlogs_a.len(),
2161        detlogs_b.len(),
2162    )?;
2163
2164    let policy = LogComparisonPolicy::from_options(opts);
2165
2166    if policy.normalization == LogNormalization::Stripped {
2167        writeln!(
2168            w,
2169            "Normalizing known nondeterministic numerical data before comparison..."
2170        )?;
2171    } else if policy.normalization == LogNormalization::Canonical {
2172        writeln!(
2173            w,
2174            "Canonicalizing host addresses (ordinal by first appearance); comparing everything else exactly..."
2175        )?;
2176    }
2177
2178    let (which, compared_a, compared_b) = match policy.comparison {
2179        LogComparisonMode::Deterministic => ("DETLOG", &detlogs_a, &detlogs_b),
2180        LogComparisonMode::Info => ("INFO", &infos_a, &infos_b),
2181        LogComparisonMode::FullTrace => ("full trace", &all_a, &all_b),
2182    };
2183
2184    // Prepare both complete streams exactly once. Address ordinals are assigned
2185    // by first appearance across the full selected stream, and every consumer
2186    // below (printing, position reporting, and comparison) receives these same
2187    // String values rather than independently rendering the logs.
2188    let prepared_a = messages_for_comparison(compared_a, policy);
2189    let prepared_b = messages_for_comparison(compared_b, policy);
2190
2191    if opts.print_logs {
2192        write_compared_logs(w, policy, &prepared_a, &prepared_b, &opts.side_labels)?;
2193    }
2194
2195    let first_different =
2196        first_different_message_indices(compared_a, &prepared_a, compared_b, &prepared_b);
2197    let first_position_candidate = first_different.and_then(|(left_index, right_index)| {
2198        left_index
2199            .and_then(|index| commit_position_at_or_before(&all_a, index))
2200            .or_else(|| right_index.and_then(|index| commit_position_at_or_before(&all_b, index)))
2201    });
2202
2203    let first_divergent_syscall_candidate = first_different.and_then(|(left, right)| {
2204        left.and_then(|index| finished_syscall_at_or_before(&all_a, index))
2205            .or_else(|| right.and_then(|index| finished_syscall_at_or_before(&all_b, index)))
2206    });
2207    let first_divergent_left_message = first_different
2208        .and_then(|(left, _)| compared_message_at_record(compared_a, &prepared_a, left));
2209    let first_divergent_right_message = first_different
2210        .and_then(|(_, right)| compared_message_at_record(compared_b, &prepared_b, right));
2211
2212    let diff_found = if opts.git_diff {
2213        git_diff(
2214            which,
2215            (compared_a, &prepared_a),
2216            (compared_b, &prepared_b),
2217            opts,
2218            w,
2219            &left_syscalls,
2220            &right_syscalls,
2221        )?
2222    } else {
2223        diff_vecs(
2224            which,
2225            (compared_a, &prepared_a),
2226            (compared_b, &prepared_b),
2227            opts,
2228            w,
2229            &left_syscalls,
2230            &right_syscalls,
2231        )?
2232    };
2233
2234    let summary = LogDiffSummary {
2235        diff_found,
2236        compared_left: compared_a.len(),
2237        compared_right: compared_b.len(),
2238        first_divergent_scheduler_turn: diff_found
2239            .then_some(first_position_candidate)
2240            .flatten()
2241            .map(|(turn, _)| turn),
2242        first_divergent_virtual_nanoseconds: diff_found
2243            .then_some(first_position_candidate)
2244            .flatten()
2245            .and_then(|(_, time)| time),
2246        first_divergent_record: diff_found
2247            .then_some(first_different)
2248            .flatten()
2249            .and_then(|(left_index, right_index)| left_index.or(right_index)),
2250        // The exact scan on both branches. The verdict still comes from the
2251        // comparator, but a prefix is reported only where the scan agrees
2252        // with it, so a `git diff -w` match over unequal counts reports none.
2253        matched_prefix_messages: matched_prefix_for_verdict(
2254            diff_found,
2255            matched_prefix_length(&prepared_a, &prepared_b),
2256            compared_a.len(),
2257            compared_b.len(),
2258        ),
2259        first_divergent_syscall: diff_found
2260            .then_some(first_divergent_syscall_candidate)
2261            .flatten(),
2262        first_divergent_left_message: diff_found.then_some(first_divergent_left_message).flatten(),
2263        first_divergent_right_message: diff_found
2264            .then_some(first_divergent_right_message)
2265            .flatten(),
2266        // This path compared the logs; only the refusal above declines to.
2267        refusal_reason: None,
2268    };
2269
2270    if diff_found {
2271        writeln!(w, "Done processing logs, differences found.")?;
2272    } else if summary.compared_left == 0 && summary.compared_right == 0 {
2273        // Say this plainly rather than letting "no differences" stand in for
2274        // "nothing was compared": a caller that treats the two alike turns a
2275        // no-result into a green.
2276        writeln!(
2277            w,
2278            "Done processing logs, but ZERO {which} messages were selected on either side: \
2279             nothing was compared (no-result, not a match)."
2280        )?;
2281    } else {
2282        writeln!(
2283            w,
2284            "Done processing logs, no substantive differences found ({} | {} {which} messages compared).",
2285            summary.compared_left, summary.compared_right,
2286        )?;
2287        // A matching pair keeps only this summary; its logs are not retained.
2288        // Without the next line there is no record of which shutdown path the
2289        // two runs took, so establishing whether a guest is exposed to the
2290        // empty-run-queue timing race at all needs many repeated runs rather
2291        // than one reading. See [`SCHEDULER_EMPTY_QUEUE_KICK`] for why the count
2292        // is the only thing that distinguishes the paths.
2293        //
2294        // Deliberately emitted here only: a diverging pair already reproduces
2295        // the messages verbatim in its diff, and widening retention past this
2296        // one line is how evidence directories stop being navigable.
2297        writeln!(
2298            w,
2299            "Logs contain {} | {} scheduler empty-run-queue kick messages",
2300            count_empty_queue_kicks(&infos_a),
2301            count_empty_queue_kicks(&infos_b),
2302        )?;
2303        // The other retained record, for the same reason and under the same
2304        // limit: one line, this record only. Here the committed virtual time is
2305        // the evidence rather than the record's presence, so it is reported
2306        // alongside the turn.
2307        //
2308        // BOTH runs' values are printed, not just run 1's, and this is a
2309        // correctness requirement rather than a precaution.
2310        //
2311        // Measured: with `strip_lines` -- the lossy comparator that plain
2312        // `--verify` uses -- known nondeterministic numerical data is normalized
2313        // before comparison, so a pair whose two runs committed the map read at
2314        // *different* virtual times is a MATCHING pair and reaches this branch.
2315        // Quoting one side there would report agreement on precisely the
2316        // quantity this record exists to expose: a drift that `--verify-strict`
2317        // catches and `--verify` does not. The counts above can likewise differ
2318        // on a matching pair -- under the default `Deterministic` mode a kick
2319        // asymmetry is not compared, so `1 | 0` is a pass.
2320        //
2321        // A run with no such record therefore has to read as "no such record"
2322        // rather than being silently represented by the other run's value.
2323        let (maps_left, first_left) = maps_read_commits(&infos_a);
2324        let (maps_right, first_right) = maps_read_commits(&infos_b);
2325        let positions = if first_left.is_none() && first_right.is_none() {
2326            String::new()
2327        } else {
2328            format!(
2329                " ({} {}, {} {})",
2330                opts.side_labels.left,
2331                describe_maps_commit(first_left),
2332                opts.side_labels.right,
2333                describe_maps_commit(first_right),
2334            )
2335        };
2336        writeln!(
2337            w,
2338            "Logs contain {maps_left} | {maps_right} scheduler COMMIT records reading /proc/self/maps{positions}",
2339        )?;
2340    }
2341    Ok(summary)
2342}
2343
2344#[cfg(test)]
2345mod test {
2346    use clap::CommandFactory;
2347    use clap::Parser;
2348    use pretty_assertions::assert_eq;
2349
2350    use super::finished_syscall_at_or_before;
2351    use super::finished_syscall_number;
2352    use crate::detlog::DetLogEvent;
2353    use crate::logdiff::DetLogFilter;
2354
2355    /// One well-formed log record. Records are delimited by their leading
2356    /// timestamp, so `body` may contain newlines and still be one record.
2357    fn record(second: usize, body: &str) -> String {
2358        format!("Apr 09 06:08:{second:02}.100  INFO detcore: {body}\n")
2359    }
2360
2361    fn structured_record(second: usize, body: &str, event: DetLogEvent) -> String {
2362        record(
2363            second,
2364            &format!("{body}{}", crate::detlog::record_suffix(event)),
2365        )
2366    }
2367
2368    fn historical(index: usize, text: &str) -> super::LogMessage<'_> {
2369        super::LogMessage {
2370            index,
2371            text,
2372            event: None,
2373        }
2374    }
2375
2376    fn indexed_text<'a>(messages: &'a [super::LogMessage<'a>]) -> Vec<(usize, &'a str)> {
2377        messages
2378            .iter()
2379            .map(|message| (message.index, message.text))
2380            .collect()
2381    }
2382
2383    fn info_opts() -> super::LogDiffOpts {
2384        super::LogDiffOpts {
2385            comparison: super::LogComparisonMode::Info,
2386            ..Default::default()
2387        }
2388    }
2389
2390    fn compare(left: &str, right: &str) -> super::PrefixComparison {
2391        super::compare_complete_prefix(left, right, &info_opts(), &mut Vec::new())
2392            .expect("comparing in-memory strings cannot fail on I/O")
2393    }
2394
2395    fn temp_log(contents: &str) -> tempfile::NamedTempFile {
2396        let file = tempfile::NamedTempFile::new().expect("create temporary log");
2397        std::fs::write(file.path(), contents).expect("write temporary log");
2398        file
2399    }
2400
2401    #[test]
2402    fn bitwise_info_v1_binds_the_complete_policy() {
2403        let labels = super::ComparisonSideLabels::new("left", "right");
2404        let options = super::bitwise_info_v1_options(labels.clone());
2405        assert!(!options.strip_lines);
2406        assert!(options.canonicalize_addresses);
2407        assert_eq!(options.comparison, super::LogComparisonMode::Info);
2408        assert_eq!(options.side_labels, labels);
2409        assert!(options.require_structured_events);
2410        assert!(!options.print_logs);
2411        assert_eq!(options.limit, 20);
2412        assert!(options.ignore_lines.is_empty());
2413        assert_eq!(options.syscall_history, 5);
2414        assert!(!options.no_color);
2415        assert!(!options.skip_commit);
2416        assert!(!options.skip_detlog);
2417        assert!(!options.git_diff);
2418        assert_eq!(
2419            options.include_detlogs,
2420            [
2421                DetLogFilter::Syscall,
2422                DetLogFilter::SyscallResult,
2423                DetLogFilter::Other,
2424            ]
2425        );
2426    }
2427
2428    #[test]
2429    fn bitwise_info_v1_matches_and_renders_current_records() -> std::io::Result<()> {
2430        let left_text = structured_record(
2431            1,
2432            &format!("DETLOG allocation={}", super::host_addr(0x1000)),
2433            DetLogEvent::Other,
2434        );
2435        let right_text = structured_record(
2436            1,
2437            &format!("DETLOG allocation={}", super::host_addr(0x9000)),
2438            DetLogEvent::Other,
2439        );
2440        let left = temp_log(&left_text);
2441        let right = temp_log(&right_text);
2442        let summary = super::try_compare_bitwise_info_v1(
2443            left.path(),
2444            right.path(),
2445            super::ComparisonSideLabels::new("left", "right"),
2446        )?;
2447        assert!(summary.matched_with_evidence());
2448        assert_eq!((summary.compared_left, summary.compared_right), (1, 1));
2449
2450        let mut rendered = Vec::new();
2451        assert_eq!(
2452            super::write_bitwise_info_v1_bytes(left_text.as_bytes(), "left", &mut rendered)?,
2453            1
2454        );
2455        let rendered = String::from_utf8(rendered).unwrap();
2456        assert!(rendered.contains("<addr1>"));
2457        assert!(!rendered.contains("0x1000"));
2458        Ok(())
2459    }
2460
2461    #[test]
2462    fn bitwise_info_v1_reports_first_divergence() -> std::io::Result<()> {
2463        let left = structured_record(1, "DETLOG payload=left", DetLogEvent::Other);
2464        let right = structured_record(1, "DETLOG payload=right", DetLogEvent::Other);
2465        let mut diagnostic = Vec::new();
2466        let (summary, records_left, records_right) =
2467            super::try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
2468                left.as_bytes(),
2469                right.as_bytes(),
2470                super::ComparisonSideLabels::new("left", "right"),
2471                super::BitwiseInfoV1Diagnostics {
2472                    difference_limit: 1,
2473                    syscall_history: 0,
2474                    no_color: true,
2475                    print_logs: false,
2476                },
2477                &mut diagnostic,
2478            )?;
2479        assert!(summary.diff_found);
2480        assert!(summary.refusal_reason.is_none());
2481        assert_eq!(summary.first_divergent_record, Some(1));
2482        assert_eq!((summary.compared_left, summary.compared_right), (1, 1));
2483        assert_eq!((records_left, records_right), (1, 1));
2484        assert!(
2485            String::from_utf8(diagnostic)
2486                .unwrap()
2487                .contains("payload=left")
2488        );
2489        Ok(())
2490    }
2491
2492    #[test]
2493    fn bitwise_info_v1_diagnostics_do_not_change_the_verdict() -> std::io::Result<()> {
2494        let left = structured_record(1, "DETLOG payload=left", DetLogEvent::Other);
2495        let right = structured_record(1, "DETLOG payload=right", DetLogEvent::Other);
2496        let baseline = super::try_compare_bitwise_info_v1_bytes_with_records(
2497            left.as_bytes(),
2498            right.as_bytes(),
2499            super::ComparisonSideLabels::default(),
2500        )?;
2501        let mut diagnostic = Vec::new();
2502        let varied = super::try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
2503            left.as_bytes(),
2504            right.as_bytes(),
2505            super::ComparisonSideLabels::default(),
2506            super::BitwiseInfoV1Diagnostics {
2507                difference_limit: 0,
2508                syscall_history: 10,
2509                no_color: true,
2510                print_logs: true,
2511            },
2512            &mut diagnostic,
2513        )?;
2514        assert_eq!(baseline, varied);
2515        assert!(!diagnostic.is_empty());
2516        Ok(())
2517    }
2518
2519    #[test]
2520    fn bitwise_info_v1_refuses_empty_missing_and_unreadable_inputs() -> std::io::Result<()> {
2521        let empty_left = temp_log("");
2522        let empty_right = temp_log("");
2523        let summary = super::try_compare_bitwise_info_v1(
2524            empty_left.path(),
2525            empty_right.path(),
2526            super::ComparisonSideLabels::default(),
2527        )?;
2528        assert!(!summary.matched_with_evidence());
2529        assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
2530
2531        let missing_parent = tempfile::tempdir()?;
2532        let missing = missing_parent.path().join("missing.log");
2533        assert!(
2534            super::try_compare_bitwise_info_v1(
2535                &missing,
2536                empty_right.path(),
2537                super::ComparisonSideLabels::default(),
2538            )
2539            .is_err()
2540        );
2541        let directory = tempfile::tempdir()?;
2542        assert!(
2543            super::try_compare_bitwise_info_v1(
2544                directory.path(),
2545                empty_right.path(),
2546                super::ComparisonSideLabels::default(),
2547            )
2548            .is_err()
2549        );
2550        Ok(())
2551    }
2552
2553    #[test]
2554    fn bitwise_info_v1_refuses_invalid_utf8_and_truncation() -> std::io::Result<()> {
2555        let valid = structured_record(1, "DETLOG payload", DetLogEvent::Other);
2556        let mut invalid = valid.clone().into_bytes();
2557        invalid.insert(invalid.len() - 1, 0x80);
2558        let error = super::try_compare_bitwise_info_v1_bytes_with_records(
2559            &invalid,
2560            valid.as_bytes(),
2561            super::ComparisonSideLabels::new("left", "right"),
2562        )
2563        .expect_err("invalid UTF-8 must refuse");
2564        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2565        assert!(error.to_string().contains("left"));
2566
2567        let truncated = temp_log(&format!("{valid}{}\n", super::TRUNCATION_MARKER));
2568        let complete = temp_log(&valid);
2569        let summary = super::try_compare_bitwise_info_v1(
2570            truncated.path(),
2571            complete.path(),
2572            super::ComparisonSideLabels::default(),
2573        )?;
2574        assert!(summary.diff_found);
2575        assert!(
2576            summary
2577                .refusal_reason
2578                .as_deref()
2579                .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
2580            "the typed result must retain the truncation refusal cause"
2581        );
2582        assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
2583        Ok(())
2584    }
2585
2586    #[test]
2587    fn bitwise_info_v1_requires_current_structured_events() {
2588        let historical = temp_log(&record(1, "DETLOG stable"));
2589        let error = super::try_compare_bitwise_info_v1(
2590            historical.path(),
2591            historical.path(),
2592            super::ComparisonSideLabels::new("left", "right"),
2593        )
2594        .expect_err("prose-only DETLOG must refuse");
2595        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2596        assert!(
2597            error
2598                .to_string()
2599                .contains("missing its structured DETLOG result")
2600        );
2601    }
2602
2603    #[test]
2604    fn bitwise_info_v1_complete_prefix_withholds_the_unfinished_tail() -> std::io::Result<()> {
2605        let common = structured_record(1, "DETLOG common", DetLogEvent::Other);
2606        let left = format!(
2607            "{common}{}",
2608            structured_record(2, "DETLOG unfinished-left", DetLogEvent::Other)
2609        );
2610        let right = format!(
2611            "{common}{}",
2612            structured_record(2, "DETLOG unfinished-right", DetLogEvent::Other)
2613        );
2614        let comparison = super::compare_complete_bitwise_info_v1_prefix(
2615            left.as_bytes(),
2616            right.as_bytes(),
2617            super::ComparisonSideLabels::new("left", "right"),
2618            super::BitwiseInfoV1Diagnostics::default(),
2619            &mut Vec::new(),
2620        )?;
2621        assert_eq!(comparison.records_available_left, 1);
2622        assert_eq!(comparison.records_available_right, 1);
2623        assert_eq!(comparison.records_compared, 1);
2624        assert!(comparison.summary.matched_with_evidence());
2625        assert_eq!(comparison.summary.compared_left, 1);
2626        assert_eq!(comparison.summary.compared_right, 1);
2627        Ok(())
2628    }
2629
2630    #[test]
2631    fn a_record_is_complete_only_once_the_next_one_starts() {
2632        assert_eq!(super::complete_record_count(""), 0);
2633
2634        // One record start means one record that is still being written.
2635        let one = record(1, "first");
2636        assert_eq!(super::complete_record_count(&one), 0);
2637
2638        // The second start is what proves the first record finished.
2639        let two = format!("{}{}", record(1, "first"), record(2, "second"));
2640        assert_eq!(super::complete_record_count(&two), 1);
2641
2642        let three = format!("{two}{}", record(3, "third"));
2643        assert_eq!(super::complete_record_count(&three), 2);
2644    }
2645
2646    #[test]
2647    fn a_multiline_record_counts_once_and_is_not_split_at_its_newlines() {
2648        let multiline = format!(
2649            "{}{}",
2650            record(1, "first\n    continued detail\n    more detail"),
2651            record(2, "second")
2652        );
2653        // Two starts, so one complete record -- the embedded newlines are part
2654        // of record one, not boundaries of their own.
2655        assert_eq!(super::complete_record_count(&multiline), 1);
2656
2657        let prefix = super::take_complete_records(&multiline, 1).unwrap();
2658        assert!(prefix.contains("continued detail"));
2659        assert!(prefix.contains("more detail"));
2660        assert!(!prefix.contains("second"));
2661    }
2662
2663    #[test]
2664    fn asking_past_the_written_end_is_none_not_a_short_answer() {
2665        let two = format!("{}{}", record(1, "first"), record(2, "second"));
2666        assert_eq!(super::take_complete_records(&two, 0), Some(""));
2667        assert!(super::take_complete_records(&two, 1).is_some());
2668        // Only one record is complete, so two is not yet readable. Returning a
2669        // truncated prefix here would let "not written yet" pass as "agrees".
2670        assert_eq!(super::take_complete_records(&two, 2), None);
2671        assert_eq!(super::take_complete_records(&two, 99), None);
2672    }
2673
2674    #[test]
2675    fn a_half_written_final_record_is_never_a_difference() {
2676        // Identical complete records; the runs differ only in the tail that
2677        // neither has finished flushing.
2678        let left = format!(
2679            "{}{}{}",
2680            record(1, "same"),
2681            record(2, "same"),
2682            record(3, "TAIL-LEFT")
2683        );
2684        let right = format!(
2685            "{}{}{}",
2686            record(1, "same"),
2687            record(2, "same"),
2688            record(3, "TAIL-RIGHT-AND-LONGER")
2689        );
2690
2691        let comparison = compare(&left, &right);
2692        assert!(
2693            !comparison.summary.diff_found,
2694            "an unfinished record must not read as a divergence"
2695        );
2696        assert_eq!(comparison.records_compared, 2);
2697        assert!(!comparison.one_side_is_ahead());
2698    }
2699
2700    #[test]
2701    fn a_difference_inside_the_completed_prefix_is_found() {
2702        let left = format!(
2703            "{}{}{}",
2704            record(1, "same"),
2705            record(2, "LEFT"),
2706            record(3, "tail")
2707        );
2708        let right = format!(
2709            "{}{}{}",
2710            record(1, "same"),
2711            record(2, "RIGHT"),
2712            record(3, "tail")
2713        );
2714
2715        let comparison = compare(&left, &right);
2716        assert!(comparison.summary.diff_found);
2717        assert_eq!(comparison.records_compared, 2);
2718    }
2719
2720    #[test]
2721    fn comparison_is_bounded_by_the_shorter_log_and_says_so() {
2722        let ahead = format!(
2723            "{}{}{}{}{}",
2724            record(1, "same"),
2725            record(2, "same"),
2726            record(3, "same"),
2727            record(4, "same"),
2728            record(5, "same")
2729        );
2730        let behind = format!(
2731            "{}{}{}",
2732            record(1, "same"),
2733            record(2, "same"),
2734            record(3, "same")
2735        );
2736
2737        let comparison = compare(&ahead, &behind);
2738        assert!(!comparison.summary.diff_found);
2739        assert_eq!(comparison.records_available_left, 4);
2740        assert_eq!(comparison.records_available_right, 2);
2741        // Only what both sides have finished writing.
2742        assert_eq!(comparison.records_compared, 2);
2743        assert!(
2744            comparison.one_side_is_ahead(),
2745            "the caller must be able to see the comparison was reading-bound"
2746        );
2747    }
2748
2749    #[test]
2750    fn the_first_differing_record_is_located_not_just_bounded() {
2751        // 40 identical records, one difference at record 13, then 40 more.
2752        let build = |marker: &str| {
2753            (1..=81)
2754                .map(|index| {
2755                    let body = if index == 13 { marker } else { "same" };
2756                    record(index % 60, &format!("record {index} {body}"))
2757                })
2758                .collect::<String>()
2759        };
2760        let left = build("LEFT");
2761        let right = build("RIGHT");
2762
2763        let found = compare(&left, &right).summary.first_divergent_record;
2764        assert_eq!(
2765            found,
2766            Some(13),
2767            "bisection must name the record, not merely the prefix that contains it"
2768        );
2769    }
2770
2771    #[test]
2772    fn identical_logs_have_no_first_divergent_record() {
2773        let same = format!("{}{}{}", record(1, "a"), record(2, "b"), record(3, "c"));
2774        assert_eq!(compare(&same, &same).summary.first_divergent_record, None);
2775        // And an empty comparison reports no location rather than record zero.
2776        assert_eq!(compare("", "").summary.first_divergent_record, None);
2777    }
2778
2779    /// The matched prefix counts COMPARED messages, not raw records: equal
2780    /// streams match over their full length, a divergence at compared message
2781    /// k leaves k - 1 matched, and a strict prefix matches only over the
2782    /// shorter length. A non-INFO record ahead of the divergence moves the raw
2783    /// record index but not the matched prefix.
2784    #[test]
2785    fn matched_prefix_counts_leading_equal_compared_messages() {
2786        let log = |bodies: &[&str]| {
2787            bodies
2788                .iter()
2789                .enumerate()
2790                .map(|(index, body)| record(index + 1, body))
2791                .collect::<String>()
2792        };
2793        let summary = |left: &str, right: &str| {
2794            super::log_diff_summary_from_strs(left, right, &info_opts(), &mut Vec::new())
2795                .expect("comparing in-memory strings cannot fail on I/O")
2796        };
2797
2798        let same = log(&["a", "b", "c"]);
2799        let identical = summary(&same, &same);
2800        assert!(!identical.diff_found);
2801        assert_eq!(identical.matched_prefix_messages, Some(3));
2802
2803        let at_first = summary(&log(&["X", "b", "c"]), &log(&["Y", "b", "c"]));
2804        assert!(at_first.diff_found);
2805        assert_eq!(at_first.matched_prefix_messages, Some(0));
2806        assert_eq!(at_first.first_divergent_record, Some(1));
2807
2808        let at_third = summary(&log(&["a", "b", "X", "d"]), &log(&["a", "b", "Y", "d"]));
2809        assert_eq!(at_third.matched_prefix_messages, Some(2));
2810        assert_eq!(at_third.first_divergent_record, Some(3));
2811
2812        let shorter = summary(&log(&["a", "b"]), &log(&["a", "b", "c", "d"]));
2813        assert!(shorter.diff_found);
2814        assert_eq!((shorter.compared_left, shorter.compared_right), (2, 4));
2815        assert_eq!(shorter.matched_prefix_messages, Some(2));
2816
2817        let with_debug = format!(
2818            "{}Apr 09 06:08:02.100 DEBUG detcore: not compared\n{}",
2819            record(1, "a"),
2820            record(3, "X")
2821        );
2822        let unit_mismatch = summary(&with_debug, &log(&["a", "Y"]));
2823        assert_eq!(unit_mismatch.matched_prefix_messages, Some(1));
2824        assert_eq!(unit_mismatch.first_divergent_record, Some(3));
2825
2826        let empty = summary("", "");
2827        assert_eq!(empty.matched_prefix_messages, Some(0));
2828    }
2829
2830    /// The reported prefix is the exact scan's, and only where that scan
2831    /// agrees with the verdict: a match covers both full streams, and a
2832    /// divergence leaves something unmatched. Every other combination is
2833    /// withheld rather than reported.
2834    #[test]
2835    fn a_matched_prefix_is_reported_only_where_the_exact_scan_agrees_with_the_verdict() {
2836        // (diff_found, exact prefix, left, right) -> reported
2837        for (case, diff_found, prefix, left, right, expected) in [
2838            ("full match", false, 3, 3, 3, Some(3)),
2839            ("empty match", false, 0, 0, 0, Some(0)),
2840            ("divergence inside both", true, 2, 4, 4, Some(2)),
2841            ("divergence at the first message", true, 0, 3, 3, Some(0)),
2842            ("strict prefix", true, 2, 2, 5, Some(2)),
2843            ("match over unequal counts", false, 2, 1, 2, None),
2844            ("match of a strict prefix", false, 1, 1, 2, None),
2845            ("match the exact scan stops inside", false, 1, 2, 2, None),
2846            ("divergence with nothing unmatched", true, 3, 3, 3, None),
2847            ("divergence of two empty streams", true, 0, 0, 0, None),
2848        ] {
2849            assert_eq!(
2850                super::matched_prefix_for_verdict(diff_found, prefix, left, right),
2851                expected,
2852                "{case}"
2853            );
2854        }
2855    }
2856
2857    /// `git diff -w` compares rendered text, so one multi-line record on the
2858    /// left and the same two lines as two records on the right MATCH with
2859    /// compared counts 1 | 2. The old code reported a matched prefix of 2 for
2860    /// that match, longer than the left stream. The exact comparator on the
2861    /// same input is the control: it diverges at the first message.
2862    #[test]
2863    fn a_git_diff_match_over_unequal_counts_reports_no_matched_prefix() {
2864        let left = "Apr 09 06:08:01.100  INFO detcore: a\nINFO detcore: b\n";
2865        let right = format!("{}{}", record(1, "a"), record(2, "b"));
2866
2867        let git_opts = super::LogDiffOpts {
2868            git_diff: true,
2869            ..info_opts()
2870        };
2871        let git = super::log_diff_summary_from_strs(left, &right, &git_opts, &mut Vec::new())
2872            .expect("comparing in-memory strings cannot fail on I/O");
2873        assert_eq!((git.compared_left, git.compared_right), (1, 2));
2874        assert!(
2875            !git.diff_found,
2876            "git diff -w must accept the split record (this test needs git on PATH)"
2877        );
2878        assert_eq!(
2879            git.matched_prefix_messages, None,
2880            "a match over 1 | 2 compared messages has no prefix covering both streams"
2881        );
2882
2883        let exact = super::log_diff_summary_from_strs(left, &right, &info_opts(), &mut Vec::new())
2884            .expect("comparing in-memory strings cannot fail on I/O");
2885        assert_eq!((exact.compared_left, exact.compared_right), (1, 2));
2886        assert!(exact.diff_found);
2887        assert_eq!(exact.matched_prefix_messages, Some(0));
2888    }
2889
2890    /// An untagged line REFUSES the comparison instead of panicking, and the
2891    /// refusal names the line. A panic is the wrong failure mode for a tool
2892    /// people reach for when something is already broken, and the `--json`
2893    /// consumer could not distinguish a crash from a real `no_result` verdict.
2894    ///
2895    /// It still refuses rather than SKIPPING: dropping the line would silently
2896    /// change the compared surface, which is a disclosed, versioned decision
2897    /// belonging to the record envelope, not to the parser.
2898    #[test]
2899    fn an_untagged_line_is_refused_by_name_rather_than_panicking() {
2900        // THE REAL SHAPE, and the reason it bites: records are delimited by the
2901        // wall-clock PREFIX, not by newlines. DBT emits no timestamp prefix at
2902        // all and writes its own untagged startup lines first, so the whole
2903        // thing forms one segment beginning with untagged text. A line placed
2904        // AFTER a timestamped record would simply be absorbed into that
2905        // record's body and never seen as its own -- which is why this fixture
2906        // has no timestamp.
2907        let log = "detcore-dbt: background client thread entered\n";
2908        let error = super::extract_log_messages(log)
2909            .expect_err("an untagged line must refuse, not be admitted");
2910        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2911        let message = error.to_string();
2912        assert!(
2913            message.contains("detcore-dbt: background client thread entered"),
2914            "the refusal must name the offending line, got: {message}"
2915        );
2916        assert!(
2917            message.contains("no ERROR/WARN/INFO/DEBUG/TRACE tag"),
2918            "the refusal must say why, got: {message}"
2919        );
2920    }
2921
2922    /// A fully tagged log is unaffected -- the refusal must not become a
2923    /// tripwire on ordinary input.
2924    #[test]
2925    fn a_fully_tagged_log_still_parses() {
2926        let log = "2026-08-24T20:16:17.897469Z  INFO detcore: DETLOG a\n\
2927                   2026-08-24T20:16:17.897470Z  WARN detcore: b\n";
2928        let records = super::extract_log_messages(log).expect("tagged log parses");
2929        assert_eq!(records.len(), 2);
2930    }
2931
2932    /// Historical logs remain readable through their `finish syscall #N`
2933    /// spelling. Current verification separately refuses that shape.
2934    #[test]
2935    fn historical_syscall_numbers_remain_readable() {
2936        assert_eq!(
2937            finished_syscall_number(&historical(
2938                0,
2939                "DETLOG [syscall][detcore, dtid 3] finish syscall #37: write(1, 0x5, 6) = Ok(6)"
2940            )),
2941            Some(37)
2942        );
2943        // `inbound` records carry no number: the guest has not got anywhere yet.
2944        assert_eq!(
2945            finished_syscall_number(&historical(
2946                0,
2947                "DETLOG [syscall][detcore, dtid 3] inbound syscall: brk(NULL) = ?"
2948            )),
2949            None
2950        );
2951        assert_eq!(
2952            finished_syscall_number(&historical(0, "no syscall here")),
2953            None
2954        );
2955    }
2956
2957    /// Reports the LAST syscall completed at or before the divergence, and
2958    /// reports NONE when none had completed. The second half is a real state
2959    /// rather than a gap: a run can diverge during startup, before the guest
2960    /// has finished a single syscall. Measured on a real 131-line log --
2961    /// diverging at record 12 reported no syscall, while diverging at record 98
2962    /// reported syscall 37.
2963    #[test]
2964    fn the_syscall_count_is_the_last_one_completed_before_the_divergence() {
2965        let finished = |n: u64| format!("finish syscall #{n}: write(1, 0x5, 6) = Ok(6)");
2966        let a = finished(2);
2967        let b = finished(37);
2968        let syscalls = vec![historical(10, a.as_str()), historical(90, b.as_str())];
2969        assert_eq!(finished_syscall_at_or_before(&syscalls, 98), Some(37));
2970        assert_eq!(finished_syscall_at_or_before(&syscalls, 90), Some(37));
2971        assert_eq!(finished_syscall_at_or_before(&syscalls, 50), Some(2));
2972        assert_eq!(
2973            finished_syscall_at_or_before(&syscalls, 9),
2974            None,
2975            "a divergence before any syscall completed has no syscall count, \
2976             and that is a state rather than a missing value"
2977        );
2978    }
2979
2980    #[test]
2981    fn structured_positions_and_syscall_counts_are_authoritative() -> std::io::Result<()> {
2982        let run = |turn: u64, time: u64, syscall: u64, value: u64| {
2983            format!(
2984                "{}{}{}",
2985                structured_record(
2986                    1,
2987                    "COMMIT turn 999 at time 999",
2988                    DetLogEvent::SchedulerCommit {
2989                        scheduler_turn: turn,
2990                        virtual_nanoseconds: time,
2991                        internal_io_poll: false,
2992                        runtime_maps_read: false,
2993                    },
2994                ),
2995                structured_record(
2996                    2,
2997                    "DETLOG [syscall] finish syscall #999: write = Ok(1)",
2998                    DetLogEvent::SyscallResult {
2999                        finished_syscall_number: syscall,
3000                    },
3001                ),
3002                structured_record(3, &format!("DETLOG value={value}"), DetLogEvent::Other),
3003            )
3004        };
3005        let options = super::LogDiffOpts {
3006            require_structured_events: true,
3007            ..Default::default()
3008        };
3009
3010        let original = super::log_diff_summary_from_strs(
3011            run(17, 123, 37, 1),
3012            run(17, 123, 37, 2),
3013            &options,
3014            &mut Vec::new(),
3015        )?;
3016        assert_eq!(original.first_divergent_scheduler_turn, Some(17));
3017        assert_eq!(original.first_divergent_virtual_nanoseconds, Some(123));
3018        assert_eq!(original.first_divergent_syscall, Some(37));
3019
3020        let mutated = super::log_diff_summary_from_strs(
3021            run(18, 124, 38, 1),
3022            run(18, 124, 38, 2),
3023            &options,
3024            &mut Vec::new(),
3025        )?;
3026        assert_eq!(mutated.first_divergent_scheduler_turn, Some(18));
3027        assert_eq!(mutated.first_divergent_virtual_nanoseconds, Some(124));
3028        assert_eq!(mutated.first_divergent_syscall, Some(38));
3029        Ok(())
3030    }
3031
3032    #[test]
3033    fn current_verification_refuses_a_missing_structured_record_by_name() {
3034        let options = super::LogDiffOpts {
3035            require_structured_events: true,
3036            ..Default::default()
3037        };
3038        let error = super::log_diff_summary_from_strs(
3039            record(1, "DETLOG value=1"),
3040            record(1, "DETLOG value=1"),
3041            &options,
3042            &mut Vec::new(),
3043        )
3044        .expect_err("current verification must not fall back to prose");
3045        assert!(
3046            error
3047                .to_string()
3048                .contains("missing its structured DETLOG result"),
3049            "refusal must name the missing result: {error}"
3050        );
3051    }
3052
3053    #[test]
3054    fn structured_kind_not_the_human_tag_selects_the_syscall_class() {
3055        let text = "INFO detcore: DETLOG [syscall] inbound syscall: read = ?";
3056        let other = super::LogMessage {
3057            index: 0,
3058            text,
3059            event: Some(DetLogEvent::Other),
3060        };
3061        let syscall = super::LogMessage {
3062            event: Some(DetLogEvent::Syscall),
3063            ..other
3064        };
3065        assert!(!super::is_detlog_syscall(&other));
3066        assert!(super::is_detlog_syscall(&syscall));
3067    }
3068
3069    #[test]
3070    fn structured_scheduler_flags_control_filtering_and_retained_counts() {
3071        let text = "INFO detcore::scheduler: COMMIT turn 999 at time 999";
3072        let internal = super::LogMessage {
3073            index: 0,
3074            text,
3075            event: Some(DetLogEvent::SchedulerCommit {
3076                scheduler_turn: 17,
3077                virtual_nanoseconds: 123,
3078                internal_io_poll: true,
3079                runtime_maps_read: false,
3080            }),
3081        };
3082        let maps_read = super::LogMessage {
3083            event: Some(DetLogEvent::SchedulerCommit {
3084                scheduler_turn: 17,
3085                virtual_nanoseconds: 123,
3086                internal_io_poll: false,
3087                runtime_maps_read: true,
3088            }),
3089            ..internal
3090        };
3091
3092        assert!(
3093            super::LogDiffOpts::default()
3094                .filter_deterministic(&[internal])
3095                .is_empty()
3096        );
3097        assert_eq!(
3098            super::LogDiffOpts::default()
3099                .filter_deterministic(&[maps_read])
3100                .len(),
3101            1
3102        );
3103        assert_eq!(super::maps_read_commits(&[internal]), (0, None));
3104        assert_eq!(
3105            super::maps_read_commits(&[maps_read]),
3106            (1, Some((17, Some(123))))
3107        );
3108    }
3109
3110    #[test]
3111    fn nothing_written_yet_is_a_no_result_not_a_match() {
3112        // A single unfinished record on each side: zero complete records.
3113        let comparison = compare(&record(1, "first"), &record(1, "first"));
3114        assert_eq!(comparison.records_compared, 0);
3115        assert!(!comparison.summary.diff_found);
3116        assert!(
3117            !comparison.summary.matched_with_evidence(),
3118            "comparing zero records must never report a match"
3119        );
3120    }
3121
3122    #[test]
3123    fn unsafe_strip_lines_cli_name_and_warning_are_explicit() {
3124        let options = super::LogDiffOpts::try_parse_from(["log-diff", "--unsafe-strip-lines"])
3125            .expect("the explicitly unsafe spelling should parse");
3126        assert!(options.strip_lines);
3127
3128        assert!(super::LogDiffOpts::try_parse_from(["log-diff", "--strip-lines"]).is_err());
3129
3130        let mut help = Vec::new();
3131        super::LogDiffOpts::command()
3132            .write_long_help(&mut help)
3133            .expect("write clap help");
3134        let help = String::from_utf8(help).expect("help is UTF-8");
3135        assert!(help.contains("--unsafe-strip-lines"));
3136        assert!(help.contains("erases timestamps and syscall values"));
3137        assert!(help.contains("make a failing parity diff pass"));
3138        assert!(help.contains("doing so is cheating"));
3139        assert!(!help.contains("--strip-lines"));
3140    }
3141
3142    #[test]
3143    fn test_compare_with_no_color() {
3144        let str1 = "test1";
3145        let str2 = "test2";
3146
3147        assert_eq!(
3148            format!("{}", super::Comparison::new(true, str1, str2))
3149                .split('\n')
3150                .collect::<Vec<&str>>(),
3151            ["Diff < left / right > :", "<\"test1\"", ">\"test2\"", "",]
3152        );
3153    }
3154
3155    #[test]
3156    fn test_compare_with_color() {
3157        let str1 = "test1";
3158        let str2 = "test2";
3159
3160        assert_eq!(
3161            format!("{}", super::Comparison::new(false, str1, str2))
3162                .split('\n')
3163                .collect::<Vec<&str>>(),
3164            [
3165                "\u{1b}[1mDiff\u{1b}[0m \u{1b}[31m< left\u{1b}[0m / \u{1b}[32mright >\u{1b}[0m :",
3166                "\u{1b}[31m<\"test\u{1b}[0m\u{1b}[1;48;5;52;31m1\u{1b}[0m\u{1b}[31m\"\u{1b}[0m",
3167                "\u{1b}[32m>\"test\u{1b}[0m\u{1b}[1;48;5;22;32m2\u{1b}[0m\u{1b}[32m\"\u{1b}[0m",
3168                "",
3169            ]
3170        );
3171    }
3172
3173    /// The two directions of the truncation refusal, on the SAME pair of logs.
3174    ///
3175    /// Both cases feed identical, matching DETLOG content; the only difference
3176    /// is whether a side carries the bounded writer's marker. So a pass here
3177    /// cannot come from the comparison being broken in general, and the refusal
3178    /// cannot come from the content differing.
3179    #[test]
3180    fn truncated_logs_are_refused_and_untruncated_logs_still_match() -> std::io::Result<()> {
3181        let body = "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)\n2022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)";
3182        let marked = format!("{body}\n{}\n", super::TRUNCATION_MARKER);
3183        let options = super::LogDiffOpts {
3184            no_color: true,
3185            ..Default::default()
3186        };
3187
3188        // Direction 1: no marker on either side -> a real comparison happens,
3189        // finds no difference, and carries nonzero evidence.
3190        let clean = super::log_diff_summary_from_strs(body, body, &options, &mut Vec::new())?;
3191        assert!(!clean.diff_found, "identical untruncated logs must match");
3192        assert!(
3193            clean.matched_with_evidence(),
3194            "the untruncated match must carry nonzero compared counts, got {clean:?}"
3195        );
3196        assert!(clean.refusal_reason.is_none());
3197
3198        // Direction 2: the marker on the left, the right, or both -> refused,
3199        // even though the retained content is byte-identical to direction 1.
3200        for (label, left, right) in [
3201            ("left", marked.as_str(), body),
3202            ("right", body, marked.as_str()),
3203            ("both", marked.as_str(), marked.as_str()),
3204        ] {
3205            let mut out = Vec::new();
3206            let summary = super::log_diff_summary_from_strs(left, right, &options, &mut out)?;
3207            assert!(
3208                summary.diff_found,
3209                "{label}: a truncated log must not be reported as a match"
3210            );
3211            assert_eq!(
3212                (summary.compared_left, summary.compared_right),
3213                (0, 0),
3214                "{label}: nothing was compared, so the counts must not claim otherwise"
3215            );
3216            assert!(
3217                summary
3218                    .refusal_reason
3219                    .as_deref()
3220                    .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
3221                "{label}: the typed result must retain the printed refusal cause"
3222            );
3223            assert!(
3224                !summary.matched_with_evidence(),
3225                "{label}: the evidence predicate must also refuse"
3226            );
3227            assert_eq!(
3228                summary.matched_prefix_messages, None,
3229                "{label}: a refused comparison measured no matched prefix"
3230            );
3231            let text = String::from_utf8(out).unwrap();
3232            assert!(
3233                text.contains("REFUSING to compare"),
3234                "{label}: the refusal must be stated, got: {text}"
3235            );
3236            assert!(
3237                !text.contains("no substantive differences found"),
3238                "{label}: a refusal must never print the match line, got: {text}"
3239            );
3240        }
3241
3242        Ok(())
3243    }
3244
3245    /// The refusal must fire on "this log was truncated", never on "this log
3246    /// mentions the marker".
3247    ///
3248    /// DETLOG records guest syscall path arguments verbatim, so the log text is
3249    /// partly guest-controlled. The first version of this refusal searched the
3250    /// whole text for a marker PREFIX, which let a guest that merely touched a
3251    /// path containing that prefix refuse its own `--verify` -- and, because
3252    /// the poisoning came from content rather than from the bound, disabling
3253    /// the bound did not help. Each case below is a way the marker text can
3254    /// appear in a log that was NOT truncated.
3255    #[test]
3256    fn marker_text_in_guest_content_is_not_truncation() -> std::io::Result<()> {
3257        let options = super::LogDiffOpts {
3258            no_color: true,
3259            ..Default::default()
3260        };
3261        let marker = super::TRUNCATION_MARKER;
3262        // The exact shape the live reproducer produced: a `statx` path argument
3263        // carrying the marker prefix, mid-line, in a log that ran to completion.
3264        let guest_path_line = format!(
3265            "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] inbound syscall: \
3266             statx(-100, 0x7fff -> \"/tmp/{marker} probe\", AtFlags(AT_NO_AUTOMOUNT), 2, 0x7fff) \
3267             = ?"
3268        );
3269        let tail_line = "2022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: \
3270             write(1, 0x2000, 1) = Ok(1)";
3271
3272        for (label, text) in [
3273            // The whole marker sentence, inside a DETLOG line, at end of file.
3274            (
3275                "marker inside the final DETLOG line",
3276                guest_path_line.clone(),
3277            ),
3278            // The marker on its own line, but the log continues afterwards --
3279            // so the writer cannot have produced it: it discards everything
3280            // after announcing.
3281            (
3282                "marker on its own line, followed by more log",
3283                format!("{guest_path_line}\n{marker}\n{tail_line}"),
3284            ),
3285            // Ends with the marker text, but not at a line boundary.
3286            (
3287                "marker at end of file but mid-line",
3288                format!("{tail_line}\nsomething {marker}"),
3289            ),
3290        ] {
3291            assert!(
3292                !super::log_was_truncated(&text),
3293                "{label}: an untruncated log must not be classified as truncated"
3294            );
3295            let mut out = Vec::new();
3296            let summary = super::log_diff_summary_from_strs(&text, &text, &options, &mut out)?;
3297            let printed = String::from_utf8(out).unwrap();
3298            assert!(
3299                !printed.contains("REFUSING to compare"),
3300                "{label}: must be compared, not refused, got: {printed}"
3301            );
3302            assert!(
3303                !summary.diff_found,
3304                "{label}: identical logs must compare equal, got {summary:?}"
3305            );
3306            assert!(
3307                summary.matched_with_evidence(),
3308                "{label}: the match must carry nonzero compared counts, got {summary:?}"
3309            );
3310        }
3311
3312        // ...and the narrowing did not go so far that real truncation escapes:
3313        // the same guest content, actually cut at the bound, is still refused.
3314        let really_truncated = format!("{guest_path_line}\n{marker}\n");
3315        assert!(
3316            super::log_was_truncated(&really_truncated),
3317            "a log ending in the marker line IS truncated and must still be caught"
3318        );
3319        let mut out = Vec::new();
3320        let summary = super::log_diff_summary_from_strs(
3321            &really_truncated,
3322            &really_truncated,
3323            &options,
3324            &mut out,
3325        )?;
3326        assert!(summary.diff_found, "real truncation must still be refused");
3327        assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
3328        assert!(
3329            String::from_utf8(out)
3330                .unwrap()
3331                .contains("REFUSING to compare"),
3332            "real truncation must still print the refusal"
3333        );
3334
3335        Ok(())
3336    }
3337
3338    #[test]
3339    fn test_log_diff_with_color() -> std::io::Result<()> {
3340        let str1 = "INFO detcore: DETLOG [syscall][detcore, dtid 3]  finish syscall #11: mmap(NULL, 3954880, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_DENYWRITE, 3, 0) = Ok(140737347883008)";
3341        let str2 = "INFO detcore: DETLOG [syscall][detcore, dtid 3]  finish syscall #15: mmap(NULL, 3954880, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_DENYWRITE, 3, 0) = Ok(140737347883008)";
3342        let mut result = Vec::<u8>::new();
3343
3344        super::log_diff_from_strs(
3345            str1,
3346            str2,
3347            &super::LogDiffOpts {
3348                limit: 1,
3349                strip_lines: false,
3350                canonicalize_addresses: false,
3351                comparison: super::LogComparisonMode::Deterministic,
3352                side_labels: super::ComparisonSideLabels::default(),
3353                require_structured_events: false,
3354                print_logs: false,
3355                syscall_history: 5,
3356                no_color: false,
3357                skip_commit: false,
3358                skip_detlog: false,
3359                git_diff: false,
3360                ignore_lines: Vec::new(),
3361                include_detlogs: vec![
3362                    DetLogFilter::Syscall,
3363                    DetLogFilter::SyscallResult,
3364                    DetLogFilter::Other,
3365                ],
3366            },
3367            &mut result,
3368        )?;
3369
3370        let output = String::from_utf8(result).unwrap();
3371        assert!(output.contains("  Comparing DETLOG messages..."));
3372        assert!(output.contains("Mismatch at log messages 0 (run 1) and 0 (run 2)"));
3373        assert!(output.contains("run 1, log message 0: INFO detcore: DETLOG [syscall][detcore, dtid 3]  finish syscall #11"));
3374        assert!(output.contains("run 2, log message 0: INFO detcore: DETLOG [syscall][detcore, dtid 3]  finish syscall #15"));
3375        assert!(!output.contains("eliding the rest"));
3376
3377        Ok(())
3378    }
3379
3380    #[test]
3381    fn test_log_diff_reports_each_runs_syscall_context() -> std::io::Result<()> {
3382        let log_a = r#"2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)
33832022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)"#;
3384        let log_b = r#"2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)
33852022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x3000, 1) = Ok(1)"#;
3386        let mut result = Vec::new();
3387        let options = super::LogDiffOpts {
3388            no_color: true,
3389            syscall_history: 1,
3390            ..Default::default()
3391        };
3392
3393        assert!(super::log_diff_from_strs(
3394            log_a,
3395            log_b,
3396            &options,
3397            &mut result
3398        )?);
3399
3400        let output = String::from_utf8(result).unwrap();
3401        assert!(output.contains("Mismatch at log messages 2 (run 1) and 2 (run 2)"));
3402        assert!(output.contains("run 1, log message 2: INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)"));
3403        assert!(output.contains("run 2, log message 2: INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x3000, 1) = Ok(1)"));
3404        assert!(output.contains("Prior completed syscalls for run 1:"));
3405        assert!(output.contains("Prior completed syscalls for run 2:"));
3406        assert_eq!(output.matches("finish syscall #1: read").count(), 2);
3407        Ok(())
3408    }
3409
3410    #[test]
3411    fn custom_side_labels_cover_mismatch_history_and_tail_diagnostics() -> std::io::Result<()> {
3412        let left = format!(
3413            "{}{}",
3414            record(1, "DETLOG [syscall] finish syscall #1: read = Ok(1)"),
3415            record(2, "DETLOG [syscall] finish syscall #2: write = Ok(1)"),
3416        );
3417        let right = format!(
3418            "{}{}",
3419            record(1, "DETLOG [syscall] finish syscall #1: read = Ok(1)"),
3420            record(2, "DETLOG [syscall] finish syscall #2: write = Err(5)"),
3421        );
3422        let options = super::LogDiffOpts {
3423            side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
3424            syscall_history: 1,
3425            no_color: true,
3426            ..Default::default()
3427        };
3428        let mut output = Vec::new();
3429        assert!(super::log_diff_from_strs(
3430            &left,
3431            &right,
3432            &options,
3433            &mut output
3434        )?);
3435        let output = String::from_utf8(output).unwrap();
3436        assert!(output.contains("Mismatch at log messages 2 (the recording) and 2 (the replay)"));
3437        assert!(output.contains("the recording, log message 2:"));
3438        assert!(output.contains("the replay, log message 2:"));
3439        assert!(output.contains("Prior completed syscalls for the recording:"));
3440        assert!(output.contains("Prior completed syscalls for the replay:"));
3441        assert!(!output.contains("run 1") && !output.contains("run 2"));
3442
3443        let left = record(1, "DETLOG stable");
3444        let right = format!("{left}{}", record(2, "DETLOG extra"));
3445        let mut output = Vec::new();
3446        assert!(super::log_diff_from_strs(
3447            &left,
3448            &right,
3449            &options,
3450            &mut output
3451        )?);
3452        let output = String::from_utf8(output).unwrap();
3453        assert!(
3454            output.contains("The replay contains 1 extra messages not matched in the recording.")
3455        );
3456        assert!(!output.contains("run 1") && !output.contains("run 2"));
3457
3458        let mut output = Vec::new();
3459        assert!(super::log_diff_from_strs(
3460            &right,
3461            &left,
3462            &options,
3463            &mut output
3464        )?);
3465        let output = String::from_utf8(output).unwrap();
3466        assert!(
3467            output.contains("The recording contains 1 extra messages not matched in the replay.")
3468        );
3469        Ok(())
3470    }
3471
3472    fn printed_labeled_log<'a>(output: &'a str, label: &str) -> &'a str {
3473        let start = format!("--- begin {label} compared log ---\n");
3474        let end = format!("--- end {label} compared log ---\n");
3475        output
3476            .split_once(&start)
3477            .expect("printed log start marker")
3478            .1
3479            .split_once(&end)
3480            .expect("printed log end marker")
3481            .0
3482    }
3483
3484    #[test]
3485    fn custom_side_labels_name_printed_logs() -> std::io::Result<()> {
3486        let options = super::LogDiffOpts {
3487            side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
3488            print_logs: true,
3489            no_color: true,
3490            ..Default::default()
3491        };
3492        let mut output = Vec::new();
3493        let summary = super::log_diff_summary_from_strs(
3494            record(1, "DETLOG recorded"),
3495            record(1, "DETLOG replayed"),
3496            &options,
3497            &mut output,
3498        )?;
3499        assert!(summary.diff_found);
3500        let output = String::from_utf8(output).unwrap();
3501        assert_eq!(
3502            printed_labeled_log(&output, "the recording"),
3503            "INFO detcore: DETLOG recorded\n"
3504        );
3505        assert_eq!(
3506            printed_labeled_log(&output, "the replay"),
3507            "INFO detcore: DETLOG replayed\n"
3508        );
3509        assert!(!output.contains("begin run 1") && !output.contains("begin run 2"));
3510        Ok(())
3511    }
3512
3513    fn printed_log(output: &str, run: u8) -> &str {
3514        let start = format!("--- begin run {run} compared log ---\n");
3515        let end = format!("--- end run {run} compared log ---\n");
3516        output
3517            .split_once(&start)
3518            .expect("printed log start marker")
3519            .1
3520            .split_once(&end)
3521            .expect("printed log end marker")
3522            .0
3523    }
3524
3525    #[test]
3526    fn printed_logs_are_the_exact_selected_comparator_inputs() -> std::io::Result<()> {
3527        let left = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG value=101\n\
35282026-08-15T01:02:03.000001Z INFO unrelated: omitted value=303";
3529        let right = "2026-08-15T04:05:06.000000Z INFO detcore: DETLOG value=202\n\
35302026-08-15T04:05:06.000001Z INFO unrelated: omitted value=404";
3531
3532        let exact = super::LogDiffOpts {
3533            print_logs: true,
3534            no_color: true,
3535            ..Default::default()
3536        };
3537        let mut exact_output = Vec::new();
3538        let exact_summary =
3539            super::log_diff_summary_from_strs(left, right, &exact, &mut exact_output)?;
3540        let exact_output = String::from_utf8(exact_output).unwrap();
3541
3542        assert!(exact_summary.diff_found);
3543        assert!(exact_output.contains("Comparison policy: Deterministic\n"));
3544        assert_eq!(
3545            printed_log(&exact_output, 1).as_bytes(),
3546            b"INFO detcore: DETLOG value=101\n"
3547        );
3548        assert_eq!(
3549            printed_log(&exact_output, 2).as_bytes(),
3550            b"INFO detcore: DETLOG value=202\n"
3551        );
3552
3553        let stripped = super::LogDiffOpts {
3554            strip_lines: true,
3555            print_logs: true,
3556            no_color: true,
3557            ..Default::default()
3558        };
3559        let mut stripped_output = Vec::new();
3560        let stripped_summary =
3561            super::log_diff_summary_from_strs(left, right, &stripped, &mut stripped_output)?;
3562        let stripped_output = String::from_utf8(stripped_output).unwrap();
3563
3564        assert!(stripped_summary.matched_with_evidence());
3565        assert!(stripped_output.contains("Comparison policy: Stripped\n"));
3566        assert_eq!(
3567            printed_log(&stripped_output, 1).as_bytes(),
3568            b"INFO detcore: DETLOG value=<NUM>\n"
3569        );
3570        assert_eq!(
3571            printed_log(&stripped_output, 1),
3572            printed_log(&stripped_output, 2)
3573        );
3574        assert_ne!(
3575            printed_log(&exact_output, 1),
3576            printed_log(&stripped_output, 1)
3577        );
3578        Ok(())
3579    }
3580
3581    #[test]
3582    fn printed_policy_name_tracks_the_selected_scope_and_normalization() -> std::io::Result<()> {
3583        let log = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\n\
35842026-08-15T01:02:03.000001Z DEBUG unrelated: diagnostic=2";
3585
3586        let cases = [
3587            (
3588                super::LogDiffOpts {
3589                    comparison: super::LogComparisonMode::Info,
3590                    print_logs: true,
3591                    no_color: true,
3592                    ..Default::default()
3593                },
3594                "Comparison policy: Info\n",
3595                "INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\n",
3596            ),
3597            (
3598                super::LogDiffOpts {
3599                    comparison: super::LogComparisonMode::FullTrace,
3600                    print_logs: true,
3601                    no_color: true,
3602                    ..Default::default()
3603                },
3604                "Comparison policy: FullTrace\n",
3605                "INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\nDEBUG unrelated: diagnostic=2\n",
3606            ),
3607            (
3608                super::LogDiffOpts {
3609                    comparison: super::LogComparisonMode::Deterministic,
3610                    canonicalize_addresses: true,
3611                    print_logs: true,
3612                    no_color: true,
3613                    ..Default::default()
3614                },
3615                "Comparison policy: Deterministic with Canonical host-address normalization\n",
3616                "INFO detcore: DETLOG stable=1 address=<addr1>\n",
3617            ),
3618            (
3619                super::LogDiffOpts {
3620                    comparison: super::LogComparisonMode::Deterministic,
3621                    strip_lines: true,
3622                    print_logs: true,
3623                    no_color: true,
3624                    ..Default::default()
3625                },
3626                "Comparison policy: Stripped\n",
3627                "INFO detcore: DETLOG stable=<NUM> address=<hostaddr <ADDR>>\n",
3628            ),
3629        ];
3630
3631        for (options, expected_name, expected_log) in cases {
3632            let mut output = Vec::new();
3633            let summary = super::log_diff_summary_from_strs(log, log, &options, &mut output)?;
3634            let output = String::from_utf8(output).unwrap();
3635            assert!(summary.matched_with_evidence());
3636            assert!(output.contains(expected_name), "{output}");
3637            assert_eq!(printed_log(&output, 1), expected_log);
3638            assert_eq!(printed_log(&output, 2), expected_log);
3639        }
3640        Ok(())
3641    }
3642
3643    #[test]
3644    fn printed_canonical_policy_uses_the_info_scope_and_address_ordinals() -> std::io::Result<()> {
3645        let left = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG stable=1\n\
36462026-08-15T01:02:03.000001Z INFO unrelated: value=101 address=<hostaddr 0xaaaa>";
3647        let right = "2026-08-15T04:05:06.000000Z INFO detcore: DETLOG stable=1\n\
36482026-08-15T04:05:06.000001Z INFO unrelated: value=202 address=<hostaddr 0xbbbb>";
3649
3650        let deterministic = super::LogDiffOpts {
3651            print_logs: true,
3652            no_color: true,
3653            ..Default::default()
3654        };
3655        let mut deterministic_output = Vec::new();
3656        let deterministic_summary = super::log_diff_summary_from_strs(
3657            left,
3658            right,
3659            &deterministic,
3660            &mut deterministic_output,
3661        )?;
3662        let deterministic_output = String::from_utf8(deterministic_output).unwrap();
3663        assert!(deterministic_summary.matched_with_evidence());
3664        assert!(deterministic_output.contains("Comparison policy: Deterministic\n"));
3665        assert_eq!(
3666            printed_log(&deterministic_output, 1).as_bytes(),
3667            b"INFO detcore: DETLOG stable=1\n"
3668        );
3669        assert_eq!(
3670            printed_log(&deterministic_output, 1),
3671            printed_log(&deterministic_output, 2)
3672        );
3673
3674        let options = super::LogDiffOpts {
3675            comparison: super::LogComparisonMode::Info,
3676            canonicalize_addresses: true,
3677            print_logs: true,
3678            no_color: true,
3679            ..Default::default()
3680        };
3681        let mut output = Vec::new();
3682        let summary = super::log_diff_summary_from_strs(left, right, &options, &mut output)?;
3683        let output = String::from_utf8(output).unwrap();
3684
3685        assert!(summary.diff_found);
3686        assert!(output.contains("Comparison policy: Canonical\n"));
3687        assert_eq!(
3688            printed_log(&output, 1).as_bytes(),
3689            b"INFO detcore: DETLOG stable=1\nINFO unrelated: value=101 address=<addr1>\n"
3690        );
3691        assert_eq!(
3692            printed_log(&output, 2).as_bytes(),
3693            b"INFO detcore: DETLOG stable=1\nINFO unrelated: value=202 address=<addr1>\n"
3694        );
3695        Ok(())
3696    }
3697
3698    #[test]
3699    fn test_full_trace_detects_unnormalized_timing_difference() -> std::io::Result<()> {
3700        let log_a = "INFO detcore: DETLOG [syscall] finish syscall #1: clock_gettime(CLOCK_MONOTONIC, 100) = Ok(0)";
3701        let log_b = "INFO detcore: DETLOG [syscall] finish syscall #1: clock_gettime(CLOCK_MONOTONIC, 101) = Ok(0)";
3702        let normalized = super::LogDiffOpts {
3703            strip_lines: true,
3704            no_color: true,
3705            ..Default::default()
3706        };
3707
3708        assert!(!super::log_diff_from_strs(
3709            log_a,
3710            log_b,
3711            &normalized,
3712            &mut Vec::new()
3713        )?);
3714
3715        let verbose = super::LogDiffOpts {
3716            comparison: super::LogComparisonMode::FullTrace,
3717            strip_lines: false,
3718            syscall_history: 1,
3719            no_color: true,
3720            ..Default::default()
3721        };
3722        let mut result = Vec::new();
3723        assert!(super::log_diff_from_strs(
3724            log_a,
3725            log_b,
3726            &verbose,
3727            &mut result
3728        )?);
3729
3730        let output = String::from_utf8(result).unwrap();
3731        assert!(output.contains("Comparing full trace messages"));
3732        assert!(output.contains("clock_gettime(CLOCK_MONOTONIC, 100)"));
3733        assert!(output.contains("clock_gettime(CLOCK_MONOTONIC, 101)"));
3734        assert!(output.contains("run 1"));
3735        assert!(output.contains("run 2"));
3736        Ok(())
3737    }
3738
3739    #[test]
3740    fn info_scope_compares_info_exactly_without_promoting_debug_diagnostics() -> std::io::Result<()>
3741    {
3742        let stable_info = "2026-08-06T01:00:00.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: write(1, 0x2, 1) = Ok(1)";
3743        let left = format!(
3744            "{stable_info}\n2026-08-06T01:00:00.000001Z DEBUG detcore: diagnostic host timing=100"
3745        );
3746        let right = format!(
3747            "{stable_info}\n2026-08-06T01:00:00.000002Z DEBUG detcore: diagnostic host timing=200"
3748        );
3749        let info = super::LogDiffOpts {
3750            comparison: super::LogComparisonMode::Info,
3751            no_color: true,
3752            ..Default::default()
3753        };
3754
3755        // Positive bracket: DEBUG remains present in both captures, but the
3756        // BitwiseInfoV1 envelope selects exactly the one INFO event per side.
3757        let matched = super::log_diff_summary_from_strs(&left, &right, &info, &mut Vec::new())?;
3758        assert!(matched.matched_with_evidence());
3759        assert_eq!(matched.compared_left, 1);
3760        assert_eq!(matched.compared_right, 1);
3761
3762        // Negative bracket: a real INFO payload difference must still fail.
3763        let divergent_info = right.replace("write(1, 0x2, 1)", "write(1, 0x6, 1)");
3764        let diverged =
3765            super::log_diff_summary_from_strs(&left, divergent_info, &info, &mut Vec::new())?;
3766        assert!(diverged.diff_found);
3767
3768        // DEBUG comparison remains an explicit diagnostic mode rather than an
3769        // implicit part of INFO parity.
3770        let full_trace = super::LogDiffOpts {
3771            comparison: super::LogComparisonMode::FullTrace,
3772            no_color: true,
3773            ..Default::default()
3774        };
3775        let debug_diverged =
3776            super::log_diff_summary_from_strs(left, right, &full_trace, &mut Vec::new())?;
3777        assert!(debug_diverged.diff_found);
3778        Ok(())
3779    }
3780
3781    #[test]
3782    fn test_log_diff_compares_detlog() -> std::io::Result<()> {
3783        let log_file_a = r#"2022-09-06T14:15:47.891501Z  INFO detcore: DETLOG [memory][detcore, dtid 3] 0x602000-0x623000 rw-p 0 0:0 0 [heap] -> 74b43faf7b78ace9443772ef63a30f66feaf9bd320256c82b8bd880634d19d46
37842022-09-06T14:15:48.903997Z  INFO detcore: DETLOG [memory][detcore, dtid 3] 0x7ffffffdd000-0x7ffffffff000 rw-p 0 0:0 0 [stack] -> 7984d1aaf386fce67eaa926624ecc1d5a4105828e4f286ee59cc69c0491cd5fe
37852022-09-06T14:15:48.904049Z  INFO detcore: DETLOG [syscall][detcore, dtid 3] inbound syscall: write(1, 0x6022a0, 70) = ?
37862022-09-06T14:15:48.904049Z  INFO detcore: COMMIT 2
37872022-09-06T14:15:48.904782Z  INFO detcore::scheduler: [sched-step5] >>>>>>>"#;
3788
3789        let log_file_b = r#"2022-09-06T14:15:47.891501Z  INFO detcore: DETLOG [memory][detcore, dtid 3] 0x602000-0x623000 rw-p 0 0:0 0 [heap] -> 74b43faf7b78ace9443772ef63a30f66feaf9bd320256c82b8bd880634d19d46
37902022-09-06T14:15:47.903997Z  INFO detcore: DETLOG [memory][detcore, dtid 3] 0x7ffffffdd000-0x7ffffffff000 rw-p 0 0:0 0 [stack] -> 1984d1aaf386fce67eaa926624ecc1d5a4105828e4f286ee59cc69c0491cd5fe
37912022-09-06T14:15:47.904049Z  INFO detcore: DETLOG [syscall][detcore, dtid 3] inbound syscall: write(1, 0x6022a0, 70) = ?
37922022-09-06T14:15:47.904049Z  INFO detcore: COMMIT 1
37932022-09-06T14:15:47.904782Z  INFO detcore::scheduler: [sched-step5] >>>>>>>"#;
3794        let mut result = Vec::<u8>::new();
3795
3796        let log_options = super::LogDiffOpts {
3797            no_color: true,
3798            git_diff: false,
3799            ..Default::default()
3800        };
3801        super::log_diff_from_strs(log_file_a, log_file_b, &log_options, &mut result)?;
3802
3803        let output = String::from_utf8(result).unwrap();
3804        assert!(output.contains("Mismatch at log messages 2 (run 1) and 2 (run 2)"));
3805        assert!(output.contains("Mismatch at log messages 4 (run 1) and 4 (run 2)"));
3806        assert!(output.contains("INFO detcore: COMMIT 2"));
3807        assert!(output.contains("INFO detcore: COMMIT 1"));
3808
3809        Ok(())
3810    }
3811
3812    #[test]
3813    fn test_filter_deterministic() {
3814        let opts = super::LogDiffOpts {
3815            include_detlogs: vec![
3816                DetLogFilter::Syscall,
3817                DetLogFilter::SyscallResult,
3818                DetLogFilter::Other,
3819            ],
3820            ..Default::default()
3821        };
3822
3823        let v = opts.filter_deterministic(
3824            &[
3825                historical(
3826                    1,
3827                    "INFO detcore: registers [dtid 3]. user_regs_struct { r15...",
3828                ),
3829                historical(
3830                    2,
3831                    "INFO DETLOG detcore: registers [dtid 3]. user_regs_struct { r15...",
3832                ),
3833                historical(
3834                    3,
3835                    "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
3836                ),
3837            ],
3838        );
3839
3840        assert_eq!(
3841            indexed_text(&v),
3842            vec![
3843                (
3844                    2,
3845                    "INFO DETLOG detcore: registers [dtid 3]. user_regs_struct { r15..."
3846                ),
3847                (
3848                    3,
3849                    "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
3850                ),
3851            ]
3852        );
3853    }
3854
3855    #[test]
3856    fn test_filter_deterministic_with_filter() {
3857        let opts = super::LogDiffOpts {
3858            include_detlogs: vec![DetLogFilter::Syscall],
3859            skip_commit: true,
3860            ..Default::default()
3861        };
3862
3863        let v = opts.filter_deterministic(
3864            &[
3865                historical(
3866                    1,
3867                    "INFO detcore: registers [dtid 3]. user_regs_struct { r15...",
3868                ),
3869                historical(2, "INFO DETLOG detcore:[syscall] syscall 1"),
3870                historical(
3871                    3,
3872                    "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
3873                ),
3874            ],
3875        );
3876        assert_eq!(
3877            indexed_text(&v),
3878            vec![(2, "INFO DETLOG detcore:[syscall] syscall 1")]
3879        );
3880    }
3881
3882    /// Regression: the deterministic comparison must ignore the scheduler bookkeeping emitted
3883    /// by nonblocking-IO poll retries, whose count is host-timing nondeterministic (e.g. how
3884    /// many times a thread re-polls a pipe before a child process makes it ready). Only the
3885    /// `{InternalIOPolling: ...}` COMMIT turn and the `advancing committed_time` clock line
3886    /// should be dropped; ordinary COMMIT turns and DETLOG entries must be retained.
3887    #[test]
3888    fn test_filter_deterministic_drops_io_polling_bookkeeping() {
3889        let opts = super::LogDiffOpts::default();
3890        let v = opts.filter_deterministic(&[
3891            historical(
3892                0,
3893                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {InternalIOPolling: W}, on previously committed 1s",
3894            ),
3895            historical(
3896                1,
3897                "DEBUG detcore::scheduler: DETLOG [sched-step1] advancing committed_time from 1 to 2",
3898            ),
3899            historical(
3900                2,
3901                "INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: read(3, 0x1000, 1) = Ok(1)",
3902            ),
3903            historical(
3904                3,
3905                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s",
3906            ),
3907        ]);
3908        // The InternalIOPolling COMMIT (0) and the committed_time line (1) are dropped; the
3909        // guest-observable syscall (2) and the ordinary COMMIT turn (3) survive.
3910        assert_eq!(
3911            indexed_text(&v),
3912            vec![
3913                (
3914                    2,
3915                    "INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: read(3, 0x1000, 1) = Ok(1)"
3916                ),
3917                (
3918                    3,
3919                    "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s"
3920                ),
3921            ]
3922        );
3923    }
3924
3925    #[test]
3926    fn test_filter_deterministic_drops_sabre_internal_pipe_resource_turn() {
3927        let opts = super::LogDiffOpts::default();
3928        let v = opts.filter_deterministic(&[
3929            historical(
3930                0,
3931                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 1s [sabre-internal-pipe-io]",
3932            ),
3933            historical(
3934                1,
3935                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 2s",
3936            ),
3937        ]);
3938
3939        assert_eq!(
3940            indexed_text(&v),
3941            vec![(
3942                1,
3943                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 2s"
3944            )]
3945        );
3946    }
3947
3948    #[test]
3949    fn test_filter_deterministic_drops_sabre_loopback_poll_yield() {
3950        let opts = super::LogDiffOpts::default();
3951        let v = opts.filter_deterministic(&[
3952            historical(
3953                0,
3954                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {SchedYield: W}, on previously committed 1s [sabre-loopback-poll-zero-timeout]",
3955            ),
3956            historical(
3957                1,
3958                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {SchedYield: W}, on previously committed 2s",
3959            ),
3960        ]);
3961
3962        assert_eq!(
3963            indexed_text(&v),
3964            vec![(
3965                1,
3966                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {SchedYield: W}, on previously committed 2s"
3967            )]
3968        );
3969    }
3970
3971    /// Regression: two runs that differ only in how many nonblocking-IO poll retries occurred
3972    /// must compare as deterministic, while a genuine guest-observable divergence must still be
3973    /// reported. `run_b` below performs one extra poll retry (an extra InternalIOPolling COMMIT
3974    /// plus its committed_time advance) but the guest syscalls are identical.
3975    #[test]
3976    fn test_log_diff_ignores_extra_io_poll_retries() -> std::io::Result<()> {
3977        let common_head = "2022-09-06T14:15:47.000000Z  INFO detcore: DETLOG [syscall][detcore, dtid 5] inbound syscall: poll(0x1000, 1, -1) = ?";
3978        let common_tail = "2022-09-06T14:15:47.100000Z  INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: poll(0x1000, 1, -1) = Ok(1)";
3979        let poll_retry = "2022-09-06T14:15:47.050000Z  INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {InternalIOPolling: W}, on previously committed 1s\n2022-09-06T14:15:47.050000Z DEBUG detcore::scheduler: DETLOG [sched-step1] advancing committed_time from 1 to 2";
3980
3981        let run_a = format!("{common_head}\n{poll_retry}\n{common_tail}");
3982        // run_b polls one extra time before the fd is ready:
3983        let run_b = format!("{common_head}\n{poll_retry}\n{poll_retry}\n{common_tail}");
3984
3985        let opts = super::LogDiffOpts {
3986            no_color: true,
3987            strip_lines: true,
3988            ..Default::default()
3989        };
3990        // Differ only in retry count -> deterministic (no diff reported):
3991        assert!(!super::log_diff_from_strs(
3992            &run_a,
3993            &run_b,
3994            &opts,
3995            &mut Vec::new()
3996        )?);
3997
3998        // But a real divergence in the guest-observable syscall result is still caught. (Use a
3999        // non-numeric change: numeric-only differences are erased by `strip_lines` normalization.)
4000        let run_c = run_a.replace("= Ok(1)", "= Err(Errno(EBADF))");
4001        assert!(super::log_diff_from_strs(
4002            &run_a,
4003            &run_c,
4004            &opts,
4005            &mut Vec::new()
4006        )?);
4007        Ok(())
4008    }
4009
4010    /// Canonical parity positive control: two runs that differ ONLY in their raw
4011    /// host addresses -- same structure, same introduction order, same aliasing
4012    /// (a pure ASLR shift) -- compare EQUAL after canonicalization. The same
4013    /// inputs compared RAW (neither stripped nor canonicalized) diverge, proving
4014    /// canonicalization is doing real work and is not just the identity.
4015    #[test]
4016    fn canonical_address_only_difference_compares_equal() -> std::io::Result<()> {
4017        let run_a =
4018            "2022-09-06T14:15:47.000000Z  INFO detcore: [t] p=<hostaddr 0x1111> q=<hostaddr 0x2222>
40192022-09-06T14:15:48.000000Z  INFO detcore: [t] use <hostaddr 0x1111> then <hostaddr 0x2222>";
4020        let run_b =
4021            "2022-09-06T14:15:47.000000Z  INFO detcore: [t] p=<hostaddr 0xaaaa> q=<hostaddr 0xbbbb>
40222022-09-06T14:15:48.000000Z  INFO detcore: [t] use <hostaddr 0xaaaa> then <hostaddr 0xbbbb>";
4023
4024        let canonical = super::LogDiffOpts {
4025            comparison: super::LogComparisonMode::FullTrace,
4026            canonicalize_addresses: true,
4027            no_color: true,
4028            ..Default::default()
4029        };
4030        assert!(
4031            !super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4032            "address-only (ASLR-shift) difference must compare EQUAL under canonicalization"
4033        );
4034
4035        // Positive control: raw comparison (no strip, no canonicalize) diverges on
4036        // the very same inputs, so the equality above is not vacuous.
4037        let raw = super::LogDiffOpts {
4038            comparison: super::LogComparisonMode::FullTrace,
4039            canonicalize_addresses: false,
4040            no_color: true,
4041            ..Default::default()
4042        };
4043        assert!(
4044            super::log_diff_from_strs(run_a, run_b, &raw, &mut Vec::new())?,
4045            "raw comparison must still see the differing addresses"
4046        );
4047        Ok(())
4048    }
4049
4050    /// Canonical parity negative control (allocation order): two runs whose only
4051    /// difference is the ORDER in which two addresses are introduced must compare
4052    /// UNEQUAL. This is exactly the divergence wholesale stripping hides -- it is
4053    /// the positive control that canonicalization preserves distinguishability.
4054    #[test]
4055    fn canonical_allocation_order_difference_compares_unequal() -> std::io::Result<()> {
4056        // Both runs: alloc, alloc, then a line pairing the two addresses. In run_b
4057        // the two addresses are introduced in the opposite order, so the ordinals
4058        // on the shared "pair" line are swapped.
4059        let run_a = "2022-09-06T14:15:47.000000Z  INFO detcore: [t] alloc <hostaddr 0x1111>
40602022-09-06T14:15:48.000000Z  INFO detcore: [t] alloc <hostaddr 0x2222>
40612022-09-06T14:15:49.000000Z  INFO detcore: [t] pair <hostaddr 0x1111> <hostaddr 0x2222>";
4062        let run_b = "2022-09-06T14:15:47.000000Z  INFO detcore: [t] alloc <hostaddr 0xbbbb>
40632022-09-06T14:15:48.000000Z  INFO detcore: [t] alloc <hostaddr 0xaaaa>
40642022-09-06T14:15:49.000000Z  INFO detcore: [t] pair <hostaddr 0xaaaa> <hostaddr 0xbbbb>";
4065
4066        let canonical = super::LogDiffOpts {
4067            comparison: super::LogComparisonMode::FullTrace,
4068            canonicalize_addresses: true,
4069            no_color: true,
4070            ..Default::default()
4071        };
4072        assert!(
4073            super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4074            "an allocation-order difference must compare UNEQUAL under canonicalization"
4075        );
4076
4077        // And wholesale stripping DOES hide it: both addresses collapse to a
4078        // single <ADDR> token.
4079        let stripped = super::LogDiffOpts {
4080            comparison: super::LogComparisonMode::FullTrace,
4081            strip_lines: true,
4082            no_color: true,
4083            ..Default::default()
4084        };
4085        assert!(
4086            !super::log_diff_from_strs(run_a, run_b, &stripped, &mut Vec::new())?,
4087            "wholesale stripping erases the allocation-order difference (the defect)"
4088        );
4089        Ok(())
4090    }
4091
4092    /// Canonical parity negative control (aliasing): a run that prints ONE address
4093    /// twice (aliased) must not match a run that prints TWO distinct addresses in
4094    /// the same positions. `<addr1>,<addr1>` vs `<addr1>,<addr2>` diverges.
4095    #[test]
4096    fn canonical_aliasing_difference_compares_unequal() -> std::io::Result<()> {
4097        let run_a = "2022-09-06T14:15:47.000000Z  INFO detcore: [t] two <hostaddr 0x1111> <hostaddr 0x1111>";
4098        let run_b = "2022-09-06T14:15:47.000000Z  INFO detcore: [t] two <hostaddr 0xaaaa> <hostaddr 0xbbbb>";
4099
4100        let canonical = super::LogDiffOpts {
4101            comparison: super::LogComparisonMode::FullTrace,
4102            canonicalize_addresses: true,
4103            no_color: true,
4104            ..Default::default()
4105        };
4106        assert!(
4107            super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4108            "an aliasing difference (1,1 vs 1,2) must compare UNEQUAL"
4109        );
4110        Ok(())
4111    }
4112
4113    /// Canonical parity exact-value control: a reproducible
4114    /// hex value printed as a syscall argument (`flock` `operation={:#x}`) is
4115    /// NOT a host address and must be compared EXACTLY. Two runs differing only
4116    /// in `operation=0x2` vs `0x6` must compare UNEQUAL under canonicalization --
4117    /// proving the canonicalizer touches only marked `<hostaddr ...>` pointers
4118    /// and does not become a "softer strip" that swallows syscall-argument
4119    /// divergence. A marked host address on the SAME line, differing only by an
4120    /// ASLR shift, must not by itself make the runs diverge.
4121    #[test]
4122    fn canonical_syscall_arg_hex_difference_compares_unequal() -> std::io::Result<()> {
4123        let run_a = "2022-09-06T14:15:47.000000Z  INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0x1111>";
4124        let run_b = "2022-09-06T14:15:47.000000Z  INFO detcore: flock(fd=3, operation=0x6) at <hostaddr 0xaaaa>";
4125
4126        let canonical = super::LogDiffOpts {
4127            comparison: super::LogComparisonMode::FullTrace,
4128            canonicalize_addresses: true,
4129            no_color: true,
4130            ..Default::default()
4131        };
4132        assert!(
4133            super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4134            "a bare syscall-argument hex difference (0x2 vs 0x6) must compare UNEQUAL: \
4135             it is reproducible and NOT a host address"
4136        );
4137
4138        // Positive control: with ONLY the marked host address differing (same
4139        // syscall argument), the ASLR shift is canonicalized away and the runs
4140        // compare EQUAL -- so the divergence above is due to the syscall arg, not
4141        // the address.
4142        let addr_only_a = "2022-09-06T14:15:47.000000Z  INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0x1111>";
4143        let addr_only_b = "2022-09-06T14:15:47.000000Z  INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0xaaaa>";
4144        assert!(
4145            !super::log_diff_from_strs(addr_only_a, addr_only_b, &canonical, &mut Vec::new())?,
4146            "an address-only difference alongside an identical syscall arg must compare EQUAL"
4147        );
4148        Ok(())
4149    }
4150
4151    /// Canonical parity exact-value control: a single virtual-time timestamp difference (a
4152    /// decimal value, NOT a `0x` address) must compare UNEQUAL -- canonicalization
4153    /// touches only host addresses and leaves every other byte for exact
4154    /// comparison. This is the sharp edge: virtual time is compared exactly even
4155    /// though the wall-clock PREFIX is stripped.
4156    #[test]
4157    fn canonical_virtual_time_difference_compares_unequal() -> std::io::Result<()> {
4158        let run_a = "2022-09-06T14:15:47.000000Z  INFO detcore: COMMIT turn 5 at time 100";
4159        let run_b = "2022-09-06T14:15:47.000000Z  INFO detcore: COMMIT turn 5 at time 200";
4160
4161        let canonical = super::LogDiffOpts {
4162            comparison: super::LogComparisonMode::FullTrace,
4163            canonicalize_addresses: true,
4164            no_color: true,
4165            ..Default::default()
4166        };
4167        assert!(
4168            super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4169            "a virtual-time (decimal) difference must compare UNEQUAL under canonicalization"
4170        );
4171        Ok(())
4172    }
4173
4174    /// Canonical parity wall-clock control: runs that differ ONLY in the real wall-clock
4175    /// timestamp PREFIX compare EQUAL -- the prefix is stripped by
4176    /// `extract_log_messages` before any comparison, so nothing else needs to see
4177    /// it. This is the one genuinely-irreproducible datum the policy discards.
4178    #[test]
4179    fn canonical_wall_clock_prefix_difference_compares_equal() -> std::io::Result<()> {
4180        let run_a = "2022-09-06T14:15:47.000000Z  INFO detcore: [t] use 0x1111
41812022-09-06T14:15:48.000000Z  INFO detcore: [t] use 0x1111";
4182        // Same message bodies, entirely different wall-clock prefixes (and format).
4183        let run_b = "Apr 09 06:08:03.100  INFO detcore: [t] use 0x1111
4184Jun 09 06:49:17.742  INFO detcore: [t] use 0x1111";
4185
4186        let canonical = super::LogDiffOpts {
4187            comparison: super::LogComparisonMode::FullTrace,
4188            canonicalize_addresses: true,
4189            no_color: true,
4190            ..Default::default()
4191        };
4192        assert!(
4193            !super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4194            "a wall-clock-prefix-only difference must compare EQUAL"
4195        );
4196        Ok(())
4197    }
4198
4199    #[test]
4200    fn one_log_canonical_info_preserves_values_and_only_rewrites_marked_addresses() {
4201        let log = "2026-08-13T01:02:03.000000Z INFO detcore: COMMIT turn 17 at time 123456 bare=0x2 marked=<hostaddr 0xaaaa>\n\
42022026-08-13T01:02:03.000001Z DEBUG detcore: diagnostic=999\n\
42032026-08-13T01:02:03.000002Z INFO detcore: DETLOG count=42 bare=0x6 marked=<hostaddr 0xaaaa> other=<hostaddr 0xbbbb>";
4204
4205        assert_eq!(
4206            super::canonical_info_from_str(log).expect("fixture log is fully tagged"),
4207            vec![
4208                "INFO detcore: COMMIT turn 17 at time 123456 bare=0x2 marked=<addr1>",
4209                "INFO detcore: DETLOG count=42 bare=0x6 marked=<addr1> other=<addr2>",
4210            ]
4211        );
4212    }
4213
4214    #[test]
4215    fn first_log_divergence_reports_preceding_commit_turn_and_virtual_time() -> std::io::Result<()>
4216    {
4217        let left = "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 12.345_678_901s\n\
42182026-08-13T01:02:03.000001Z INFO detcore: DETLOG count=42";
4219        let right = "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 12.345_678_901s\n\
42202026-08-13T01:02:04.000001Z INFO detcore: DETLOG count=43";
4221        let opts = super::LogDiffOpts {
4222            comparison: super::LogComparisonMode::Info,
4223            canonicalize_addresses: true,
4224            no_color: true,
4225            ..Default::default()
4226        };
4227
4228        let diverged = super::log_diff_summary_from_strs(left, right, &opts, &mut Vec::new())?;
4229        assert!(diverged.diff_found);
4230        assert_eq!(diverged.first_divergent_scheduler_turn, Some(17));
4231        assert_eq!(
4232            diverged.first_divergent_virtual_nanoseconds,
4233            Some(12_345_678_901)
4234        );
4235        assert_eq!(
4236            diverged.first_divergent_left_message.as_deref(),
4237            Some("INFO detcore: DETLOG count=42")
4238        );
4239        assert_eq!(
4240            diverged.first_divergent_right_message.as_deref(),
4241            Some("INFO detcore: DETLOG count=43")
4242        );
4243
4244        let matched = super::log_diff_summary_from_strs(left, left, &opts, &mut Vec::new())?;
4245        assert!(matched.matched_with_evidence());
4246        assert_eq!(matched.first_divergent_scheduler_turn, None);
4247        assert_eq!(matched.first_divergent_virtual_nanoseconds, None);
4248        assert_eq!(matched.first_divergent_left_message, None);
4249        assert_eq!(matched.first_divergent_right_message, None);
4250        Ok(())
4251    }
4252
4253    #[test]
4254    fn first_divergent_messages_keep_event_content_not_separate_positions() -> std::io::Result<()> {
4255        let left = "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 110, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed 1_767_225_600.031_999_250s";
4256        let right = "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 111, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed 1_767_225_600.031_994_250s";
4257        let opts = super::LogDiffOpts {
4258            comparison: super::LogComparisonMode::Info,
4259            canonicalize_addresses: true,
4260            no_color: true,
4261            ..Default::default()
4262        };
4263
4264        let same_event = super::log_diff_summary_from_strs(left, right, &opts, &mut Vec::new())?;
4265        assert!(same_event.diff_found);
4266        assert_eq!(
4267            same_event.first_divergent_left_message, same_event.first_divergent_right_message,
4268            "turn and committed-time values are recorded separately, so they must not split one event into two"
4269        );
4270        assert_eq!(
4271            same_event.first_divergent_left_message.as_deref(),
4272            Some(
4273                "INFO detcore::scheduler: COMMIT turn <NUM>, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed <NANOSECONDS>"
4274            )
4275        );
4276        assert_eq!(
4277            super::first_divergent_message(&historical(
4278                0,
4279                "INFO detcore::scheduler: COMMIT turn 110 at time 123\npayload at time 456"
4280            )),
4281            "INFO detcore::scheduler: COMMIT turn <NUM> at time <NANOSECONDS>\npayload at time 456",
4282            "only the structured first line carries the separately recorded position"
4283        );
4284
4285        let after_longer_shared_prefix = super::log_diff_summary_from_strs(
4286            format!("2026-08-13T01:02:02.000000Z INFO detcore: shared event\n{left}"),
4287            format!("2026-08-13T01:02:02.000000Z INFO detcore: shared event\n{right}"),
4288            &opts,
4289            &mut Vec::new(),
4290        )?;
4291        assert_ne!(
4292            same_event.first_divergent_record, after_longer_shared_prefix.first_divergent_record,
4293            "a longer shared trace must move the record observation in this fixture"
4294        );
4295        assert_eq!(
4296            (
4297                same_event.first_divergent_left_message.as_deref(),
4298                same_event.first_divergent_right_message.as_deref(),
4299            ),
4300            (
4301                after_longer_shared_prefix
4302                    .first_divergent_left_message
4303                    .as_deref(),
4304                after_longer_shared_prefix
4305                    .first_divergent_right_message
4306                    .as_deref(),
4307            ),
4308            "the same event after a longer trace must keep the same compared messages"
4309        );
4310
4311        let different_event = super::log_diff_summary_from_strs(
4312            left,
4313            "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 111, dettid 2 using resources {Device(ContainerStderr): W}, on previously committed 1_767_225_600.031_994_250s",
4314            &opts,
4315            &mut Vec::new(),
4316        )?;
4317        assert_ne!(
4318            different_event.first_divergent_left_message,
4319            different_event.first_divergent_right_message,
4320            "different event content must remain distinguishable"
4321        );
4322        Ok(())
4323    }
4324
4325    #[test]
4326    fn log_divergence_without_commit_metadata_reports_no_position() -> std::io::Result<()> {
4327        let opts = super::LogDiffOpts {
4328            comparison: super::LogComparisonMode::Info,
4329            canonicalize_addresses: true,
4330            no_color: true,
4331            ..Default::default()
4332        };
4333        let summary = super::log_diff_summary_from_strs(
4334            "INFO detcore: DETLOG count=42",
4335            "INFO detcore: DETLOG count=43",
4336            &opts,
4337            &mut Vec::new(),
4338        )?;
4339        assert!(summary.diff_found);
4340        assert_eq!(summary.first_divergent_scheduler_turn, None);
4341        assert_eq!(summary.first_divergent_virtual_nanoseconds, None);
4342        Ok(())
4343    }
4344
4345    /// NEGATIVE: two logs with nothing to compare must NOT be reported as a
4346    /// match with evidence. `diff_found` is legitimately false (there is no
4347    /// difference between two empty selections), but the counts are zero, so
4348    /// `matched_with_evidence()` must refuse. This is the "green with zero
4349    /// executed work" no-result, and it is the reason the counts exist.
4350    #[test]
4351    fn empty_selection_is_a_no_result_not_a_match() -> std::io::Result<()> {
4352        let canonical = super::LogDiffOpts {
4353            comparison: super::LogComparisonMode::FullTrace,
4354            canonicalize_addresses: true,
4355            no_color: true,
4356            ..Default::default()
4357        };
4358        let summary = super::log_diff_summary_from_strs("", "", &canonical, &mut Vec::new())?;
4359        assert!(!summary.diff_found, "two empty logs do not differ");
4360        assert_eq!(summary.compared_left, 0);
4361        assert_eq!(summary.compared_right, 0);
4362        assert!(
4363            !summary.matched_with_evidence(),
4364            "zero compared messages must never count as a verified match"
4365        );
4366        Ok(())
4367    }
4368
4369    /// POSITIVE: the same predicate must still FIRE on a real comparison, so the
4370    /// guard is not merely refusing everything.
4371    #[test]
4372    fn nonempty_identical_selection_is_a_match_with_evidence() -> std::io::Result<()> {
4373        let run = "Apr 09 06:08:03.100  INFO detcore: [t] finish syscall: close(2) = Ok(0)
4374Apr 09 06:08:03.200  INFO detcore: [t] finish syscall: exit_group(0)";
4375        let canonical = super::LogDiffOpts {
4376            comparison: super::LogComparisonMode::FullTrace,
4377            canonicalize_addresses: true,
4378            no_color: true,
4379            ..Default::default()
4380        };
4381        let summary = super::log_diff_summary_from_strs(run, run, &canonical, &mut Vec::new())?;
4382        assert!(!summary.diff_found);
4383        assert_eq!(summary.compared_left, 2);
4384        assert_eq!(summary.compared_right, 2);
4385        assert!(
4386            summary.matched_with_evidence(),
4387            "a real, nonempty, identical comparison must count as a match"
4388        );
4389        Ok(())
4390    }
4391
4392    #[test]
4393    fn test_filter_infos() {
4394        let v = super::filter_infos(&[
4395            historical(
4396                0,
4397                "DEBUG detcore::scheduler: [sched-step3] advancing committed_time from 946684799165300000 to 946684799205300000",
4398            ),
4399            historical(
4400                1,
4401                "INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, ...",
4402            ),
4403        ]);
4404        assert_eq!(
4405            indexed_text(&v),
4406            vec![(
4407                1,
4408                "INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, ..."
4409            )]
4410        );
4411    }
4412
4413    #[test]
4414    fn test_extract_log_messages() {
4415        let s = "
4416Jan 09 06:08:03.100  INFO detcore: [detcore, dtid 2]  finish syscall: close(2) = Ok(0)
4417Feb 09 06:49:17.742 DEBUG detcore::scheduler: [sched-step3] advancing committed_time from 946684799165300000 to 946684799205300000
4418Apr 09 06:49:17.742  INFO detcore::scheduler: [scheduler] >>>>>>>
4419
4420 COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000
4421Jan 09 06:49:03.100  INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, r14: 0, r13: 1, r12: 946684799000118840, rbp: 140737488344736, rbx: 0, r11: 518, r10: 140737488342434, r9: 0, r8: 1, rax: 0, rcx: 0, rdx: 2, rsi: 0, rdi: 140737354052880, orig_rax: 18446744073709551615, rip: 140737351875567, cs: 51, eflags: 66118, rsp: 140737488344064, ss: 43, fs_base: 0, gs_base: 0, ds: 0, es: 0, fs: 0, gs: 0 }
4422Jun 09 06:49:17.742 TRACE detcore::scheduler: [scheduler] Guest unblocked (<ivar Go>); clear ivars for the next turn on dettid 2
4423";
4424
4425        let v = super::extract_log_messages(s).expect("fixture log is fully tagged");
4426        eprintln!("Split into {} log messages", v.len());
4427        for x in &v {
4428            eprintln!("{:?}", x);
4429        }
4430        assert_eq!(v.len(), 5);
4431    }
4432
4433    #[test]
4434    fn test_canonicalize_addresses_in_line() {
4435        use std::collections::HashMap;
4436        let mut map = HashMap::new();
4437        let mut next = 1usize;
4438        // First appearance numbers marked addresses by order; a repeated address
4439        // reuses its ordinal (identity + aliasing). A BARE `0x...` literal and
4440        // decimal values are left untouched and compared exactly.
4441        assert_eq!(
4442            super::canonicalize_addresses_in_line(
4443                "a=<hostaddr 0x1111> b=<hostaddr 0x2222> c=<hostaddr 0x1111> raw=0x4444 n=42",
4444                &mut map,
4445                &mut next
4446            ),
4447            "a=<addr1> b=<addr2> c=<addr1> raw=0x4444 n=42"
4448        );
4449        // State threads across lines within one run: a known address keeps its
4450        // ordinal, a new one continues the count.
4451        assert_eq!(
4452            super::canonicalize_addresses_in_line(
4453                "use <hostaddr 0x2222> then <hostaddr 0x3333>",
4454                &mut map,
4455                &mut next
4456            ),
4457            "use <addr2> then <addr3>"
4458        );
4459        // A bare hex literal is never canonicalized, even one identical to a
4460        // marked address seen earlier: reproducible hex is compared exactly.
4461        assert_eq!(
4462            super::canonicalize_addresses_in_line("bare 0x1111", &mut map, &mut next),
4463            "bare 0x1111"
4464        );
4465    }
4466
4467    #[test]
4468    fn test_strip_log() {
4469        assert_eq!(super::strip_log_entry("800.709_180s"), "<NANOSECONDS>");
4470        assert_eq!(super::strip_log_entry("98.91618ms"), "<NUM>");
4471        assert_eq!(super::strip_log_entry("98.91619ms"), "<NUM>");
4472        assert_eq!(super::strip_log_entry("x86_64"), "x86_64");
4473        assert_eq!(
4474            super::strip_log_entry(
4475                "COMMIT turn 66, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946_684_800.709_180_000s"
4476            ),
4477            "COMMIT turn <NUM>, dettid <NUM> using resources {Path(\"/proc/<PID>/fd/<NUM>\"): W} at time <NANOSECONDS>"
4478        );
4479    }
4480
4481    /// Erasing a `/tmp` path must consume the path and nothing else.
4482    ///
4483    /// The pattern was previously `/tmp/.*"`, whose greedy `.*` ran to the LAST
4484    /// quote on the line rather than the path's own closing quote. Every field
4485    /// after the path was therefore erased too, so two entries differing only
4486    /// downstream of a `/tmp` path compared EQUAL under the stripped
4487    /// comparator -- a divergence silently reported as a match.
4488    #[test]
4489    fn strip_tmp_path_does_not_swallow_rest_of_line() {
4490        let read = super::strip_log_entry(r#"open path="/tmp/scratch" flags="O_RDONLY""#);
4491        let write = super::strip_log_entry(r#"open path="/tmp/scratch" flags="O_WRONLY""#);
4492
4493        assert_eq!(read, r#"open path="/tmp/<somewhere>" flags="O_RDONLY""#);
4494        assert_eq!(write, r#"open path="/tmp/<somewhere>" flags="O_WRONLY""#);
4495        assert_ne!(
4496            read, write,
4497            "entries differing after a /tmp path must not collapse to equal"
4498        );
4499    }
4500
4501    /// The narrowed pattern must still do its job: two entries whose only
4502    /// difference is the host-chosen `/tmp` path still compare equal, which is
4503    /// the whole reason this erasure exists.
4504    #[test]
4505    fn strip_tmp_path_still_erases_a_differing_tmp_path() {
4506        assert_eq!(
4507            super::strip_log_entry(r#"open path="/tmp/hermit-aaaa/f" flags="O_RDONLY""#),
4508            super::strip_log_entry(r#"open path="/tmp/hermit-bbbb/f" flags="O_RDONLY""#),
4509        );
4510    }
4511
4512    /// Two distinct `/tmp` paths on one line are each erased individually,
4513    /// rather than the first one swallowing the second along with everything
4514    /// between them.
4515    #[test]
4516    fn strip_tmp_path_erases_each_path_separately() {
4517        assert_eq!(
4518            super::strip_log_entry(r#"rename from="/tmp/a" to="/tmp/b" ok="1""#),
4519            r#"rename from="/tmp/<somewhere>" to="/tmp/<somewhere>" ok="<NUM>""#
4520        );
4521    }
4522
4523    const KICK_LINE_PREFIX: &str = "Logs contain";
4524    const KICK_LINE_SUFFIX: &str = "scheduler empty-run-queue kick messages";
4525
4526    fn kick_opts() -> super::LogDiffOpts {
4527        super::LogDiffOpts {
4528            comparison: super::LogComparisonMode::Info,
4529            canonicalize_addresses: true,
4530            no_color: true,
4531            ..Default::default()
4532        }
4533    }
4534
4535    /// A run that passes through the empty-run-queue kick still exits through
4536    /// the ordinary message afterwards, so both shutdown paths end identically
4537    /// and only the kick distinguishes them.
4538    fn log_with_kick() -> &'static str {
4539        "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 1s\n\
45402026-08-13T01:02:03.000001Z INFO detcore::scheduler: scheduler (step2_process_blocked): zero threads left anywhere, fizzling.\n\
45412026-08-13T01:02:03.000002Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4542    }
4543
4544    fn log_without_kick() -> &'static str {
4545        "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 1s\n\
45462026-08-13T01:02:03.000002Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4547    }
4548
4549    fn run_diff(left: &str, right: &str) -> std::io::Result<(super::LogDiffSummary, String)> {
4550        let mut out = Vec::new();
4551        let summary = super::log_diff_summary_from_strs(left, right, &kick_opts(), &mut out)?;
4552        Ok((
4553            summary,
4554            String::from_utf8(out).expect("diff output is utf-8"),
4555        ))
4556    }
4557
4558    /// A matching pair keeps only its summary, so without this count there is no
4559    /// record of which shutdown path either run took. Both directions of the
4560    /// pass are covered: both runs kicked, and neither did.
4561    #[test]
4562    fn a_matching_pair_records_the_empty_queue_kick_count() -> std::io::Result<()> {
4563        let (kicked, kicked_out) = run_diff(log_with_kick(), log_with_kick())?;
4564        assert!(kicked.matched_with_evidence(), "both-kicked pair must pass");
4565        assert!(
4566            kicked_out.contains(&format!("{KICK_LINE_PREFIX} 1 | 1 {KICK_LINE_SUFFIX}")),
4567            "a passing pair that kicked must retain the count, got:\n{kicked_out}"
4568        );
4569
4570        let (quiet, quiet_out) = run_diff(log_without_kick(), log_without_kick())?;
4571        assert!(
4572            quiet.matched_with_evidence(),
4573            "neither-kicked pair must pass"
4574        );
4575        assert!(
4576            quiet_out.contains(&format!("{KICK_LINE_PREFIX} 0 | 0 {KICK_LINE_SUFFIX}")),
4577            "a passing pair that did not kick must say so explicitly, got:\n{quiet_out}"
4578        );
4579        Ok(())
4580    }
4581
4582    /// The other half of the bracket, and the half that matters: recording the
4583    /// count must not move any verdict. A pair differing only by the kick was a
4584    /// divergence before this line existed and must remain one — the count must
4585    /// never stand in for agreement.
4586    #[test]
4587    fn recording_the_kick_count_does_not_move_any_verdict() -> std::io::Result<()> {
4588        let (diverged, diverged_out) = run_diff(log_with_kick(), log_without_kick())?;
4589        assert!(
4590            diverged.diff_found,
4591            "a pair differing only by the kick must still diverge"
4592        );
4593        assert!(
4594            !diverged_out.contains(KICK_LINE_SUFFIX),
4595            "the count is scoped to passing pairs; a diverging pair already \
4596             reproduces the messages in its diff, got:\n{diverged_out}"
4597        );
4598
4599        // Identical inputs keep every summary field they had, so the extra
4600        // writeln! cannot be smuggling a verdict change in behind the text.
4601        let (matched, _) = run_diff(log_with_kick(), log_with_kick())?;
4602        assert!(!matched.diff_found);
4603        assert_eq!(matched.first_divergent_scheduler_turn, None);
4604        assert_eq!(matched.first_divergent_virtual_nanoseconds, None);
4605        assert_eq!(matched.first_divergent_record, None);
4606        assert_eq!(matched.compared_left, matched.compared_right);
4607        Ok(())
4608    }
4609
4610    const MAPS_LINE_SUFFIX: &str = "scheduler COMMIT records reading /proc/self/maps";
4611
4612    /// A guest whose runtime scans its own memory map during bootstrap. The
4613    /// committed virtual time is the part that drifts between runs, so it is
4614    /// parameterised.
4615    fn log_with_maps_read(committed: &str) -> String {
4616        format!(
4617            "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 10, dettid 3 using resources {{Path(\"/proc/self/maps\"): R}}, on previously committed {committed}\n\
46182026-08-13T01:02:03.000001Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4619        )
4620    }
4621
4622    #[test]
4623    fn custom_side_labels_name_the_maps_read_summary() -> std::io::Result<()> {
4624        let scanned = log_with_maps_read("12.345_678_901s");
4625        let options = super::LogDiffOpts {
4626            comparison: super::LogComparisonMode::Info,
4627            canonicalize_addresses: true,
4628            side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
4629            no_color: true,
4630            ..Default::default()
4631        };
4632        let mut output = Vec::new();
4633        let summary = super::log_diff_summary_from_strs(&scanned, &scanned, &options, &mut output)?;
4634        assert!(summary.matched_with_evidence());
4635        let output = String::from_utf8(output).unwrap();
4636        assert!(output.contains(
4637            "(the recording first at turn 10, committed virtual time 12345678901ns, \
4638             the replay first at turn 10, committed virtual time 12345678901ns)"
4639        ));
4640        assert!(!output.contains("run 1") && !output.contains("run 2"));
4641        Ok(())
4642    }
4643
4644    /// A matching pair discards its logs, so without this record there is no way
4645    /// to tell a guest that never reads its own memory map — and therefore
4646    /// cannot drift this way — from one that reads it and happened to agree.
4647    #[test]
4648    fn a_matching_pair_records_the_maps_read_commit() -> std::io::Result<()> {
4649        let scanned = log_with_maps_read("12.345_678_901s");
4650        let (summary, out) = run_diff(&scanned, &scanned)?;
4651        assert!(summary.matched_with_evidence(), "the pair must pass");
4652        assert!(
4653            out.contains(&format!("Logs contain 1 | 1 {MAPS_LINE_SUFFIX}")),
4654            "a passing pair that read the map must retain the record, got:\n{out}"
4655        );
4656        // The committed virtual time is the evidence, not just the count: it is
4657        // what a later run is compared against to see drift.
4658        assert!(
4659            out.contains(
4660                "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4661                 run 2 first at turn 10, committed virtual time 12345678901ns)"
4662            ),
4663            "the retained record must carry the turn and the committed virtual \
4664             time, got:\n{out}"
4665        );
4666
4667        let (quiet, quiet_out) = run_diff(log_without_kick(), log_without_kick())?;
4668        assert!(quiet.matched_with_evidence());
4669        assert!(
4670            quiet_out.contains(&format!("Logs contain 0 | 0 {MAPS_LINE_SUFFIX}")),
4671            "a passing pair that never read the map must say so explicitly, \
4672             got:\n{quiet_out}"
4673        );
4674        Ok(())
4675    }
4676
4677    /// The load-bearing half. A pair differing only in the committed virtual
4678    /// time of the retained record was a divergence before this line existed and
4679    /// must remain one; recording the value must never stand in for agreeing on
4680    /// it.
4681    #[test]
4682    fn recording_the_maps_read_commit_does_not_move_any_verdict() -> std::io::Result<()> {
4683        let (diverged, diverged_out) = run_diff(
4684            &log_with_maps_read("12.345_678_901s"),
4685            &log_with_maps_read("12.345_678_902s"),
4686        )?;
4687        assert!(
4688            diverged.diff_found,
4689            "a one-nanosecond difference in the committed time must still diverge"
4690        );
4691        assert_eq!(diverged.first_divergent_scheduler_turn, Some(10));
4692        assert!(
4693            !diverged_out.contains(MAPS_LINE_SUFFIX),
4694            "the record is scoped to passing pairs; a diverging pair already \
4695             prints both times in its diff, got:\n{diverged_out}"
4696        );
4697        Ok(())
4698    }
4699
4700    /// Every other test here forces `LogComparisonMode::Info`, but the default
4701    /// is `Deterministic`, which selects a different set of messages. Both
4702    /// retained lines must appear on that path too, and the kick counts must be
4703    /// attributed per side — under this mode a kick asymmetry is *not* compared,
4704    /// so `1 | 0` is a passing pair and quoting a single side would report it as
4705    /// agreement.
4706    #[test]
4707    fn both_records_are_retained_under_the_default_comparison_mode() -> std::io::Result<()> {
4708        let default_opts = super::LogDiffOpts {
4709            no_color: true,
4710            ..Default::default()
4711        };
4712        assert_eq!(
4713            default_opts.comparison,
4714            super::LogComparisonMode::Deterministic,
4715            "this test exists to cover the default mode; if the default changes \
4716             it must be re-pointed, not deleted"
4717        );
4718
4719        let mut out = Vec::new();
4720        let summary = super::log_diff_summary_from_strs(
4721            log_with_kick(),
4722            log_without_kick(),
4723            &default_opts,
4724            &mut out,
4725        )?;
4726        let out = String::from_utf8(out).expect("diff output is utf-8");
4727        assert!(
4728            summary.matched_with_evidence(),
4729            "a kick asymmetry is not compared under the default mode, so this \
4730             pair must pass; got:\n{out}"
4731        );
4732        assert!(
4733            out.contains(&format!("Logs contain 1 | 0 {KICK_LINE_SUFFIX}")),
4734            "the asymmetry must be visible per side on the default path, \
4735             got:\n{out}"
4736        );
4737        assert!(
4738            out.contains(&format!("Logs contain 0 | 0 {MAPS_LINE_SUFFIX}")),
4739            "the map-read line must also be emitted on the default path, \
4740             got:\n{out}"
4741        );
4742
4743        // The same path, with the map read present, must attribute both sides.
4744        let scanned = log_with_maps_read("12.345_678_901s");
4745        let mut out = Vec::new();
4746        let summary =
4747            super::log_diff_summary_from_strs(&scanned, &scanned, &default_opts, &mut out)?;
4748        let out = String::from_utf8(out).expect("diff output is utf-8");
4749        assert!(summary.matched_with_evidence());
4750        assert!(
4751            out.contains(
4752                "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4753                 run 2 first at turn 10, committed virtual time 12345678901ns)"
4754            ),
4755            "both runs' values must be printed under the default mode, \
4756             got:\n{out}"
4757        );
4758        Ok(())
4759    }
4760
4761    /// The reason both sides must be printed, as a reachable case rather than a
4762    /// precaution. `strip_lines` is the lossy comparator plain `--verify` uses:
4763    /// it normalizes numeric data before comparing, so two runs that committed
4764    /// the map read at different virtual times are a *matching* pair. Reporting
4765    /// only run 1 there would claim agreement on the exact quantity this record
4766    /// exists to expose — a drift `--verify-strict` catches and `--verify` does
4767    /// not.
4768    #[test]
4769    fn a_stripped_pass_shows_both_runs_diverging_map_read_times() -> std::io::Result<()> {
4770        let opts = super::LogDiffOpts {
4771            strip_lines: true,
4772            no_color: true,
4773            ..Default::default()
4774        };
4775        let mut out = Vec::new();
4776        let summary = super::log_diff_summary_from_strs(
4777            log_with_maps_read("12.345_678_901s"),
4778            log_with_maps_read("12.345_678_902s"),
4779            &opts,
4780            &mut out,
4781        )?;
4782        let out = String::from_utf8(out).expect("diff output is utf-8");
4783        assert!(
4784            summary.matched_with_evidence(),
4785            "the stripped comparator normalizes the times, so this pair passes; \
4786             got:\n{out}"
4787        );
4788        assert!(
4789            out.contains(
4790                "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4791                 run 2 first at turn 10, committed virtual time 12345678902ns)"
4792            ),
4793            "a passing pair whose runs committed at DIFFERENT times must show \
4794             both values; showing one would report agreement on a real drift, \
4795             got:\n{out}"
4796        );
4797        Ok(())
4798    }
4799
4800    /// A run that never performed the read must say so, rather than being
4801    /// silently represented by the other run's value.
4802    #[test]
4803    fn a_run_without_the_maps_read_is_named_not_borrowed() {
4804        assert_eq!(super::describe_maps_commit(None), "no such record");
4805        assert_eq!(
4806            super::describe_maps_commit(Some((10, Some(12_345_678_901)))),
4807            "first at turn 10, committed virtual time 12345678901ns"
4808        );
4809        assert_eq!(
4810            super::describe_maps_commit(Some((10, None))),
4811            "first at turn 10, committed virtual time unrecorded"
4812        );
4813    }
4814
4815    /// The record is identified by both halves — a COMMIT *and* that resource —
4816    /// so neither a COMMIT on another path nor an unrelated mention of the map
4817    /// is counted.
4818    #[test]
4819    fn only_a_maps_read_commit_is_counted() {
4820        assert_eq!(
4821            super::maps_read_commits(&[
4822                historical(
4823                    0,
4824                    "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s"
4825                ),
4826                historical(
4827                    1,
4828                    "INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #257: openat(-100, \"/proc/self/maps\", 0x0) = Ok(4)"
4829                ),
4830            ]),
4831            (0, None),
4832            "a COMMIT on another path, and a syscall naming the map, are both \
4833             excluded"
4834        );
4835        assert_eq!(
4836            super::maps_read_commits(&[historical(
4837                0,
4838                "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 10, dettid 3 using resources {Path(\"/proc/self/maps\"): R}, on previously committed 12.345_678_901s"
4839            )]),
4840            (1, Some((10, Some(12_345_678_901))))
4841        );
4842    }
4843
4844    /// The count reads the message text, so an unrelated scheduler line must not
4845    /// be mistaken for a kick.
4846    #[test]
4847    fn only_the_kick_message_is_counted() {
4848        assert_eq!(
4849            super::count_empty_queue_kicks(&[
4850                historical(
4851                    0,
4852                    "INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4853                ),
4854                historical(
4855                    1,
4856                    "INFO detcore::scheduler: COMMIT turn 18, dettid 2, on previously committed 2s"
4857                ),
4858            ]),
4859            0
4860        );
4861        assert_eq!(
4862            super::count_empty_queue_kicks(&[historical(
4863                0,
4864                "INFO detcore::scheduler: scheduler (step2_process_blocked): zero threads left anywhere, fizzling."
4865            )]),
4866            1
4867        );
4868    }
4869}