1use serde::Deserialize;
9use serde::Serialize;
10
11pub const LOG_DIFF_REPORT_SCHEMA: u64 = 2;
17
18pub 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 AllRecordsV1,
36 DbtEvidenceTransportV1,
45 CrossBackendDetcoreV1,
50 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 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#[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 #[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 #[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 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 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 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 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 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 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 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 schema_2(LogDiffVerdict::Matched, 4, 4, Some(4))
416 .require_consistent_matched_prefix()
417 .unwrap();
418 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 schema_2(LogDiffVerdict::Diverged, 2, 5, Some(2))
427 .require_consistent_matched_prefix()
428 .unwrap();
429
430 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 (
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 (
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 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}