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