Skip to main content

hermit/
logdiff_report.rs

1//! Typed machine-readable reports produced by `hermit log-diff --json`.
2//!
3//! This lives in the library rather than the CLI module because the manifest
4//! runner and scorecard consume the report.  Keeping one producer-owned type is
5//! what prevents a textual `log-diff` success banner from becoming a parity
6//! verdict after the producer changes shape.
7
8use serde::Deserialize;
9use serde::Serialize;
10
11/// Schema written by the current producer.
12///
13/// Schema 2 added [`LogDiffReport::matched_prefix_records`]. Schema-1 reports
14/// remain readable (historical schema-10 ledger rows embed them); they simply
15/// carry no matched prefix.
16pub const LOG_DIFF_REPORT_SCHEMA: u64 = 2;
17
18/// Every schema a reader accepts, oldest first.
19pub const READABLE_LOG_DIFF_REPORT_SCHEMAS: [u64; 2] = [1, LOG_DIFF_REPORT_SCHEMA];
20
21#[derive(
22    Clone,
23    Copy,
24    Debug,
25    Deserialize,
26    Eq,
27    Ord,
28    PartialEq,
29    PartialOrd,
30    Serialize
31)]
32#[serde(rename_all = "snake_case")]
33pub enum RecordEnvelopePolicy {
34    /// Preserve every parsed log record.
35    AllRecordsV1,
36    /// Exclude only records emitted by the DBT evidence transport about
37    /// itself. Those records are real and present in a live evidence stream
38    /// (`evidence_emit_image_initialization`, reverie-dbt native/client.c).
39    /// Offline `hermit log-diff` applies this selection to archived evidence
40    /// logs when asked. Live DBT verification does not select it: it compares
41    /// under `AllRecordsV1`, with initialization records at their stream
42    /// positions, and also compares Reverie's authenticated initialization
43    /// count as separate typed evidence.
44    DbtEvidenceTransportV1,
45    /// Select only records whose target is Detcore or one of its modules;
46    /// comparison then selects INFO. Every other target is excluded, including
47    /// shared records emitted outside Detcore. Detcore payloads remain exact:
48    /// virtual time, RCBs, syscall values, flags, sizes, and I/O-buffer hashes.
49    CrossBackendDetcoreV1,
50    /// A predicate whose semantics are not one of the named canonical policies.
51    CallerDefined,
52}
53
54impl RecordEnvelopePolicy {
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::AllRecordsV1 => "all_records_v1",
58            Self::DbtEvidenceTransportV1 => "dbt_evidence_transport_v1",
59            Self::CrossBackendDetcoreV1 => "cross_backend_detcore_v1",
60            Self::CallerDefined => "caller_defined",
61        }
62    }
63
64    /// Whether this envelope may support same-backend bitwise parity.
65    pub fn is_canonical(self) -> bool {
66        matches!(self, Self::AllRecordsV1 | Self::DbtEvidenceTransportV1)
67    }
68}
69
70#[derive(
71    Clone,
72    Copy,
73    Debug,
74    Deserialize,
75    Eq,
76    Ord,
77    PartialEq,
78    PartialOrd,
79    Serialize
80)]
81#[serde(rename_all = "snake_case")]
82pub enum LogDiffVerdict {
83    NoResult,
84    Refused,
85    Matched,
86    IdenticalSoFar,
87    Diverged,
88    NoComparableMessages,
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct LogDiffMessageCounts {
94    pub left: usize,
95    pub right: usize,
96}
97
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
99#[serde(deny_unknown_fields)]
100pub struct LogDiffRecords {
101    pub compared: usize,
102    pub available_left: usize,
103    pub available_right: usize,
104    pub withheld_incomplete_tail: bool,
105}
106
107/// Identity of the exact bytes captured and decoded by the comparator.
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct LogDiffInput {
111    pub sha256: String,
112    pub bytes: u64,
113}
114
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
116#[serde(deny_unknown_fields)]
117pub struct LogDiffInputs {
118    pub left: LogDiffInput,
119    pub right: LogDiffInput,
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
123#[serde(deny_unknown_fields)]
124pub struct LogDiffComparison {
125    pub stream: String,
126    pub record_envelope: RecordEnvelopePolicy,
127    pub unsafe_strip_lines: bool,
128    pub canonicalize_host_addresses: bool,
129    pub require_structured_events: bool,
130    pub ignored_line_substrings: Vec<String>,
131    pub skip_commit: bool,
132    pub skip_detlog: bool,
133    pub included_detlog_kinds: Vec<String>,
134    pub git_diff: bool,
135}
136
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138#[serde(deny_unknown_fields)]
139pub struct LogDiffReport {
140    pub schema: u64,
141    pub verdict: LogDiffVerdict,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub refusal: Option<String>,
144    pub selected_messages: LogDiffMessageCounts,
145    pub records: LogDiffRecords,
146    /// Older reports did not identify their captured bytes. They remain
147    /// readable, but cannot establish current cross-backend parity.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub inputs: Option<LogDiffInputs>,
150    pub comparison: LogDiffComparison,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub follow_stopped_because: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub first_divergent_record: Option<usize>,
155    /// Number of leading selected messages that are equal on both sides, in
156    /// the units of [`Self::selected_messages`] (schema 2 and later).
157    ///
158    /// On a match it equals both selected counts. After a divergence it is the
159    /// count of selected messages before the first difference; when one
160    /// selected stream is a strict prefix of the other it is the shorter
161    /// length, because the extra messages are the divergence. It is `None`
162    /// when nothing was measured: a refusal, a pending report, an empty
163    /// comparison, or a schema-1 report.
164    ///
165    /// This is deliberately not `first_divergent_record - 1`:
166    /// `first_divergent_record` is a raw log-record index that also counts
167    /// records the comparison did not select, and `records.compared` is
168    /// `min(available_left, available_right)`, a bound on what was read rather
169    /// than a matched length.
170    ///
171    /// Omitted rather than written as `null` when absent, so that a schema-1
172    /// report re-serializes to exactly the bytes it was read from. Stored
173    /// ledger rows hash the re-serialized typed witness into their evidence
174    /// identity; writing a new `null` key would change every historical hash.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub matched_prefix_records: Option<usize>,
177    pub first_divergent_syscall: Option<u64>,
178    pub first_divergent_scheduler_turn: Option<u64>,
179    pub first_divergent_virtual_nanoseconds: Option<u64>,
180    pub first_divergent_left_message: Option<String>,
181    pub first_divergent_right_message: Option<String>,
182}
183
184impl LogDiffReport {
185    /// Refuse a schema this reader does not know. Every reader of a stored
186    /// report goes through this before trusting its fields.
187    pub fn require_readable_schema(&self) -> Result<(), String> {
188        if READABLE_LOG_DIFF_REPORT_SCHEMAS.contains(&self.schema) {
189            Ok(())
190        } else {
191            Err(format!(
192                "log-diff report schema must be one of {READABLE_LOG_DIFF_REPORT_SCHEMAS:?}, got {}",
193                self.schema
194            ))
195        }
196    }
197
198    /// Check [`Self::matched_prefix_records`] against the verdict and counts.
199    ///
200    /// Schema 1 predates the field, so it must be absent there. From schema 2 a
201    /// comparison verdict must carry it, a match must have matched every
202    /// selected message on both sides, and a divergence must have matched
203    /// fewer than the longer side selected. A comparison that selected nothing
204    /// on either side measured nothing, so it carries no prefix whatever its
205    /// verdict: `0 of 0` is not a full match.
206    ///
207    /// The schema is checked here too, so that a caller using this check on its
208    /// own cannot have an unknown schema read as schema 2.
209    pub fn require_consistent_matched_prefix(&self) -> Result<(), String> {
210        self.require_readable_schema()?;
211        let selected = &self.selected_messages;
212        let longer = selected.left.max(selected.right);
213        let shorter = selected.left.min(selected.right);
214        match (self.schema, self.matched_prefix_records) {
215            (1, None) => Ok(()),
216            (1, Some(_)) => Err("schema-1 log-diff report carries a matched prefix".into()),
217            (_, None)
218                if matches!(
219                    self.verdict,
220                    LogDiffVerdict::Matched
221                        | LogDiffVerdict::Diverged
222                        | LogDiffVerdict::IdenticalSoFar
223                ) && longer > 0 =>
224            {
225                Err(format!(
226                    "log-diff {:?} report omitted its matched prefix",
227                    self.verdict
228                ))
229            }
230            (_, None) => Ok(()),
231            (_, Some(prefix)) if prefix > shorter => Err(format!(
232                "log-diff matched prefix {prefix} exceeds the shorter selected stream ({shorter})"
233            )),
234            (_, Some(prefix)) => match self.verdict {
235                LogDiffVerdict::NoResult
236                | LogDiffVerdict::Refused
237                | LogDiffVerdict::NoComparableMessages => Err(format!(
238                    "log-diff {:?} report carries a matched prefix",
239                    self.verdict
240                )),
241                _ if longer == 0 => Err(format!(
242                    "log-diff {:?} report carries a matched prefix over 0 | 0 selected messages",
243                    self.verdict
244                )),
245                LogDiffVerdict::Matched | LogDiffVerdict::IdenticalSoFar
246                    if prefix != selected.left || prefix != selected.right =>
247                {
248                    Err(format!(
249                        "log-diff {:?} report matched {prefix} of {} | {} selected messages",
250                        self.verdict, selected.left, selected.right
251                    ))
252                }
253                LogDiffVerdict::Diverged if prefix >= longer => Err(format!(
254                    "log-diff divergence matched all {longer} selected messages"
255                )),
256                _ => Ok(()),
257            },
258        }
259    }
260
261    /// Require the exact non-lossy policy used for a cross-backend parity
262    /// verdict.  A report may be typed yet still be unsuitable (empty,
263    /// truncated, relaxed, or produced under another record envelope).
264    pub fn require_cross_backend_evidence(&self) -> Result<(), String> {
265        self.require_readable_schema()?;
266        self.require_consistent_matched_prefix()?;
267        if !matches!(
268            self.verdict,
269            LogDiffVerdict::Matched | LogDiffVerdict::Diverged
270        ) {
271            return Err(format!(
272                "log-diff did not reach a parity verdict: {:?}",
273                self.verdict
274            ));
275        }
276        if self.refusal.is_some()
277            || self.follow_stopped_because.is_some()
278            || self.records.withheld_incomplete_tail
279        {
280            return Err("log-diff parity evidence is refused, followed, or incomplete".into());
281        }
282        let inputs = self.inputs.as_ref().ok_or_else(|| {
283            "log-diff parity evidence omitted captured input identities".to_string()
284        })?;
285        for (label, input) in [("left", &inputs.left), ("right", &inputs.right)] {
286            if input.bytes == 0
287                || input.sha256.len() != 64
288                || !input
289                    .sha256
290                    .bytes()
291                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
292            {
293                return Err(format!(
294                    "log-diff {label} captured input identity is invalid"
295                ));
296            }
297        }
298        if self.selected_messages.left == 0
299            || self.selected_messages.right == 0
300            || self.records.compared == 0
301        {
302            return Err("log-diff parity evidence compared no shared Detcore INFO records".into());
303        }
304        // The one-shot producer reads both complete inputs before selecting
305        // their shared INFO envelope. Raw record populations can differ across
306        // backends; the selected streams of a match cannot.
307        if self.records.compared
308            != self
309                .records
310                .available_left
311                .min(self.records.available_right)
312            || self.selected_messages.left > self.records.available_left
313            || self.selected_messages.right > self.records.available_right
314        {
315            return Err("log-diff parity record counts are incomplete or inconsistent".into());
316        }
317        if self.verdict == LogDiffVerdict::Matched
318            && self.selected_messages.left != self.selected_messages.right
319        {
320            return Err("log-diff match has unequal selected INFO counts".into());
321        }
322        let comparison = &self.comparison;
323        if comparison.stream != "info"
324            || comparison.record_envelope != RecordEnvelopePolicy::CrossBackendDetcoreV1
325            || comparison.unsafe_strip_lines
326            || !comparison.canonicalize_host_addresses
327            || !comparison.require_structured_events
328            || !comparison.ignored_line_substrings.is_empty()
329            || comparison.skip_commit
330            || comparison.skip_detlog
331            || comparison.included_detlog_kinds != ["syscall", "syscall_result", "other"]
332            || comparison.git_diff
333        {
334            return Err(
335                "log-diff parity evidence did not use CrossBackendDetcoreV1 canonical INFO policy"
336                    .into(),
337            );
338        }
339        Ok(())
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    /// A cross-backend match exactly as the schema-1 producer wrote it: no
348    /// `matched_prefix_records` key, and `first_divergent_record` omitted.
349    const SCHEMA_1_MATCH: &str = r#"{"schema":1,"verdict":"matched","selected_messages":{"left":3,"right":3},"records":{"compared":5,"available_left":5,"available_right":6,"withheld_incomplete_tail":false},"inputs":{"left":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":210},"right":{"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","bytes":252}},"comparison":{"stream":"info","record_envelope":"cross_backend_detcore_v1","unsafe_strip_lines":false,"canonicalize_host_addresses":true,"require_structured_events":true,"ignored_line_substrings":[],"skip_commit":false,"skip_detlog":false,"included_detlog_kinds":["syscall","syscall_result","other"],"git_diff":false},"first_divergent_syscall":null,"first_divergent_scheduler_turn":null,"first_divergent_virtual_nanoseconds":null,"first_divergent_left_message":null,"first_divergent_right_message":null}"#;
350
351    fn schema_1_match() -> LogDiffReport {
352        serde_json::from_str(SCHEMA_1_MATCH).expect("a schema-1 report must still parse")
353    }
354
355    fn schema_2(
356        verdict: LogDiffVerdict,
357        left: usize,
358        right: usize,
359        prefix: Option<usize>,
360    ) -> LogDiffReport {
361        LogDiffReport {
362            schema: LOG_DIFF_REPORT_SCHEMA,
363            verdict,
364            selected_messages: LogDiffMessageCounts { left, right },
365            matched_prefix_records: prefix,
366            first_divergent_record: (verdict == LogDiffVerdict::Diverged)
367                .then(|| prefix.unwrap_or(0) + 1),
368            ..schema_1_match()
369        }
370    }
371
372    #[test]
373    fn a_schema_1_report_still_parses_and_carries_no_matched_prefix() {
374        let report = schema_1_match();
375        assert_eq!(report.schema, 1);
376        assert_eq!(report.matched_prefix_records, None);
377        // Byte-identical re-serialization: ledger evidence identities hash the
378        // re-serialized typed witness, so a schema-1 row must keep its bytes.
379        assert_eq!(serde_json::to_string(&report).unwrap(), SCHEMA_1_MATCH);
380        report.require_readable_schema().unwrap();
381        report.require_consistent_matched_prefix().unwrap();
382        report.require_cross_backend_evidence().unwrap();
383
384        // A schema-1 report claiming the schema-2 field is not a report any
385        // producer wrote.
386        let mut forged = report;
387        forged.matched_prefix_records = Some(3);
388        assert!(forged.require_cross_backend_evidence().is_err());
389    }
390
391    #[test]
392    fn schema_2_round_trips_and_an_unknown_schema_or_field_is_refused() {
393        let report = schema_2(LogDiffVerdict::Matched, 3, 3, Some(3));
394        let text = serde_json::to_string(&report).unwrap();
395        assert!(text.contains(r#""schema":2"#));
396        assert!(text.contains(r#""matched_prefix_records":3"#));
397        let parsed: LogDiffReport = serde_json::from_str(&text).unwrap();
398        assert_eq!(parsed, report);
399        parsed.require_cross_backend_evidence().unwrap();
400
401        let future = LogDiffReport {
402            schema: 3,
403            ..report
404        };
405        assert!(future.require_readable_schema().is_err());
406        assert!(future.require_cross_backend_evidence().is_err());
407
408        let extra = SCHEMA_1_MATCH.replacen('{', r#"{"matched_prefix":1,"#, 1);
409        assert!(serde_json::from_str::<LogDiffReport>(&extra).is_err());
410    }
411
412    #[test]
413    fn a_schema_2_matched_prefix_must_agree_with_the_verdict() {
414        // Identical: the whole of both streams.
415        schema_2(LogDiffVerdict::Matched, 4, 4, Some(4))
416            .require_consistent_matched_prefix()
417            .unwrap();
418        // Divergence at the first selected message, and further in.
419        schema_2(LogDiffVerdict::Diverged, 4, 4, Some(0))
420            .require_consistent_matched_prefix()
421            .unwrap();
422        schema_2(LogDiffVerdict::Diverged, 4, 4, Some(2))
423            .require_consistent_matched_prefix()
424            .unwrap();
425        // A strict prefix: diverged, matched over the shorter side only.
426        schema_2(LogDiffVerdict::Diverged, 2, 5, Some(2))
427            .require_consistent_matched_prefix()
428            .unwrap();
429
430        // Follow mode stopped before the streams diverged: every selected
431        // message on both sides matched.
432        schema_2(LogDiffVerdict::IdenticalSoFar, 4, 4, Some(4))
433            .require_consistent_matched_prefix()
434            .unwrap();
435
436        for (label, report, message) in [
437            (
438                "a match missing its prefix",
439                schema_2(LogDiffVerdict::Matched, 4, 4, None),
440                "log-diff Matched report omitted its matched prefix",
441            ),
442            (
443                "a partial match",
444                schema_2(LogDiffVerdict::Matched, 4, 4, Some(3)),
445                "log-diff Matched report matched 3 of 4 | 4 selected messages",
446            ),
447            (
448                "a divergence matching everything",
449                schema_2(LogDiffVerdict::Diverged, 4, 4, Some(4)),
450                "log-diff divergence matched all 4 selected messages",
451            ),
452            (
453                "a prefix past the shorter side",
454                schema_2(LogDiffVerdict::Diverged, 2, 5, Some(3)),
455                "log-diff matched prefix 3 exceeds the shorter selected stream (2)",
456            ),
457            (
458                "a divergence missing its prefix",
459                schema_2(LogDiffVerdict::Diverged, 4, 4, None),
460                "log-diff Diverged report omitted its matched prefix",
461            ),
462            (
463                "a refusal with a prefix",
464                schema_2(LogDiffVerdict::Refused, 0, 0, Some(0)),
465                "log-diff Refused report carries a matched prefix",
466            ),
467            (
468                "a no-result report with a prefix",
469                schema_2(LogDiffVerdict::NoResult, 0, 0, Some(0)),
470                "log-diff NoResult report carries a matched prefix",
471            ),
472            (
473                "an empty comparison with a prefix",
474                schema_2(LogDiffVerdict::NoComparableMessages, 0, 0, Some(0)),
475                "log-diff NoComparableMessages report carries a matched prefix",
476            ),
477            (
478                "a follow-mode report missing its prefix",
479                schema_2(LogDiffVerdict::IdenticalSoFar, 4, 4, None),
480                "log-diff IdenticalSoFar report omitted its matched prefix",
481            ),
482            (
483                "a partial follow-mode match",
484                schema_2(LogDiffVerdict::IdenticalSoFar, 4, 4, Some(3)),
485                "log-diff IdenticalSoFar report matched 3 of 4 | 4 selected messages",
486            ),
487            // 0 == 0 == 0 satisfies "equal to both counts", so these two passed
488            // before the empty-selection rule, as a full match of nothing.
489            (
490                "a match of nothing",
491                schema_2(LogDiffVerdict::Matched, 0, 0, Some(0)),
492                "log-diff Matched report carries a matched prefix over 0 | 0 selected messages",
493            ),
494            (
495                "a follow-mode match of nothing",
496                schema_2(LogDiffVerdict::IdenticalSoFar, 0, 0, Some(0)),
497                "log-diff IdenticalSoFar report carries a matched prefix over 0 | 0 selected messages",
498            ),
499            (
500                "a divergence of nothing",
501                schema_2(LogDiffVerdict::Diverged, 0, 0, Some(0)),
502                "log-diff Diverged report carries a matched prefix over 0 | 0 selected messages",
503            ),
504            // The check on its own must not read an unknown schema as schema 2.
505            (
506                "a future schema",
507                LogDiffReport {
508                    schema: 3,
509                    ..schema_2(LogDiffVerdict::Matched, 4, 4, Some(4))
510                },
511                "log-diff report schema must be one of [1, 2], got 3",
512            ),
513            (
514                "schema zero",
515                LogDiffReport {
516                    schema: 0,
517                    ..schema_2(LogDiffVerdict::Matched, 4, 4, Some(4))
518                },
519                "log-diff report schema must be one of [1, 2], got 0",
520            ),
521        ] {
522            assert_eq!(
523                report.require_consistent_matched_prefix(),
524                Err(message.to_string()),
525                "{label} must be refused for its own reason"
526            );
527        }
528        // Nothing compared: nothing measured, and that is consistent. The
529        // follow-mode timeout can stop before either side selected anything.
530        for verdict in [
531            LogDiffVerdict::NoComparableMessages,
532            LogDiffVerdict::IdenticalSoFar,
533        ] {
534            schema_2(verdict, 0, 0, None)
535                .require_consistent_matched_prefix()
536                .unwrap();
537        }
538    }
539}